Skip to content

About

Canonical demonstration of elriseio/application-layer-bundle on the Tariff bounded context.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

demo_application_layer

A runnable Symfony application that demonstrates the CQRS-shaped DDD application-layer pattern in the smallest possible code surface. The repository is the canonical "how to adopt" example: it shows the architectural shape, the boundaries between layers, the contract each layer implements, and the practical mechanics of adding a new use-case end-to-end.

A. Project name & description

demo_application_layer is a one-bounded-context Symfony 7.2 / PHP 8.3 application that codifies a single architectural pattern: the application layer is the seam between transport and domain. HTTP controllers, console commands, and any future transport (API Platform, RPC, Messenger) stay thin adapters; the use-case lives in a CommandHandler or QueryHandler that takes an immutable DTO and returns a view DTO. The pattern is supported by the elriseio/application-layer-bundle package, which provides the contracts, the tagged locators, the request-to-DTO pipeline, and the optional queue dispatcher. This repository is the working example: the rule, the code shape, and the boundary.

B. Executive summary

Aspect Value
Pattern CQRS-shaped DDD with an explicit Application Layer
Demo goal "How to adopt" — concrete code, not theory
Language / framework PHP 8.3 / Symfony 7.2
Bounded context Tariff (one aggregate, six use-cases)
Persistence In-memory repository (the pattern is persistence-agnostic)
Transports wired HTTP (JSON) + Symfony Console, both converging on the same handler
Tooling PHPUnit 11 (4 testsuites), PHPStan 6, php-cs-fixer (@Symfony + @PHP82Migration)
Maturity Demo, drafted. Code lives in src/, tests not yet written

C. Quick start

make up                          # build & start php-fpm + nginx + postgres
make sh                          # open a shell in the PHP container
> php bin/console list tariff

# HTTP
curl -sS -X POST http://localhost:8080/api/tariffs \
  -H 'Content-Type: application/json' \
  -d '{"code":"BASIC","name":"Basic plan","pricePerUnitCents":990,"currency":"EUR"}'
curl -sS http://localhost:8080/api/tariffs
curl -sS http://localhost:8080/api/tariffs/BASIC
curl -sS -X PATCH http://localhost:8080/api/tariffs/1/price \
  -H 'Content-Type: application/json' \
  -d '{"newPricePerUnitCents":1290,"currency":"EUR"}'
curl -sS -X POST http://localhost:8080/api/tariffs/1/activate
curl -sS -X POST http://localhost:8080/api/tariffs/1/deactivate

# Console
> php bin/console tariff:create --code=BASIC --name="Basic plan" --price-per-unit-cents=990 --currency=EUR
> php bin/console tariff:list

make down   # stop containers
make clean  # stop + delete volumes

D. The pattern

Three commitments:

  1. Transport does not call the domain. Controllers and console commands translate payloads into immutable DTOs and forward them. They never touch a repository, an entity, or a service.
  2. The use-case is a handler. Every endpoint maps to one handler that implements CommandHandlerInterface (mutates state, returns a result) or QueryHandlerInterface (returns read-side data, no side effects).
  3. The boundary is an immutable DTO. final readonly class with constructor-promoted properties. No setters, no mutable arrays.

What this gives the project

  • Use-cases are unit-testable in isolation — a handler is one dependency, dispatch a DTO, assert the result.
  • Intent is explicit — "this endpoint mutates" vs "this endpoint reads" is visible at the handler's contract.
  • Transport is replaceable — moving an endpoint from HTTP to Messenger is a new adapter that calls the same handler with the same DTO.
  • The bundle is replaceable — the contracts are public, the pipeline is small; the bundle provides the locators, conversion, and the optional queue dispatcher.

The four layers

   Interface   ──►  Application  ──►  Domain   ◄── Infrastructure
   (HTTP, CLI)        (DTO + handlers)    (model + VO)    (repo impl)
  • Domain (src/Domain/). Tariff aggregate, Money / Currency value objects, TariffRepositoryInterface contract. Zero framework dependencies.
  • Application (src/Application/). Immutable DTOs (Command / Query), handlers, TariffView projection. No state.
  • Infrastructure (src/Infrastructure/). InMemoryTariffRepository. The only stateful component, behind the repository interface.
  • Interface (src/Interface/). TariffController for HTTP, CreateTariffConsoleCommand / ListTariffsConsoleCommand for console. Both routes converge on the same handler.

