v1.0
Docs / Domain Layer

Domain Layer

The Domain layer contains pure business logic with no dependencies on CodeIgniter or any framework. This page explains each component with detailed examples.

Table of Contents

  1. Why a Separate Domain Layer?
  2. Domain Generator (Spark Command)
  3. Entities (Step-by-Step)
  4. Value Objects (Step-by-Step)
  5. PHP Enums (Step-by-Step)
  6. Repository Interfaces
  7. Domain Services
  8. Policies
  9. Domain Exceptions
  10. Docker Verification

1. Why a Separate Domain Layer?

Goal: Understand why "Business Logic" should not live in Controllers or Models.

The Problem

In typical CI4 apps, business logic is mixed with framework code:

// Model with mixed concerns
class UserModel extends Model
{
    protected $table = 'users';

    public function registerUser($data)
    {
        // Validation - business rule
        if (strlen($data['password']) < 8) {
            throw new Exception('Password too short');
        }
        
        // Email normalization - business rule
        $data['email'] = strtolower($data['email']);
        
        // Hash password - security logic
        $data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
        
        // Database insert - infrastructure
        return $this->insert($data);
    }
}

Problems:

The Solution

Separate pure business logic from framework code:

// Domain Entity - pure PHP, no framework
class User
{
    private Email $email;          // Value object validates email
    private string $passwordHash;

    public function __construct(Email $email, string $password)
    {
        PasswordPolicy::validate($password);  // Business rule
        $this->email = $email;
        $this->passwordHash = password_hash($password, PASSWORD_DEFAULT);
    }
}

// Infrastructure Repository - handles database
class UserRepository implements UserRepositoryInterface
{
    public function save(User $user): void
    {
        $this->db->table('users')->insert([...]);
    }
}

Simple Analogy

Think of Domain like a rulebook for a game:

ConceptGame AnalogyCode
EntityGame pieces (king, knight)User, Order, Product
Value ObjectBoard squares (A1, B2)Email, Money, Address
PolicyRules (how knight moves)PasswordPolicy
RepositoryGame box (stores pieces)UserRepository

The rulebook doesn't care if you're playing on a wooden board or a computer—it just defines the rules.


2. Domain Generator (Spark Command)

Quick Start: Use php spark make:domain to generate a complete domain structure instantly!

Usage

# Generate full domain structure
docker exec ci4-php php spark make:domain Product

# Generate only Entity file
docker exec ci4-php php spark make:domain Invoice --entity-only

# Force overwrite existing files
docker exec ci4-php php spark make:domain Product --force

Generated Structure

The command creates a complete DDD domain with the following structure:

app/Domain/Product/
├── Entities/
│   └── Product.php           # Entity with properties and methods
├── Contracts/
│   └── ProductRepositoryInterface.php  # Repository interface
├── Repositories/
│   └── ProductRepository.php  # Database implementation
├── Services/
│   └── ProductService.php     # Domain service
├── UseCases/
│   └── CreateProduct.php      # Use case implementation
├── Exceptions/
│   └── ProductException.php   # Domain exceptions
└── Enums/
    └── ProductStatus.php      # Status enum

Generated Files Overview

FilePurpose
EntityCore domain object with business methods
RepositoryInterfaceContract defining data access operations
RepositoryDatabase implementation of the interface
ServiceBusiness logic orchestration
UseCaseSingle-purpose action (CQRS pattern)
ExceptionDomain-specific error handling
EnumType-safe status/state values

Example Output

Creating domain: Product

  Created: Domain/Product/Entities/
  Created: Domain/Product/Contracts/
  Created: Domain/Product/Services/
  Created: Domain/Product/UseCases/
  Created: Domain/Product/Exceptions/
  Created: Domain/Product/Enums/
  Created: Domain/Product/Repositories/
  Created: Entities/Product.php
  Created: Contracts/ProductRepositoryInterface.php
  Created: Repositories/ProductRepository.php
  Created: Services/ProductService.php
  Created: Exceptions/ProductException.php
  Created: Enums/ProductStatus.php
  Created: UseCases/CreateProduct.php

Domain 'Product' created successfully!
Location: app/Domain/Product/

3. Entities (Step-by-Step)

Task: Create a `User` entity that protects its state (no public properties!).

Technical Definition

An Entity is a core business object with a unique identity. Two entities with the same data but different IDs are different.

Is it the same as a Database Table?

No. A table is for storage (foreign keys, efficiency). An Entity is for behavior (business rules). Sometimes they map 1:1, but often an Entity is richer than a table row.

Step 1: Create the Entity File

Where to put it:app/Domain/{Context}/Entities/. It is a single PHP file.

