ADR-0006: Adopt Hexagonal Architecture For YourBOX Domain Layers
Status
Section titled “Status”Accepted
Context
Section titled “Context”The legacy YourBOX codebase mixes domain logic, HTML rendering, database access and HTTP
handling in the same files. The Vote plugin builds SQL strings, the Comments plugin
writes to $_SESSION, the search code formats HTML inside the search function, and the
upload handler decodes form data while moving uploaded files. Testing any piece of
business logic requires booting half the application.
ADR-0002 introduces Symfony as the framework and ADR-0003 introduces Doctrine and
Meilisearch as adapters. Without a domain boundary, the framework choice leaks into the
business code and the same problem repeats after the migration. The modernization
explicitly targets a hexagonal architecture and a codebase that does not require
require_once everywhere.
Decision
Section titled “Decision”YourBOX organizes its application code into four bounded contexts: Catalog (videos),
Engagement (comments and votes), Identity (users, profile and roles) and Moderation
(reports and admin actions). Each context follows the same hexagonal layout under
app/src/<Context>/:
Domain/holds entities, value objects, domain services and domain events. No Symfony, no Doctrine, no HTTP class is referenced. Methods carry the business invariants.Application/holds use cases as command and query handlers. It depends on theDomain/layer and on outbound port interfaces declared inside the same layer, but it does not know the framework.Infrastructure/holds the adapters: Doctrine repositories, Meilisearch index writers, Flysystem storage adapters, Mailer adapters, Authentik claim mappers. Adapters implement the outbound ports declared inApplication/.UserInterface/holds the Symfony controllers, Twig templates, command line entry points and form types. Controllers are thin: they parse input, dispatch a command or a query through Symfony Messenger, render the response.
The buses are Symfony Messenger transports. The command bus is synchronous by default; the event bus is asynchronous and dispatches to one or more handlers (for example, a video publication event triggers a Meilisearch reindex command).
Cross context communication goes through published domain events only. A bounded context
never depends on another context’s Domain/ or Application/ layer directly.
Each context owns its Doctrine mapping. The schema migrations live under
app/migrations/ but the metadata files live under
app/src/<Context>/Infrastructure/Doctrine/Mapping/. This makes future extraction of a
context into a separate service feasible without code relocation.
The legacy YourBOX/ tree is not refactored into hexagonal layers. As routes are
rewritten, their code moves into the matching bounded context and the legacy file is
deleted, per the strangler procedure documented in the YourBOX service pages.
Consequences
Section titled “Consequences”- Domain unit tests run without booting Symfony, without a database, and without the filesystem. The test suite gets faster and more readable.
- Replacing an adapter (for example, swapping MinIO for a different object store) does not touch the domain or application layers.
- Reviewers can spot framework leakage by grepping for Symfony imports under
Domain/orApplication/. A PHPStan baseline forbids those imports. - Adding a new bounded context follows a known shape, which reduces architectural drift.
- The codebase grows in file count. The benefit is paid back the first time the application needs to add a new query path or replace an integration.
- Cross context contracts are limited to a small list of published events. Adding a new event is a deliberate decision rather than a cross context method call snuck in by accident.