The pipeline

HTTP request or console input
        │
        ▼
  Controller / ConsoleCommand   ← builds the Request, declares the DTO + handler
        │
        ▼
  DtoRequestHandler              ← the seam (provided by the bundle)
        │
        ├── sanitize  (commands only; optional)
        ├── convert   (Request → immutable DTO via Symfony Serializer)
        ├── resolve   (tagged locator: app_layer.command_handler | app_layer.query_handler)
        ├── invoke    (handler->handle(Request, DTO))
        └── dispatch  (optional queue dispatch, commands only)

The six use-cases

Use-case Type HTTP Console
Create a tariff Command POST /api/tariffs tariff:create
Update price Command PATCH /api/tariffs/{id}/price —
Activate Command POST /api/tariffs/{id}/activate —
Deactivate Command POST /api/tariffs/{id}/deactivate —
Find by code Query GET /api/tariffs/{code} —
List active Query GET /api/tariffs tariff:list

E. Adding a new use-case

Touching one file per layer. Order matters.

1. DTO (src/Application/Command/):

final readonly class RenameTariffCommand
{
    public function __construct(
        public int $tariffId,
        public string $newName,
    ) {
    }
}

2. Handler (src/Application/Handler/Command/):

final readonly class RenameTariffHandler implements CommandHandlerInterface
{
    public function __construct(
        private TariffRepositoryInterface $repository,
    ) {
    }

    public function handle(Request $request, object $command): TariffView
    {
        \assert($command instanceof RenameTariffCommand);

        $tariff = $this->repository->findById($command->tariffId)
            ?? throw new \DomainException(sprintf('Tariff with ID "%d" was not found.', $command->tariffId));

        if ($command->newName === '') {
            throw new \InvalidArgumentException('Tariff name cannot be empty.');
        }

        $this->repository->save($tariff);

        return TariffView::fromEntity($tariff);
    }
}

3. Tag (config/services.yaml):

    App\Application\Handler\Command\RenameTariffHandler:
        tags: ['app_layer.command_handler']

4. HTTP route (src/Interface/Http/TariffController.php):

    #[Route('/{id}/rename', methods: ['POST'], requirements: ['id' => '\d+'])]
    public function rename(int $id, Request $request): JsonResponse
    {
        $request = $this->withTariffId($request, $id);

        try {
            $view = $this->dtoRequestHandler->dispatchCommand(
                request: $request,
                commandFqcn: RenameTariffCommand::class,
                handlerFqcn: RenameTariffHandler::class,
            );
        } catch (RequestException $e) {
            return new JsonResponse(['error' => $e->getMessage(), 'details' => $e->getDetails()], 400);
        } catch (\DomainException $e) {
            return new JsonResponse(['error' => $e->getMessage()], 404);
        }

        \assert($view instanceof TariffView);

        return new JsonResponse($view, 200);
    }

5. (Optional) Console command in src/Interface/Console/ — same shape as CreateTariffConsoleCommand.

6. Run the gate:

make check

No new dependencies, no new abstractions, no new files beyond the layer that owns the change.

F. Development workflow

Make targets

Command What it does
make up / make down / make clean Stack lifecycle
make sh / make logs / make restart Container access
make test 4-testsuite PHPUnit run
make stan PHPStan
make cs / make cs-fix php-cs-fixer (dry run / apply)
make check cs:check + stan + test (full gate)

Local (no-Docker) fallback

composer install
APP_ENV=dev APP_SECRET=local php -S 127.0.0.1:8000 -t public

Conventions

  • Domain getters are noun-style (code(), pricePerUnit(), isActive()); mutators are verbs (changePrice(), activate(), deactivate()).
  • Handlers are final readonly class in src/Application/Handler/{Command,Query}/.
  • DTOs are final readonly class with constructor-promoted properties and zero behavior.
  • Controllers are thin. Build the Request, delegate to DtoRequestHandler, translate the result. Domain exceptions map to 404, RequestException to 400.
  • Identity is repository-only. Tariff::assignId() is not part of the public domain API.

