What a service boundary actually decides: subdomains, bounded contexts and services

Three terms get used interchangeably in boundary discussions. Mixing them up leads to bad splits.

A subdomain is an implementable model of one slice of business functionality. It contains business logic, organized as DDD aggregates that enforce business rules, and adapters that talk to the outside world. In a Java codebase, that might be a set of packages compiled into one JAR.

A bounded context is the scope in which one model and its vocabulary stay consistent. Inside it, "order" or "account" has exactly one meaning. Outside it, the same word may refer to something different.

A service is a deployable, executable component that holds one or more subdomains.

Drawing microservice service boundaries is therefore a packaging decision: which subdomains get deployed together. It is not a matter of inventing services from nouns in a requirements document, from technical layers, or from database tables. Those approaches create services that don't line up with how the system behaves.

The system's behavior is a set of operations. Each operation is triggered by a client request, an event from another service or application, or the passage of time, and it reads or changes aggregates in one or more subdomains. That leads to the thesis of this guide: group subdomains so that most operations complete inside one service owned by one team, and treat every cross-service call as a cost you accept deliberately. Each such call adds latency, new ways to fail, and a need for coordination.

This guide is for backend and platform engineers who are designing a new system, splitting a monolith, or questioning an existing split. The model above follows the Microservice Architecture pattern.

Finding model seams with bounded contexts before thinking about deployment

Deployment decisions are expensive to reverse. Once two subdomains live in separate services with separate databases, merging them back means migrating data and rewriting the calls between them. Domain-driven design comes first because it finds where the business model already splits, so the deployment boundary follows a real seam instead of an imagined one.

Look for words that change meaning

The clearest seam is a term that means different things to different teams. In checkout, an "order" is a cart being priced, discounted and paid for. Its rules concern totals, tax and payment state. In fulfillment, an "order" is a set of items to pick, pack and ship. Its rules concern stock location, parcel weight and carrier. These two models change for different reasons and at different times. That makes the line between them a strong boundary candidate. A term like "customer email" that means the same thing everywhere usually does not mark a seam.

The practical limits

Finding bounded contexts properly takes access to domain experts and time spent modeling. Teams that skip this work tend to split by entity (a "Product service", an "Order service") or by technical layer. Those splits produce chatty services that have to call each other for almost every operation.

When expert time is scarce, cheaper signals still help:

  • Compare how different teams describe the same database records.
  • Look at where validation rules for the same field differ between modules.
  • Notice which fields each team ignores entirely.

How to test a candidate boundary by tracing system operations

Those signals suggest where the model seams might be, but they don't tell you whether a grouping will work at runtime. To check that, trace the system operations through each candidate boundary.

Step 1: List the operations

Start with what the system does, not with the services you expect to build. An operation can be triggered in four ways:

  • Synchronous client requests, such as placing an order or fetching a cart
  • Asynchronous client requests, such as a submitted export job
  • Events published by other applications or services, such as a payment-settled notification
  • The passage of time, such as nightly reconciliation or reservation expiry

Time-based triggers are easy to forget, and they often touch more data than any user-facing request.

Step 2: Record reads and writes per operation

For each operation, write down which subdomains and aggregates it reads and which it changes. Keep reads and writes separate because they cost different amounts across a boundary. You can usually replicate data for a cross-service read or serve it from a cache. A cross-service write means coordinating a change, which usually requires a saga and compensating actions.

Step 3: Map operations onto the candidate services

Assign each aggregate to a proposed service. Flag every operation that touches entities in more than one service as a distributed operation. Then weight each flag by how often the operation runs and how much it matters to the business.

Take checkout as an example. If Order, Inventory and Payment sit in one service, checkout runs in one local transaction. If they are split across three services, the same request becomes a call chain or saga with three failure points and no shared transaction. A rare admin report that crosses a boundary is cheap. Checkout crossing one is expensive.

Keep the operation inventory as data and have a script count the crossings. That lets you score several alternative groupings against the same inventory and compare them on equal terms.


Note: The brief asked for an ASCII diagram of checkout under the two groupings, but it also said not to write code blocks, and a diagram won't render correctly outside one. So I described the comparison in prose under Step 3 instead. If you want the diagram, it can go in alongside the separate code example.

