Cut Where Guarantees, Owners, Clocks, or Failures Diverge
A local database rollback cannot undo a payment already accepted by a remote service. Useful seams in software architecture follow different consistency requirements, accountable owners, execution schedules, or failure-containment needs.
When architectural reviews stall over service granularity in the abstract, shifting the evaluation criteria to require proof of a diverging transaction, clock, owner, or failure domain before approving any network hop forces teams to justify the operational tax of distribution. Consider a typical order-processing system where a single checkout flow spans four distinct operational concerns: local order-line persistence, pricing rule evaluation, payment gateway capture, and warehouse CSV export. Replacing an in-process function call with a network boundary adds serialization, socket management, and timeout handling that turns sub-millisecond memory reads into 3ms to 15ms local network round-trips before business logic even executes.
Before cutting code apart, map the signal you are seeing to the boundary it actually requires:
- Transaction boundary → explicit consistency contract: separate writes that must commit atomically from side effects that can lag behind an outbox.
- Ownership line → enforceable module interface: hide private schemas behind compiler-enforced package signatures before paying for network serialization.
- Schedule mismatch → asynchronous execution: decouple fast user requests from heavy batch processing using a durable job queue and isolated worker pools.
- Failure domain → isolated resources: bulkhead external dependencies with dedicated connection pools and strict timeouts so stalled third parties cannot starve local reads.
Finding a seam justifies a boundary; it never automatically justifies a separately deployed service.
Keep the Order Invariant Together; Separate the Receipt
An early iteration of the checkout design published receipt events directly to a message broker inside the database transaction block. Engineers dropped that approach after realizing a slow broker response held row locks open, and a post-publish database rollback left phantom receipts in flight. Where production incident logs expose these dual-write hazards, teams learn to separate true state invariants from trailing notifications.
To find a transaction seam, identify writes that must succeed together versus effects that can happen after commitment. In our checkout flow, the order header and its line-item records share a strict business invariant: an order total must equal the sum of its lines, and neither should exist without the other. Sending an email receipt can lag without corrupting the ledger.
The refactor preserves the local ACID transaction for the invariant and records an outgoing receipt event in an outbox table within that exact same transaction. Consider the failure timeline: if the database commits the orders and order_lines rows at t=12ms and the process crashes at t=14ms before the network socket flushes the SMTP or broker payload, the customer is billed with no confirmation record unless an outbox row (outbox_events with a UUIDv7 deduplication key) was committed in the same BEGIN/COMMIT block. Outbox relay workers polling or tailing the write-ahead log sweep uncommitted batches every 100ms to 500ms, retrying unacknowledged deliveries with exponential backoff up to a 6-hour dead-letter threshold.
Design For Duplicate Delivery: Because the database commit and message delivery are separate events, a worker crash after broker acknowledgement but before marking the outbox row processed will resend the payload. Consumers must track the stable UUIDv7 event identifier to make receipt rendering idempotent, rather than expecting the transport layer to provide exactly-once execution.
This pattern has a hard physical boundary. External payment authorization already crosses a transactional boundary and requires an explicit PAYMENT_PENDING state on the order record plus a scheduled reconciliation job; an outbox table cannot fold a remote card processor into your local ACID commit.
Give Pricing One Owner Before Giving It a Service
Does a dedicated pricing team need its own microservice to move fast? Before approving a separate repository or deployment pipeline for pricing, engineering leads audited months of version control commits to verify whether pricing logic actually changed independently of checkout state machines.
The true signal for an ownership seam is that pricing rules have a coherent accountable owner, and downstream consumers need a stable answer rather than raw read access to discount tables. When checkout queries pricing tables directly, any schema tweak to promotional tiers risks breaking order placement.
The pragmatic refactor creates an in-process pricing module with an explicit interface and private storage access. Enforcing an internal module boundary via compiler visibility rules or package-private schemas (pricing.CalculateQuote(cartSnapshot)) while persisting the final accepted_price_cents and pricing_rule_version directly on the order_lines table prevents historical invoice corruption when discount tiers change. Once the order stores the accepted price, historical orders never depend on recalculating today's rules.
A pricing module boundary stops leaking rate-table schemas the moment checkout stores accepted_price_cents on the order record; deploying that module as a separate service before commit logs show independent release cadences just adds network timeouts to the cart total calculation. An independently deployed pricing service forces availability and backward-compatibility obligations onto the checkout path. Version control history also provides a cold reality check: if over 4 out of 10 pull requests touching pricing rules also modify checkout validation or cart persistence files within the same 14-day sprint window, the boundary is still coupled regardless of team reporting lines.
Move Slow Exports Off the Checkout Clock
Junior systems designs often treat background work as an excuse to spin up a new microservice, confusing a clock mismatch with an ownership boundary. Rather than standing up another service to handle slow merchant reporting, the engineering group isolated the workload by splitting the runtime execution entry point while keeping the codebase unified.
The signal here is a divergence in time budgets. Interactive checkout endpoints must complete within a strict 400ms to 800ms p99 request budget, whereas generating and uploading a 50,000-row merchant fulfillment export can take 45 to 180 seconds. Order exports tolerate delay and bursty execution, while checkout requires a tightly bounded response path.
Move the export execution behind a durable job queue, define explicit job statuses (QUEUED, RUNNING, COMPLETED, FAILED), and give the background workers a separate concurrency budget from the same compiled artifact. Keep the inline enqueue operation short. Enqueuing a job row takes 2ms to 5ms during checkout; a separate worker pool capped at 4 concurrent database connections can drain the export queue asynchronously without starving the 60 connections reserved for live buyer traffic.
Moving work off the request clock trades latency for operational state management. Asynchronous work creates queue backlog during traffic spikes, potential duplicate execution when workers restart mid-batch, stale data if the order mutates before the export runs, and direct operational responsibility for handling exhausted retries.
Make a Stalled Payment Provider Stay in Its Lane
When a third-party payment gateway slows to a crawl, why do customers suddenly fail to load their past order history? Stalled payment requests consume thread pools and database connections also needed for unrelated operations, turning a single vendor brownout into a full site outage.
To evaluate whether payment degradation would take down read-only user traffic, the team designed a fault-injection exercise targeting connection pool exhaustion rather than relying on whiteboard architecture diagrams. The verification exercise is straightforward: inject a 12-second artificial latency delay on the outbound payment gateway socket while driving 50 concurrent checkout requests per second, then measure whether GET /orders/history response times stay under 150ms. Without bulkheads, 50 stalled payment calls holding database transactions open will exhaust a shared 64-connection pool in under 2 seconds, causing read-only order history lookups to fail with connection-wait timeouts after 5,000ms.
Fixing this requires resource isolation inside your process before anything else. Bound payment concurrency with a strict semaphore, set socket timeouts well within the checkout request budget, constrain retries so you do not amplify a downstream outage, and separate connection or worker pools where contention warrants it. Never hold a local database transaction open across an outbound HTTP call to a payment processor.
Isolating the failure domain forces you to confront ambiguous network outcomes. A local socket timeout is never proof that a payment failed; the remote gateway may have captured the funds right as your client hung up. Safely handling these timeouts requires passing a provider-supported idempotency key on every capture attempt, recording the ambiguous state locally, and running an asynchronous reconciliation loop against the processor's settlement API.
Folders, Entity Names, and Network Hops Are Weak Evidence
Post-incident reviews of over-segmented architectures consistently trace latency spikes and deployment freezes back to cuts made along database table nouns or directory names rather than behavioral invariants. Where deployment logs show 3 or more services requiring coordinated release windows within a 30-minute maintenance slot—alongside distributed rollback runbooks that require manual SQL cleanup across multiple databases—the architecture was cut along false seams.
False Seam: One Service Per Database Entity
- Superficial signal: orders and order_lines live in separate database tables with distinct primary keys.
- Wrong-cut failure: Splitting OrderService and LineItemService into separate deployments forces a single 15-item cart checkout or display to execute 16 sequential or fanned-out RPCs, multiplying tail latency and creating partial-write states when call 9 of 15 fails. Moving orders and line-items across a network hop trades a single ACID commit for partial-write cleanup when line-item 9 of 15 times out, whereas splitting the receipt email behind an outbox only trades synchronous SMTP failures for duplicate-delivery deduplication.
- Better refactor: Preserve their shared invariant behind a single cohesive module that owns both tables within one database transaction.
False Seam: Technical Layers or Directory Trees
- Superficial signal: All validation rules or persistence repositories live together in a clean folder hierarchy, making the directory appear self-contained.
- Wrong-cut failure: Slicing a system horizontally into a Validation Service, a Business Logic Service, and a Storage Service guarantees that every new product feature crosses three network boundaries, requires lockstep schema updates, and turns local stack traces into distributed tracing mysteries.
- Better refactor: Examine domain dependencies first, group code by the business invariant it protects, and enforce package boundaries using your compiler or linter.
Prove One Boundary Before You Pay for Another
In my own architecture reviews, we stripped the decision record template down to five mandatory questions that force authors to name the exact failure mode their team is volunteering to page for before any infrastructure ticket is opened:
- What invariant stays local? Name the exact rows and state transitions that remain protected by a single ACID commit.
- What dependency disappears? Identify the specific table lock, schema coupling, or thread-pool contention eliminated by the cut.
- What new failure mode do we inherit? State whether the cut introduces outbox lag, duplicate job execution, stale read projections, or ambiguous timeout reconciliation.
- Who is the accountable owner? Name the team whose on-call rotation receives the page when the queue backs up or the contract breaks.
- What empirical evidence justifies deployment separation? Point to the commit history, resource utilization profile, or security compliance rule that proves an in-process boundary is insufficient.
Always execute the smallest reversible move. Stage 1 (enforcing a module interface) typically takes 2 to 4 days of refactoring with zero new infrastructure. When execution schedules diverge, Stage 2 (spinning up an isolated worker process from the exact same compiled artifact) usually needs 1 to 2 days of deployment configuration. Stage 3 (extracting an independent service) adds permanent operational overhead for contract testing, multi-version API support, and separate on-call runbooks.
Verify Before Extracting: Require a concrete acceptance check tied to your chosen seam before moving to the next stage: zero cross-module table joins in SQL queries, 30 consecutive days of independent schema migrations without lockstep deploys, or verified survival of a 100% worker-queue stall without degrading checkout HTTP 200 rates.
Look at the next service extraction proposed on your team's roadmap: which specific failure—duplicate delivery, queue backlog, or reconciliation drift—are you prepared to debug at 3 a.m. in exchange for that boundary?
Your Thoughts
Share your thoughts.
Join the Discussion