0031 - Adopt DDD, Hexagonal Architecture, and CQRS for Python Domain Services¶
Date¶
2026-05-11
Status¶
Proposed
Context¶
BetterFleet Python services increasingly need a consistent structure for domain behaviour, persistence, API boundaries, events, and tests.
bf-manage-core already contains detailed DDD and hexagonal architecture guidance in docs/DDD/, including a target layering model with domain, service, ports, adapters, API, CQRS command/query split, unit of work, repositories, domain events, event store, outbox, and projections. The local bf-python-ddd-architecture skill also encodes the same rules for implementation and review work.
The current cross-product ADR set records related decisions such as repository-based persistence, domain events, event bus direction, service granularity, database choices, and repository testing. It does not yet record the wider architectural direction that links those decisions together.
Without a cross-product ADR, new services and substantial refactors can drift into inconsistent layering, direct ORM access from business logic, thin domain models, router-heavy workflows, or mixed command/query paths.
Decision¶
We will adopt Domain-Driven Design, hexagonal architecture, and CQRS as the default architectural direction for new Python domain services and substantial Python domain refactors.
For this decision:
- Domain code owns aggregates, entities, value objects, domain events, invariants, and state transitions.
- Domain code must not depend on FastAPI, Pydantic API DTOs, ORM models, database sessions, adapter implementations, or infrastructure clients.
- Service command code coordinates use cases through repository and unit-of-work ports. Business rules stay in domain objects.
- Service query code reads through query ports and returns read models or DTOs, not aggregates.
- Ports define task-focused contracts for repositories, queries, unit of work, event stores, outboxes, event buses, and external lookups.
- Adapters implement ports and contain persistence, ORM, external API, event bus, and infrastructure details.
- API routers stay thin: they handle HTTP concerns, auth wiring, request/response DTOs, dependency injection, and translation to service calls.
- Commands and queries are separated. Commands mutate aggregates through command-side repositories or unit-of-work ports. Queries use read-optimized query ports or projections.
- State-sourced aggregates are the default persistence pattern.
- Event sourcing is used selectively where audit history, replay, dispute handling, or time-ordered domain behaviour justify the extra cost.
- State-sourced aggregates may emit domain events for projections and downstream reactions.
- Reliable asynchronous publication should use an outbox-style flow rather than direct publication from routers or domain objects.
- Cross-context interaction should use domain events, anti-corruption adapters, or explicit ports rather than direct imports of another context's aggregates or persistence models.
- Tests should follow the same boundaries: domain tests for invariants, service tests with fake ports/adapters, adapter tests for persistence mapping, API tests for HTTP translation, and event-handler tests for idempotency and projection behaviour.
This ADR sets the cross-product direction. Detailed naming, placement, import, event, and testing rules remain in bf-manage-core/docs/DDD/ and the bf-python-ddd-architecture skill.
Legacy services are expected to migrate incrementally. This decision does not require broad rewrites without a product or technical reason.
Consequences¶
New Python domain work has a clearer default structure and a shared review standard.
Domain behaviour becomes easier to test without databases, HTTP frameworks, or external services.
Persistence, API, and infrastructure choices can change with less impact on domain and service code.
CQRS lets read paths use simpler, read-optimized models while command paths keep invariants in aggregates.
Event-driven work aligns with 0023 - Domain Event-Driven Architecture and 0024 - Domain Event Bus Tech Stack, while repository work aligns with 0018 - Repository Pattern for Database Access.
The approach adds more explicit files, ports, adapters, and tests than simple CRUD code. Small scripts, prototypes, or low-risk glue code may not need the full structure.
Teams need to be deliberate when applying this to existing services. Incremental migration should keep production paths stable and avoid mixed patterns inside one aggregate boundary.
If this ADR is accepted, future Python service designs and reviews should treat unexplained violations of these boundaries as architecture issues.