← Back to the section · level 3 of 3 · previous: level 2
At Level 2, separate Handler classes appeared for each operation — and that is already a big step. But business rules still live right inside the Handlers, and database and external-call code is mixed with the logic. When there are many important rules, this becomes a problem. Level 3 solves it with two ideas at once: DDD (a domain with explicit rules) and Hexagonal (infrastructure behind ports).
The pain it solves
Imagine your payment service has a rule: you cannot pay for an already cancelled order. If that rule is checked right inside PayOrderHandler, sooner or later a second Handler will appear — say, RetryPaymentHandler — and someone will forget to copy the check into it. The bug quietly slips through review.
The second problem: the payment gateway today is Stripe, tomorrow the customer wants to swap it for another. There is no boundary in the code marking "here the logic ends, here Stripe begins". The swap turns into a search across the whole project.
Level 3 solves both scenarios: the rules move into an aggregate, and the infrastructure hides behind ports.
The aggregate — keeper of the rules
An Aggregate Root is an ordinary class that holds inside itself all the business rules related to a single object. All state changes go only through its methods — never through direct field writes.
Example: Order knows that payment is impossible on a cancelled order. This rule is described once — inside Order.pay(). No matter how many Handlers invoke payment, none of them can bypass it.
public class Order {
private OrderStatus status;
private Money total;
private final List<DomainEvent> events = new ArrayList<>();
public void pay(Money amount) {
if (status == OrderStatus.CANCELLED) {
throw new IllegalStateException("Cannot pay for a cancelled order");
}
if (!amount.equals(total)) {
throw new IllegalArgumentException("Amount does not match");
}
this.status = OrderStatus.PAID;
events.add(new OrderPaid(this.id, amount));
}
public List<DomainEvent> pullEvents() {
var copy = List.copyOf(events);
events.clear();
return copy;
}
}
The Handler then turns into an orchestrator: load the aggregate → call a method → save → publish events. No "you cannot" logic — it all lives inside the aggregate.
public final class PayOrderHandler implements UseCaseHandler<PayOrderCommand, Void> {
private final OrderRepository repo;
private final DomainEventPublisher publisher;
@Override
public Void handle(PayOrderCommand cmd) {
Order order = repo.findById(cmd.orderId()).orElseThrow();
order.pay(cmd.amount()); // rules: status, amount, event registration
repo.save(order);
publisher.publishAll(order.pullEvents());
return null;
}
}
type PayOrderHandler struct {
repo OrderRepository
publisher DomainEventPublisher
}
func (h *PayOrderHandler) Handle(cmd PayOrderCommand) error {
order, err := h.repo.FindByID(cmd.OrderID)
if err != nil {
return err
}
if err := order.Pay(cmd.Amount); err != nil {
return err
}
if err := h.repo.Save(order); err != nil {
return err
}
return h.publisher.PublishAll(order.PullEvents())
}
export class PayOrderHandler implements UseCaseHandler<PayOrderCommand, void> {
constructor(
private readonly repo: OrderRepository,
private readonly publisher: DomainEventPublisher,
) {}
async handle(cmd: PayOrderCommand): Promise<void> {
const order = await this.repo.findById(cmd.orderId);
order.pay(cmd.amount);
await this.repo.save(order);
await this.publisher.publishAll(order.pullEvents());
}
}
from dataclasses import dataclass
@dataclass
class PayOrderHandler:
repo: OrderRepository
publisher: DomainEventPublisher
def handle(self, cmd: PayOrderCommand) -> None:
order = self.repo.find_by_id(cmd.order_id)
order.pay(cmd.amount)
self.repo.save(order)
self.publisher.publish_all(order.pull_events())
Value Object — a type instead of a primitive
Storing money as a BigDecimal number is dangerous: in one place they are added, in another compared without regard to currency. A Value Object is a small immutable class that carries the rules of its own type.
Money knows that different currencies cannot be added. Email knows the string must contain @. OrderId rules out the mix-up of "passing userId instead of orderId" — the compiler won't let it through.
Value Objects are compared by value, not by identity. They have no identifier — two Money(100, "RUB") are equal even if they are different objects.
Domain Event — a fact that happened
When an order is paid, several parts of the system need to know: deduct bonus points, send an email, notify logistics. Instead of calling them all directly from the Handler, the aggregate registers a Domain Event — OrderPaid.
An event is named as a verb in the past tense: OrderPaid, RefundIssued, ItemShipped. It is a fact that has already happened. The Handler publishes it after saving the aggregate.
Ports — the boundary with the outside world
Hexagonal Architecture (also known as "ports and adapters") answers the question: how do you isolate database and external-service code from business logic?
The idea is simple: the logic works through interfaces (ports), while the implementations (adapters) live separately. Want to replace Stripe with another gateway — you change only the adapter, the logic stays untouched.
The folder structure reflects this separation:
core/
domain/ ← aggregates, value objects, events
usecase/ ← UseCase + Handler
port/ ← interfaces (OrderRepository, PaymentGateway, ...)
adapter/
in/rest/ ← REST controller calls the UseCase
in/kafka/ ← Kafka consumer calls the same UseCase
out/postgres/ ← OrderRepository implementation via the database
out/payment/ ← PaymentGateway implementation via an external service
The main rule: core/ knows nothing about infrastructure. No framework annotations, no SQL, no HTTP in core/domain/ or core/usecase/. Dependencies flow strictly inward — from adapters to the core, never the other way around.
A single UseCase can therefore be invoked from several entry points: from REST, from Kafka, and from a scheduler — it knows nothing about that itself.
Boundary checks in CI
The discipline of "don't import infrastructure into the core" is easily broken under deadline pressure. That's why the boundaries are checked automatically:
- Java — ArchUnit:
noClasses().that().resideInAPackage("..core..").should().dependOnClassesThat().resideInAPackage("..adapter..") - Go —
go-arch-lint - Node/TypeScript —
dependency-cruiser - Python —
import-linter
Just 3–5 rules are enough to catch 90% of the cases where someone accidentally reaches into the database straight from a Handler.
When to take this level
Level 3 is justified when at least a few of these conditions hold:
- There are complex rules that must not be broken (negative balance, payment in a closed session).
- Many external integrations that need to be swapped out in tests.
- A single UseCase is invoked from several entry points (REST + Kafka + cron).
- The product is long-lived — the business logic will outlive several changes of infrastructure.
When you should not take it:
- A CRUD service without non-trivial rules.
- An early product stage, the logic is still unstable.
- A small team without DDD experience — the learning curve is steep.
Level 3 doubles the number of classes and requires discipline at review. It is an investment that pays off on a complex, long-term domain. Within a single service, different modules can live at different levels: the business core at level 3, simple reference data at level 1.
In short
- At Level 3, business rules move from the Handler into an aggregate — breaking them from the outside is impossible.
- A Value Object wraps a primitive and carries its rules:
Money,Email,OrderId. - A Domain Event is a past-tense verb (
OrderPaid); the aggregate registers the fact, the Handler publishes it. - A Repository is an interface in
core/port/, the implementation is inadapter/out/. - The Handler at this level is an orchestrator: load → call the aggregate method → save → publish events.
core/knows nothing about infrastructure; the direction of dependencies is inward only.- A single UseCase is invoked from REST, Kafka, and cron — it doesn't know who calls it.
- Boundaries are checked in CI by static analysis (ArchUnit, go-arch-lint, dependency-cruiser, import-linter).
- This is not the "best" level — it's the "necessary one for a complex domain and a long horizon".
Further reading
- DDD tactical patterns — Entity, Value Object, Aggregate, Domain Event, Repository.
- DDD strategic patterns — Bounded Context, Context Map.
- Hexagonal architecture — a detailed breakdown of ports and adapters.
- Distributed patterns — Outbox, Saga, Idempotent Consumer.
- Resilience patterns — what belongs in an outbound-port adapter.