Hexagonal Architecture (Ports & Adapters) and Domain-Driven Design principles. ALWAYS activate when creating domain entities, use cases, repository interfaces, application services, infrastructure adapters, or when the project CLAUDE.md specifies hexagonal/DDD architecture. Use when the user mentions "domain", "use case", "port", "adapter", "repository interface", "application service", "domain entity", "value object", "aggregate", "bounded context", "DDD", "hexagonal", "clean architecture", "archi hexagonale".
The domain is the heart of the application. It knows nothing about databases, frameworks, HTTP, or any infrastructure concern. All external systems talk to the domain through ports (interfaces). Adapters implement those ports.
┌─────────────────────────────────────────────────────┐
│ INFRASTRUCTURE │ ← Frameworks, DB, HTTP, queues, external APIs
│ ┌──────────────────────────────────────────────┐ │
│ │ APPLICATION │ │ ← Use cases, orchestration, DTOs
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ DOMAIN │ │ │ ← Entities, value objects, domain services
│ │ │ Pure business logic — no imports │ │ │ aggregates, domain events, repo interfaces
│ │ └────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Arrows point inward only. Infrastructure depends on Application which depends on Domain. Domain depends on nothing.
Objects with identity that persists over time. Identity is what matters, not attributes.
// Good — plain PHP, no framework imports
final class Lesson
{
private LessonId $id;
private LearnerId $learnerId;
private LessonStatus $status;
private CourseContent $content;
public function __construct(
LessonId $id,
LearnerId $learnerId,
Subject $subject,
) {
$this->id = $id;
$this->learnerId = $learnerId;
$this->status = LessonStatus::Pending;
$this->content = CourseContent::empty();
}
/** @throws LessonAlreadyClosedException */
public function close(): void
{
if ($this->status->isClosed()) {
throw new LessonAlreadyClosedException($this->id);
}
$this->status = LessonStatus::Closed;
$this->recordEvent(new LessonClosed($this->id));
}
public function id(): LessonId { return $this->id; }
public function status(): LessonStatus { return $this->status; }
}
Immutable, no identity — equality is defined by value, not by reference.
final class LessonId
{
public function __construct(private readonly string $value)
{
if (empty($value)) {
throw new \InvalidArgumentException('LessonId cannot be empty');
}
}
public static function generate(): self
{
return new self((string) \Ramsey\Uuid\Uuid::uuid4());
}
public static function from(string $value): self
{
return new self($value);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function toString(): string { return $this->value; }
public function __toString(): string { return $this->value; }
}
enum LessonStatus
{
case Pending;
case Generating;
case Active;
case Closed;
public function isClosed(): bool
{
return $this === self::Closed;
}
public function canReceiveMessages(): bool
{
return $this === self::Active;
}
}
The domain defines what it needs — never how it is stored.
interface LessonRepository
{
public function findById(LessonId $id): ?Lesson;
/** @throws LessonNotFoundException */
public function getById(LessonId $id): Lesson;
/** @return Lesson[] */
public function findByLearner(LearnerId $learnerId): array;
public function save(Lesson $lesson): void;
public function delete(LessonId $id): void;
}
Stateless logic that does not naturally belong to a single entity.
final class CreditAllocationService
{
/** @throws InsufficientCreditsException */
public function ensureSufficientCredits(CreditBalance $balance, int $required): void
{
if ($balance->isLessThan($required)) {
throw new InsufficientCreditsException($balance, $required);
}
}
}
Signal that something meaningful happened in the domain.
final class LessonClosed
{
public function __construct(
public readonly LessonId $lessonId,
public readonly \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
) {}
}
Orchestrate domain objects to fulfill a single business scenario. Receive ports as dependencies (injected via constructor). Return DTOs or primitive values — never domain entities to infrastructure callers.
final class CloseLesson
{
public function __construct(
private readonly LessonRepository $lessons,
private readonly EventDispatcher $events,
) {}
/** @throws LessonNotFoundException */
/** @throws LessonAlreadyClosedException */
public function execute(CloseLessonCommand $command): void
{
$lesson = $this->lessons->getById(
LessonId::from($command->lessonId)
);
$lesson->close();
$this->lessons->save($lesson);
$this->events->dispatch(...$lesson->pullDomainEvents());
}
}
DTOs that carry input data to use cases.
// Command — intent to change state
final class CloseLessonCommand
{
public function __construct(
public readonly string $lessonId,
public readonly string $requestedByUserId,
) {}
}
// Query — intent to read state
final class GetLessonDetailsQuery
{
public function __construct(
public readonly string $lessonId,
) {}
}
Interfaces for everything outside the domain (email, IA, payment…).
interface CourseGenerationService
{
/** @throws CourseGenerationFailedException */
public function generateAsync(GenerateCourseRequest $request): GenerationJobId;
}
interface CreditGateway
{
public function getBalance(LearnerId $learnerId): CreditBalance;
public function deduct(LearnerId $learnerId, int $amount, string $reason): void;
}
Implement domain repository interfaces using the ORM/database of choice.
// Implements the domain port — lives in Infrastructure, not Domain
final class EloquentLessonRepository implements LessonRepository
{
public function findById(LessonId $id): ?Lesson
{
$model = LessonModel::query()->find($id->toString());
return $model ? $this->toDomain($model) : null;
}
public function getById(LessonId $id): Lesson
{
return $this->findById($id)
?? throw new LessonNotFoundException($id);
}
public function save(Lesson $lesson): void
{
LessonModel::query()->updateOrCreate(
['id' => $lesson->id()->toString()],
$this->toModel($lesson),
);
}
private function toDomain(LessonModel $model): Lesson
{
// Map ORM model → domain entity
return Lesson::reconstitute(
LessonId::from($model->id),
LearnerId::from($model->learner_id),
LessonStatus::from($model->status),
);
}
/** @return array<string, mixed> */
private function toModel(Lesson $lesson): array
{
return [
'learner_id' => $lesson->learnerId()->toString(),
'status' => $lesson->status()->name,
];
}
}
Thin. Validate input → call use case → return response. Zero business logic.
final class CloseLessonController
{
public function __construct(private readonly CloseLesson $closeLesson) {}
public function __invoke(CloseLessonRequest $request, string $lessonId): JsonResponse
{
$this->closeLesson->execute(new CloseLessonCommand(
lessonId: $lessonId,
requestedByUserId: $request->user()->id,
));
return response()->json(status: 204);
}
}
Implement application ports for third-party APIs.
final class HttpCourseGenerationService implements CourseGenerationService
{
public function __construct(private readonly BloomIaClient $client) {}
public function generateAsync(GenerateCourseRequest $request): GenerationJobId
{
$response = $this->client->post('/generate/course', [
'learner_id' => $request->learnerId->toString(),
'subject' => $request->subject->value,
]);
return GenerationJobId::from($response['job_id']);
}
}
app/
├── Domain/
│ ├── Lesson/
│ │ ├── Lesson.php # Entity
│ │ ├── LessonId.php # Value Object
│ │ ├── LessonStatus.php # Enum
│ │ ├── LessonRepository.php # Port (interface)
│ │ ├── LessonNotFoundException.php # Domain exception
│ │ └── Events/
│ │ └── LessonClosed.php # Domain event
│ └── Credit/
│ ├── CreditBalance.php
│ ├── CreditGateway.php # Port
│ └── InsufficientCreditsException.php
│
├── Application/
│ ├── UseCases/
│ │ └── CloseLesson/
│ │ ├── CloseLesson.php # Use case
│ │ └── CloseLessonCommand.php # Input DTO
│ └── Ports/
│ └── CourseGenerationService.php # Port for external IA
│
└── Infrastructure/
├── Persistence/
│ ├── Models/
│ │ └── LessonModel.php # ORM model (not a domain entity)
│ └── Repositories/
│ └── EloquentLessonRepository.php
├── Http/
│ ├── Controllers/
│ │ └── CloseLessonController.php
│ └── Requests/
│ └── CloseLessonRequest.php
├── Queue/
│ └── Jobs/
│ └── GenerateCourseJob.php
└── External/
└── BloomIa/
└── HttpCourseGenerationService.php
use Illuminate\..., use Symfony\..., etc.)final and immutable (readonly where possible)Request, Response…)execute())if branches, no domain logic// Service Provider — Infrastructure binding
$this->app->bind(LessonRepository::class, EloquentLessonRepository::class);
$this->app->bind(CourseGenerationService::class, HttpCourseGenerationService::class);
| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
| Domain entity extends ORM model | Couples domain to persistence | Separate domain entity from ORM model, add mapper |
| Controller with business logic | Bypass use case | Extract to use case, controller calls it |
Use case using DB:: or ORM directly | Bypasses repository port | Inject repository interface, use domain model |
| Repository returns ORM model | Leaks persistence to application | Map to domain entity before returning |
| Domain imports framework class | Couples domain to framework | Define a port interface in Domain, implement in Infrastructure |
| Fat domain entity with HTTP context | Domain knows about HTTP | Pass DTOs or primitives, not Request objects |
| God use case doing 10 things | Violates SRP | Split into multiple focused use cases |