# Operation inventory as plain data. runs_per_day and criticality (1-3) are
# assumed inputs for illustration, not measurements.
OPS = [
    # name, trigger, reads, writes, runs_per_day, criticality
    ("checkout", "sync", ["Cart", "Customer"], ["Order", "Inventory", "Payment"], 5000, 3),
    ("get_cart", "sync", ["Cart", "Product"], [], 40000, 2),
    ("payment_settled", "event", ["Payment"], ["Order"], 5000, 3),
    ("expire_reservations", "time", ["Order"], ["Inventory"], 24, 2),
    ("sales_report", "async", ["Order", "Payment", "Customer"], [], 1, 1),
]

# Candidate groupings: aggregate -> owning service.
BASE = {"Cart": "cart", "Product": "catalog", "Customer": "customer"}
GROUPINGS = {
    "A: order+inventory+payment together": {**BASE, "Order": "ordering", "Inventory": "ordering", "Payment": "ordering"},
    "B: one service per aggregate": {**BASE, "Order": "order", "Inventory": "inventory", "Payment": "payment"},
}

def score(owner):
    cross_reads = cross_writes = 0
    for name, trigger, reads, writes, per_day, crit in OPS:
        # Home service owns the first aggregate written (or read, if read-only).
        home = owner[(writes or reads)[0]]
        r = sum(owner[a] != home for a in reads)  # remote reads: cache or replicate
        w = len({owner[a] for a in writes}) - 1 if writes else 0  # extra services to coordinate: saga
        if r or w:
            weight = per_day * crit  # frequency x business importance
            cross_reads += r * weight
            cross_writes += w * weight
            print(f"  DISTRIBUTED {name} ({trigger}): {r} remote reads, {w} remote writes, weight {weight}")
    return cross_reads, cross_writes

for label, owner in GROUPINGS.items():
    print(label)
    reads, writes = score(owner)
    print(f"  weighted cross reads={reads}, weighted cross writes={writes}\n")

An operation inventory as plain data plus a script that flags distributed operations under two candidate groupings and totals their cross-boundary reads and writes, weighted by assumed frequency and business criticality.

When to split and when to keep subdomains together

Every proposed split should name the force that justifies it. The Microservice Architecture pattern describes two opposing sets. Simpler components, team autonomy, fast build and test pipelines, multiple technology stacks, and segregation by scalability, availability or security requirements push subdomains apart. Simple interactions, efficient interactions and a preference for ACID over eventual consistency pull them together. "It feels like a separate thing" is not on either list. If you can't name a force, you don't have a reason to split.

Force Evidence it really applies Cost of acting on it
Simpler components Developers struggle to understand or change the combined codebase More components to operate
Team autonomy Teams regularly wait on each other to release Cross-team API contracts to maintain
Fast pipeline Build and test time slows feedback Separate pipelines and integration tests
Multiple stacks A subdomain needs a different language or framework Duplicated tooling and expertise
Different scalability, availability or security needs One subdomain's requirements differ from the rest and drive cost or risk Network calls where there were function calls
Simple interactions Operations stay local or need few calls Tracing distributed failures
Efficient interactions Few round trips, small payloads Latency and data transfer
ACID over BASE Invariants must hold immediately Sagas and compensating actions

Runtime forces often conflict with domain cohesion. A split justified only by load can pull one aggregate's behavior across two services, which leaves its invariants protected by messages instead of a transaction. The fix for load is usually to scale that one component, not to divide the model.

Rule of thumb: if a split forces a frequent, high-value operation into a saga or a chatty call chain, the boundary is probably wrong.

Implementing the boundary: whole aggregates, exclusive data ownership and team alignment

Once you have a grouping, three rules turn it into something the codebase and the organization can enforce.

Keep every aggregate whole inside one service. An aggregate is the consistency boundary for its business rules. An order's total must match its line items, and a reservation can't exceed available stock. Inside one service, a local ACID transaction protects that invariant. If you split the aggregate across services, the same rule becomes a distributed coordination problem, with compensating actions, partial failures and windows where the data is wrong.

Give each service exclusive ownership of its data. Other services get data through the owner's API or its published events. They never read its tables directly. A shared database brings back the coupling the boundary was meant to remove, because a schema change becomes a cross-team negotiation and the owner can no longer reason about who depends on what. The example below contrasts a query against another service's table with consuming a published event to maintain a local read model. The read model accepts eventual consistency, and in return the consumer is decoupled from the owner's schema.

Align each service with one owning team. One team can own several services, but each service should have exactly one owner. If a typical feature routinely needs coordinated changes and deployments across teams, the boundary works against team autonomy, however clean the code looks. Conway's law applies in both directions. Systems tend to mirror the communication structure of the teams that build them, and teams organized around the boundaries you want make those boundaries easier to keep. Reshaping teams is an organizational decision, though, and engineers can't make it alone.

