DDD · Architecture

Finding
Domain Boundaries

Dividing a domain well isn't about classifying nouns — it's about finding the boundary between change and responsibility. Before anything else, observe which things need to change together as features grow.

It's natural for a service to start with a single model. But once features that change for different reasons — like orders, payments, and shipping — start mixing into that one model, even a small change can ripple further than expected. What's needed at that point isn't more abstraction, but a fresh look at the boundaries.

The boundary here isn't just a criterion for splitting tables or microservices. It determines which rules must hold together at once, which changes a single team is responsible for, and at what point asynchronous collaboration becomes acceptable. A poorly drawn boundary forces every request to lock multiple models; a well-drawn one keeps changes contained even as features grow.

Before You Start: A Domain Is Not the Same as a Boundary

The phrase “order domain” is too broad. To a customer it means a completed purchase; to a seller it's the start of intake and settlement; to a logistics team it's a work item to ship. Even sharing the same order number, each party cares about different states and rules. The point where a model's language and rules diverge like this is a candidate for a Bounded Context.

Conversely, there's no need to physically split a service from the start. You can draw a clear boundary even within a single module, and that's often better for fast feedback. What matters is that neither side reads or modifies the other's internal state directly — they should communicate only through clear command, query, and event contracts.

1. Find What Changes Together

Boundaries reveal themselves through reasons for change more than through data types. If a change in discount policy also requires modifying the order's state transitions, the two may still belong in the same model. Conversely, if a shipping carrier's policy change has nothing to do with order-creation rules, shipping is likely an independent responsibility.

Ask repeatedly: “Is this changed at the same time, for the same reason, by the same person?”

This question is especially useful when sorting requirements that come out of a meeting. “Only one coupon may apply per order” and “the order total must equal the sum of its line items” both need to be validated together at order-creation time. On the other hand, a rule like “validate the address before shipment” is more likely to change during logistics processing than order creation. The former belongs closer to the order model, the latter to the shipping model.

Looking at change history also helps. If a particular file or table always changes together and gets reviewed by the same person, it may still be a single responsibility. Conversely, if different release schedules and owners have emerged, that's a signal to consider splitting. That said, drawing boundaries from team structure alone risks making the model a slave to the org chart, so this judgment must always be paired with business rules.

2. Decide Who Owns Each Invariant

An Aggregate isn't a box that collects all the data. It's a unit that protects a rule which must hold within a single transaction. For instance, the order Aggregate can be responsible for keeping line-item counts and the total consistent, but trying to also guarantee inventory deduction inside the order can make the boundary too large.

A Practical Rule of Thumb

Keep only the rules that need strong consistency inside the Aggregate. Connect the rest through domain events and downstream processing, so each model can focus on its own rules.

At the moment a cart is confirmed into an order, the quantity, unit price, coupon, and total must not contradict each other. This rule can be validated immediately within a single order Aggregate. But rules like “the order is confirmed only once payment succeeds” and “inventory must be reserved” involve an external payment method and an inventory system. Bundling all of this into one transaction increases failure propagation and lock time.

So instead of changing payment status directly, the order Aggregate publishes a fact like OrderPlaced. Payment and inventory each receive that fact and carry out their own rules. Making failure a visible possibility in the model, and handling compensation or retries as a separate flow, is a more realistic consistency model.

Example: The Rules an Order Should Guarantee

The code below is an example containing only the minimal rules the order itself must protect. Inventory quantity or payment-approval results are never changed here. Only the fact that the order was created is left as an event, for the next model to handle.

public record OrderLine(String productId, int quantity, long unitPrice) {
}

public record OrderPlaced(String orderId) {
}

public class Order {
    private final List<Object> events = new ArrayList<>();
    private final String id;
    private final String customerId;
    private final List<OrderLine> lines;

    private Order(String id, String customerId, List<OrderLine> lines) {
        this.id = id;
        this.customerId = customerId;
        this.lines = lines;
    }

    public static Order place(String id, String customerId, List<OrderLine> lines) {
        if (lines.isEmpty()) {
            throw new IllegalArgumentException("At least one order line is required.");
        }
        if (lines.stream().anyMatch(line -> line.quantity() <= 0)) {
            throw new IllegalArgumentException("Quantity must be at least 1.");
        }
        Order order = new Order(id, customerId, lines);
        order.events.add(new OrderPlaced(id));
        return order;
    }