File: app/Domain/User/Entities/User.php

<?php

namespace App\Domain\User\Entities;

use App\Domain\Shared\ValueObjects\Email;

class User
{
    private int $id;
    private string $name;
    private Email $email;
    private string $passwordHash;
    private array $roles = [];
    private \DateTimeImmutable $createdAt;

    public function __construct(
        string $name,
        Email $email,
        string $passwordHash
    ) {
        $this->name = $name;
        $this->email = $email;
        $this->passwordHash = $passwordHash;
        $this->createdAt = new \DateTimeImmutable();
    }
}

Step 2: Add Getters

    // --- Getters (read access) ---
    
    public function getId(): int
    {
        return $this->id;
    }
    
    public function getName(): string
    {
        return $this->name;
    }
    
    public function getEmail(): Email
    {
        return $this->email;
    }
    
    public function getPasswordHash(): string
    {
        return $this->passwordHash;
    }
    
    public function getRoles(): array
    {
        return $this->roles;
    }
    
    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

Step 3: Add Business Methods

    // --- Business Logic (not just setters!) ---
    
    /**
     * Assign a role to this user
     */
    public function assignRole(string $role): void
    {
        if (!in_array($role, $this->roles, true)) {
            $this->roles[] = $role;
        }
    }
    
    /**
     * Check if user has a specific role
     */
    public function hasRole(string $role): bool
    {
        return in_array($role, $this->roles, true);
    }
    
    /**
     * Remove a role from this user
     */
    public function removeRole(string $role): void
    {
        $this->roles = array_values(
            array_filter($this->roles, fn($r) => $r !== $role)
        );
    }
    
    /**
     * Change user's password
     */
    public function changePassword(string $newPasswordHash): void
    {
        $this->passwordHash = $newPasswordHash;
    }
    
    /**
     * Verify a password against stored hash
     */
    public function verifyPassword(string $password): bool
    {
        return password_verify($password, $this->passwordHash);
    }

Usage Example

// Create a new user
$email = new Email('john@example.com');
$user = new User('John Doe', $email, password_hash('secret123', PASSWORD_DEFAULT));

// Use business methods
$user->assignRole('member');
$user->assignRole('admin');

if ($user->hasRole('admin')) {
    echo "User is an admin!";
}

// Verify password
if ($user->verifyPassword('secret123')) {
    echo "Password correct!";
}

3. Value Objects (Step-by-Step)

Task: Create an `Email` value object that validates itself upon creation.

Technical Definition

A Value Object is an immutable object defined by its value, not identity. Two value objects with the same value are equal.

Step 1: Create Email Value Object

File: app/Domain/Shared/ValueObjects/Email.php

<?php

namespace App\Domain\Shared\ValueObjects;

class Email
{
    private string $value;

    public function __construct(string $email)
    {
        // Normalize: lowercase and trim
        $email = strtolower(trim($email));
        
        // Validate format
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException(
                "Invalid email format: {$email}"
            );
        }
        
        $this->value = $email;
    }

    public function getValue(): string
    {
        return $this->value;
    }

    public function getDomain(): string
    {
        return substr($this->value, strpos($this->value, '@') + 1);
    }

    public function equals(Email $other): bool
    {
        return $this->value === $other->value;
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

Step 2: Usage Examples

// Valid email - works
$email = new Email('John.DOE@Example.COM');
echo $email->getValue();  // "john.doe@example.com" (normalized!)
echo $email->getDomain(); // "example.com"

// Invalid email - throws exception
try {
    $email = new Email('not-an-email');
} catch (\InvalidArgumentException $e) {
    echo $e->getMessage();  // "Invalid email format: not-an-email"
}

// Comparing emails
$email1 = new Email('user@example.com');
$email2 = new Email('USER@EXAMPLE.COM');
var_dump($email1->equals($email2));  // true (both normalized to same value)

Step 3: Create Money Value Object

File: app/Domain/Shared/ValueObjects/Money.php

<?php

namespace App\Domain\Shared\ValueObjects;

class Money
{
    private int $cents;      // Store as cents to avoid floating point issues
    private string $currency;

    public function __construct(int $cents, string $currency = 'USD')
    {
        if ($cents < 0) {
            throw new \InvalidArgumentException('Money cannot be negative');
        }
        
        $this->cents = $cents;
        $this->currency = strtoupper($currency);
    }

    public static function fromDollars(float $dollars, string $currency = 'USD'): self
    {
        return new self((int) round($dollars * 100), $currency);
    }

    public function getCents(): int
    {
        return $this->cents;
    }

    public function getDollars(): float
    {
        return $this->cents / 100;
    }

    public function getCurrency(): string
    {
        return $this->currency;
    }

    public function add(Money $other): Money
    {
        $this->ensureSameCurrency($other);
        return new Money($this->cents + $other->cents, $this->currency);
    }

    public function format(): string
    {
        $symbol = match($this->currency) {
            'USD' => '$',
            'EUR' => '€',
            'GBP' => '£',
            default => $this->currency . ' ',
        };
        
        return $symbol . number_format($this->getDollars(), 2);
    }

    private function ensureSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException(
                "Cannot combine {$this->currency} with {$other->currency}"
            );
        }
    }
}

