Domain Layer Policy
NENE2 separates framework infrastructure (HTTP runtime, DI, config, database adapters) from application logic (use cases, repositories, domain rules). This document defines the conventions for the application layer that sits between HTTP handlers and database adapters.
Position
The domain layer is the set of use cases and repository interfaces that express what the application does, independent of how requests arrive or how data is stored.
text
HTTP handler (thin)
→ UseCase (application logic, business invariants)
→ RepositoryInterface (data access contract)
→ PdoRepositoryAdapter (persistence detail)Framework infrastructure lives in src/. Application-specific use cases and repository interfaces should live in a namespace the client project controls. NENE2 provides conventions and minimal working examples; it does not force a namespace on application code.
UseCase Convention
A use case expresses one application operation. It receives a readonly input DTO, enforces business invariants, and returns a typed output.
Interface shape
php
interface CreateItemUseCaseInterface
{
public function execute(CreateItemInput $input): CreateItemOutput;
}Rules:
- One method per use case interface, always named
execute. - Input and output are typed readonly DTOs, never raw arrays or PSR-7 objects.
- The interface lives next to or above its adapters, not inside a framework directory.
- Use cases may throw domain-specific exceptions for invariant violations that callers must act on.
- Use cases do not know about HTTP, sessions, templates, or queues.
- Use cases do not call the PSR-11 container directly.
Input DTO
php
final readonly class CreateItemInput
{
public function __construct(
public string $name,
public int $year,
) {
}
}readonlyandfinalby default.- Constructor receives already-validated values; format validation happens in the handler before calling the use case.
- Business invariants (uniqueness, state rules) are checked inside the use case, not here.
Output DTO
php
final readonly class CreateItemOutput
{
public function __construct(
public int $id,
public string $name,
public int $year,
) {
}
}- Carry only what callers need. Do not expose persistence IDs or internal state unless the caller requires them.
- Return a typed output even for side-effectful operations; callers should not reach into repositories for the result.
Implementation
php
final class CreateItemUseCase implements CreateItemUseCaseInterface
{
public function __construct(
private readonly ItemRepositoryInterface $items,
) {
}
public function execute(CreateItemInput $input): CreateItemOutput
{
if ($this->items->existsByName($input->name)) {
throw new ItemAlreadyExistsException($input->name);
}
$id = $this->items->save(new Item(name: $input->name, year: $input->year));
return new CreateItemOutput(id: $id, name: $input->name, year: $input->year);
}
}- Constructor injection only.
- No
newcalls for dependencies that need to be testable. - No database transactions here unless the use case owns the boundary; transactions belong in the adapter or a transaction manager service.
Repository Interface Convention
A repository interface describes a data access contract for one aggregate or domain concept. Adapters implement it.
Interface shape
php
interface ItemRepositoryInterface
{
public function findById(int $id): ?Item;
public function existsByName(string $name): bool;
public function save(Item $item): int;
}Rules:
- Methods use domain terms, not SQL verbs.
findById, notselectById. - Return types use domain objects or primitives, not PDO result rows or raw arrays.
- Nullable return (
?Item) instead of throwing for "not found" when absence is a valid case; throw only when absence signals a programming error. - Interfaces live in the application namespace, not in
src/Database/.
Domain object
Use a small readonly class for the aggregate root when persistence details should stay inside the adapter:
php
final readonly class Item
{
public function __construct(
public string $name,
public int $year,
public ?int $id = null,
) {
}
}idis nullable before persistence.- Keep domain objects free of ORM annotations or database coupling.
PDO Adapter
php
final class PdoItemRepository implements ItemRepositoryInterface
{
public function __construct(
private readonly DatabaseQueryExecutorInterface $query,
) {
}
public function findById(int $id): ?Item
{
$row = $this->query->fetchOne('SELECT id, name, year FROM items WHERE id = ?', [$id]);
return $row !== null
? new Item(name: $row['name'], year: (int) $row['year'], id: (int) $row['id'])
: null;
}
public function existsByName(string $name): bool
{
return $this->query->fetchOne('SELECT 1 FROM items WHERE name = ?', [$name]) !== null;
}
public function save(Item $item): int
{
return $this->query->insert('INSERT INTO items (name, year) VALUES (?, ?)', [$item->name, $item->year]);
}
}- Use
DatabaseQueryExecutorInterfacefromsrc/Database/, not raw PDO. - All SQL stays inside the adapter. Use cases and domain objects have no SQL.
- Cast database row values to typed PHP values on the way out.
- Adapter class name prefix:
Pdo(e.g.,PdoItemRepository).
Handler (Controller) Boundary
Handlers stay thin. Their job is to map the HTTP request into a use case input, call the use case, and return a response.
php
final class CreateItemHandler
{
public function __construct(
private readonly CreateItemUseCaseInterface $useCase,
private readonly JsonResponseFactory $response,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$body = (array) json_decode((string) $request->getBody(), associative: true);
$input = new CreateItemInput(
name: (string) ($body['name'] ?? ''),
year: (int) ($body['year'] ?? 0),
);
$output = $this->useCase->execute($input);
return $this->response->ok(['id' => $output->id, 'name' => $output->name, 'year' => $output->year]);
}
}Rules:
- Handlers do not contain business logic.
- Format validation and DTO construction happen here; business invariants stay in the use case.
- Handlers do not call repositories directly.
- Handlers receive the use case through constructor injection, typed to the interface.
Code Layout
Framework infrastructure lives in src/ under framework namespaces:
src/
Database/ DB adapter boundaries (interfaces + PDO impls)
DependencyInjection/
Config/
Http/
Middleware/
Routing/
Validation/
Error/
View/
Mcp/Application-specific code (use cases, repositories, domain objects) should live in a directory and namespace that fits the client project. NENE2 example code uses src/ directly until a client project defines its own namespace.
Suggested layout for a client project that extends NENE2:
src/
Item/
CreateItemInput.php
CreateItemOutput.php
CreateItemUseCaseInterface.php
CreateItemUseCase.php
Item.php
ItemRepositoryInterface.php
ItemAlreadyExistsException.php
PdoItemRepository.php
CreateItemHandler.phpGroup by domain concept, not by layer type. Avoid UseCases/, Repositories/, Handlers/ top-level directories that scatter a single concept across unrelated files.
PSR-11 Container Wiring
Register use cases and repositories in a focused service provider:
php
final class ItemServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder): void
{
$builder->bind(ItemRepositoryInterface::class, static function (ContainerInterface $c): ItemRepositoryInterface {
return new PdoItemRepository($c->get(DatabaseQueryExecutorInterface::class));
});
$builder->bind(CreateItemUseCaseInterface::class, static function (ContainerInterface $c): CreateItemUseCaseInterface {
return new CreateItemUseCase($c->get(ItemRepositoryInterface::class));
});
$builder->bind(CreateItemHandler::class, static function (ContainerInterface $c): CreateItemHandler {
return new CreateItemHandler(
$c->get(CreateItemUseCaseInterface::class),
$c->get(JsonResponseFactory::class),
);
});
}
}Rules:
- Bind interfaces, not concrete classes, as service identifiers where test substitution matters.
- Keep providers small and grouped by domain concept.
- Do not use the container as a service locator inside use cases or domain objects.
- Register the provider in
src/Http/RuntimeContainerFactory.phpor the equivalent bootstrap path.
Testing
Use case unit tests
Use case tests run without a database. Inject a test double that implements the repository interface.
php
final class CreateItemUseCaseTest extends TestCase
{
public function test_throws_when_item_name_already_exists(): void
{
$items = new InMemoryItemRepository([new Item(name: 'duplicate', year: 2026, id: 1)]);
$useCase = new CreateItemUseCase($items);
$this->expectException(ItemAlreadyExistsException::class);
$useCase->execute(new CreateItemInput(name: 'duplicate', year: 2026));
}
public function test_returns_output_with_new_id(): void
{
$items = new InMemoryItemRepository([]);
$useCase = new CreateItemUseCase($items);
$output = $useCase->execute(new CreateItemInput(name: 'new-item', year: 2026));
$this->assertSame('new-item', $output->name);
}
}An InMemoryItemRepository implements ItemRepositoryInterface and uses an in-memory array. It lives in tests/ and is never shipped with production code.
Repository adapter integration tests
Adapter tests exercise real SQL against the test database. Use the focused database test command:
bash
docker compose run --rm app composer test:databaseFor adapter tests that require a service database:
bash
docker compose up -d mysql
docker compose run --rm app composer test:database:mysqlAdapter test classes extend the project's database test case (see docs/development/test-database-strategy.md). They test SQL correctness, type casting, and edge cases that cannot be covered by in-memory doubles.
Error Handling
- Throw named domain exceptions for business invariant violations (
ItemAlreadyExistsException,ItemNotFoundException). - Map domain exceptions to Problem Details at the HTTP error boundary, not inside use cases.
- Add a mapping entry in
src/Error/ErrorHandlerMiddleware.phpor the equivalent error handler. - Do not expose SQL errors, stack traces, or internal identifiers in error responses.
Non-Goals
- Active record or Eloquent-style models.
- Automatic code generation from OpenAPI or database schemas.
- CQRS, event sourcing, or saga patterns in the first pass.
- Dependency injection by reflection or annotation.
- Service locator calls inside use cases or domain objects.
- Business logic inside middleware or router callbacks.
Related Work
- Coding standards:
docs/development/coding-standards.md - Request validation policy:
docs/development/request-validation.md - Database adapter boundaries:
src/Database/ - Database test strategy:
docs/development/test-database-strategy.md - Dependency injection policy:
docs/development/dependency-injection.md - Endpoint scaffold workflow:
docs/development/endpoint-scaffold.md - Client project start guide:
docs/development/client-project-start.md - GitHub Issue:
#182