Style & static analysis

  • .php-cs-fixer.php runs @Symfony, @Symfony:risky, @PHP82Migration.
  • phpstan.neon is level 6 across src/ and tests/.
  • composer check runs the canonical local gate.

G. Codebase tour

Path Contents
src/Domain/ Tariff aggregate, Money / Currency value objects, TariffRepositoryInterface
src/Application/Command/ 4 commands
src/Application/Query/ 2 queries
src/Application/View/ TariffView projection
src/Application/Handler/Command/ 4 command handlers
src/Application/Handler/Query/ 2 query handlers
src/Infrastructure/Repository/ InMemoryTariffRepository
src/Interface/Http/ TariffController
src/Interface/Console/ CreateTariffConsoleCommand, ListTariffsConsoleCommand
src/Kernel.php Symfony kernel; registers AppLayerProcessorExtension
config/bundles.php AppLayerBundle + FrameworkBundle + dev tooling
config/services.yaml Autowire + handler tags + repo alias
config/packages/framework.yaml Framework configuration
config/routes/attributes.yaml Attribute-based route loader
config/packages.test/ Test-only doctrine + messenger
config/packages.unused/ Disabled Symfony bundles (kept for sibling demos; do not enable without a reason)
docker/php/ PHP 8.3 FPM Dockerfile + php.ini + www.conf
docker/nginx/ nginx vhost
compose.yaml php + nginx + postgres stack
bin/console / public/index.php Console / HTTP entry points
phpunit.xml.dist 4 testsuites (Unit, Functional, Integration, StaticAnalysis)
phpstan.neon PHPStan level 6
.php-cs-fixer.php @Symfony + @Symfony:risky + @PHP82Migration

Start reading here

  1. src/Domain/Model/Tariff.php — aggregate root (private constructor, explicit mutations, ID assigned by repository only).
  2. src/Application/Handler/Command/CreateTariffHandler.php — canonical handler shape.
  3. src/Interface/Http/TariffController.php — thin-controller principle.
  4. vendor/elriseio/application-layer-bundle/src/Handler/DtoRequestHandler.php — the seam.

H. Testing

phpunit.xml.dist declares four testsuites; tests/ currently holds only bootstrap.php. Tests are not yet written.

Testsuite Intended scope
Unit Pure domain tests (Tariff invariants, Money arithmetic)
Functional Single-handler tests in isolation against the in-memory repo
Integration Boot the kernel, hit HTTP / console, assert end-to-end payloads
StaticAnalysis PHPUnit-level assertions over the codebase
make test     # vendor/bin/phpunit
make stan     # PHPStan
make check    # cs:check + stan + test

Tests run under APP_ENV=test (framework.test: true, DATABASE_URL=sqlite:///:memory:). beStrictAboutOutputDuringTests, failOnRisky, failOnWarning are all on.

I. Known limitations

This is a demo. Out of scope:

  • No tests yet. Adding a Unit test for Tariff::create and a Functional test for CreateTariffHandler would be the cheapest first step.
  • No DBAL persistence. InMemoryTariffRepository is the only concrete repo; state is lost on every container restart.
  • No API Platform state provider / processor pattern. That has its own demo; the integration is a Provider / Processor that delegates to the same handler.
  • No PATCH / Placeholder sentinel pattern. The PATCH endpoint here uses a flat DTO with explicit fields.
  • No queue dispatch. The bundle's dispatchToQueue: true path is not exercised; the optional Messenger integration is wired but unused.
  • No CSRF / session / security. The HTTP API is open.

J. License & contributing

MIT. The bundle it depends on (elriseio/application-layer-bundle) is also MIT, by AlexK <alexk@elrise.io>.

Contributions follow the standard Symfony flow: open a feature branch, run make check locally, submit a PR. New use-cases follow the established template (DTO + handler + optional tariff: console command + HTTP route). New handlers MUST be tagged with the matching app_layer.* locator tag in config/services.yaml. Do not introduce a new transport without first reading the boundary rules in section D.

About

Canonical demonstration of elriseio/application-layer-bundle on the Tariff bounded context.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages