What a seam is and why it should drive your first extraction

A seam is a place in the codebase where you can redirect behavior without much editing on either side of the boundary. For a service extraction, that has three concrete properties:

  • A narrow caller interface. The rest of the monolith reaches the module through a few well-defined entry points, not dozens of call sites that touch its internals.
  • Tables no one else joins. The module's data is read and written only by its own code. If reporting queries or other modules join against those tables, the data cannot move without breaking them.
  • Writes outside shared transactions. The module commits its own changes. It is not enlisted in a database transaction alongside another module's writes, because that atomicity disappears once a network sits between them.

A candidate with all three can be moved behind a remote call with little change to its callers. A candidate missing any of them needs untangling first.

This matters most for the first extraction. The obvious candidates are the most important module or the most painful one, but the first extraction's real job is to build the migration machinery: routing, observability, deployment pipelines, data migration, and rollback. It also has to show that the process is safe to repeat. A clean seam keeps the risk in that machinery rather than in the module's own complexity.

This guide is for teams with an existing monolith and a real need for independent deployment. Expect the trade-off: the cleanest seam often delivers the least direct business value. That is acceptable, because its payoff is every extraction that follows.

Mapping coupling before you choose a candidate

Coupling is not one number, so measure it from three angles before choosing anything.

Code dependencies. Build a call graph or import graph for the candidate module. Count the inbound and outbound references that cross its boundary. Every crossing becomes a network call or a published contract after extraction.

Data access. Check query logs, or the ORM models each module touches, to see which tables it reads and writes. A module can look clean in code and still join against tables owned by three other modules. Shared tables are the most expensive coupling to break, because the database has been enforcing consistency for free.

Change history. Version control shows which files tend to change together. Run git log --name-only --pretty=format: over the last year to list the files in each commit, then count how often each pair of files appears in the same commit. If the candidate's files change in lockstep with code outside it, the seam is in the wrong place. Every feature would then need coordinated deploys across two services, which cancels the main benefit of extracting.

All three are proxies. They miss runtime coupling such as shared cache keys, background jobs that touch the module's data, configuration read from a common source, and implicit ordering, where one component assumes another has already run. So check each candidate against production traffic. Instrument the proposed boundary and record how often it is crossed per request. A boundary crossed dozens of times per request will be slow and fragile once those calls go over the network.

#!/usr/bin/env bash
# Rank file pairs that change together, where one file is inside the
# candidate module and the other is outside it (the seam-crossing pairs).
set -euo pipefail

CANDIDATE="${1:-src/billing/}"   # path prefix of the module you might extract

# One '@@' marker per commit, followed by the files that commit touched.
# Merge commits are skipped so changes are not double-counted.
git log --since="1 year ago" --no-merges --name-only --pretty=format:'@@' \
| awk -v cand="$CANDIDATE" '
    function flush(   i, j, a, b, t) {
      # Skip huge commits (renames, formatting sweeps); they add noise.
      if (n > 1 && n <= 50) {
        for (i = 1; i <= n; i++) for (j = i + 1; j <= n; j++) {
          a = files[i]; b = files[j]
          if (a > b) { t = a; a = b; b = t }   # stable pair order
          # Keep only pairs where exactly one side is in the candidate.
          if ((index(a, cand) == 1) != (index(b, cand) == 1))
            print a "\t" b
        }
      }
      n = 0
    }
    /^@@$/ { flush(); next }
    NF     { files[++n] = $0 }
    END    { flush() }
  ' \
| sort | uniq -c | sort -rn | head -20
# Output: <commits together> <file A> <file B>. High counts mean the
# boundary would force coordinated changes across two services.

A git and awk pipeline that counts how often files inside a candidate module change in the same commit as files outside it over the past year, so you can see which cross-boundary pairs change together most often.

Scoring candidates: which module to extract first

With crossing counts in hand, score each candidate from 1 (poor) to 3 (good) on five criteria:

  • Inbound vs outbound dependencies. A module that many others call but that calls little back is easy to put behind a network boundary. Heavy outbound calls mean the new service will depend on the monolith for every request.
  • Exclusive data ownership. If only this module writes its tables, they can move with it. Shared writers force you into dual-write or sync schemes before you ship anything.
  • Transactional entanglement. Count the transactions that span this module and others. Each one becomes a consistency problem once the network sits in the middle.
  • Change rate. Extraction only pays off if the module changes often enough to benefit from independent deploys.
  • Business risk. A first extraction will hit operational surprises. Have them happen where a failure is tolerable.
Candidate Dependencies Data ownership Transactions Risk if it fails
Notifications Mostly inbound Exclusive Rarely shared Delayed email
File/media handling Mostly inbound Exclusive Rarely shared Broken uploads
Search indexing Inbound, async-friendly Derived copy None Stale results
Reporting Read-only outbound Read replica None Late reports
Orders Both directions Shared with billing Heavy Lost revenue
Billing Both directions Shared with orders Heavy Wrong charges