Money Usage Example

// Create money
$price = Money::fromDollars(19.99);
$tax = Money::fromDollars(1.60);

// Add them
$total = $price->add($tax);

echo $total->format();  // "$21.59"
echo $total->getCents(); // 2159

4. Repository Interfaces

Technical Definition

A Repository Interface is a contract that defines data access operations. Domain defines WHAT is needed; Infrastructure implements HOW.

3.1 More Value Object Examples (Top 5)

Value Objects clarify code intent significantly. Here are 5 common examples:

Value ObjectSolves ProblemExample Value
DistanceUnit confusion (Miles vs Km)new Distance(100, 'km')
TemperatureScale confusion (F vs C)new Temperature(37, 'C')
CoordinatesLat/Lon pairing validationnew Coordinates(40.7128, -74.0060)
PasswordHashing logic/Complexity rulesPassword::fromRaw('secret123')
DateRangeStart > End validationnew DateRange($start, $end)
// Without Value Objects
function calculateShipping(float $dist, string $unit) { ... }
calculateShipping(100, 'miles'); // Easy to pass wrong string

// With Value Objects
function calculateShipping(Distance $dist) { ... }
calculateShipping(Distance::fromMiles(100)); // Type-safe and clear!

Create the Interface

File: app/Domain/User/Repositories/UserRepositoryInterface.php

<?php

namespace App\Domain\User\Repositories;

use App\Domain\User\Entities\User;
use App\Domain\Shared\ValueObjects\Email;

interface UserRepositoryInterface
{
    /**
     * Find user by ID
     */
    public function findById(int $id): ?User;
    
    /**
     * Find user by email
     */
    public function findByEmail(Email $email): ?User;
    
    /**
     * Get all users with pagination
     */
    public function findAll(int $limit = 20, int $offset = 0): array;
    
    /**
     * Save a new user
     */
    public function save(User $user): void;
    
    /**
     * Update an existing user
     */
    public function update(User $user): void;
    
    /**
     * Delete a user
     */
    public function delete(int $id): void;
    
    /**
     * Check if email already exists
     */
    public function emailExists(Email $email): bool;
    
    /**
     * Count total users
     */
    public function countAll(): int;
}

Why Interfaces?

// Domain Service uses INTERFACE (doesn't know about MySQL)
class UserService
{
    public function __construct(
        private UserRepositoryInterface $repo  // Interface, not concrete class
    ) {}
    
    public function findUser(int $id): ?User
    {
        return $this->repo->findById($id);
    }
}

// In production: use real MySQL repository
$service = new UserService(new MySQLUserRepository());

// In tests: use mock repository
$mockRepo = new InMemoryUserRepository();
$service = new UserService($mockRepo);

3.5. PHP Enums (Step-by-Step)

Task: Create a `UserRole` enum that replaces magic strings and provides type safety.

Technical Definition

PHP 8.1 Enums (Enumerations) are a type-safe way to define a set of named values. They replace magic strings and constants.

Enums vs Value Objects

FeatureEnumValue Object
Use CaseFixed set of values (roles, statuses)Values that need validation (email, money)
ValidationBuilt-in (only defined cases allowed)Custom validation in constructor
EqualityCase comparison (===)equals() method
ExampleUserRole::ADMINnew Email('user@example.com')

Step 1: Create UserRole Enum

File: app/Domain/User/Enums/UserRole.php

<?php

namespace App\Domain\User\Enums;

enum UserRole: string
{
    case ADMIN = 'admin';
    case MEMBER = 'member';
    case GUEST = 'guest';

    public function label(): string
    {
        return match($this) {
            self::ADMIN => 'Administrator',
            self::MEMBER => 'Member',
            self::GUEST => 'Guest',
        };
    }

    public function isAdmin(): bool
    {
        return $this === self::ADMIN;
    }
}

Step 2: Usage in Entity

<?php

namespace App\Domain\User\Entities;

use App\Domain\User\Enums\UserRole;

