How to organize business logic using aggregates, enforce boundaries, publish domain events, and structure services like Kitchen and Order.
Every service has business logic at its core. How you organize that logic has a big impact on how easy the service is to understand, test, and change. There are two main patterns for organizing business logic: Transaction Script and Domain Model. They are alternatives to each other.
The Transaction Script pattern organizes business logic as a set of procedural methods. Each method handles one request from start to finish. The behavior (what to do) and the state (the data) live in separate places: methods contain the logic, and simple data objects hold the state.
Analogy: Think of a recipe written as one long list of steps. It works for a simple dish. But for a complex banquet with many dishes, a single long list becomes confusing. You need a better way to organize the work.
The Domain Model pattern organizes business logic as an object-oriented model. Classes have both state and behavior together. Service methods are thin — they delegate most of the work to domain objects.
| Aspect | Transaction Script | Domain Model |
|---|---|---|
| Style | Procedural | Object-oriented |
| State & behavior | Separated | Together in classes |
| Simple logic | Good fit | Overkill |
| Complex logic | Scales poorly | Good fit |
| Extensibility | Hard to extend | Easy to extend |
Key point: For microservices with complex business logic, the Domain Model pattern is usually the better choice. It is refined further by Domain-Driven Design (DDD) and the Aggregate pattern.
Domain-Driven Design (DDD) is an approach to building software that closely models the business domain. DDD provides a set of building blocks for creating domain models:
The most important DDD building block for microservices is the Aggregate. It solves several problems that arise when a domain model is used across service boundaries.
Recall from Chapter 2: A Bounded Context means each service has its own domain model. The same real-world concept (like "Order") can have a different representation in each service. DDD aggregates work within these bounded contexts.
A traditional domain model is a web of interconnected objects. In a monolith, this works fine. In a microservice architecture, fuzzy boundaries between objects cause serious problems. The Aggregate pattern solves this by drawing explicit boundaries around groups of objects.
Without clear boundaries, a domain model is just a web of interconnected classes. This causes three problems in a microservice architecture:
Problem: A web of interconnected objects without explicit boundaries leads to tight coupling between services, unclear transaction scope, and broken business rules. The Aggregate pattern exists to solve these three problems.
An Aggregate is a cluster of domain objects that are treated as a single unit. Every aggregate has a root entity (the aggregate root) that is the only object outsiders can reference. All other objects inside the aggregate are accessed only through the root.
Analogy: Think of an aggregate as a household. The household has one main address (the root). Outsiders send mail to the household address, not directly to individual family members. The household manages its internal affairs on its own.
Three rules govern how aggregates work. These rules enforce loose coupling and consistency in a microservice architecture.
From outside the aggregate, you can only reference the aggregate root. You cannot directly access or hold references to internal objects. This ensures the root can enforce all business invariants before any change is made.
Aggregates reference each other using primary keys (IDs), not direct object references. This keeps aggregates loosely coupled. It also makes the model friendly for NoSQL databases and for sharding data across multiple machines. There is no risk of accidentally creating a cross-service object reference.
A single database transaction must only create or update one aggregate. If a business operation needs to update multiple aggregates, you must use a Saga (Chapter 4) to coordinate the updates across separate transactions.
Why Rule #3 fits microservices: Each service has its own database. You cannot have a single ACID transaction span two services. By limiting each transaction to one aggregate, the model naturally fits the one-database-per-service constraint. Cross-aggregate consistency is handled by sagas.
| Rule | What it says | Why it matters |
|---|---|---|
| #1 | Reference only the aggregate root from outside | Enforces business invariants |
| #2 | Inter-aggregate references by primary key | Loose coupling, NoSQL/sharding friendly |
| #3 | One transaction per aggregate | Fits microservice model; use sagas for multi-aggregate updates |
How big should an aggregate be? There is a tradeoff between fine-grained and coarse-grained aggregates.
| Aspect | Fine-grained aggregates | Coarse-grained aggregates |
|---|---|---|
| Size | Small, focused | Large, contains more objects |
| Scalability | Better (less contention) | Worse (more contention on the root) |
| Atomic updates | Needs sagas for multi-aggregate operations | Can update more objects in one transaction |
| Complexity | More sagas to coordinate | Simpler within a single aggregate |
Guideline: Make aggregates as small as possible. This improves scalability and reduces contention. Accept the extra complexity of sagas for operations that span multiple aggregates.
Business logic in a microservice is organized around aggregates. A typical service contains:
The service follows a hexagonal architecture. The business logic (aggregates, services, repositories, sagas) sits in the core. Around the core, there are two types of adapters:
Architecture summary: Inbound adapters → Business logic core (Aggregates + Domain Services + Repositories + Sagas) → Outbound adapters. The core does not depend on external technologies.
Aggregates publish domain events when their state changes. Other services subscribe to these events to keep their own data up to date. This section covers the full lifecycle of domain events: why they exist, what they look like, and how to generate, publish, and consume them.
In a microservice architecture, services need to react to changes in other services. For example, when an order is created, the Kitchen Service needs to know about it so it can prepare the food. Domain events make this possible.
Common reasons to publish events:
A domain event is a message that describes something that happened to an aggregate. It is named using a past-participle verb, like OrderCreated, TicketAccepted, or DeliveryPickedUp.
Each event typically contains:
Naming convention: Always name domain events with a past-participle verb. This makes it clear that the event describes something that already happened: OrderCreated, not CreateOrder.
Event enrichment means including extra data in the event that consumers will need. Instead of just publishing "OrderCreated" with only the order ID, you also include the order details (items, total, customer ID, etc.).
| Approach | Benefit | Drawback |
|---|---|---|
| Minimal event (ID only) | Event is stable — does not change when the aggregate changes | Consumer must call back to the source service for details |
| Enriched event (includes data) | Consumer has everything it needs — simpler and more self-contained | Event structure may need to change when the aggregate changes |
Tradeoff: Enriched events are simpler for consumers because they avoid extra network calls. But they couple the event structure to the aggregate's internal data, which can reduce stability. Choose based on your needs.
How do you find the domain events your system needs? Two techniques help:
Look for requirements that follow the pattern: "When X happens, do Y." The "X" part is a domain event. For example: "When an order is created, notify the kitchen" tells you that OrderCreated is a domain event.
Event Storming is a workshop technique. Domain experts and developers work together to map out all the events in a business process. They write events on sticky notes, put them on a timeline, and then identify the commands and aggregates that produce them. It is a fast way to discover the events in a system.
There are two common ways for an aggregate to generate domain events:
The aggregate method that changes state returns a list of events. The service class then passes these events to the publisher. This is the preferred approach because it keeps the aggregate simple and free of infrastructure dependencies.
The aggregate extends an AbstractAggregateRoot base class that maintains an internal list of events. When the aggregate's state changes, it adds events to this list. The service class collects the accumulated events after calling the method. This approach is slightly more convenient but ties the aggregate to a base class.
Events must be published reliably. If the service updates the database but fails to publish the event (or vice versa), the system becomes inconsistent. The solution is the Transactional Outbox pattern (Chapter 3).
The service inserts the event into an OUTBOX table as part of the same ACID database transaction that updates the aggregate. A separate process reads the OUTBOX table and publishes the events to the message broker. This guarantees that the database update and the event publication are atomic.
For type safety, a service can use a typed publisher like AbstractAggregateDomainEventPublisher<A, E> that is parameterized with the aggregate type (A) and the event type (E). This prevents accidentally publishing the wrong type of event for an aggregate.
Critical: Never publish events directly to the message broker outside of a database transaction. Always use the Transactional Outbox pattern to guarantee atomicity between database updates and event publishing.
A service consumes domain events using a DomainEventDispatcher. The dispatcher subscribes to event channels and routes each incoming event to the correct handler method based on the event type.
Consuming events is an inbound adapter in the hexagonal architecture. The handler method receives the event, looks up the relevant aggregate, and calls a method on it to react to the event.
Pattern chain: Aggregate state change → generates domain event → published via Transactional Outbox → delivered through message broker → consumed by DomainEventDispatcher → handled by target service.
The Kitchen Service is a relatively simple service. It participates in sagas started by other services (like the Order Service) but does not orchestrate sagas itself. It handles commands, updates its aggregates, and publishes domain events.
The central aggregate in Kitchen Service is the Ticket. A Ticket represents a kitchen order — the food items that need to be prepared. The Ticket aggregate:
TicketAccepted, TicketPreparationStarted).The Kitchen Service follows the hexagonal architecture:
Analogy: Think of the Ticket as a paper order slip in a restaurant kitchen. A waiter (saga command) places the slip. The kitchen reads it, prepares the food, and marks the slip as done. The kitchen does not decide what to cook — it just follows orders.
The Order Service is more complex than the Kitchen Service. It orchestrates sagas that span multiple services. It also maintains replicas of data from other services (like restaurant menu data) by subscribing to their domain events.
The Order aggregate is the central aggregate in Order Service. It represents a customer's order and contains order line items, the order total, and the current order state.
Because the Order Service uses sagas, the Order aggregate uses *_PENDING states as semantic locks. For example, when a saga is creating an order, the order is in APPROVAL_PENDING state. While in a PENDING state, certain operations are blocked to prevent conflicting updates. Once the saga completes, the state moves to a final value like APPROVED or REJECTED.
Recall from Chapter 4: Semantic locks are a saga countermeasure against isolation anomalies. A *_PENDING state signals that the aggregate is currently being modified by a saga, so other operations should wait or fail.
The Order Service needs restaurant data (like menu items and prices) to validate orders. Instead of calling the Restaurant Service synchronously, the Order Service maintains a local Restaurant aggregate that is a replica of data from the Restaurant Service. It keeps this replica up to date by subscribing to domain events published by the Restaurant Service (e.g., RestaurantMenuUpdated).
Microservice-specific pattern: Services maintain replicas of other services' data by subscribing to domain events. This eliminates synchronous dependencies and improves availability. The trade-off is eventual consistency — the replica may be slightly behind the source.
The OrderService class is the main entry point for Order Service business logic. Most of its methods create sagas rather than directly updating the Order aggregate. This is because order operations (create, revise, cancel) involve multiple services that must be coordinated.
For example, when a customer creates an order:
OrderService.createOrder() method creates an Order aggregate in APPROVAL_PENDING state.CreateOrderSaga that orchestrates the steps across Kitchen Service, Accounting Service, and others.APPROVED. If any step fails, compensating transactions roll back the changes and the Order becomes REJECTED.The Order Service follows the hexagonal architecture:
Analogy: The OrderService is like a project manager. It does not do all the work itself. Instead, it starts a project (saga), assigns tasks to different teams (services), tracks progress, and decides the final outcome based on whether all teams succeed.
Designing business logic for microservices differs from designing it for a monolith in several important ways:
Summary: The aggregate pattern, primary-key references, single-aggregate transactions, domain events, and data replicas are the five key differences between monolithic and microservice business logic design.