Edge capabilities score well because they sit at the end of a call chain and tolerate eventual consistency. Orders and billing fail on almost every criterion at once.

If nothing scores well, don't extract yet. Do the same if the boundary doesn't match team ownership, because one team would still change both sides in lockstep and pay network costs for no gain. A well-modularized monolith is a legitimate outcome.

Building the seam inside the monolith first

Once you have a candidate, build the boundary in-process before any network exists. This is branch by abstraction. Define an internal interface for the module's capabilities, such as BillingService with createInvoice and getBalance. Give it an in-process implementation that wraps the existing code, then migrate every caller onto the interface. Each step ships as ordinary refactoring behind normal review and deploys. Later, a remote implementation can replace the in-process one without touching the call sites.

The interface is a hard contract only if nothing goes around it. Search for other modules reading or writing the candidate's tables directly and route those through the interface as well. Every leftover query is a hidden dependency that will break at cutover.

Untangling the data

Data usually moves in three stages:

  1. Shared tables. Any module reads and writes anything.
  2. Module-owned tables in the same database. Only the owning module touches them. Lint rules or database grants enforce this.
  3. A separate schema or database.

To reach stage 2, replace cross-module joins with calls through the interface, or with a replicated read model when a caller needs bulk or low-latency reads. Drop foreign keys that cross the boundary on purpose. The constraint they enforced now belongs in application logic or reconciliation jobs.

Expect the database split to take longer than the code split. Reporting jobs, BI dashboards, and ad-hoc queries written by people outside the team all depend on those joins, and nobody tracks them in code. Inventory these consumers early, because they surface as breakage only after the tables move.

// billing/BillingService.ts: the seam other modules depend on
export interface BillingService {
  createInvoice(customerId: string, amountCents: number): Promise<string>;
  getBalance(customerId: string): Promise<number>;
}

// billing/InProcessBillingService.ts: wraps existing code, no network yet
import { Db } from "../db";

export class InProcessBillingService implements BillingService {
  constructor(private db: Db) {}

  async createInvoice(customerId: string, amountCents: number): Promise<string> {
    // Same SQL the monolith already ran; only billing touches these tables now
    const row = await this.db.one(
      "INSERT INTO invoices (customer_id, amount_cents) VALUES ($1, $2) RETURNING id",
      [customerId, amountCents]
    );
    return row.id;
  }

  async getBalance(customerId: string): Promise<number> {
    const row = await this.db.one(
      "SELECT COALESCE(SUM(amount_cents), 0) AS total FROM invoices WHERE customer_id = $1",
      [customerId]
    );
    return Number(row.total);
  }
}

// orders/checkout.ts: call site migrated off direct table access
export async function completeOrder(billing: BillingService, order: { customerId: string; totalCents: number }) {
  // Before: db.one("INSERT INTO invoices ...") straight from the orders module
  // After: go through the interface so a remote implementation can replace it later
  return billing.createInvoice(order.customerId, order.totalCents);
}

A BillingService interface with an in-process implementation that wraps the existing invoice queries, and an orders call site moved off direct writes to the invoices table and onto the interface.

How cutover works with the strangler fig pattern

Cutover starts by putting a routing layer in front of the capability. That layer can be a reverse proxy, an API gateway, or an in-code facade at the seam you built in the previous step. Every caller goes through it, so moving traffic means changing a routing decision and nothing else. Callers do not need to be redeployed.

  callers ──► routing layer ──┬──► monolith implementation (default)
              (flag: % split) │
                              └──► new service
              shadow: copy request to new service,
                      compare responses, discard its result

Run in shadow mode first. The monolith still serves the response, while a copy of each request goes to the new service and the two results are compared. Shadowing only works for reads and idempotent operations. If you shadow writes, you get double side effects such as duplicate emails or charges. Once the mismatch rate is understood, shift real traffic behind a flag in small steps, for example one tenant at a time or a small percentage of requests. Keep the monolith path deployable and test the switch back on purpose before the first shift. A rollback path that has never been exercised is not a real rollback path.

While both implementations are live, you pay for both. Every bug fix and schema change has to land twice, on-call covers two code paths, and the comparison tooling is itself code you have to maintain. That cost is acceptable only if it is temporary. A migration that stalls at "mostly cut over" and never deletes the old path ends up as a distributed monolith. You take on network hops and partial failures and still carry the original coupling. Put the deletion of the monolith path on the plan as a dated task, not as cleanup someone may get to later.

 +---------+       +------------------------------+
 | callers |------>|        routing layer         |
 +---------+       |  proxy / gateway / facade    |
                   |  flag: % split or by tenant  |
                   +------------------------------+
                      |            |            |
             primary  |   shadow   |  shifted   |
             (default)| (reads and |  traffic   |
                      | idempotent)|  (flagged) |
                      v            v            v
 +--------------------------+  +--------------------------+
 | monolith implementation  |  |       new service        |
 |  serves the response;    |  |  shadow result discarded |
 |  kept deployable as the  |  |  until flag shifts real  |
 |  tested rollback target  |  |  traffic to it           |
 +--------------------------+  +--------------------------+
              |                             |
              +-------------+---------------+
                            v
              +---------------------------+
              | compare responses, track  |
              |      mismatch rate        |
              +---------------------------+