class User
{
    public function __construct(
        private int $id,
        private string $email,
        private UserRole $role  // Type-safe! Only valid UserRole values
    ) {}

    public function getRole(): UserRole
    {
        return $this->role;
    }

    public function isAdmin(): bool
    {
        return $this->role->isAdmin();
    }

    public function changeRole(UserRole $newRole): void
    {
        $this->role = $newRole;
    }
}

Step 3: Practical Examples

// Creating users with type-safe roles
$admin = new User(1, 'admin@example.com', UserRole::ADMIN);
$member = new User(2, 'user@example.com', UserRole::MEMBER);

// Type safety prevents invalid values
$user = new User(3, 'test@example.com', 'superuser'); // ERROR! Type error

// Checking roles
if ($user->getRole() === UserRole::ADMIN) {
    echo "User is an admin";
}

// Using helper methods
if ($user->getRole()->isAdmin()) {
    echo "Admin access granted";
}

// Getting label for display
echo $user->getRole()->label(); // "Administrator"

Step 4: OrderStatus Enum (With State Transitions)

File: app/Domain/Order/Enums/OrderStatus.php

<?php

namespace App\Domain\Order\Enums;

enum OrderStatus: string
{
    case PENDING = 'pending';
    case PROCESSING = 'processing';
    case SHIPPED = 'shipped';
    case DELIVERED = 'delivered';
    case CANCELLED = 'cancelled';

    public function canTransitionTo(self $newStatus): bool
    {
        return match($this) {
            self::PENDING => in_array($newStatus, [
                self::PROCESSING,
                self::CANCELLED,
            ]),
            self::PROCESSING => in_array($newStatus, [
                self::SHIPPED,
                self::CANCELLED,
            ]),
            self::SHIPPED => $newStatus === self::DELIVERED,
            default => false,
        };
    }

    public function color(): string
    {
        return match($this) {
            self::PENDING => 'yellow',
            self::PROCESSING => 'blue',
            self::SHIPPED => 'purple',
            self::DELIVERED => 'green',
            self::CANCELLED => 'red',
        };
    }
}

Usage with State Validation

class Order
{
    public function __construct(
        private int $id,
        private OrderStatus $status = OrderStatus::PENDING
    ) {}

    public function updateStatus(OrderStatus $newStatus): void
    {
        if (!$this->status->canTransitionTo($newStatus)) {
            throw new \DomainException(
                "Cannot transition from {$this->status->value} to {$newStatus->value}"
            );
        }

        $this->status = $newStatus;
    }
}

// Valid transition
$order = new Order(1);
$order->updateStatus(OrderStatus::PROCESSING); // Works!

// Invalid transition
$order->updateStatus(OrderStatus::DELIVERED); // DomainException!

Step 5: PaymentMethod Enum (Backed with Business Logic)

File: app/Domain/Payment/Enums/PaymentMethod.php

<?php

namespace App\Domain\Payment\Enums;

enum PaymentMethod: string
{
    case CREDIT_CARD = 'credit_card';
    case PAYPAL = 'paypal';
    case BANK_TRANSFER = 'bank_transfer';
    case CASH = 'cash';

    public function isOnline(): bool
    {
        return in_array($this, [
            self::CREDIT_CARD,
            self::PAYPAL,
        ]);
    }

    public function processingFeePercentage(): float
    {
        return match($this) {
            self::CREDIT_CARD => 2.9,
            self::PAYPAL => 3.5,
            self::BANK_TRANSFER => 0.0,
            self::CASH => 0.0,
        };
    }
}

Real-World Usage

class Payment
{
    public function __construct(
        private float $amount,
        private PaymentMethod $method
    ) {}

    public function getTotalWithFees(): float
    {
        $feePercentage = $this->method->processingFeePercentage();
        return $this->amount * (1 + $feePercentage / 100);
    }
}

$payment = new Payment(100.00, PaymentMethod::CREDIT_CARD);
echo $payment->getTotalWithFees(); // 102.90

$cashPayment = new Payment(100.00, PaymentMethod::CASH);
echo $cashPayment->getTotalWithFees(); // 100.00

When to Use Enums

Use Enums when:
Use Value Objects when:

4. Repository Interfaces

Technical Definition

A Domain Service contains business logic that doesn't belong to a single Entity. It orchestrates operations.

Create UserService

File: app/Domain/User/Services/UserService.php

<?php

namespace App\Domain\User\Services;

use App\Domain\User\Entities\User;
use App\Domain\User\Repositories\UserRepositoryInterface;
use App\Domain\User\Exceptions\UserNotFoundException;
use App\Domain\User\Exceptions\UserAlreadyExistsException;
use App\Domain\User\Policies\PasswordPolicy;
use App\Domain\Shared\ValueObjects\Email;