// Shipping service needs the customer's delivery address.
// The Customers service owns the customers table and publishes events.

// ANTI-PATTERN: reach into another service's database.
// Shipping now depends on Customers' private schema; renaming or
// splitting a column breaks Shipping without Customers knowing.
function addressForShipment(customerId):
    row = customersDb.query(
        "SELECT street, city, postal_code FROM customers WHERE id = ?",
        customerId)
    return row

// BOUNDARY-RESPECTING: consume the owner's published event and keep
// a local read model containing only the fields Shipping needs.
// The event payload is the contract; the owner's tables stay private.
on event CustomerAddressChanged(customerId, street, city, postalCode, version):
    existing = shippingDb.findAddress(customerId)
    // Events can arrive twice or out of order; ignore stale ones.
    if existing != null and existing.version >= version:
        return
    shippingDb.upsertAddress(customerId, street, city, postalCode, version)

// Reads are local and fast, but eventually consistent: a just-changed
// address may not be visible until the event has been processed.
function addressForShipment(customerId):
    return shippingDb.findAddress(customerId)

Contrasts a Shipping service querying the Customers service's table directly with consuming a CustomerAddressChanged event to maintain its own idempotent, eventually consistent read model.

When to avoid splitting, and the alternatives

Often the team you actually have is the strongest argument for fewer boundaries, not more.

Drawing many boundaries is the wrong move in four situations:

  • The domain is still poorly understood. Your bounded contexts will move as you learn.
  • One small team owns everything. There is no team autonomy to gain.
  • No subdomain has different scaling, availability or security needs.
  • You can't yet operate distributed systems well. That means tracing, versioned APIs, sagas and on-call coverage per service.

In these cases you have three good options:

  • A modular monolith with enforced module boundaries. Use build-time dependency rules and a separate schema per module.
  • Fewer, larger services.
  • Splitting out only the one subdomain that has a concrete reason to stand alone.

Starting with fewer services is the cheaper bet because of how each mistake gets fixed. A well-isolated module already owns its data and talks to others through an interface, so extracting it is mostly packaging and deployment work. Merging services that were split too early is harder. You have to consolidate databases, retire APIs that other teams use, and swap sagas back for local transactions.

This approach avoids two failure modes:

  • Too coarse: builds are slow, and unrelated changes wait behind each other in one pipeline.
  • Too fine: you get a distributed monolith. You pay microservice overhead, but services still have to be released together.
Modular monolith Coarse-grained services Fine-grained services
Deployment independence None: one deployable Per service; teams sharing a service share releases High in principle; lost if services must release together
Transaction model Local ACID throughout ACID inside a service, sagas across a few Sagas and eventual consistency for most operations
Operational overhead Low Moderate High
Cost of changing a boundary later Low: refactor in one codebase Moderate High: data migration and API retirement

Impact on cost and engineering time, and the signs a boundary needs redrawing

Each boundary turns some in-process calls into network calls. Every crossing brings added latency, partial-failure handling, retries with idempotency, and a versioned API that both sides must keep compatible. Sagas add compensating logic, harder debugging, and intermediate states that users and support staff can see and ask about.

Illustrative estimate (assumed numbers, not a benchmark)

Extra calls per day = operations per day × share of operations crossing a boundary × extra round trips per crossing

Assume 2,000,000 operations/day, 30% crossing and 3 extra round trips: 2,000,000 × 0.30 × 3 = 1,800,000 extra network calls per day, each one a possible timeout. Redraw the boundary so only 10% cross, and the figure falls to 600,000. Cost grows linearly with crossing frequency, so that share is the variable to design against.

Well-drawn boundaries save engineering time in four ways. Codebases are smaller. Deploys are independent. Pipelines run faster. Teams need fewer cross-team coordination meetings.

Warning sign Likely cause Corrective action
Two services always deploy in lockstep One model split across services Merge them, or move the shared aggregate
Same change repeated in several codebases Duplicated business rule Give one service ownership of the rule
Deep synchronous call chain on a hot path Operation spans too many services Co-locate the subdomains, or replicate read data
Frequent cross-service consistency bugs Invariant enforced across a boundary Pull the invariant into one aggregate

Treat boundaries as hypotheses that you revisit as your understanding of the domain improves. Redrawing has real costs. Data migrations need backfills and a cutover plan. API deprecations need a window for consumers to move off the old API. Ownership changes need on-call duties, runbooks and context to move with the code. Act on warning signs that persist rather than on a single incident, and move one aggregate at a time.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to microservice service boundaries, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.