    public long totalAmount() {
        return lines.stream()
                .mapToLong(line -> (long) line.quantity() * line.unitPrice())
                .sum();
    }
}

The key point of this code is that Order only knows its own invariants. Injecting an InventoryRepository or a payment SDK might look convenient, but then the order model would have to know about every external policy and failure. The moment that happens, the boundary blurs and testing becomes harder.

3. Note Where the Language Changes

When the same word starts meaning different things to different teams, that can be a signal of a Bounded Context. To customer support, an “order” might be the subject of a refund or inquiry; to logistics, it's a unit of packing and shipment. Trying to express both meanings with a single model just piles up fields and blurs the rules.

Writing down the verbs people use during event storming or requirements workshops makes this difference clearer. In the order context, “create,” “cancel,” and “discount” matter; in the shipping context, “dispatch,” “ship,” and “track” take center stage. Rather than sharing a single Order class, letting each context express only the information it needs, in its own language, keeps the model simpler.

The cost of translation between boundaries is not something to avoid — it's a mechanism that preserves intent. If the shipping service receives a ShippingRequested event instead of joining the orders table, it's less affected when the order's internal structure changes. An event should carry only the stable information the other model needs, and should avoid serializing internal objects as-is.

Example: Connecting Boundaries with Events

public record OrderPlaced(
        String orderId,
        String customerId,
        List<OrderItem> items) {
}

public record OrderItem(String productId, int quantity) {
}

public record StockReservationFailed(String orderId, String reason) {
}

// The inventory context consumes the order event.
public class ReserveStockHandler {
    private final InventoryClient inventory;
    private final EventPublisher eventBus;

    public ReserveStockHandler(InventoryClient inventory, EventPublisher eventBus) {
        this.inventory = inventory;
        this.eventBus = eventBus;
    }

    public void handle(OrderPlaced event) {
        ReservationResult result = inventory.reserve(event.items());

        if (!result.success()) {
            eventBus.publish(new StockReservationFailed(event.orderId(), result.reason()));
        }
    }
}

In this flow, the order doesn't wait for stock to be reserved. Instead, it receives a “stock reservation failed” state and carries out follow-up policies such as cancelling the order, notifying the customer, or retrying. This requires handling a few more states than a model that assumes immediate success, but it preserves each system's availability and responsibility. The consumer must always be implemented idempotently, so the outcome doesn't change even if the same event arrives twice.

4. Read Models Can Cross Boundaries

Strictly separating command-model boundaries doesn't mean the screen has to be assembled the same way. A “My Orders” screen needs to show order status, payment status, and shipment tracking all at once. To satisfy that kind of query requirement, it's better to build a screen-specific read model from events than to bundle multiple Aggregates into a single transaction.

At this point, you need to specify the delay the user can tolerate. Whether it's fine for shipping status to be empty for a few seconds right after payment, or whether an operator's screen must reflect changes immediately, determines the choice between synchronous queries and asynchronous projections. Designing boundaries isn't a technical problem — it's a decision made jointly about the product's consistency expectations.

5. Boundaries Start as a Hypothesis

You don't need to find the perfect boundary from the start. It's enough to form a small hypothesis based on current change patterns, team responsibilities, and data-consistency requirements, and then validate it. What matters is putting a clear API or event contract between boundaries, to lower the cost of splitting or merging them later.

At first, splitting into modules within a single application is enough. Between modules, use only public interfaces, and never reference another module's Repository or entity directly. Later, when release cadence, performance needs, or team ownership diverge, that module can be pulled out into an independent service. Physical separation is the last decision, not the first step in creating a boundary.

Questions for Checking a Boundary

  • Must this rule hold within a single transaction?
  • Do these two features change for the same reason and on the same cadence?
  • Does the same word carry different meanings across teams or screens?
  • Can this feature only be implemented by directly knowing another model's internal data?
  • On failure, is a synchronous rollback required, or can a compensating flow resolve it?

A good boundary isn't about looking beautiful in a diagram — it localizes change and makes conversations between teams clearer. The starting point is the habit of asking “what changes together, and why” before splitting a model.

Further reading in the repo

docs/architecture/strategic-ddd.md — Subdomain, Bounded Context, and Context Map · docs/architecture/tactical-ddd.md — Aggregate/Entity/Value Object boundary criteria once a Bounded Context is chosen