Software Architecture · Distributed Architecture
Drawing Service Boundaries Around Consistency Requirements
How invariants determine where a service boundary can fall, what a shared database does to that boundary, and which cross-service operations need a different mechanism entirely.
The most common way to get a service boundary wrong is to draw it through an invariant. Two services that each hold half of a rule the business depends on will spend the rest of their existence reconciling, and every reconciliation job is a live admission that the split was in the wrong place. Consistency requirements are therefore a better first cut than org charts or noun lists, because they are checkable and they do not change when a team reorganises.
The transaction as the boundary marker
A local transaction against a single database gives atomicity, isolation, and durability for a set of writes. It is the strongest primitive generally available, and its scope is a natural definition of a service boundary: everything that must be written together lives together.
The practical test is a single question about each business rule. Does the rule have to hold at every instant another transaction can observe the data, or may it hold eventually with a bounded and stated delay? A rule of the first kind forces the data it reads and writes into one transactional scope. A rule of the second kind can cross a boundary, at the price of a convergence mechanism.
Most rules are the first kind. “An account balance never goes negative” cannot be enforced by two services each holding a shard of the ledger and exchanging messages, unless one of them owns the balance and the other owns a request queue. “An order total equals the sum of its line items” is enforceable inside the order, because both are in the same aggregate. “The same seat is never sold twice” requires the seat allocation and the sale to commit together, which is why inventory reservation is one of the hardest things to distribute.
The aggregate pattern from domain-driven design expresses the same constraint structurally. A transaction modifies one aggregate, and aggregates refer to each other by identity rather than by holding references to each other’s state. The rule exists precisely because object graphs encourage code that mutates several aggregates in one unit of work, which works in a monolith and becomes a distributed transaction the moment those aggregates live in different services.
Read paths are not part of this constraint. A query that joins data from several domains does not need atomicity; it needs a defensible answer when one of the sources is missing or stale. Separating the read model from the write model is what allows the write side to keep narrow transactional boundaries while the read side spans them, and it is a much cheaper separation than stretching the write boundary to cover every consumer. The constraint on the read side is freshness, which is a number that can be stated, measured, and bounded.
One writer per fact follows from the same reasoning. A field written by two services has no single place where its invariant is enforced, and the two writers will diverge under concurrency no matter how carefully each one validates. Data ownership is therefore not a matter of team pride; it is the requirement that every fact has exactly one component whose transaction protects it, and every other component holds a copy that it is prepared to see stale.
A conditional update is often all the enforcement that is needed:
BEGIN;
UPDATE seat
SET holder_id = $1, held_until = now() + interval '10 minutes'
WHERE seat_id = $2
AND holder_id IS NULL;
-- Zero rows means the seat was taken between the read and the write.
-- The caller gets a conflict without ever holding a lock across a network call.
INSERT INTO seat_hold (seat_id, holder_id, expires_at)
VALUES ($2, $1, now() + interval '10 minutes');
COMMIT;
The WHERE clause carries the invariant into the database, where the row lock and the update are one atomic step. Checking availability with a SELECT and then updating in a second statement reintroduces the race the transaction was supposed to remove.
What a shared database does to two services
When two services read and write the same tables, the split is nominal. Every coupling the monolith had through shared memory reappears through the schema.
A foreign key spanning the two services means neither can delete or restructure its own rows without the other. A schema migration that adds a NOT NULL column requires both services to write it, so the migration and two deploys become one release. Row-level locks held by one service block the other, so p99 latency of one becomes p99 latency of the other. A deadlock can now span two independent codebases, and neither team can diagnose it from its own logs. Backup and restore are all-or-nothing, so recovering one service’s data destroys the other’s writes made since the backup.
The usual justification is reporting or referential integrity. Reporting can be served from a replicated read model rather than from the operational tables, and referential integrity across a boundary is a coupling the boundary was meant to remove. If two datasets genuinely cannot be separated, they belong in one service, and the honest move is to merge rather than to maintain the fiction of two.
Distributed commit exists and is usually the wrong answer. Two-phase commit needs a coordinator that survives its own failures, holds locks on participants from prepare until commit, and requires every participant to expose the protocol. PostgreSQL supports PREPARE TRANSACTION, but prepared transactions remain visible in pg_prepared_xacts and continue to hold locks until resolved, and the feature is disabled by default because a prepared transaction left unresolved blocks vacuum from reclaiming anything it touched. Systems built on XA carry the same property: availability of the whole is bounded by the coordinator, and a coordinator outage turns into a fleet-wide stall.
Cross-boundary operations and what they cost
Once a use case spans two boundaries, there are four honest options, and each has a distinct failure mode.
A saga runs local transactions in sequence and compensates the earlier ones when a later step fails. It has no isolation, so intermediate states are visible, and a compensation is a new business action rather than an undo. Compensating Transactions in Multi-Service Workflows covers the mechanics and the visibility problems.
A transactional outbox makes the state change and the notification atomic within one service, and moves the cross-service step to a relay that retries. It solves the dual-write problem inside a boundary; it does not make the remote step atomic. The pattern is described in The Dual-Write Problem and the Transactional Outbox.
A reservation moves the contention earlier. Inventory is decremented in its owning service with a short-lived hold, and the order proceeds against the hold. The invariant “never oversold” holds in the inventory service, and the order service owns a promise with an expiry. Compensation for an abandoned checkout is then a timeout rather than a message.
Reconciliation accepts divergence and repairs it. It works when the rule is arithmetic and convergent, such as “the ledger sum equals the invoice total”, and it fails when the rule is a business decision, because two conflicting corrections have no defined order. Reconciliation also needs an authoritative source; without one, it oscillates.
| Approach | Invariant window | Failure mode |
|---|---|---|
| Single transaction | Always holds | Boundary too large to deploy independently |
| Saga | Visible intermediate states | Compensation fails or is not semantically possible |
| Outbox | Local step atomic, remote delayed | Duplicate delivery, ordering per aggregate only |
| Reservation | Holds until expiry | Stale holds block capacity |
| Reconciliation | Converges given an authority | Oscillation, unresolvable conflicts |
Decomposition by noun and the graph it produces
Splitting by business noun produces services named Customer, Order, Product, and Payment. Each name maps to a table family, which makes the split easy to justify and easy to draw. It also produces call graphs where a single checkout touches all four synchronously, so the composed availability is the product of four services for one user action, and every new screen adds edges.
Splitting by use case or capability produces services that own a complete operation, such as Checkout, Fulfilment, and Catalogue. These have fewer inbound edges per user action and more internal duplication. The trade is real in both directions: noun boundaries minimise duplicated data, use-case boundaries minimise cross-service calls. Which one is right depends on whether the dominant cost is inconsistency or latency, and that answer differs per business.
What does not work is a hybrid where nouns own the tables and use cases own the orchestration, with the orchestration service reading the noun tables directly. That is a monolith with extra network hops.
Testing a candidate boundary
Three checks catch most mistakes before they are built.
List the writes that must be atomic for each business rule. If a boundary separates two entries on the same list, the boundary is wrong or the rule needs redefining.
Count the edges a single user action traverses. More than three synchronous hops for a read path, or more than one for a write path, means the boundary set is organised around data rather than around work.
Ask what happens during a partial failure. If the answer is a manual repair procedure with no automatic convergence, the operation spans a boundary that cannot support it. The repair procedure will run, and it will run at the worst possible time.
References
- Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
- Life Beyond Distributed Transactions: an Apostate's Opinion — Pat Helland, CIDR 2007
- PostgreSQL Documentation — PREPARE TRANSACTION — PostgreSQL Global Development Group