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 complexitySourceTypical remedies
Business complexityMany business rules, many exceptions, ambiguous semantics, fast-changing requirementsDomain modeling, bounded contexts, ubiquitous language
Technical complexityHigh concurrency, distribution, storage choices, performance bottlenecksArchitectural patterns, middleware, capacity planning
Organizational complexityMany people, many modules, long collaboration chainsTeam 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:

LayerProblem it solvesCore outputCost of failure
Strategic designWhere the boundaries areSubdomains, bounded contexts, ubiquitous language, context mappingIf the boundary is wrong, no amount of model refinement helps—and refactoring costs are enormous
Tactical designHow to model within a boundaryEntities, value objects, aggregates, domain services, domain events, repositoriesThe 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"| D

Strategic 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:

TypeMeaningInvestment strategySignals
Core domainThe source of the company’s competitiveness; determines differentiationBuild in-house; commit your strongest resources and your best peopleFailing at it means losing competitiveness; peers do it very differently; it changes fast
Supporting subdomainNecessary for the business, but not a source of competitive advantageBuild or buy; aim for stability and low costIt must be done, but customers do not care how well
Generic subdomainIndustry-wide, commodity capabilityBuy it or use open source; do not build it yourselfPeers 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 dimensionMeaningConsequence of crossing it
Language boundaryA term has exactly one meaning within itRequirements reviews turn into people talking past each other; code mixes Order and Trade
Model boundaryThe model serves only this context’s business rulesOne User class carries fields for login, membership, support, and risk control
Transactional boundaryStrong consistency is not pursued across contextsDistributed 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 --> B3

There are four common mapping shapes:

ShapeWhen it happensRisk
1 subdomain : 1 contextIdeal—business and system boundaries coincideNone
1 subdomain : N contextsLarge teams, differing scalability needs, very different read/write loadsCoordination cost between contexts rises
N subdomains : 1 contextThe capabilities are weak, and merging costs less than splittingModels easily contaminate each other; stay alert
1 subdomain : 0 contextsThe subdomain is purchased, or not yet startedMust 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:

StageWhat the business saysThe fractured codeConsequence
RequirementsCancel orderdeleteOrderCancelling is a state transition; deleting is physical disappearance—completely different semantics
APICancel orderPOST /order/removeThe contract cannot express the constraint that only some orders are cancellable
StorageCancel orderDELETE FROM ordersAudit and reconciliation data is lost forever
TestsCancel ordertestDelete()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:

  1. 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.
  2. Naming is a review item: in code review, whether OrderCancelReason really corresponds to the business’s “reason for cancellation” is a first-class review point.
  3. Tests are documentation: name test cases in the business’s own words, e.g. Test_CancelOrder_ShouldReject_WhenAlreadyShipped.
  4. 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.

PatternMeaningWhen it appliesCost
Shared KernelSeveral contexts share a small part of the modelTwo highly collaborative teams, with a stable modelAny change by either side pulls on the other; requires strict control
Customer-SupplierThe upstream adjusts its interface to suit the downstreamA clear service relationship between upstream and downstreamThe upstream must schedule work for the downstream, who bears waiting costs
ConformistThe downstream simply conforms to the upstream model, with no adaptationThe 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 LayerThe downstream adds a translation layer to isolate upstream model changesThe upstream model differs sharply from local semantics, or is unstableAn extra layer to maintain and map
Open Host ServiceThe upstream offers one protocol for many consumersOne upstream, many downstreamsThe protocol must be stable; version management gets complex
Published LanguageEvents or data published in a shared formatAsynchronous integration across contextsRequires schema governance and a compatibility strategy
Separate WaysThe two contexts have no direct relationshipThey really are unrelated in the businessPossible duplicated effort
PartnershipBoth sides succeed or fail together; they schedule for each otherTwo tightly coupled parts of the businessHigh communication cost; one side’s delay drags the whole down
Big Ball of MudA shared model with no boundaries and no rulesNone—avoid itAny 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"| Trade

How 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:

  1. 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”.
  2. Separate core, supporting, and generic subdomains: establish investment priorities.
  3. Find language boundaries: when the same word means different things in different parts of the business, that is a splitting signal.
  4. 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.
  5. Look at team boundaries: Conway’s Law is inescapable; a bounded context is best owned by a single team.
  6. 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.
  7. Look at scalability needs: two capabilities with wildly different read/write ratios, peak QPS, or latency sensitivity are cheaper to deploy separately.
  8. Look at compliance and security boundaries: data classification, cross-border compliance, and audit requirements often form context boundaries naturally.
  9. 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"]
