Best Practices — DDD & Hexagonal Architecture
Key points reference. This document summarises the design rules and principles behind the seedwork packages. It is intentionally concise — each section captures the essential constraints. For deeper context, see the references at the end.
1. Hexagonal Architecture — Fundamental Rules
The dependency rule is the single constraint that everything else follows from:
Domain ← Application ← Infrastructure
- Domain layer — pure business logic. No framework, no database, no HTTP. Depends on nothing outside itself.
- Application layer — orchestration and use cases. Depends on domain types and abstract ports (interfaces/protocols). No concrete infrastructure.
- Infrastructure layer — implements ports. Depends inward. Never imported by domain or application.
Ports are owned by the inner layer. A port is an interface or protocol defined in domain or application. The adapter that implements it lives in infrastructure. The inner layer dictates the contract; the outer layer conforms.
Do
- Keep the dependency arrow always pointing inward.
- Define ports in the layer that needs them, not the layer that implements them.
Don't
- Import infrastructure types from application or domain.
- Let framework annotations or database types leak into domain objects.
2. Domain Components
2.1 Entity
An entity has identity. Two entity instances with the same ID are the same entity regardless of their other fields. Equality is identity-based, not structural.
Key points
- Identity is established at construction and never changes.
- State changes only through explicit behaviour methods — no public setters.
- Behaviour methods return new instances (immutability). They never mutate
this/self. - Invariants are enforced in the constructor and behaviour methods. Invalid state must be impossible to construct.
Do
- Model meaningful domain operations as named methods (
confirm(),suspend(),assign()). - Throw a
DomainErrorsubclass when an invariant is violated.
Don't
- Expose setters or public mutable fields.
- Put orchestration or persistence logic inside an entity.
2.2 Value Object
A value object has no identity. Two instances with the same field values are equal. They are always immutable.
Key points
- Equality is structural (all fields must match).
- Validated at construction — a value object is either valid or it cannot exist.
- Replaces primitive obsession: prefer
Money,Email,DateRangeover raw scalars. - Self-contained: carries its own validation and behaviour (e.g.
Money.add(other)).
Do
- Use value objects for any concept defined entirely by its attributes.
- Validate inside the constructor — throw
DomainErroron invalid input.
Don't
- Use a value object for a concept that needs to be tracked over time — that is an entity.
- Share mutable state between value objects.
2.3 Aggregate
An aggregate is a consistency boundary. It groups one or more entities and value objects under a single root (the aggregate root) that enforces all invariants that span the group.
Key points
- The aggregate root is the only entry point. External code never modifies inner entities directly.
- All state changes go through behaviour methods on the root.
- Reference other aggregates by ID only — never hold object references across aggregate boundaries.
- Behaviour methods return new instances and append domain events.
- Keep aggregates small. A large aggregate with many inner entities is almost always a sign of a missing bounded context boundary.
Do
- Design the aggregate around the business invariants it must enforce.
- Use
reconstitute(or equivalent) static factory when loading from persistence — no events.
Don't
- Put infrastructure or application logic inside the aggregate.
- Let an aggregate grow to cover unrelated concepts — split it.
2.4 Domain Event
A domain event is an immutable fact that something meaningful happened within the domain.
Key points
- Named in past tense, using business language:
OrderConfirmed,PaymentProcessed,CustomerUpgradedToPremium. - Emitted by the aggregate root after a state change.
- Payload contains primitives only — no value object instances, no aggregate references.
- Processed synchronously within the same transaction. Domain event handlers run before the transaction commits.
- Scope: in-process, same bounded context.
Do
- Apply the business-language test: would a non-technical stakeholder understand this event name without explanation?
- Keep payloads minimal and serializable.
Don't
- Name events after technical operations (
UserUpdated,RecordCreated). - Use domain events to communicate across bounded context boundaries — use integration events for that.
- Embed value objects or entities in the payload.
2.5 Domain Service
A domain service encapsulates domain logic that does not naturally belong to a single aggregate or value object.
Key points
- Stateless — no mutable fields, no persistence.
- Operates on domain objects passed to it as arguments.
- Justified when: the logic involves multiple aggregates, or requires a read from a domain port to make a domain decision.
- Should be rare. If most of your logic lives in services rather than aggregates, you have an anemic domain model.
Do
- Keep domain services slim and focused on a single coordination concern.
- Name them after the domain concept they represent, not the operation (
PricingPolicy,TransferAuthority).
Don't
- Use domain services as a dumping ground for logic that belongs in aggregates.
- Inject infrastructure adapters into a domain service — use ports.
- Make domain services stateful.
2.6 Repository
A repository is the outbound domain port for aggregate persistence. It abstracts the storage mechanism behind a collection-like interface.
Key points
- Only identity-based operations:
find_by_id,save,delete_by_id. - Returns full domain aggregates — not DTOs, not raw rows.
- One repository per aggregate root.
- The interface is defined in the domain layer; the implementation lives in infrastructure.
Do
- Keep the repository interface minimal — only what the domain actually needs.
- Return
Nonefromfind_by_idwhen not found — let the caller decide.
Don't
- Add query methods to the domain repository (
findByEmail,findByStatus). Use a separate read repository in the application layer. - Implement business logic inside the repository.
- Let the repository return partial aggregates or raw data structures.
2.7 Domain Ports (Outbound)
Domain ports are abstract interfaces owned by the domain or application layer that allow inner layers to interact with the outside world without depending on it.
Key points
- Defined in the layer that needs them (domain or application). Implemented in infrastructure.
- Read ports — no side effects. Can be called outside of a transaction. Examples: ACL adapters that query external services, read repositories.
- Write ports — have side effects. Must participate in the transaction if they mutate state. Examples:
Repository.save,IntegrationEventPublisher(via outbox),TaskScheduler(via outbox). - The Anti-Corruption Layer (ACL) pattern translates external models into domain models at the port boundary. The adapter calls the external service; a parser maps the response to domain types.
Do
- Define ports at the granularity of a single concern.
- Use ACL parsers to isolate external model changes from the domain.
Don't
- Mix read and write responsibilities in a single port.
- Let external data structures cross the port boundary into the domain.
3. Application Components
3.1 Command and CommandHandler
A command expresses intent to change state. The handler orchestrates the operation.
Key points
- One command per write use case.
- Commands are plain frozen dataclasses — they carry the data needed to perform the operation.
- The handler's responsibility is orchestration only: load aggregate → call domain method → save. No business logic in handlers.
- The handler returns nothing — outcome is communicated via
Resultby the command bus.
Do
- Keep handlers thin. If a handler contains conditionals over domain state, that logic probably belongs in the aggregate.
Don't
- Put business rules in the handler.
- Return data from a command handler — use a query for that.
3.2 Query and QueryHandler
A query is a request for data with no side effects.
Key points
- Returns
TResult | None— a result orNonewhen absent. - Query handlers are strictly read-only. No state changes, no command dispatching.
- Use read repositories (ad-hoc ports defined in the application layer), not the domain repository. The domain repository loads full aggregates; queries often need projections.
- Returns DTOs (plain data objects with primitive fields) — never domain entities.
Do
- Define a dedicated read port per query when the projection differs from the aggregate.
- Return
Nonefor both not-found and unauthorized — do not reveal resource existence to unauthorized callers.
Don't
- Load a full aggregate in a query handler just to extract two fields.
- Dispatch commands or trigger side effects from a query handler.
- Return domain aggregates as query results.
3.3 Unit of Work
The Unit of Work defines the transaction boundary for a command execution.
Key points
- Wraps the entire command handler execution: aggregate load, domain method call, save, domain event dispatch — all inside one transaction.
- On commit: all changes persist and domain event handlers execute.
- On rollback: no state persisted, no events published.
- The transaction boundary is the command. Never let a transaction span multiple commands.
Do
- Use
TransactionalCommandBusto apply the Unit of Work transparently — handlers never see it. - Ensure domain event handlers run inside the same transaction.
Don't
- Open a transaction manually in a handler.
- Span a transaction across multiple aggregate roots or multiple commands.
3.4 Application Ports (Outbound)
Two outbound ports at the application layer manage eventual side effects:
IntegrationEventPublisher
- Publishes integration events to external consumers via the outbox pattern.
- The publish call writes to the outbox inside the current transaction. Actual delivery to the broker happens after commit, asynchronously.
- The application layer depends on the interface; infrastructure provides the outbox-backed implementation.
TaskScheduler
- Schedules background tasks for the same service via the outbox pattern.
- Same transactional guarantee: scheduling writes to the outbox inside the transaction.
Key points
- Both ports are write ports but their delivery is eventual — they do not block the command response.
- Transactional atomicity is guaranteed by the outbox: either the aggregate saves and the outbox record is written, or neither happens.
- The application layer is agnostic to the delivery mechanism (broker, worker queue, etc.).
Do
- Call
IntegrationEventPublisher.publishandTaskScheduler.schedulefrom command handlers or domain event handlers — never from the domain layer.
Don't
- Assume synchronous delivery.
- Publish directly to a broker from a handler — always go through the port.
3.5 Result
Result is the value-based error contract for commands.
Key points
- Two states:
ok()(success) andfailed(errors)(failure with a list ofResultError). ResultErrorcarries acode(machine-readable) and adescription(human-readable).- The command bus catches
DomainErrorthrown by handlers and converts them toResult.failed. Handlers never returnResultdirectly. - Infrastructure failures (timeouts, connection drops) propagate as exceptions — they are not wrapped in
Result.
Do
- Inspect
Resultat the entry point (controller, API handler) to decide the HTTP response. - Use error
codevalues for programmatic branching; usedescriptionfor logging and user messages.
Don't
- Throw
DomainErrorand catch it in the handler — let the bus handle the conversion. - Wrap infrastructure exceptions in
Result.
3.6 Error Handling
| Error type | Origin | Handling |
|---|---|---|
DomainError |
Aggregate / domain service | Caught by command bus → Result.failed |
| Infrastructure exception | Repository, external adapter | Propagates — do not catch or wrap |
Key points
- The entry point (controller) only needs to handle
Resultfor domain failures. It never catchesDomainError. - Infrastructure exceptions bubble up to a global error handler.
- Never use exceptions for flow control within the domain — that is what
DomainError→Resultis for.
Do
- Define a
DomainErrorsubclass for each distinct business rule violation with a stablecode.
Don't
- Catch
DomainErrorin handlers. - Return
Result.failedfor infrastructure failures. - Use generic exception types for domain errors.
3.7 Operation Flows
Write operation
sequenceDiagram
participant API
participant Bus as CommandBus stack<br/>(transaction → coordinator → registry)
participant Handler as CommandHandler
participant Agg as Aggregate
participant PubRepo as DomainEventPublishingRepository
participant DeferredBus as DeferredDomainEventBus
participant EH as DomainEventHandler
participant IEP as IntegrationEventPublisher
participant TS as TaskScheduler
Note over API: create command<br/>(domain values validated in __post_init__)
API->>Bus: dispatch(command)
Note over Bus: 1. begin transaction
Bus->>Handler: handle(command)
Handler->>PubRepo: find_by_id(id)
PubRepo-->>Handler: aggregate
Handler->>Agg: behaviourMethod(...)
Agg-->>Handler: new aggregate + domain events
Handler->>PubRepo: save(aggregate)
PubRepo->>DeferredBus: publish(domainEvents)
Note over DeferredBus: events buffered — not dispatched yet
Handler-->>Bus: (returns)
Note over Bus: 2. dispatch deferred events<br/>(DomainEventCoordinatorCommandBus)
Bus->>DeferredBus: dispatch()
DeferredBus->>EH: handle(event)
EH->>IEP: publish(integrationEvent)
Note over IEP: written to outbox (same TX)
EH->>TS: schedule(task)
Note over TS: written to outbox (same TX)
EH-->>Bus: (returns)
Note over Bus: 3. commit transaction
Bus-->>API: Result
Read operation
sequenceDiagram
participant API
participant Bus as QueryBus
participant Handler as QueryHandler
participant ReadRepo as ReadRepository
Note over API: create query<br/>(domain values validated in __post_init__)
API->>Bus: ask(query)
Bus->>Handler: handle(query)
Handler->>ReadRepo: find projection
ReadRepo-->>Handler: DTO | None
Handler-->>Bus: TResult | None
Bus-->>API: TResult | None
4. Domain Events · Integration Events · Background Tasks
This is the most consequential design decision in a service. Using the wrong mechanism introduces either excessive coupling or unnecessary complexity.
4.1 Domain Event
A domain event is an in-process fact within the same bounded context.
| Scope | In-process, same bounded context |
| Consistency | Strong — synchronous, within the same transaction |
| Delivery | Guaranteed — in-process method call |
| Audience | Other aggregates within the same bounded context |
Key points
- Processed by
DomainEventHandlerimplementations registered on theDomainEventBus. - Handlers run within the same transaction as the command — if the transaction rolls back, their effects are not persisted.
- The
DomainEventPublishingRepositorydecorator publishes events automatically aftersave. Handlers are unaware of the publishing mechanism. - The deferred bus (
DeferredDomainEventBus) buffers events and dispatches them after the command handler completes but before the transaction commits. Idempotent by event ID — saving the same aggregate twice in one transaction does not duplicate events.
Do
- Use domain events for intra-bounded-context reactions: updating a read model, enforcing a cross-aggregate invariant reactively, triggering a follow-up operation within the same service.
Don't
- Use domain events to notify other services or bounded contexts.
- Perform I/O with side effects outside the transaction in a domain event handler.
4.2 Integration Event
An integration event is a public notification that something relevant happened, intended for consumers outside the bounded context.
| Scope | Cross-service, cross-bounded-context |
| Consistency | Eventual — outbox + message broker |
| Delivery | At-least-once (idempotent consumers required) |
| Audience | Other services / bounded contexts |
Key points
- Published via
IntegrationEventPublisher. The outbox pattern guarantees atomicity: the event record is written in the same transaction as the aggregate. - Carries
type,version,correlation_id, and optionallycausation_id. These fields are mandatory because integration events are a public, versioned contract. - Schema evolution: add optional fields (backwards-compatible). Never rename, remove, or change the type of existing fields.
- Consumers must be idempotent — at-least-once delivery is the default guarantee of any message broker.
Do
- Publish integration events from domain event handlers (reacting to a domain fact).
- Version integration events from day one. Even
v1is a version. - Include
correlation_idfrom the originating command.
Don't
- Assume that publishing an integration event means the consumer received it immediately.
- Change the schema of a published integration event without introducing a new version.
- Use integration events for work that stays within the same service — use a background task instead.
4.3 Background Task
A background task is deferred work within the same service.
| Scope | Same service |
| Consistency | Eventual — outbox + internal worker |
| Delivery | At-least-once (idempotent handlers required) |
| Audience | A TaskHandler within the same service |
Key points
- Scheduled via
TaskScheduler. LikeIntegrationEventPublisher, it writes to the outbox inside the transaction. - Use for: long-running operations, retryable work, resource-intensive processing, or anything that should not block the command response.
- The key distinction from integration events: a background task stays within the service and is processed by a
TaskHandlerin the same codebase. No external consumer. - Handlers must be idempotent — the worker may deliver the task more than once.
Do
- Include
correlation_id(andcausation_idwhen applicable) in every task. - Design
TaskHandlerimplementations to be idempotent by task ID.
Don't
- Use background tasks to communicate with other services — that is an integration event.
- Perform fire-and-forget work without the outbox guarantee — if the service crashes between scheduling and execution, the task would be lost.
4.4 Decision Guide
Did something meaningful happen in the domain?
└── YES → Emit a Domain Event
│
├── Does another bounded context need to know?
│ └── YES → Also publish an Integration Event
│ (from a DomainEventHandler)
│
└── Does work need to happen asynchronously within this service?
└── YES → Schedule a Background Task
(from a DomainEventHandler)
Concrete examples
| Scenario | Mechanism |
|---|---|
| Order confirmed → update order read model | Domain Event |
| Order confirmed → notify shipping service | Integration Event |
| Order confirmed → generate PDF invoice (slow) | Background Task |
| Payment processed → update account balance | Domain Event |
| Payment processed → notify customer service | Integration Event |
| User registered → send welcome email (retryable) | Background Task |
4.5 Cross-cutting Concerns
Naming — all three use past tense and business language. The name must be meaningful to a non-technical stakeholder without explanation.
Idempotency — at-least-once delivery is the default. Every domain event handler, integration event consumer, and task handler must handle duplicates. Use the event/task id as the deduplication key.
Traceability — propagate correlation_id from the originating command through every downstream event and task. Use causation_id to record the ID of the event or command that directly caused this one. These two fields are what make distributed traces reconstructable.
5. References
- Evans, E. (2003). Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley. 1
- Vernon, V. (2013). Implementing Domain-Driven Design. Addison-Wesley. 2
- Robert C. Martin, Clean Architecture: A Craftsman's Guide to Software Structure and Design 3
- .NET Microservices: Architecture for Containerized .NET Applications 4
- Harry Percival & Bob Gregory, Architecture Patterns with Python 5
- Cockburn, A. (2005). Hexagonal Architecture. 6