← Back to the section · level 2 of 3 · previous: level 1
At the first level, business operations are dissolved into service classes with dozens of methods. Six months later it is unclear what the service can do, checks drift apart between calls, and metrics and auditing get bolted on by hand every single time.
Level 2 introduces one rule: every business operation is a separate UseCase + Handler. That is enough to stop drowning in service classes and to get a single list of what the service can do.
One operation — two classes
The service used to look roughly like this:
class OrderService {
Order create(...) { ... }
Order findById(...) { ... }
void cancel(...) { ... }
List<Order> list(...) { ... }
// five more methods...
}
Such a class is hard to test in isolation, its metrics make it unclear what relates to what, and errors in one method spill over into its neighbours.
At Level 2, each operation becomes a separate pair of classes:
UseCase— a structure holding the operation's input data (what we want to do).Handler— the execution logic: checks, database access, response.
// Operation input data
record CreateOrderUseCase(String customerId, List<String> items)
implements UseCaseCommand<CreatedOrderResult> {}
// Logic
@Component
class CreateOrderUseCaseHandler
implements UseCaseHandler<CreateOrderUseCase, CreatedOrderResult> {
@Override
@Transactional
public CreatedOrderResult handle(CreateOrderUseCase uc) {
// checks, write to DB, return result
}
}
Creating an order is one pair. Fetching an order by identifier is another. Not "one OrderService with five methods", but five separate pairs.
The controller hands the operation to the dispatcher
The controller knows nothing about the specific handler. It parses the HTTP request, assembles a UseCase object, and passes it to the dispatcher — which finds the right handler by type on its own:
@RestController
class OrderController {
private final UseCaseDispatcher dispatcher;
@PostMapping("/orders")
ResponseEntity<CreatedOrderResult> create(@RequestBody CreateOrderRequest req) {
var result = dispatcher.dispatch(new CreateOrderUseCase(req.customerId(), req.items()));
return ResponseEntity.ok(result);
}
}
Business checks and database access live inside the handler, nowhere else. The controller stays thin: received → passed on → wrapped the response.
Metrics come built in automatically
One of the main advantages of level 2 is that the library automatically records metrics for every UseCase: how many times it was invoked, how many times it failed, how long it took. The labels include the operation name, its type (command or query), and the result status.
You do not need to add metrics by hand in every method — they appear automatically for any new handler.
Handler tests without infrastructure
A handler is an ordinary class with dependencies. It can be tested without starting Spring, without HTTP:
class CreateOrderHandlerTest {
CreateOrderUseCaseHandler handler = new CreateOrderUseCaseHandler(
new InMemoryOrderRepository()
);
@Test
void createsOrder() {
var result = handler.handle(new CreateOrderUseCase("cust-1", List.of("item-a")));
assertThat(result.orderId()).isNotNull();
}
}
This is faster, more reliable, and simpler than testing through the controller.
CQRS — an option at this level
CQRS (separating read and write operations) is not a separate maturity level but an option of Level 2. You turn it on when reads and writes start getting in each other's way: long queries slow the system down, queries need aggressive caching, and commands need strict transactions.
Explicit separation by type. Commands implement the UseCaseCommand marker — they change state. Queries implement UseCaseQuery — they only read. This is visible in the type and verified when reviewing the code.
record CancelOrderUseCase(String orderId)
implements UseCaseCommand<Void> {} // command — changes state
record GetOrderUseCase(String orderId)
implements UseCaseQuery<OrderView> {} // query — reads only
Read Model — a separate model for reading. Instead of reading from the same table the commands write to, queries go to an optimized representation: a materialized view, a denormalized table, a cache. This lets you tune reads independently of writes.
Commands return the bare minimum — an identifier, a short result, or nothing. For the full data the client makes a separate query. Otherwise the point of the separation is lost.
Eventual consistency. The Read Model is updated asynchronously — through events, materialized views, or periodic recomputation. A query may return slightly stale data. This must be explicitly captured in the system's behavior.
What you should not do at this level
Level 2 is deliberately simple. You do not need to:
- introduce aggregates, value objects, and domain events — that is Level 3;
- build ports and adapters — also Level 3;
- introduce Event Sourcing — that is a separate complexity, not directly tied to CQRS.
When to move to level 3
Level 2 stops being enough when:
- there are many business rules and they start getting copy-pasted between handlers;
- the team uses different terms for the same thing — you need a shared domain language;
- explicit invariants emerge ("you cannot pay for an unconfirmed order", "the balance cannot go negative") and you want them to live in one place;
- self-contained modules with clear boundaries begin to take shape.
Then it is time for Level 3: DDD + Hexagonal.
In short
- One rule: every business operation is a separate
UseCase+Handler. - The controller hands the
UseCaseto the dispatcher, which finds the handler by type — the controller stays thin. - Metrics for every operation come built in automatically — no need to add them by hand.
- Business logic lives inside the handler and is tested without infrastructure.
- CQRS is an option: commands on
UseCaseCommand, queries onUseCaseQuery, a Read Model for reading. - Aggregates, value objects, and ports are Level 3 — you do not need them here.
Further reading
- CQRS — separating reads and writes as a standalone pattern.
- Level 3: DDD + Hexagonal — when you need aggregates and bounded contexts.
- Distributed patterns — Outbox for atomic event publishing.