PhaseParticipantsKey outputCommon mistake
Big PictureBusiness + product + engineeringA timeline of domain events on the wallGetting lost in details, arguing over fields and table structures
Process LevelProduct + engineeringClosed command-event-policy loops; hotspots and bottlenecksModeling only the “happy path” and skipping compensation and exceptional branches
Design LevelEngineeringAggregates, invariants, bounded contexts, context mappingJumping 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.

ConceptMeaning in the trading contextMeaning in the fulfillment contextBasis for splitting
OrderA purchase contract, an amount, a discountA batch of picking tasks awaiting dispatchOne manages money, the other manages goods; different rates of change and different teams
ProductA sellable SKU, a price, an inventory snapshotPhysical goods, bin location, volume and weightTrading cares about price; fulfillment cares about physical attributes
UserBuyer account, delivery details, loyalty tierRecipient, contact details, addressTrading needs a profile; fulfillment only needs deliverability
CompletePayment succeeded means completeOnly signed-for counts as completeOne 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 splittingCosts of splitting
Independent deployment and releaseNetwork calls replace in-process calls; latency and failure rates rise
Independent scaling, resources aimed at hotspotsCross-service consistency requires eventual consistency plus compensation
Team autonomy, less coordination blockingThe complexity of distributed tracing, log aggregation, and chain alerting
Fault isolation, so one failure does not take down everythingContract management, version compatibility, integration testing costs
Cleaner model boundaries, less mutual contaminationLoss 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:

DimensionEntityValue object
EqualitySame identity means equalEqual only when every attribute matches
MutabilityMutable (identity fixed, state may change)Immutable; modification returns a new object
Concurrency safetyNeeds version numbers or lockingInherently thread-safe; freely shareable
LifecycleA clear lifecycle and state machineNo lifecycle; used and discarded
Typical examplesOrder, account, couponMoney, 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 carriesMeaningDirect consequence
Invariant boundaryEvery business rule inside the aggregate must hold at all timesDecides “which objects must live together”
Transactional boundaryOne transaction modifies one aggregateDecides “which changes can be completed atomically”
Concurrency boundaryThe scope of locks and version numbersDecides “which operations block each other”

Four aggregate design rules follow from this, along with why they hold:

RuleReason
Keep aggregates smallThe 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 IDHolding 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 transactionAtomicity across aggregates forces distributed transactions, whose availability costs are extremely high
Use domain events and eventual consistency across aggregatesTrade 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:#f8f9fa

A 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:

  1. 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.
  2. Atomicity: is there a modification that must succeed or fail as a unit? If not, they can be split.
  3. 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:

DimensionDomain eventIntegration event
ScopeInside a bounded context, to decouple aggregatesAcross contexts, for external integration
ContentMay carry internal aggregate structure; rich fieldsCarries only contract fields; must stay backward-compatible
Contract stabilityMay change as the model is refactoredOnce published it is a public commitment; fields may be added, never removed
TransportIn-process publish/subscribeMessage 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 OrderPlaced

Publishing 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 typeBelongs toReason
Load an aggregate by IDRepositoryServes writes; needs the complete aggregate
Find aggregates by business conditionsRepositoryThe result still drives business decisions
Multi-table joins, paginated lists, reportsRead model / QueryServiceNo 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):

SignalSymptomExplanation
Shared modelSeveral services import entity definitions from the same common packageThe model has no boundary; one change ripples everywhere
Synchronous call chainOne request passes serially through four or more servicesAvailability multiplies out; one broken link breaks everything
Strong consistency transactionsDistributed transactions across services, or “update A then update B, roll back on failure”The boundary and the transactional boundary disagree
Coupled releasesTwo services must be released together to go liveYou 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 layerDDD layerResponsibilityWhat must not appear here
ServerInfrastructure / UIHTTP and gRPC startup, middleware, lifecycleBusiness decisions
ServiceApplication layerInterface adaptation, DTO conversion, use-case orchestrationBusiness rules, domain computation
BizDomain layerDomain model, use cases, Repository interfacesSQL, framework dependencies, concrete middleware
DataInfrastructure layerRepository implementations, database, cache, external RPCBusiness 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:

  1. Do the domain objects have behavior?
  2. Are the Repository interfaces defined for aggregate roots?
  3. 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 model

