DDD in Practice: From Domain Partitioning to Microservice Delivery#
When many teams split a monolith into microservices, their first instinct is to split “by table” or “by feature”. The result is services that grow ever finer-grained, call chains that grow ever longer, transactional consistency that grows ever harder, and business logic scattered across four or five services. The problem is usually not the technology—it is that the boundaries were never drawn clearly. Worse still, such a split is nearly irreversible: once downstream services consume your API contracts, the cost of redrawing a boundary rises exponentially with the number of consumers. The value of DDD is precisely that it offers a method for drawing boundaries and organizing models from the business perspective, so that a boundary is justified before the first line of code is written.
I. What DDD Is: Which Kind of Complexity It Actually Attacks#
Software complexity comes from roughly three sources:
| Type of complexity | Source | Typical remedies |
|---|---|---|
| Business complexity | Many business rules, many exceptions, ambiguous semantics, fast-changing requirements | Domain modeling, bounded contexts, ubiquitous language |
| Technical complexity | High concurrency, distribution, storage choices, performance bottlenecks | Architectural patterns, middleware, capacity planning |
| Organizational complexity | Many people, many modules, long collaboration chains | Team topologies, Conway’s Law, platform engineering |
DDD targets the first one. It does not solve “how to handle 100k QPS”, nor “how to keep 50 teams from blocking one another”—although it does influence the latter. Many teams apply DDD to technical complexity and end up with a pile of layers and abstractions that nobody can understand.
The positioning of DDD, in one sentence: DDD earns its keep only when the gap between “how the business talks” and “how the code is written” becomes the dominant cost.
It is usually divided into two parts:
| Layer | Problem it solves | Core output | Cost of failure |
|---|---|---|---|
| Strategic design | Where the boundaries are | Subdomains, bounded contexts, ubiquitous language, context mapping | If the boundary is wrong, no amount of model refinement helps—and refactoring costs are enormous |
| Tactical design | How to model within a boundary | Entities, value objects, aggregates, domain services, domain events, repositories | The model is anemic and business rules are scattered, but the boundary itself can still be preserved |
flowchart TB
subgraph PS["Problem Space (business side)"]
D["Domain (the business as a whole)"] --> SD1["Core Domain"]
D --> SD2["Supporting Subdomain"]
D --> SD3["Generic Subdomain"]
end
subgraph SS["Solution Space (system side)"]
BC1["Bounded Context A"]
BC2["Bounded Context B"]
BC1 <-->|"Context mapping"| BC2
end
SD1 -->|"Strategic design: draw boundaries"| BC1
SD2 -->|"Strategic design: draw boundaries"| BC2
BC1 -->|"Tactical design: build the model"| M["Entities / Value Objects / Aggregates / Domain Events / Repositories"]
BC1 -.->|"Two-way calibration"| DStrategic design decides how the system is split; tactical design decides how to build each piece after the split. Many teams learn only tactical design, diving straight into aggregate roots and value objects while ignoring bounded contexts. The result is an exquisitely built model sitting in the wrong place, with service boundaries still in disarray. Strategic design matters more than tactical design—and it is harder, because no compiler checks it for you.
II. Strategic Design: Think It Through in the Problem Space First#
The essence of strategic design is performing one slice in each of the two spaces: slicing subdomains in the problem space, and bounded contexts in the solution space. The relationship between the two is the single most confusing point in all of DDD.
1. Domains and Subdomains: Slicing the Problem Space#
A domain is the business problem space itself; a subdomain is a natural slice of that domain, usually along business capabilities. Note that a subdomain is a slice from the business perspective—it has nothing to do with “how many development teams your company has” or “how many tables are in the database”.
Subdomains come in three kinds, and the point of the classification is to decide where to invest—this is one of the few parts of DDD that directly guides resource allocation:
| Type | Meaning | Investment strategy | Signals |
|---|---|---|---|
| Core domain | The source of the company’s competitiveness; determines differentiation | Build in-house; commit your strongest resources and your best people | Failing at it means losing competitiveness; peers do it very differently; it changes fast |
| Supporting subdomain | Necessary for the business, but not a source of competitive advantage | Build or buy; aim for stability and low cost | It must be done, but customers do not care how well |
| Generic subdomain | Industry-wide, commodity capability | Buy it or use open source; do not build it yourself | Peers do it essentially the same way; mature commercial solutions exist |
Judging the core domain is simple: if the company does this badly, will it lose its competitiveness? If yes, it is the core domain.
But watch out for two common misjudgments:
- Treating a supporting subdomain as core and building it in-house: self-built IM, self-built workflow engine, self-built permission system—huge investment, zero differentiation, and the mainline engineering effort gets dragged down.
- Treating the core domain as supporting and outsourcing it: the core domain is precisely the part that cannot be outsourced, because the accumulated knowledge is the competitive advantage.
Add a dynamic perspective: core domains drift. Whether a subdomain is core today depends on company strategy. In early e-commerce, trading was the core domain; as the fulfillment network became the moat, fulfillment may itself become core. So the core-domain judgment should be a conclusion you revisit periodically, not a one-time label.
2. Bounded Contexts: Boundaries in the Solution Space#
The bounded context is one of the most important concepts in DDD, and the direct basis for splitting microservices.
Its core rule is:
Within a bounded context, every term must have one and only one precise meaning.
Take the word “product”:
- In the trading context, a product is “a sellable SKU + price + inventory snapshot”;
- In the fulfillment context, a product is “physical goods + bin location + parcel”;
- In the after-sales context, a product is “a warranty object + warranty period”.
One word, three meanings—that alone tells you they belong to different bounded contexts.
A bounded context actually carries three kinds of boundary at once, which is why it is far weightier than “splitting into modules”:
| Boundary dimension | Meaning | Consequence of crossing it |
|---|---|---|
| Language boundary | A term has exactly one meaning within it | Requirements reviews turn into people talking past each other; code mixes Order and Trade |
| Model boundary | The model serves only this context’s business rules | One User class carries fields for login, membership, support, and risk control |
| Transactional boundary | Strong consistency is not pursued across contexts | Distributed transactions everywhere; lock contention and retry storms crush the call chain |
3. Subdomains Are Not Bounded Contexts#
This is the easiest trap to fall into in practice. Subdomains live in the problem space (how the business talks); bounded contexts live in the solution space (how the system is built). The two do not correspond one-to-one by nature:
flowchart LR
subgraph OS["Problem Space: Subdomains"]
A1["Subdomain A: Trading"]
A2["Subdomain B: Fulfillment"]
end
subgraph SS["Solution Space: Bounded Contexts"]
B1["Trading Context"]
B2["Picking Context"]
B3["Delivery Context"]
end
A1 -->|"1:1"| B1
A2 -->|"1:N — driven by tech, teams, or scalability"| B2
A2 --> B3There are four common mapping shapes:
| Shape | When it happens | Risk |
|---|---|---|
| 1 subdomain : 1 context | Ideal—business and system boundaries coincide | None |
| 1 subdomain : N contexts | Large teams, differing scalability needs, very different read/write loads | Coordination cost between contexts rises |
| N subdomains : 1 context | The capabilities are weak, and merging costs less than splitting | Models easily contaminate each other; stay alert |
| 1 subdomain : 0 contexts | The subdomain is purchased, or not yet started | Must be marked as an external system in the context map |
Rule of thumb: subdomains can only be derived from the business; bounded contexts may additionally take technical and organizational factors into account.
4. Ubiquitous Language: More Than a Glossary#
The ubiquitous language is the shared vocabulary of the team and the code. It must run through requirements, design, code, tests, and documentation alike.
If the business says “cancel the order” while the code calls it deleteOrder, that is a language fracture. And the fracture spreads down the chain:
| Stage | What the business says | The fractured code | Consequence |
|---|---|---|---|
| Requirements | Cancel order | deleteOrder | Cancelling is a state transition; deleting is physical disappearance—completely different semantics |
| API | Cancel order | POST /order/remove | The contract cannot express the constraint that only some orders are cancellable |
| Storage | Cancel order | DELETE FROM orders | Audit and reconciliation data is lost forever |
| Tests | Cancel order | testDelete() | Test names no longer line up with business use cases |
The ubiquitous language is not a glossary you write once and file away; it must be reflected in code naming, interface definitions, and database fields. Here is an executable calibration mechanism:
- Maintain the glossary in both directions: every business-side noun maps to exactly one type name in the code; adding a term means updating both sides at once.
- Naming is a review item: in code review, whether
OrderCancelReasonreally corresponds to the business’s “reason for cancellation” is a first-class review point. - Tests are documentation: name test cases in the business’s own words, e.g.
Test_CancelOrder_ShouldReject_WhenAlreadyShipped. - A language fracture is a refactoring signal: when one business concept shows up under two names, or one name carries two meanings, question the boundary first—do not paper over it with a translation layer.
5. Context Mapping: Nine Patterns and the Power Relations Behind Them#
Once the contexts are divided, you still have to define the relationships between them. A context map is not merely a technical integration plan; it is also a record of team collaboration and negotiation outcomes—who the upstream is and who holds the leverage determines which pattern you should use.
| Pattern | Meaning | When it applies | Cost |
|---|---|---|---|
| Shared Kernel | Several contexts share a small part of the model | Two highly collaborative teams, with a stable model | Any change by either side pulls on the other; requires strict control |
| Customer-Supplier | The upstream adjusts its interface to suit the downstream | A clear service relationship between upstream and downstream | The upstream must schedule work for the downstream, who bears waiting costs |
| Conformist | The downstream simply conforms to the upstream model, with no adaptation | The upstream is dominant and its model is good enough (e.g. a cloud vendor’s API) | The downstream model is held hostage by the upstream |
| Anti-Corruption Layer | The downstream adds a translation layer to isolate upstream model changes | The upstream model differs sharply from local semantics, or is unstable | An extra layer to maintain and map |
| Open Host Service | The upstream offers one protocol for many consumers | One upstream, many downstreams | The protocol must be stable; version management gets complex |
| Published Language | Events or data published in a shared format | Asynchronous integration across contexts | Requires schema governance and a compatibility strategy |
| Separate Ways | The two contexts have no direct relationship | They really are unrelated in the business | Possible duplicated effort |
| Partnership | Both sides succeed or fail together; they schedule for each other | Two tightly coupled parts of the business | High communication cost; one side’s delay drags the whole down |
| Big Ball of Mud | A shared model with no boundaries and no rules | None—avoid it | Any change can have unpredictable effects |
The power relationship between upstream and downstream can be summarized in this decision diagram:
flowchart TB
Q1{"Is the upstream willing<br/>to adapt for downstream?"}
Q1 -->|"Willing, and peers"| P1["Partnership / Shared Kernel"]
Q1 -->|"Willing, but upstream leads"| P2["Customer-Supplier"]
Q1 -->|"Unwilling, upstream dominates"| Q2{"Is the upstream model<br/>good enough as it stands?"}
Q2 -->|"Yes"| P3["Conformist"]
Q2 -->|"Very different or unstable"| P4["Anti-Corruption Layer (ACL)"]
Q1 -->|"Upstream serves many consumers"| P5["Open Host Service + Published Language"]A typical e-commerce context map:
flowchart LR
Product["Product Context"]
Trade["Trading Context"]
Settle["Settlement Context"]
Fulfill["Fulfillment Context"]
After["After-Sales Context"]
Pay["Third-Party Payment"]
Logistics["Third-Party Logistics"]
Product -->|"OHS + PL: SKU events"| Trade
Trade -->|"Customer-Supplier: order events"| Fulfill
Trade -->|"Customer-Supplier: payables"| Settle
Settle -->|"ACL: isolating the payment gateway model"| Pay
Fulfill -->|"ACL: isolating the carrier model"| Logistics
After -.->|"Shared Kernel: after-sales order and order number"| TradeHow to read this: any system you do not control (payment, logistics) gets an anti-corruption layer; between contexts you yourself lead, decouple with events; use a shared kernel only where collaboration must be genuinely tight, and keep its scope small.
Keeping foreign models from leaking in is the anti-corruption layer’s primary job. Its code skeleton looks like this:
// internal/biz/payment_gateway.go — the port defined by the domain layer
type PaymentGateway interface {
Pay(ctx context.Context, orderNo string, amount Money) (PaymentID, error)
}
// internal/data/payment_gateway.go — the infrastructure implementation (the ACL)
type alipayGateway struct {
client *alipay.Client
}
func (g *alipayGateway) Pay(ctx context.Context, orderNo string, amount Money) (PaymentID, error) {
// Translation from the upstream model to the local model lives here and goes no further
resp, err := g.client.TradePay(ctx, &alipay.TradePayRequest{
OutTradeNo: orderNo,
TotalAmount: amount.YuanString(), // the vendor wants a decimal string in yuan; we store integer cents
})
if err != nil {
return "", mapAlipayError(err) // translate upstream error codes into domain-semantic errors
}
return PaymentID(resp.TradeNo), nil
}Note the last line: errors must be translated too. Throwing upstream codes such as ACQ.TRADE_HAS_SUCCESS straight into business code pours the upstream’s language into your domain layer, and the anti-corruption layer was added for nothing.
III. How to Divide the Domain: From Business Capabilities to Boundaries#
Domain partitioning is not guesswork. It can proceed through the following steps:
- Inventory business capabilities: list every business capability together with the business side, rather than starting from database tables. The opening question should be “how does the company make money”, not “what systems do we have”.
- Separate core, supporting, and generic subdomains: establish investment priorities.
- Find language boundaries: when the same word means different things in different parts of the business, that is a splitting signal.
- Look at rate of change: things that change at the same rhythm belong together; things that change at different rhythms should be separated. Fast-changing parts should not contaminate stable ones.
- Look at team boundaries: Conway’s Law is inescapable; a bounded context is best owned by a single team.
- Look at transactional boundaries: modifications to one aggregate should complete within one transaction. If two subdomains demand a strongly consistent transaction, the boundary is probably drawn wrong.
- Look at scalability needs: two capabilities with wildly different read/write ratios, peak QPS, or latency sensitivity are cheaper to deploy separately.
- Look at compliance and security boundaries: data classification, cross-border compliance, and audit requirements often form context boundaries naturally.
- Validate with an event storming workshop: make the business process and the boundaries visible through domain events, commands, aggregates, and policies.
1. Event Storming: Three Phases with Different Outputs#
Event storming is often dismissed as “a team-building exercise with sticky notes”, but it actually has well-defined phases and outputs.
flowchart LR
A["Phase 1: Big Picture<br/>Put domain events on the wall, reconstruct the process"] --> B["Phase 2: Process Level<br/>Close the command-event-policy loop"]
B --> C["Phase 3: Design Level<br/>Identify aggregates and bounded contexts"]
C --> D["Output: context map<br/>+ aggregate inventory + key domain events"]| Phase | Participants | Key output | Common mistake |
|---|---|---|---|
| Big Picture | Business + product + engineering | A timeline of domain events on the wall | Getting lost in details, arguing over fields and table structures |
| Process Level | Product + engineering | Closed command-event-policy loops; hotspots and bottlenecks | Modeling only the “happy path” and skipping compensation and exceptional branches |
| Design Level | Engineering | Aggregates, invariants, bounded contexts, context mapping | Jumping straight to technical implementation and skipping the language consistency check |
What gets missed most often in phase two is the policy—“when this event occurs, which command should it trigger”. Policies are exactly where cross-aggregate and cross-context collaboration lands:
flowchart LR
C1["Command: Place order"] --> E1["Event: Order placed"]
E1 --> P1{"Policy: Is stock sufficient?"}
P1 -->|"Yes"| C2["Command: Reserve stock"]
P1 -->|"No"| C3["Command: Cancel order"]
C2 --> E2["Event: Stock reserved"]
E2 --> P2["Policy: Initiate payment"]
P2 --> C4["Command: Create payment"]The key to reading this diagram: a command produces an event, the event triggers a policy, and the policy issues another command. That loop is the skeleton of eventual consistency. Draw it, and the transactional boundaries between contexts surface on their own.
2. An E-Commerce Example: The Split and the Evidence for It#
- Trading context: placing orders, cancellation, price calculation;
- Fulfillment context: warehouse, shipping, logistics;
- Settlement context: payment, refunds, reconciliation;
- After-sales context: returns and exchanges, repairs, warranties.
Every context may contain the notions of “user”, “order”, and “product”—but with different meanings and different models. Note the same-name-different-meaning pairs in the table below: they are direct evidence of the boundary.
| Concept | Meaning in the trading context | Meaning in the fulfillment context | Basis for splitting |
|---|---|---|---|
| Order | A purchase contract, an amount, a discount | A batch of picking tasks awaiting dispatch | One manages money, the other manages goods; different rates of change and different teams |
| Product | A sellable SKU, a price, an inventory snapshot | Physical goods, bin location, volume and weight | Trading cares about price; fulfillment cares about physical attributes |
| User | Buyer account, delivery details, loyalty tier | Recipient, contact details, address | Trading needs a profile; fulfillment only needs deliverability |
| Complete | Payment succeeded means complete | Only signed-for counts as complete | One process, two terminal states—the most classic boundary signal |
The key principle: domain partitioning rests on business capabilities and language boundaries, not on database tables or technical layering.
3. Boundary Granularity: The Benefits Diminish, the Costs Do Not#
Once the boundaries are drawn, you still have to answer “how big is right”. Splitting is not free:
| Benefits of splitting | Costs of splitting |
|---|---|
| Independent deployment and release | Network calls replace in-process calls; latency and failure rates rise |
| Independent scaling, resources aimed at hotspots | Cross-service consistency requires eventual consistency plus compensation |
| Team autonomy, less coordination blocking | The complexity of distributed tracing, log aggregation, and chain alerting |
| Fault isolation, so one failure does not take down everything | Contract management, version compatibility, integration testing costs |
| Cleaner model boundaries, less mutual contamination | Loss of data redundancy and the ability to query across stores |
Rule of thumb: for every service you split out, you must be able to state clearly which one of the costs above it solves. If you cannot, do not split. The number of services is not itself a measure of architectural quality.
IV. Tactical Design: Modeling Inside a Boundary#
Only after entering a bounded context do you build the model with tactical design. Tactical design has exactly one goal: to give every business rule a single, readable, unbypassable home.
1. Entities and Value Objects: Not a Taxonomy, but Constraints#
- Entity: has a unique identity that stays constant across its lifetime. E.g. an order, a user.
- Value object: has no unique identity; equality is decided by attribute values. E.g. an amount, an address, a time range.
The difference lies in equality semantics and the concurrency properties that follow from it:
| Dimension | Entity | Value object |
|---|---|---|
| Equality | Same identity means equal | Equal only when every attribute matches |
| Mutability | Mutable (identity fixed, state may change) | Immutable; modification returns a new object |
| Concurrency safety | Needs version numbers or locking | Inherently thread-safe; freely shareable |
| Lifecycle | A clear lifecycle and state machine | No lifecycle; used and discarded |
| Typical examples | Order, account, coupon | Money, address, time interval, coordinates |
The greatest value of value objects is often underrated: they are the tool for eliminating Primitive Obsession. When an amount, a phone number, or an email address is modeled as a value object, its validation rules, format constraints, and arithmetic logic have exactly one home, instead of being rewritten in every service that touches them.
type Money struct {
cents int64 // store integer cents to avoid floating-point error
currency string
}
// The constructor is the only validation entry point; an invalid state simply cannot be created
func NewMoney(cents int64, currency string) (Money, error) {
if currency == "" {
return Money{}, ErrCurrencyRequired
}
return Money{cents: cents, currency: currency}, nil
}
// Value objects return a new object rather than mutating themselves
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrCurrencyMismatch
}
return Money{cents: m.cents + other.cents, currency: m.currency}, nil
}Note that currency check inside Add. If an amount were just an int64, this rule would be scattered across every service that performs addition, and one missed spot becomes a production incident.
2. Aggregates: The Essence of a Consistency Boundary#
The aggregate is the most technically demanding concept in DDD, and the one most easily reduced to “a main table with a few child tables”.
The real definition: a cluster of objects that must maintain a consistency invariant together, with the aggregate root as the only entry point. Unpacked, an aggregate is three kinds of boundary at once:
| Boundary an aggregate carries | Meaning | Direct consequence |
|---|---|---|
| Invariant boundary | Every business rule inside the aggregate must hold at all times | Decides “which objects must live together” |
| Transactional boundary | One transaction modifies one aggregate | Decides “which changes can be completed atomically” |
| Concurrency boundary | The scope of locks and version numbers | Decides “which operations block each other” |
Four aggregate design rules follow from this, along with why they hold:
| Rule | Reason |
|---|---|
| Keep aggregates small | The smaller the aggregate, the lower the chance of concurrent conflict and the less data loaded. Aggregate granularity is directly the probability of lock contention |
| Reference other aggregates by ID | Holding an object reference invites coordinated changes across aggregates, breaking the transactional boundary; it also bloats the object graph and invites lazy-loading traps |
| Modify only one aggregate per transaction | Atomicity across aggregates forces distributed transactions, whose availability costs are extremely high |
| Use domain events and eventual consistency across aggregates | Trade strong consistency for asynchronous propagation of ordered events, buying availability with idempotence and compensation |
Choosing aggregate granularity is essentially a trade-off between conflict rate and load cost:
flowchart TB
subgraph AR["Order Aggregate (one transactional boundary)"]
ROOT["Order (aggregate root<br/>owns all invariants)"]
ROOT --> I1["OrderItem (entity)"]
ROOT --> I2["OrderItem (entity)"]
ROOT --> V1["Address (value object)"]
ROOT --> V2["Money (value object)"]
end
ROOT -->|"Holds only an ID reference, not the object"| SKU["Product Aggregate"]
ROOT -->|"Domain event OrderPlaced"| EV["Event publication"]
EV -.->|"Separate transaction"| INV["Inventory Aggregate: decrement stock"]
EV -.->|"Separate transaction"| CRM["CRM Aggregate: update customer profile"]
style AR fill:#f8f9faA counter-example warning: never let Order hold a Product object directly. The reason is that if the product’s price changes, the order’s historical price is retroactively rewritten; and the product object drags the entire product table into the order transaction’s load path. The right approach is to store a price snapshot plus the product ID in the order item—this is snapshot-based modeling applied to aggregate design.
Aggregate concurrency control usually means optimistic locking with a version number:
type Order struct {
ID string
version int64 // optimistic-lock version, maintained by the Repository
Status OrderStatus
Items []OrderItem
}
// State-transition rules must be sealed inside the aggregate; nothing outside can bypass them
func (o *Order) Cancel(reason CancelReason, now time.Time) error {
if o.Status == OrderShipped {
return ErrOrderAlreadyShipped // a shipped order cannot be cancelled
}
if o.Status == OrderCancelled {
return nil // idempotent: cancelling twice is not an error
}
o.Status = OrderCancelled
o.CancelReason = reason
o.CancelledAt = now
o.AddEvent(OrderCancelled{OrderID: o.ID, Reason: reason})
return nil
}Three questions give you a quick self-check on whether the granularity is right:
- Invariants: is there a rule between these two pieces of data that must hold at all times? If not, they do not belong in the same aggregate.
- Atomicity: is there a modification that must succeed or fail as a unit? If not, they can be split.
- Conflict rate: after merging, does concurrent write contention rise noticeably? If so, the aggregate is too coarse.
3. Domain Events and Eventual Consistency: From “Publish an Event” to “Never Lose an Event”#
A domain event represents a fact that has already happened in the domain, such as OrderPlaced or PaymentCompleted. Name them in the past tense—they describe an unchangeable fact, not a request.
Here is a distinction many teams blur:
| Dimension | Domain event | Integration event |
|---|---|---|
| Scope | Inside a bounded context, to decouple aggregates | Across contexts, for external integration |
| Content | May carry internal aggregate structure; rich fields | Carries only contract fields; must stay backward-compatible |
| Contract stability | May change as the model is refactored | Once published it is a public commitment; fields may be added, never removed |
| Transport | In-process publish/subscribe | Message queue / event bus |
Publishing domain events straight to a message queue is a common mistake: the moment the local model is refactored, every downstream consumer breaks. The right approach is one translation at the context boundary—domain events circulate internally, and are translated into stable integration events on the way out.
Now comes the part with real technical depth: the timing trap of event publication.
The naive approach publishes after the transaction commits, but there is a fatal window in the middle:
sequenceDiagram
participant S as Application Service
participant DB as Database
participant MQ as Message Queue
S->>DB: COMMIT (the order is persisted)
Note over S,DB: ⚠️ Crash window: if the process dies here, the event is lost forever
S->>MQ: Publish OrderPlacedPublishing the message first and committing afterwards runs into the opposite problem: the transaction rolls back, but the message has already gone out, and downstream sees an order that never existed.
Neither failure mode can be fixed by “reordering”, because a database transaction and a message broker cannot share one atomic commit (unless you use a broker that supports transactional messages). The mature engineering answer is the Transactional Outbox:
sequenceDiagram
participant App as Application Service
participant DB as Database
participant Relay as Relay (polling / CDC)
participant MQ as Message Queue
participant Sub as Subscriber
App->>DB: BEGIN
App->>DB: UPDATE aggregate state
App->>DB: INSERT outbox record (same transaction)
App->>DB: COMMIT
Note over App,DB: State and event record are written atomically — both succeed or both fail
Relay->>DB: Poll undelivered events / tail the binlog
Relay->>MQ: Deliver at-least-once
Relay->>DB: Mark as delivered
MQ->>Sub: Consume
Note over Sub: Consumers must be idempotent (dedup table / unique key)This pattern downgrades “distributed atomicity” into “a local transaction plus at-least-once delivery”, at the cost of requiring idempotent consumption. Together these form the standard skeleton of eventual consistency—none of the pieces can be dropped:
- At-least-once delivery guarantees nothing is lost, but produces duplicates;
- Idempotent consumption absorbs duplicates, usually via a business unique key or a dedup table;
- Compensating transactions handle business-level eventual failure, such as cancelling the order and refunding when stock reservation fails;
- Reconciliation is the last line of defense, catching every anomaly the layers above did not.
In one sentence: distributed systems have no “exactly once”—only “it may be delivered twice, and that must still be handled correctly”.
4. Domain Services, Application Services, and Policies#
- Domain service: handles domain logic that spans entities or aggregates and does not fit inside a single entity. For example, “compute the final discount from the user’s tier, the order amount, and the campaign rules”.
- Application service: orchestrates use cases and contains no business rules. It handles transactions, authorization, logging, and event publication, delegating the actual decisions to domain objects.
- Policy / Specification: turns composable rules into objects, for complex validation and filtering conditions.
The division of labor can be remembered in one sentence: the application service decides “what to do”, the domain service decides “how to do it according to the business rules”, and an entity decides “I am the authority on my own state”.
A common anti-pattern is the bloated application service—a transaction-script style of coding that piles every rule into the application service until it becomes a god class that knows and touches everything, while the domain objects degrade into data transfer objects.
5. Repositories, Factories, and Dependency Inversion#
- Repository: oriented around aggregate roots, not tables. The interface is defined in the domain layer and implemented in the infrastructure layer.
- Factory: handles the creation logic of complex aggregates, especially when the creation process itself involves business rules.
- Unit of Work: provides the mechanism for landing “one transaction modifies one aggregate”.
A repository’s semantics are those of a collection, not a DAO. The distinction is practical:
// Defined in the domain layer: the semantics are "a collection of aggregates"
type OrderRepo interface {
NextID(ctx context.Context) (string, error)
Save(ctx context.Context, order *Order) error // create and update are both Save
FindByID(ctx context.Context, id string) (*Order, error)
FindByUser(ctx context.Context, userID string, status OrderStatus) ([]*Order, error)
}
// Counter-example: a repository written as a DAO, exposing SQL-style operations
// type OrderDAO interface {
// UpdateStatus(ctx context.Context, id string, status int) error // ❌ bypasses the aggregate, breaks invariants
// UpdateAmount(ctx context.Context, id string, amount int64) error // ❌ business rules have nowhere to live
// }The key test: if a repository interface contains UpdateXxx-style “change one field” methods, the aggregate’s behavior is being bypassed and its invariants are decoration.
As for queries like FindByUser—it returns a collection of aggregates used for business decisions, so it belongs in the repository. Complex queries for reports and list pages, however, should go through a separate read model (the Q side of CQRS) rather than polluting the repository interface:
| Query type | Belongs to | Reason |
|---|---|---|
| Load an aggregate by ID | Repository | Serves writes; needs the complete aggregate |
| Find aggregates by business conditions | Repository | The result still drives business decisions |
| Multi-table joins, paginated lists, reports | Read model / QueryService | No aggregate behavior needed; going through a repository causes over-fetching |
6. A Full Comparison: The Anemic Model vs. the Rich Model#
The same “cancel order” business operation, written two ways, is the watershed for whether DDD has truly landed.
// ❌ Anemic model: business rules scattered in the application service; any caller can bypass them
func (s *OrderService) Cancel(ctx context.Context, orderID string) error {
order, err := s.repo.FindByID(ctx, orderID)
if err != nil {
return err
}
if order.Status == 3 { // magic number: what is 3? every caller must check again
return errors.New("already shipped")
}
order.Status = 4 // direct assignment; the rule can be bypassed at will
return s.repo.UpdateStatus(ctx, orderID, 4)
}// ✅ Rich model: rules sealed inside the aggregate, with a single entry point for change
type Order struct {
ID string
Status OrderStatus
Version int64
events []DomainEvent
}
func (o *Order) Cancel(reason CancelReason, now time.Time) error {
if !o.Status.Cancellable() { // the rule is expressed by the state itself, not by a magic number
return ErrOrderNotCancellable{Status: o.Status}
}
o.Status = OrderCancelled // only the aggregate may change its own state
o.CancelReason = reason
o.CancelledAt = now
o.events = append(o.events, OrderCancelled{OrderID: o.ID, Reason: reason})
return nil
}
// The application service only orchestrates: load -> delegate -> save -> publish events
func (uc *OrderUsecase) Cancel(ctx context.Context, orderID string, reason CancelReason) error {
order, err := uc.repo.FindByID(ctx, orderID)
if err != nil {
return err
}
if err := order.Cancel(reason, uc.clock.Now()); err != nil {
return err
}
if err := uc.repo.Save(ctx, order); err != nil {
return err
}
return uc.publisher.Publish(ctx, order.Events()...)
}The difference is not that one version is wordier. It is the enforceability of the constraint: with an anemic model, anyone adding a new entry point may forget the check; with a rich model, an invalid state cannot be constructed at all.
V. DDD and Microservices: How Boundaries Map to Deployment#
A bounded context is a candidate boundary for splitting microservices, but it is not a mandatory one-to-one correspondence.
flowchart TB
BC["Bounded Context<br/>(a logical boundary, decided by language and model)"]
BC -->|"Most common"| M1["1:1 — one microservice"]
BC -->|"Driven by scalability or teams"| M2["1:N — split into several services<br/>e.g. read/write separation, CQRS"]
BC -->|"Use with caution"| M3["N:1 — one service containing several contexts"]
M3 -.->|"Consequence"| BAD["Distributed monolith:<br/>shared model + synchronous calls + strong consistency"]- One bounded context usually maps to one microservice;
- It may also be split into several services according to team, performance, or deployment needs;
- One service containing several bounded contexts is not recommended—that becomes a distributed monolith.
Four signals of a distributed monolith (two or more should put you on alert):
| Signal | Symptom | Explanation |
|---|---|---|
| Shared model | Several services import entity definitions from the same common package | The model has no boundary; one change ripples everywhere |
| Synchronous call chain | One request passes serially through four or more services | Availability multiplies out; one broken link breaks everything |
| Strong consistency transactions | Distributed transactions across services, or “update A then update B, roll back on failure” | The boundary and the transactional boundary disagree |
| Coupled releases | Two services must be released together to go live | You gained nothing but a process boundary |
The key to splitting microservices is not “how fine-grained the split is”, but whether the boundaries match the business language. If two services share one model, call each other synchronously all the time, and need strongly consistent transactions, they most likely belong to the same bounded context.
The reverse caution also applies: do not split for the sake of “architectural sophistication”. Force an internal system with a few thousand daily users into twelve microservices and what you get is not scalability but a distributed monolith that needs dedicated operations staff.
VI. DDD and the Kratos Four-Layer Architecture#
The Kratos four-layer architecture is usually understood as Server / Service / Biz / Data, plus api and cmd.
It maps onto DDD layering as follows:
flowchart TB
S["Server layer<br/>HTTP / gRPC startup, middleware, lifecycle"]
SV["Service layer<br/>Application layer: interface adaptation, DTO conversion, use-case orchestration"]
B["Biz layer<br/>Domain layer: aggregates, entities, value objects, use cases, Repository interfaces"]
D["Data layer<br/>Infrastructure: Repository implementations, DB, cache, external RPC"]
S --> SV --> B
B -.->|"Dependency inversion: depends only on interfaces"| D
D -.->|"Implementations injected by wire at runtime"| B
style B fill:#f8f9fa| Kratos layer | DDD layer | Responsibility | What must not appear here |
|---|---|---|---|
| Server | Infrastructure / UI | HTTP and gRPC startup, middleware, lifecycle | Business decisions |
| Service | Application layer | Interface adaptation, DTO conversion, use-case orchestration | Business rules, domain computation |
| Biz | Domain layer | Domain model, use cases, Repository interfaces | SQL, framework dependencies, concrete middleware |
| Data | Infrastructure layer | Repository implementations, database, cache, external RPC | Business rules (beyond persistence details) |
Kratos is a DDD-ready container, but it does not force you to write DDD code.
If the Biz layer holds nothing but a pile of procedural use cases operating on anemic structs, with business rules scattered across the Service layer and SQL, then it is merely a layered architecture, not DDD.
The test:
- Do the domain objects have behavior?
- Are the Repository interfaces defined for aggregate roots?
- Do business rules live in the domain objects, or are they scattered across application services?
If all three answers are “no”, you have not entered DDD yet.
A typical Kratos directory structure:
api/ # proto contracts: public commitments that must stay compatible
cmd/ # wire assembly, startup
internal/
server/ # HTTP / gRPC startup, middleware
service/ # application services: interface adaptation, DTO <-> domain model conversion
biz/
order.go # aggregates, entities, value objects, domain events
order_status.go # status enum and state-transition rules
order_usecase.go # use-case orchestration: transactions, event publication
order_repo.go # Repository interface (defined in the domain layer)
data/
order_repo.go # Repository implementation (infrastructure layer)
model/ # ORM models, kept separate from the domain modelThe Repository interface is defined in Biz and implemented in Data—that is dependency inversion, and it means the domain layer does not depend on any infrastructure at compile time.
Here is the point in practice where people are vaguest: should the aggregate in Biz and the ORM model in Data be the same struct?
| Approach | How | Pros | Cons |
|---|---|---|---|
Reuse one struct (often tagged biz:"-") | The aggregate carries ORM tags directly | One less conversion layer; less code | The domain model is polluted by persistence details; refactoring is constrained |
| Separate models | biz.Order and data.OrderPO are distinct | The domain model stays clean; each side evolves independently | Conversion code must be written and maintained |
Another frequent question: who manages transactions? A common approach is for the application service to hold a Transaction abstraction, open the transaction at the use-case layer, and pass a transaction-carrying ctx down to the repository implementation. That makes the transactional boundary explicit and visible in the use case, so you can see at a glance how many aggregates it touches:
func (uc *OrderUsecase) Cancel(ctx context.Context, orderID string, reason CancelReason) error {
return uc.tx.Transaction(ctx, func(ctx context.Context) error {
order, err := uc.repo.FindByID(ctx, orderID)
if err != nil {
return err
}
if err := order.Cancel(reason, uc.clock.Now()); err != nil {
return err
}
// Inside this transaction: save the aggregate + write the event (done inside the repo)
// Cross-aggregate / cross-context actions are handed to events for asynchronous processing
return uc.repo.Save(ctx, order)
})
}Note the comment inside the function body: the transaction contains writes to this aggregate only. If a direct call to a second aggregate shows up here, the transactional boundary has been broken.
VII. A Rollout Roadmap: Advance in Stages#
DDD is hard to roll out across the board in one go. Proceeding at this pace carries the least risk:
| Stage | Key activities | Deliverables | Participants | Typical duration |
|---|---|---|---|---|
| 1. Event storming | Reconstruct the business process; identify domain events, commands, policies, read models | Event wall, closed-loop process diagram | Business + product + engineering | 1–2 day workshop |
| 2. Divide subdomains | Separate core, supporting, and generic subdomains | Subdomain inventory + investment strategy | Business owners + architects | 1 week |
| 3. Define bounded contexts | Run the language consistency check; settle the boundaries | Context inventory + justification for each boundary | Engineering-led | 1–2 weeks |
| 4. Establish the ubiquitous language | Maintain the glossary in both directions; land it in naming conventions | Glossary + naming conventions + review checklist | The whole team | Ongoing |
| 5. Map the contexts | Decide integration styles and upstream/downstream relationships | Context map + decision records for integration styles | Architects + team representatives | 1 week |
| 6. Design aggregates | Identify invariants; keep aggregates small; reference by ID | Aggregate inventory + domain event inventory | Engineering | Alongside development |
| 7. Define repository interfaces | Oriented around aggregate roots, not tables | Interface definitions in the domain layer | Engineering | Alongside development |
| 8. Pilot | Start with one context in the core domain; run the whole flow end to end | One complete DDD service | The pilot team | 3–6 weeks |
| 9. Review and spread | Distill patterns and anti-patterns; expand gradually | Internal team standards document | The pilot team | Ongoing |
Three execution points:
- The order cannot be reversed: skipping strategic design and going straight to tactical design means the team will polish the model to perfection inside the wrong boundary. Strategy first, tactics second.
- Pilot before you scale: do not roll it out company-wide. Pick a context of moderate complexity where the business side is cooperative, prove it there, then copy the approach.
- Allow boundaries to evolve: the first partitioning is almost never fully correct. Treat the boundary as an iterative hypothesis and validate it during the pilot—but remember that the cost of adjusting a boundary grows with the number of consumers, so the later you adjust, the more expensive it gets.
VIII. Common Pitfalls: Symptoms, Root Causes, and Remedies#
| Pitfall | Symptom | Root cause | Remedy |
|---|---|---|---|
| Anemic model | Entities have only getters and setters; the service layer is entirely procedural; the same rule is duplicated in several places | Domain objects treated as DTOs; rules have nowhere to live | Move the rules back into the aggregate; guard invalid states with constructors; forbid public setters |
| Splitting services by table | One service for the user table, one for the order table; changing one feature touches three services | A data model substituted for a domain model; the ER diagram used as the basis for boundaries | Go back to language boundaries and business capabilities; use the same-name-different-meaning matrix |
| Contexts that are too fine-grained | One context per tiny feature; distributed transactions and call chains explode | Confusing “module” with “context”; ignoring distributed costs | Merge contexts that share a rate of change and a team; test with “can this be released independently” |
| Aggregates that are too large | Dozens of entities in one aggregate; heavy concurrent conflict; hundreds of rows loaded per fetch | Aggregate boundaries derived from object associations rather than invariants | Re-split using “is there an invariant that must hold at all times”; switch to ID references |
| Ubiquitous language that stays in the document | A beautifully written glossary, while the code mixes Order, Trade, and Deal | The language was never made an engineering constraint | Maintain the glossary in both directions; make naming a required code-review item |
| Ignoring the org chart | Boundaries clash badly with team boundaries; two teams co-write one context | Conway’s Law ignored; only the technical design was done | Either align the boundary with the team or adjust the team; do not expect a model to be co-owned across teams |
| Over-applying tactical design | Simple CRUD sprints into aggregates, domain events, and CQRS; the code triples in size | Treating DDD as a template rather than matching the means to the complexity | Use simple solutions for simple subdomains; DDD investment should be proportional to business complexity |
| Missing context mapping | Service-to-service calls go wherever convenient, forming a mesh of dependencies | The split was done but the relationships were never defined | Draw the context map and state the pattern of every connection (ACL / OHS / events) |
| Using domain events as integration events | One refactor of the internal model takes down every downstream service | Confusing the contract-stability requirements of the two kinds of event | Translate once at the context boundary; expose only stable integration events externally |
| Publishing the message before committing | Intermittently, downstream receives an order that does not exist, or an event is lost | The database and the message queue cannot commit atomically | Introduce a transactional outbox, paired with idempotent consumption and reconciliation |
IX. Conclusion#
The essence of DDD is reducing business complexity, not showing off technical sophistication. Whether a DDD effort succeeded is not measured by how many patterns were used, but by three questions:
- Are the business side and engineering discussing requirements in the same vocabulary?
- Does every new business rule have one clear, unique home?
- Are the service boundaries stable—does most change stay within a single context?
The core order is:
Strategy before tactics; boundaries before models; business language before technical implementation.
The threads running through this article:
- A bounded context is the basis for splitting microservices, and it is simultaneously a language boundary, a model boundary, and a transactional boundary;
- An aggregate is the boundary of transactional consistency; its granularity is decided by invariants, and collaboration across aggregates can only rely on domain events and eventual consistency;
- Domain events are the means of decoupling contexts, but what actually makes them reliable in production is the combination of outbox + idempotence + compensation + reconciliation;
- The ubiquitous language is the foundation of team collaboration, and it must live in the code, not in a document;
- Context mapping is not just an integration plan; it records the power relationships and collaboration styles between teams.
A framework like Kratos can provide the physical layering for DDD, but real DDD depends on whether you model seriously in the Biz layer, whether you let domain objects carry the business rules, and whether you draw boundaries using the language of the business.
Boundaries matter more than technology; models matter more than frameworks. And before either of those comes a more important precondition: is this business actually complex enough to be worth DDD at all?