What a split-ready module looks like and who this checklist is for
Service extraction means taking a module that currently runs inside a monolith and moving it into its own deployable process, with its own data store and a network interface. The process itself is mostly mechanical. The hard part is knowing when to extract a microservice. The working rule of this checklist is that a module is ready only when it already behaves like a service while it is still inside the monolith. In practice that means:
- Clear boundaries. Other code calls it through a defined interface, not by reaching into its internals.
- Its own data. No other module reads or writes its tables directly.
- Low measurable coupling. Static dependencies and co-change history show it evolves mostly on its own.
- An operational reason. It needs a different deploy cadence, scaling profile, or failure isolation than the rest of the system.
If you extract before these conditions hold, the coupling stays in place. You stop paying for it in function calls and start paying for it in network calls. The result is a distributed monolith: services that must be deployed together, that share a database, and that fail in chains. You keep all the coordination costs of the monolith and add latency, partial failure, and harder debugging on top.
This checklist is for backend and platform engineers who are deciding whether a specific module should be split out. It assumes you are starting from a modular monolith, where boundaries can be drawn, enforced, and measured cheaply before you add any network hop.
Why extract at all: the business forces that justify a split
Extraction trades in-process calls, shared transactions and a single deploy for network boundaries, distributed failure modes and more infrastructure. That trade only pays off when a specific, recurring business problem gets cheaper to solve with the module running as its own process. These are the forces that usually justify it:
| Force | Symptom in the monolith | What a separate service changes |
|---|---|---|
| Different scaling profile | One CPU- or memory-heavy module forces you to scale every instance | You scale and size that workload on its own |
| Different release cadence | A module that ships daily waits on the full monolith pipeline and release train | It deploys on its own schedule with its own rollback |
| Team ownership conflict | Two teams queue behind each other for deploys and merges | Each team owns its deploy and on-call |
| Fault isolation | A memory leak or runaway query in one module takes down unrelated features | Failures stay inside one process and blast radius |
| Technology constraint | The module needs a runtime, library or hardware the monolith cannot host | It runs on whatever stack it needs |
"Cleaner architecture" is not on this list. Enforced module boundaries, explicit interfaces and private data already give you clean architecture, and a modular monolith provides those without a network hop.
Extraction also does not fix unclear domain ownership. Suppose most features still require changes from several teams to the new service. Then the organizational coupling is still there, and every feature now also needs coordinated deploys across process boundaries. Settle who owns the domain first. If ownership is still disputed, the module is not ready to split.
Measuring module coupling: static metrics and change coupling
Once ownership is settled, measure how entangled the module actually is. Start with the import graph. Afferent coupling (Ca) counts the modules that depend on this one. Efferent coupling (Ce) counts the modules it depends on. Instability, Ce / (Ca + Ce), ranges from 0 to 1. A high value means the module mostly consumes others. A low value means many callers rely on it, so every interface change ripples outward. Also flag cyclic package dependencies. A cycle that crosses the proposed boundary turns into a distributed cycle after the split. Record these numbers on every build so you have a baseline and can see whether boundary work is helping.
Static metrics miss coupling that only appears when code changes. To measure change coupling, run git log --name-only over a fixed window and group files by commit. Then count how often commits that touch the module also touch code outside it. Each of those commits becomes a coordinated multi-service deploy after extraction, with versioning, ordering, and rollback across process boundaries.
Illustrative estimate (assumed inputs): Assume Ca = 4 and Ce = 12, so instability is 12 / 16 = 0.75. Assume 200 commits touched the module last year and 70 of them also touched billing. That is a 35% co-change rate, or roughly 70 cross-service releases a year.
Treat these metrics as proxies. Shared tables, caches, configuration, and implicit ordering create coupling that never shows up in imports or diffs. Imports of harmless utilities, such as logging or date helpers, inflate the counts without adding real risk.
#!/usr/bin/env bash
# Change coupling for one module, taken from git history.
# Run from the repository root. The paths below are placeholders.
set -euo pipefail
MODULE="src/orders/" # module you want to extract
OTHER="src/billing/" # neighbour you suspect it is coupled to
SINCE="12 months ago" # fixed window, so runs stay comparable over time
# One marker line per commit, followed by the files that commit changed.
# Merges are skipped so that each change is counted once.
# No pathspec is given, so every file in a commit is listed.
git log --since="$SINCE" --no-merges --name-only --pretty=format:'@@commit' |
awk -v mod="$MODULE" -v other="$OTHER" '
# Called at each commit boundary to close out the previous commit.
function flush() {
if (inmod) { total++; if (outside) cross++; if (hit) pair++ }
inmod = outside = hit = 0
}
/^@@commit$/ { flush(); next }
NF == 0 { next }
{
# index() == 1 does a literal prefix match, so no regex escaping is needed.
if (index($0, mod) == 1) inmod = 1
else { outside = 1; if (index($0, other) == 1) hit = 1 }
}
END {
flush()
if (total == 0) { print "no commits touched " mod; exit }
printf "commits touching %s: %d\n", mod, total
printf "also touching anything outside: %d (%.0f%%)\n", cross, 100 * cross / total
printf "also touching %s: %d (%.0f%%)\n", other, pair, 100 * pair / total
}'
This script reads git log over a fixed window. It counts how many commits that touch the candidate module also change files outside it, and how many also change one named neighbour. Each of those co-changes would turn into a coordinated multi-service deploy after the split.
Data ownership, interface shape, and transactional assumptions
Data ownership
- The module is the only reader and writer of its tables. Audit query logs, ORM model references, raw SQL strings, reporting jobs, and migration scripts for any access from outside the module. Treat every hit as a blocker. Once the tables move behind a network boundary, each outside query either breaks or becomes a hidden dependency that keeps the old schema alive.
- No cross-module joins or foreign keys. A join across a boundary means two modules share one data model. Replace it with an ID reference, and have the caller resolve the related data through the module's interface.
Interface shape
- Callers use a narrow, explicit facade. Nothing reaches into internal classes or repositories.
- Calls are coarse-grained. In-process, a loop that makes fifty getter calls costs almost nothing. Over a network, each call adds latency, a possible timeout, and a partial-failure case the caller has to handle. Redesign chatty call sites now, while a mistake is still cheap to fix.
Transactional assumptions
- List every workflow that relies on a single ACID transaction spanning this module and another one. For each workflow, decide in advance which approach it will use:
- Eventual consistency is acceptable.
- Operations can be retried safely because they are idempotent.
- The workflow needs a saga with compensating actions, with an outbox so that events are published reliably.
Some workflows cannot tolerate any of these approaches. Ledger postings and inventory reservations with strict invariants are common examples. If a workflow like that crosses this boundary, the module is not ready to extract. Also plan for harder debugging. A failure that used to be one rollback becomes intermediate states spread across services, and you will need correlation IDs and tracing to reconstruct what happened.
-- Data-ownership audit for a module whose tables live in schema 'billing' (PostgreSQL).
-- Every row returned is a blocker to resolve before extraction.
-- 1. Foreign keys that cross the module boundary in either direction.
SELECT c.conname,
c.conrelid::regclass AS referencing_table,
c.confrelid::regclass AS referenced_table
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcns ON srcns.oid = src.relnamespace
JOIN pg_class dst ON dst.oid = c.confrelid
JOIN pg_namespace dstns ON dstns.oid = dst.relnamespace
WHERE c.contype = 'f'
AND (srcns.nspname = 'billing') <> (dstns.nspname = 'billing');
-- 2. Views outside the module that read its tables. These are often reporting
-- joins. This view lists only views owned by roles you hold, so run it as a
-- role that owns the reporting views.
SELECT view_schema, view_name, table_name
FROM information_schema.view_table_usage
WHERE table_schema = 'billing'
AND view_schema <> 'billing';
-- 3. Observed queries that touch module tables. This requires the
-- pg_stat_statements extension. It only matches schema-qualified names, so
-- also grep ORM models, raw SQL strings and job code for these table names.
SELECT calls, left(query, 120) AS query_start
FROM pg_stat_statements
WHERE query ILIKE '%billing.%'
ORDER BY calls DESC;
PostgreSQL queries that find cross-module foreign keys, outside views that read a module's tables, and observed queries against those tables, so that each data-ownership violation can be listed as a blocker.
Enforcing the boundary inside the monolith and preparing to operate it
Prove the boundary in-process
Make the boundary enforceable before it becomes a network hop. Add an architecture test or a build-level dependency rule that fails CI on violations. A typical rule: code outside billing may import only billing.api, and nothing in billing.internal may be referenced from another module. Without a failing build, boundaries erode one convenient import at a time.
Apply the same discipline to data. Move the module's tables into a separate schema, or at least a dedicated prefix, and grant the rest of the monolith no direct access to them. Replace synchronous cross-module calls that only exist to notify with in-process events. This tests whether consumers can tolerate eventual consistency while a rollback is still cheap.
Then wait. Extract only after the boundary has held across several release cycles with no exceptions granted. If teams keep requesting waivers, the seam is in the wrong place.
Prepare to operate one more deployable
The new service needs the following before it takes traffic:
- Its own CI/CD pipeline, so it can build, test, and deploy without the monolith's release train.
- Distributed tracing and correlation IDs propagated through every call and event, so one request can still be followed end to end.
- A named on-call owner with runbooks and alerts. A service nobody pages for is an outage waiting to happen.
- Contract tests between the service and each consumer, so breaking changes fail in CI rather than in production.
- An API versioning policy that states how long old versions are supported and how deprecation is announced.
// Architecture test that fails CI when the billing boundary is violated.
// Uses ArchUnit with its JUnit 5 integration (archunit-junit5 on the test classpath).
package com.example.shop.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
// Scan production code only, so test helpers cannot mask or cause violations.
@AnalyzeClasses(
packages = "com.example.shop",
importOptions = ImportOption.DoNotIncludeTests.class)
class BillingBoundaryTest {
// Other modules may reach billing only through its published API package.
@ArchTest
static final ArchRule onlyBillingApiIsVisible =
noClasses().that().resideOutsideOfPackage("com.example.shop.billing..")
.should().dependOnClassesThat()
.resideInAPackage("com.example.shop.billing.internal..")
.because("billing internals may change or move out of process");
// Billing must not reach into another module's internals either,
// otherwise extraction would drag hidden dependencies with it.
@ArchTest
static final ArchRule billingUsesOnlyOtherModulesApis =
noClasses().that().resideInAPackage("com.example.shop.billing..")
.should().dependOnClassesThat()
.resideInAnyPackage("com.example.shop.orders.internal..",
"com.example.shop.customers.internal..")
.because("billing must depend only on published module APIs");
}
An ArchUnit test that fails the build if code outside billing references billing.internal, or if billing reaches into another module's internals.
Running the extraction incrementally
Start by routing calls through the module's existing interface, not around it. Put a switch behind that interface that sends a configurable share of traffic to the new service while the in-process implementation stays live. Consumers don't change, so you can move traffic gradually and send it back without a deploy.
- Shift reads before writes. Reads can be compared against the monolith's answer at low risk. Writes commit you to a second source of truth.
- Treat data migration as the riskiest phase. Dual writes diverge when one side fails after the other succeeds, because no shared transaction covers both. Change data capture avoids the partial-write problem, but it adds replication lag that consumers may notice. Backfills of historical rows can overwrite updates that live traffic made a moment earlier, so make them idempotent and version-aware.
- Budget for reconciliation. This step is often underestimated. Run a job that compares both stores, reports mismatches by category, and keeps running until the mismatch rate stays at zero.
- Define rollback points in advance. For each stage, write down how traffic and data return to the monolith and when that path stops being possible. For example, once the old tables stop receiving writes, rolling back requires a reverse migration.
- Account for network costs. Every extracted call adds latency and can fail partially. Each call needs an explicit timeout, and retries need idempotency keys so a retry doesn't apply the same write twice.
- Set cutover criteria. Cutover is complete when the service handles all traffic, reconciliation stays clean for an agreed period, and the in-process code path and old tables have been deleted.
+-----------------------------+
| Consumers (unchanged) |
+--------------+--------------+
v
+-------------------------------------------------------+
| Existing module interface |
| Traffic switch: share N% to service, rest in-process |
| Shift reads first; change share by config, no deploy |
+-----------+-------------------------------+-----------+
| 100-N% | N%: timeout,
v v retry w/ idempotency key
+-----------------------+ +-----------------------+
| In-process module | | New service |
| (stays live until | | (latency, partial |
| cutover) | | failure) |
+-----------+-----------+ +-----------+-----------+
v v
+-----------------------+ CDC +-----------------------+
| Monolith tables |------>| Service store |
| (rollback point: ends | | CDC lag or dual-write |
| when writes stop) | | divergence; idempotent|
+-----------+-----------+ | version-aware backfill|
| +-----------+-----------+
+---------------+---------------+
v
+-------------------------------+
| Reconciliation job |
| compare stores, mismatches by |
| category; run until zero |
+-------------------------------+
A traffic switch behind the module's existing interface shifts a configurable share of calls to the new service while CDC or dual writes populate its store and a reconciliation job compares both stores until mismatches reach zero.
When to avoid extraction, alternatives, and impact on cost and engineering time
Staying a modular monolith is a valid outcome, not a failure to finish the job. If the module's boundary is enforced, its owning team can ship without coordinating releases, and its load fits the monolith's scaling profile, extraction adds a network hop and buys nothing. When the boundary is wrong, refactor it in place first. Moving code across a package line is cheap. Moving it across a network and a database is not.
| Option | Ongoing cost | Good fit when |
|---|---|---|
| Stay modular monolith | Lowest: one pipeline, one deploy | Boundary holds and there is no scaling or ownership pain |
| Refactor boundary in place | One-off refactoring effort | Coupling metrics show the seam is in the wrong place |
| Extract service | Per-service overhead, indefinitely | Hard blockers are cleared and scaling or ownership pain is real |
Each new service needs its own pipeline, dashboards, alerts, dependency patching and an on-call rotation. Illustrative estimate (assumed numbers, not a benchmark): monthly overhead = pipeline upkeep + alert tuning + patching + on-call share. With 2 + 2 + 3 + 8 hours, that is 15 hours a month, roughly a tenth of one engineer's time per service on a small team. Extracting along a wrong boundary costs far more, because you pay that overhead while also rebuilding distributed contracts and migrating data back.
The checklist saves time by surfacing blockers before anyone builds infrastructure. Don't use it as a reason to stall, though. If scaling or ownership pain is already concrete, clear the blockers and extract.
Go/no-go
Hard blockers. Any one of these means no-go:
- Tables shared with other modules
- Cyclic dependencies
- Cross-module transactions
- No owning team
Soft signals. Score 1 point each. Treat 2 or more as a go:
- High change frequency in isolation
- Independent scaling needs
- Release coordination is blocking the owning team
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 when to extract microservice, 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
.envby 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.