The 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?

ApproachHowProsCons
Reuse one struct (often tagged biz:"-")The aggregate carries ORM tags directlyOne less conversion layer; less codeThe domain model is polluted by persistence details; refactoring is constrained
Separate modelsbiz.Order and data.OrderPO are distinctThe domain model stays clean; each side evolves independentlyConversion 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:

StageKey activitiesDeliverablesParticipantsTypical duration
1. Event stormingReconstruct the business process; identify domain events, commands, policies, read modelsEvent wall, closed-loop process diagramBusiness + product + engineering1–2 day workshop
2. Divide subdomainsSeparate core, supporting, and generic subdomainsSubdomain inventory + investment strategyBusiness owners + architects1 week
3. Define bounded contextsRun the language consistency check; settle the boundariesContext inventory + justification for each boundaryEngineering-led1–2 weeks
4. Establish the ubiquitous languageMaintain the glossary in both directions; land it in naming conventionsGlossary + naming conventions + review checklistThe whole teamOngoing
5. Map the contextsDecide integration styles and upstream/downstream relationshipsContext map + decision records for integration stylesArchitects + team representatives1 week
6. Design aggregatesIdentify invariants; keep aggregates small; reference by IDAggregate inventory + domain event inventoryEngineeringAlongside development
7. Define repository interfacesOriented around aggregate roots, not tablesInterface definitions in the domain layerEngineeringAlongside development
8. PilotStart with one context in the core domain; run the whole flow end to endOne complete DDD serviceThe pilot team3–6 weeks
9. Review and spreadDistill patterns and anti-patterns; expand graduallyInternal team standards documentThe pilot teamOngoing

Three execution points:

  1. 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.
  2. 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.
  3. 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#

PitfallSymptomRoot causeRemedy
Anemic modelEntities have only getters and setters; the service layer is entirely procedural; the same rule is duplicated in several placesDomain objects treated as DTOs; rules have nowhere to liveMove the rules back into the aggregate; guard invalid states with constructors; forbid public setters
Splitting services by tableOne service for the user table, one for the order table; changing one feature touches three servicesA data model substituted for a domain model; the ER diagram used as the basis for boundariesGo back to language boundaries and business capabilities; use the same-name-different-meaning matrix
Contexts that are too fine-grainedOne context per tiny feature; distributed transactions and call chains explodeConfusing “module” with “context”; ignoring distributed costsMerge contexts that share a rate of change and a team; test with “can this be released independently”
Aggregates that are too largeDozens of entities in one aggregate; heavy concurrent conflict; hundreds of rows loaded per fetchAggregate boundaries derived from object associations rather than invariantsRe-split using “is there an invariant that must hold at all times”; switch to ID references
Ubiquitous language that stays in the documentA beautifully written glossary, while the code mixes Order, Trade, and DealThe language was never made an engineering constraintMaintain the glossary in both directions; make naming a required code-review item
Ignoring the org chartBoundaries clash badly with team boundaries; two teams co-write one contextConway’s Law ignored; only the technical design was doneEither align the boundary with the team or adjust the team; do not expect a model to be co-owned across teams
Over-applying tactical designSimple CRUD sprints into aggregates, domain events, and CQRS; the code triples in sizeTreating DDD as a template rather than matching the means to the complexityUse simple solutions for simple subdomains; DDD investment should be proportional to business complexity
Missing context mappingService-to-service calls go wherever convenient, forming a mesh of dependenciesThe split was done but the relationships were never definedDraw the context map and state the pattern of every connection (ACL / OHS / events)
Using domain events as integration eventsOne refactor of the internal model takes down every downstream serviceConfusing the contract-stability requirements of the two kinds of eventTranslate once at the context boundary; expose only stable integration events externally
Publishing the message before committingIntermittently, downstream receives an order that does not exist, or an event is lostThe database and the message queue cannot commit atomicallyIntroduce 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:

  1. Are the business side and engineering discussing requirements in the same vocabulary?
  2. Does every new business rule have one clear, unique home?
  3. 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?

你又来啦!好开心 🐟