Design and implement DDD patterns (entities, value objects, aggregates, CQRS)...
Design and implement rich domain models following DDD, hexagonal architecture, and CQRS patterns.
Success Criteria:
make deptrac shows zero violationsRich Domain Models, Not Anemic
Business logic belongs in the Domain layer. Application layer orchestrates, Domain executes.
Domain āāāāāāāāāāāāāāāāā> (NO dependencies - pure PHP)
ā
ā
Application āāāāāāāāāā> Domain + Infrastructure
ā
ā
Infrastructure āāāāāāā> Domain + Application
Allowed Dependencies:
| Layer | Can Import |
|---|---|
| Domain | ā Nothing (pure PHP, SPL, domain-specific libraries only) |
| Application | ā Domain, Infrastructure, Symfony, API Platform |
| Infrastructure | ā Domain, Application, Symfony, Doctrine ORM |
Template examples may show Doctrine ODM/MongoDB constructs. In this service, use Doctrine ORM with MySQL (
EntityManagerInterface,.orm.xmlmappings).
See: DIRECTORY-STRUCTURE.md for complete file placement guide.
ā FORBIDDEN in Domain:
use Symfony\...)ā ALLOWED in Domain:
ā BAD (Anemic):
class Customer {
public function setName(string $name): void {
$this->name = $name; // No validation!
}
}
ā GOOD (Rich):
class Customer {
public function changeName(CustomerName $name): void {
// Business rules enforced
$this->record(new CustomerNameChanged($this->id, $name));
$this->name = $name;
}
}
ā BAD: Validation in Domain with Symfony
use Symfony\Component\Validator\Constraints as Assert;
class Customer {
#[Assert\NotBlank] // ā Framework in Domain!
private string $name;
}
ā GOOD: Validation in YAML config (Preferred)
# config/validator/Customer.yaml
App\Application\DTO\CustomerCreate:
properties:
name:
- NotBlank: ~
- Length:
min: 2
max: 100
Framework validators should always be used when possible. They provide:
Value Objects should only be used when:
See: REFERENCE.md for complete validation patterns.
// src/Core/{Context}/Application/Command/{Action}{Entity}Command.php
final readonly class CreateCustomerCommand implements CommandInterface
{
public function __construct(
public string $id,
public string $name,
public string $email
) {}
}
// src/Core/{Context}/Application/CommandHandler/{Action}{Entity}CommandHandler.php
final readonly class CreateCustomerCommandHandler implements CommandHandlerInterface
{
public function __invoke(CreateCustomerCommand $command): Customer
{
// Minimal orchestration only
$customer = Customer::create(
Ulid::fromString($command->id),
new CustomerName($command->name),
new Email($command->email)
);
$this->repository->save($customer);
$this->eventBus->publish(...$customer->pullDomainEvents());
return $customer;
}
}
See: REFERENCE.md for complete CQRS patterns.
// src/Core/{Context}/Domain/Repository/{Entity}RepositoryInterface.php
interface CustomerRepositoryInterface
{
public function save(Customer $customer): void;
public function findById(string $id): ?Customer;
}
// src/Core/{Context}/Infrastructure/Repository/{Entity}Repository.php
final class CustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
private readonly DocumentManager $documentManager
) {}
public function save(Customer $customer): void
{
$this->documentManager->persist($customer);
$this->documentManager->flush();
}
}
Register in config/services.yaml:
App\Core\Customer\Domain\Repository\CustomerRepositoryInterface:
alias: App\Core\Customer\Infrastructure\Repository\CustomerRepository
class Customer extends AggregateRoot // Provides event recording
{
public function changeName(CustomerName $name): void
{
$this->name = $name;
$this->record(new CustomerNameChanged($this->id, $name));
}
}
// src/Core/{Context}/Application/EventSubscriber/{Event}Subscriber.php
final readonly class CustomerNameChangedSubscriber implements DomainEventSubscriberInterface
{
public function __invoke(CustomerNameChanged $event): void
{
// React to event (e.g., send notification)
}
}
See: REFERENCE.md for complete event-driven patterns.
Domain/Entity/Domain/ValueObject/Domain/Repository/Infrastructure/Repository/Application/Command/Application/CommandHandler/make deptrac shows zero violationsSee: examples/ for complete working examples.
If make deptrac shows violations:
Use: deptrac-fixer skill for step-by-step fix patterns.
deptrac.yaml to allow violationsarray, list, or iterable for domain object collections ā create and pass typed collection classes instead (OAuthProviderCollection, UserCollection, RecoveryCodeCollection, AuthSessionCollection, PasswordResetTokenCollection, DomainEventCollection). Psalm enforces this repo-wide in src/.json_encode/json_decode in production source ā use Symfony SerializerInterface (enforced by Psalm in src/)new OAuthProvider(...) in production source code ā use OAuthProvider::fromString()new StringableArrayNormalizer() outside of Doctrine types (Doctrine types are exempt since they lack DI support)new for reviewed domain events and collections in production source code ā use dedicated Factory classes insteadIteratorAggregate, Countable) when a module exposes repeated domain object groups. Existing collections: OAuthProviderCollection, UserCollection, RecoveryCodeCollection, AuthSessionCollection, PasswordResetTokenCollection, DomainEventCollectionSerializerInterface for serialization in infrastructure repositoriesmake deptrac after changessrc/Core/{Context}/
āāā Domain/
ā āāā Entity/
ā ā āāā {Entity}.php # Pure PHP, no attributes
ā āāā ValueObject/
ā ā āāā {ValueObject}.php # Validation logic here
ā āāā Repository/
ā ā āāā {Entity}RepositoryInterface.php
ā āāā Event/
ā ā āāā {Event}.php
ā āāā Exception/
ā āāā {Exception}.php
āāā Application/
ā āāā Command/
ā ā āāā {Action}{Entity}Command.php
ā āāā CommandHandler/
ā ā āāā {Action}{Entity}CommandHandler.php
ā āāā EventSubscriber/
ā āāā {Event}Subscriber.php
āāā Infrastructure/
āāā Repository/
āāā {Entity}Repository.php
ā
No violations found
After implementing DDD patterns:
CommandInterfaceCommandHandlerInterfaceDomainEventSubscriberInterfacemake deptrac shows zero violationsmake ci passesFor detailed patterns, workflows, and examples:
// ā BAD: Logic in handler
class CreateCustomerHandler {
public function __invoke($command) {
if (strlen($command->name) < 2) { // ā Validation in handler!
throw new Exception();
}
// ...
}
}
// ā BAD: Symfony in Domain
use Symfony\Component\Validator\Constraints as Assert;
class Customer {
#[Assert\NotBlank] // ā Framework coupling!
private string $name;
}
// ā BAD: Just getters/setters
class Customer {
public function setName(string $name): void {
$this->name = $name; // No business rules!
}
}
This project follows CodelyTV's hexagonal architecture patterns:
See: DIRECTORY-STRUCTURE.md for complete hierarchy.