Strangler fig cutover: a flagged routing layer sends live traffic to the monolith by default, shadows reads to the new service for comparison, and shifts real traffic incrementally while the monolith stays deployable for rollback.

Replacing in-process guarantees: transactions, retries, and consistency

Inside the monolith, an order write and its ledger entry commit or roll back together. Once the ledger lives behind a network call, that guarantee is gone. The call can succeed while your local commit fails, which leaves an orphaned ledger record. Your commit can also succeed while the call times out, so you don't know whether the remote side acted. Retrying blindly then produces duplicate side effects such as double charges or repeated emails.

Three mechanisms replace the shared transaction:

  • Idempotency keys. The caller generates a key per logical operation and the callee stores it. A retry with the same key returns the original result instead of acting again.
  • Retries with timeouts. Bound every call and retry only operations that are idempotent. Retries without idempotency turn transient failures into data corruption.
  • Outbox or sagas. In the outbox pattern, you write the event row in the same local transaction as the business row, and a relay publishes it afterwards. When a multi-step operation fails partway, compensating actions in a saga undo the earlier steps.

The example below shows both halves: the outbox insert alongside the business write, and a consumer that records processed message IDs before applying effects. Expect briefly stale reads while events propagate.

Every hop adds latency, a new partial-failure mode, and serialization cost. Before you choose between synchronous calls and events, ask the business how stale data can be and which steps can be undone.

-- Producer (order service, PostgreSQL): business row and event row commit together or not at all.
BEGIN;

INSERT INTO orders (id, customer_id, total_cents, status)
VALUES ('7f3c2a10-5b1e-4c9d-9a2e-1d4b6f8e0c21', 'cust_1042', 4999, 'placed');

-- A separate relay reads unpublished rows and sends them to the broker.
-- If this transaction rolls back, no event exists to publish.
INSERT INTO outbox (id, aggregate_id, event_type, payload, published_at)
VALUES (
  gen_random_uuid(),
  '7f3c2a10-5b1e-4c9d-9a2e-1d4b6f8e0c21',
  'OrderPlaced',
  '{"order_id": "7f3c2a10-5b1e-4c9d-9a2e-1d4b6f8e0c21", "amount_cents": 4999}',
  NULL  -- the relay sets this after the broker acknowledges the event
);

COMMIT;

-- Consumer (ledger service): the relay may deliver the same event more than once.
-- processed_messages.message_id is the PRIMARY KEY, so a redelivery conflicts.
BEGIN;

WITH claimed AS (
  INSERT INTO processed_messages (message_id, processed_at)
  VALUES ('b2e8d4f6-3a71-4e0c-8f5d-6c9a1b2d3e47', now())
  ON CONFLICT (message_id) DO NOTHING
  RETURNING message_id
)
-- The ledger entry is written only if this message was claimed just now.
-- A duplicate claims nothing, so it inserts zero rows.
INSERT INTO ledger_entries (order_id, amount_cents, source_message_id)
SELECT '7f3c2a10-5b1e-4c9d-9a2e-1d4b6f8e0c21', 4999, message_id
FROM claimed;

COMMIT;

The order service writes an outbox row in the same transaction as the order, and the ledger consumer claims each message ID in the same transaction as its effect, so redelivered events do not create duplicate ledger entries.

Impact on cost and engineering time, alternatives, and exit criteria

The extracted service is the smaller deliverable. The larger one is the platform you had to build to run it: a service template, a deployment pipeline, service-to-service authentication, distributed tracing that propagates a correlation ID across every hop, centralized logs, and a named owning team with an on-call rotation. The second extraction reuses all of this, so its cost is mostly domain work instead of plumbing. That payoff is deferred, while the operational load starts immediately. Someone now gets paged for a network hop that did not exist last month. For a team of a few engineers, that load can outweigh the benefits for a long time.

Option What you gain What it costs Fits when
Modular monolith with enforced boundaries Clear ownership, one deploy, in-process transactions No independent deploys or scaling, and boundaries erode without checks in CI The team is small and the main problem is coupling
Extract a core module first Tests the hardest seam early Largest blast radius, and the platform is built under pressure Rarely, and only when the core blocks everything else
Full rewrite A clean design Long feature freeze, and undocumented behaviour has to be rediscovered Almost never
Do nothing No new operational load Coupling keeps compounding Deploys and ownership are not a bottleneck

Exit criteria

  • The monolith code path and its tables are deleted, not left dormant.
  • The service deploys independently, and it actually does so on a regular basis.
  • The new hop has an agreed latency budget and error budget.
  • The team has written down the condition under which it will abandon the service or fold it back into the monolith.

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 extract service from monolith, 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 shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.