Skip to content
QSWEQB
hardDesignConceptMidSeniorStaffLead

How do you decide where one service ends and the next begins, and when is a modular monolith the more honest answer?

Draw boundaries on data ownership so exactly one service writes each table, then accept that a workflow crossing them needs a saga of compensating steps and an outbox to make the write and the publish atomic. While the boundaries are still moving, a modular monolith is the honest answer.

7 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate use data ownership as the boundary test rather than layers, nouns or team convenience
  • Whether independent deployability is checked against real change scenarios instead of asserted
  • That the dual-write problem is named as the reason an outbox exists, not just the pattern's shape
  • Can they say what a saga gives up compared with a transaction, and what that means for the user interface
  • Whether a monolith is presented as a defensible current answer rather than as technical debt

Answer

The boundary is a data boundary

Everything else about service design is downstream of one rule: exactly one service writes each piece of data, and everyone else reads it through that service's interface or through events it publishes. If two services write the same table you do not have two services, you have one service with two deployment artefacts and a shared mutable variable between them. Nobody can change a column, nobody can change a constraint, and the database has become the coupling you were trying to remove.

That gives you a usable test for a proposed split. Take the three or four changes you expect this year, and ask whether each lands inside one service. If adding a field to an order means touching an order service, a fulfilment service and a notification service, and releasing them together in a fixed sequence, the boundary is in the wrong place. Boundaries should fall where change does not cross, and the way you find those seams is by looking at the business capabilities and the language people use — the bounded contexts, in domain-driven design terms, where the same word means genuinely different things. "Order" in checkout is a basket with a payment intent; "order" in the warehouse is a pick list. Those are two models, and forcing them into one shared schema is how a canonical data model quietly becomes a coupling nobody can remove.

Team ownership is the second constraint, and it is not a soft one. A service with no clear owning team decays; a team owning six chatty services spends its time on coordination. Aim for boundaries a single team can hold entirely, because the boundaries that survive are the ones that match how decisions get made.

The distributed monolith

The failure this question is hunting for has a specific shape, and it is much more common than a monolith that should have been split. You divide the system by technical layer or by database table, give each part a repository and a deployment pipeline, and end up with services that cannot be released independently. Every feature touches three of them. There is a release train. One team's migration blocks another's deploy. A single user request fans out into eleven synchronous hops, so your availability is the product of eleven availabilities and your latency is their sum, and one slow dependency stalls threads all the way back to the edge.

You now pay every cost of distribution — network failure, partial failure, versioning, distributed debugging, eventual consistency, a much harder local development story — and collect none of the benefits, because the benefit was always independent deployability and you do not have it. Naming this trade explicitly is what separates an experienced answer: the network boundary is not free modularity, it is modularity bought with a permanent tax, and if the boundary is wrong the tax is pure loss. Worse, a wrong boundary inside a process is a refactor an IDE can perform in an afternoon; a wrong boundary across a network is a migration with dual writes, backfills and a cutover plan.

When the monolith is the right answer

A modular monolith is one deployable unit with internal boundaries that are actually enforced: modules with explicit public interfaces, private schemas or at minimum non-overlapping table ownership, no reaching into another module's tables, and a build-time check or architecture test that fails when someone does. You get in-process calls with no serialisation, real transactions across the whole workflow, one thing to deploy and debug, and stack traces that cross the whole request.

Say it is the right answer when you do not yet know where the boundaries go — a new product, an unproven domain — because the cost of being wrong is a rename rather than a migration. Say it when the team is small enough that deployment contention is not the constraint you are solving. Say it when the load profile is uniform, so there is nothing that needs to scale independently of everything else. And say what would change your mind: a component with a genuinely different scaling shape, a compliance boundary requiring separate data handling, a team large enough that one release pipeline has become a queue, or a part of the system that needs a different runtime. Extracting a well-bounded module later is comparatively cheap, and that cheapness is the whole point of enforcing the boundaries while it is still one process.

Sagas, once a workflow crosses a boundary

Split order, payment and inventory into three services with three databases and you have given up the transaction that used to make "reserve stock, charge card, confirm order" atomic. A saga replaces it with a sequence of local transactions, each committing in one service, plus a compensating action for each step that can undo its effect after the fact.

Compensation is not rollback, and the difference is the thing to be precise about. The intermediate states were committed and visible, so someone may have read them, and undoing a charge means issuing a refund rather than pretending nothing happened. You may not be able to compensate at all: an email is sent, a warehouse has shipped. That is why sagas are ordered around a pivot point, with reversible steps before it and steps that must be retried until they succeed after it. It is also why the user interface is part of the design — "your order is being confirmed" is a state your product now has to express, because "your order is confirmed" is a claim you cannot yet make.

Choreography has each service react to events, which keeps coupling low and makes the overall workflow implicit and hard to reason about once there are five participants. Orchestration puts the sequence in one coordinator that issues commands, which is explicit, testable and monitorable, at the cost of a component that knows about everyone. Anything with real money or a compliance story in it usually wants orchestration, because "where is this order stuck" needs to be a query rather than an investigation.

Every step must be idempotent, without exception. Messaging is at-least-once, retries happen, and a compensating action applied twice must not refund twice.

Why the outbox exists

The saga assumes a service can commit its local change and publish its event as one unit, and by default it cannot. Committing to Postgres and then publishing to a broker is two writes to two systems with no shared transaction; a crash between them either loses the event, so the saga stalls with an order nobody progresses, or — if you publish first — announces something that never committed. This is the dual-write problem and it is not solved by ordering the two calls more carefully, because there is no order that survives a crash in the gap.

The outbox pattern removes the gap by making the event a row in the same database.

BEGIN;
UPDATE orders SET status = 'AWAITING_PAYMENT' WHERE id = 4412;

-- Same transaction, same database: the event cannot exist without the
-- state change, and the state change cannot exist without the event.
INSERT INTO outbox (id, aggregate_id, type, payload)
VALUES (gen_random_uuid(), 4412, 'OrderPlaced', '{"orderId":4412}');
COMMIT;

A separate relay then moves rows to the broker, either by polling for unpublished rows or by tailing the write-ahead log with change data capture, which avoids the polling load and the contention on a hot table. Either way the relay can crash after publishing and before marking the row sent, so it delivers at least once — which is the same reason every consumer needs a deduplication key, and why "the outbox gives you exactly-once" is the wrong thing to say. What it gives you is atomicity between state and intent, with duplicates pushed into a place where they are cheap to handle.

The first arrow is the whole of the outbox guarantee, and the last one is the compensation that stands in for a rollback you no longer have.

sequenceDiagram
    participant O as Order service
    participant DB as Order database
    participant R as Outbox relay
    participant S as Saga orchestrator
    participant I as Inventory
    participant P as Payment
    O->>DB: Order row and outbox row in one commit
    R->>DB: Poll unpublished rows
    R->>S: OrderPlaced, at least once
    S->>I: Reserve stock
    I-->>S: Reserved
    S->>P: Charge card
    P-->>S: Declined
    S->>I: Release stock - compensating step

Boundaries drawn on data ownership are the only ones that hold, and if a workflow keeps crossing them you have not found a saga to write, you have found evidence that the boundary is wrong.

Likely follow-ups

  • Two services keep needing the same customer record. What are your options besides sharing the table?
  • A saga's compensating step fails. What now?
  • How would you extract one service out of an existing monolith without a big-bang cutover?
  • Your consumers receive the same event twice. Whose problem is that, and where is it solved?

Related questions

Further reading

microservicesservice-boundariessagaoutbox-patternmodular-monolith