class UserService
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    /**
     * Register a new user
     */
    public function register(string $name, string $emailStr, string $password): User
    {
        // 1. Create email value object (validates format)
        $email = new Email($emailStr);
        
        // 2. Check if email already exists
        if ($this->userRepository->emailExists($email)) {
            throw new UserAlreadyExistsException($emailStr);
        }
        
        // 3. Validate password against policy
        PasswordPolicy::validate($password);
        
        // 4. Create user entity
        $user = new User(
            $name,
            $email,
            password_hash($password, PASSWORD_DEFAULT)
        );
        
        // 5. Assign default role
        $user->assignRole('member');
        
        // 6. Save to repository
        $this->userRepository->save($user);
        
        return $user;
    }

    /**
     * Find user by ID
     */
    public function findById(int $id): User
    {
        $user = $this->userRepository->findById($id);
        
        if ($user === null) {
            throw UserNotFoundException::withId($id);
        }
        
        return $user;
    }

    /**
     * Assign role to user
     */
    public function assignRole(int $userId, string $role): void
    {
        $user = $this->findById($userId);
        $user->assignRole($role);
        $this->userRepository->update($user);
    }

    /**
     * Check if email is available
     */
    public function isEmailAvailable(string $emailStr): bool
    {
        $email = new Email($emailStr);
        return !$this->userRepository->emailExists($email);
    }
}

6. Policies

Technical Definition

A Policy encapsulates complex business rules that can be reused.

File: app/Domain/User/Policies/PasswordPolicy.php

<?php

namespace App\Domain\User\Policies;

class PasswordPolicy
{
    public const MIN_LENGTH = 8;
    public const REQUIRE_UPPERCASE = true;
    public const REQUIRE_LOWERCASE = true;
    public const REQUIRE_NUMBER = true;
    public const REQUIRE_SPECIAL = false;

    public static function validate(string $password): void
    {
        $errors = [];

        if (strlen($password) < self::MIN_LENGTH) {
            $errors[] = 'Password must be at least ' . self::MIN_LENGTH . ' characters';
        }

        if (self::REQUIRE_UPPERCASE && !preg_match('/[A-Z]/', $password)) {
            $errors[] = 'Password must contain at least one uppercase letter';
        }

        if (self::REQUIRE_LOWERCASE && !preg_match('/[a-z]/', $password)) {
            $errors[] = 'Password must contain at least one lowercase letter';
        }

        if (self::REQUIRE_NUMBER && !preg_match('/[0-9]/', $password)) {
            $errors[] = 'Password must contain at least one number';
        }

        if (self::REQUIRE_SPECIAL && !preg_match('/[!@#$%^&*(),.?":{}|<>]/', $password)) {
            $errors[] = 'Password must contain at least one special character';
        }

        if (!empty($errors)) {
            throw new \InvalidArgumentException(implode('. ', $errors));
        }
    }
}

Usage

// Valid password
PasswordPolicy::validate('MySecure123');  // No exception

// Invalid password
try {
    PasswordPolicy::validate('weak');
} catch (\InvalidArgumentException $e) {
    echo $e->getMessage();
    // "Password must be at least 8 characters. Password must contain..."
}

7. Domain Exceptions

File: app/Domain/User/Exceptions/UserNotFoundException.php

<?php

namespace App\Domain\User\Exceptions;

class UserNotFoundException extends \DomainException
{
    public static function withId(int $id): self
    {
        return new self("User with ID {$id} not found");
    }

    public static function withEmail(string $email): self
    {
        return new self("User with email {$email} not found");
    }
}

File: app/Domain/User/Exceptions/UserAlreadyExistsException.php

<?php

namespace App\Domain\User\Exceptions;

class UserAlreadyExistsException extends \DomainException
{
    public function __construct(string $email)
    {
        parent::__construct("User with email {$email} already exists");
    }
}

8. Docker Verification

Check Domain Structure

docker-compose exec php ls -la /var/www/html/app/Domain/

# Output:
# Shared
# User

docker-compose exec php ls -la /var/www/html/app/Domain/User/

# Output:
# Entities
# Exceptions
# Policies
# Repositories
# Services

Test Value Object

# Open PHP console
docker-compose exec php php spark tinker

# Test Email
> $email = new \App\Domain\Shared\ValueObjects\Email('TEST@EXAMPLE.COM');
> echo $email->getValue();  // "test@example.com"

# Test invalid email
> $email = new \App\Domain\Shared\ValueObjects\Email('invalid');
// Throws InvalidArgumentException
ESC

Start typing to search the documentation