System Design ·

Pragmatic Service Decomposition: When and How to Split Your Monolith

A senior engineer's guide to service decomposition that starts with the real question: should you split at all? Covers concrete extraction signals, boundary identification techniques, data ownership strategies, and incremental migration patterns with TypeScript examples.

Pragmatic Service Decomposition: When and How to Split Your Monolith

Most teams split their monolith too early, for the wrong reasons, and along the wrong boundaries. The result is a distributed monolith: all the operational complexity of microservices with none of the independence. Deployments still require coordination. A schema change in one service still breaks three others. The only thing that actually changed is that debugging now requires a trace ID and six terminal tabs.

This is not an argument for monoliths forever. Some systems genuinely need service boundaries. The argument is that decomposition is a tool with a high cost, and using it well means knowing when the cost is worth paying, where to cut, and how to migrate without losing your mind.

The Question You Should Answer First

Before any architectural discussion about service boundaries, answer one question honestly: what specific problem will decomposition solve that a well-structured monolith cannot?

If the answer is “it feels like the right thing to do” or “we want to use microservices,” stop. Those are not engineering reasons. They are vibes.

Valid reasons exist. Independent deployment velocity for teams that genuinely block each other. Isolated scaling for a subsystem with fundamentally different resource characteristics. Hard security boundaries between trust zones. Enabling a team to choose a different runtime or storage engine because the problem demands it.

Each of these is observable and measurable. If you cannot point to a specific pain you are experiencing today (not one you imagine having later), the correct move is to invest in modular structure inside the monolith. You get most of the organizational benefits of services without the operational tax.

Modular Monolith: The Step Most Teams Skip

A modular monolith enforces boundaries through code structure rather than network calls. Modules own their domain logic and expose clean interfaces. Internal dependencies are explicit. The key constraint: modules cannot reach into each other’s internals.

// billing/index.ts — the public API for this module
export { createInvoice } from "./commands/create-invoice";
export { getInvoiceById, listInvoicesForAccount } from "./queries/invoices";
export type { Invoice, InvoiceLineItem } from "./types";

// Everything else in billing/ is private.
// Other modules import from "billing", never from "billing/internal/..."

Enforce this with linting rules, barrel exports, or build-time checks. The mechanism matters less than the discipline. If another module needs data from billing, it calls the public interface. It never queries the billing tables directly.

// eslint rule or custom lint: restrict deep imports across module boundaries
// ❌ import { calculateProration } from "billing/internal/proration";
// ✅ import { createInvoice } from "billing";

This structure gives you something critical: it makes future extraction possible without a rewrite. When a module already has a clean boundary, turning it into a service means putting a network interface in front of the same API. When modules are tangled, extraction means untangling first, which is the hard part, and doing it under the pressure of a migration makes everything worse.

If your monolith does not have clear module boundaries today, that is your first job. Not decomposition. Structure.

Recognizing Real Extraction Signals

Once you have a modular monolith, certain signals indicate that a module is ready to become a service. None of these signals alone is sufficient. Look for clusters.

Deployment coupling is measurable. Track how often a change in one module forces a deploy of unrelated code. If the billing team ships three times a week and each deploy includes the entire application because the notification module’s tests are flaky, that is a real signal. Measure it before acting on it.

Scaling requirements diverge concretely. Your API serving user requests needs many small instances with low latency. Your report generation module needs few large instances with high memory. Running both in the same process means over-provisioning one axis to satisfy the other. But verify the cost first. Sometimes over-provisioning is cheaper than operating two services.

Team boundaries are stable and aligned. Conway’s Law is descriptive, not prescriptive, but ignoring it is expensive. If a single team owns billing end-to-end (domain logic, data, on-call), extracting billing as a service aligns ownership with architecture. If billing logic is spread across three teams with shared tables, extraction will not fix your organizational problem. It will distribute it.

Failure isolation is a hard requirement. If a bug in the recommendation engine should never be able to take down the checkout flow, process isolation is worth the cost. But in-process isolation (separate thread pools, circuit breakers around internal calls) can sometimes achieve this without a network boundary. Evaluate both options.

Finding the Right Boundaries

The hardest part of decomposition is not the infrastructure. It is deciding where to cut. Cut along the wrong boundary and you create services that cannot operate independently, which defeats the purpose.

Follow Data Ownership, Not Feature Grouping

A common mistake is grouping services by feature (“the search service,” “the dashboard service”). Features often read from many domains. A better heuristic: group by data ownership. Each service owns the tables it writes to. No other service writes to those tables. Ever.

Ownership map for an e-commerce system:

  Account Service     →  owns: users, organizations, auth_tokens
  Catalog Service     →  owns: products, categories, inventory_levels
  Order Service       →  owns: orders, order_items, shipments
  Billing Service     →  owns: invoices, payments, subscriptions

When you map ownership this way, you often discover that what you thought were separate services actually share write access to the same tables. That is a sign they are not separate services. They are one service with two names.

Use Domain Events to Identify Natural Seams

Look at where your system already uses asynchronous communication, or where it should. If the order service needs to tell billing to create an invoice, that interaction is a natural seam. The order service publishes an OrderConfirmed event. The billing service subscribes and creates the invoice on its own schedule.

Contrast this with the order service needing to check real-time inventory during checkout. That is a synchronous dependency. If you put a network boundary there, checkout latency now includes inventory service latency, plus timeout handling, plus retry logic. You might still do it, but the cost is concrete and you should measure it.

// Natural seam: async, loosely coupled
// Order service publishes after committing the order
await eventBus.publish("order.confirmed", {
  orderId: order.id,
  accountId: order.accountId,
  lineItems: order.items,
  confirmedAt: new Date().toISOString(),
});

// Billing service handles it independently
eventBus.subscribe("order.confirmed", async (event) => {
  await billingModule.createInvoiceFromOrder(event);
});

// Tight coupling: sync, request-scoped
// If this is across a network boundary, checkout latency grows
async function validateCheckout(cart: Cart): Promise<ValidationResult> {
  const availability = await inventoryService.checkBulk(
    cart.items.map((i) => ({ sku: i.sku, quantity: i.quantity }))
  );
  // Network failure here means checkout is broken
  return processAvailability(availability);
}

Map Dependencies Before You Cut

Before extracting anything, draw the dependency graph. For each module, list what it calls and what calls it. Count the edges. Dense clusters of bidirectional dependencies are not good extraction candidates. They will become chatty services that cannot function independently.

Good extraction candidates have few inbound edges (other things rarely call them synchronously) and clear data ownership. They are “leaf” modules in your dependency graph, not hubs.

The Strangler Fig: Incremental Extraction

Do not rewrite. Migrate incrementally. The strangler fig pattern routes traffic gradually from the old implementation to the new service, with the ability to roll back at every step.

Phase 1: Duplicate Reads

Deploy the new service alongside the monolith. Route a percentage of read traffic to the new service. Compare results with the monolith’s responses (shadow mode). Fix discrepancies until the new service matches behavior.

// Router that gradually shifts read traffic
async function getInvoice(
  invoiceId: string,
  ctx: RequestContext
): Promise<Invoice> {
  const useNewService = await featureFlag.evaluate(
    "billing-service-reads",
    ctx.accountId
  );

  if (useNewService) {
    try {
      return await billingServiceClient.getInvoice(invoiceId);
    } catch (err) {
      logger.error("billing-service-read-failed", { invoiceId, err });
      // Fall back to monolith during migration
      return await monolithBilling.getInvoice(invoiceId);
    }
  }

  return await monolithBilling.getInvoice(invoiceId);
}

Phase 2: Migrate Writes

This is the dangerous phase. Writes must go to exactly one place at a time. The migration sequence:

  1. New service starts handling writes for a subset of accounts (canary).
  2. Monitor error rates, latency, and data consistency for days, not hours.
  3. Expand gradually. 1%, 5%, 25%, 50%, 100%.
  4. At 100%, the monolith’s write path is dead code. Remove it.
// Write migration with explicit ownership
async function createInvoice(
  input: CreateInvoiceInput,
  ctx: RequestContext
): Promise<Invoice> {
  const migratedAccount = await featureFlag.evaluate(
    "billing-service-writes",
    ctx.accountId
  );

  if (migratedAccount) {
    // New service owns this account's billing data
    return await billingServiceClient.createInvoice(input);
  }

  // Monolith still owns this account's billing data
  return await monolithBilling.createInvoice(input);
}

Phase 3: Data Migration

The final step: move the data. Not a big-bang migration. Dual-write during the transition, with the new service as the source of truth for migrated accounts. Backfill historical data in batches. Validate row counts and checksums.

After the data is fully migrated, remove the old tables from the monolith’s schema. This is the point of no return, so do not rush it. Run both in parallel for weeks, comparing query results, before decommissioning the old path.

Data Ownership Across Service Boundaries

Once a service owns its data, other services cannot query those tables. This creates a real problem: how do other services get the data they need?

API Calls for Real-Time Reads

When service A needs fresh data from service B during a request, it calls service B’s API. This is the simplest pattern and the right default for low-volume, latency-tolerant reads.

The catch: this creates a runtime dependency. If service B is down, service A’s functionality degrades. Circuit breakers and timeouts help, but they do not eliminate the coupling. Every synchronous dependency is a shared fate dependency during outages.

Event-Carried State Transfer for Read-Heavy Patterns

When a service needs frequent access to another service’s data, subscribing to change events and maintaining a local read replica avoids the runtime dependency.

// Account service publishes changes
eventBus.publish("account.updated", {
  accountId: account.id,
  name: account.name,
  plan: account.plan,
  updatedAt: new Date().toISOString(),
});

// Billing service maintains a local projection
eventBus.subscribe("account.updated", async (event) => {
  await db.accountProjections.upsert({
    accountId: event.accountId,
    name: event.name,
    plan: event.plan,
    lastSyncedAt: event.updatedAt,
  });
});

// Billing queries use the local projection, no network call needed
async function getAccountPlan(accountId: string): Promise<string> {
  const projection = await db.accountProjections.findOne({ accountId });
  return projection.plan;
}

The tradeoff is eventual consistency. The local projection may be seconds or minutes behind the source. For billing lookups during invoice generation, this is usually fine. For authorization checks, it might not be. Know your consistency requirements before choosing this pattern.

What Goes Wrong (and How to Prepare)

Decomposition failures follow patterns. Knowing them helps you avoid them or at least detect them early.

Distributed transactions. If your extraction requires two services to agree on a write atomically, you have a boundary problem. Sagas and compensating transactions work, but they are complex and fragile. If you need distributed transactions frequently, the services are probably one service.

Shared databases. The “we will split the database later” approach always becomes permanent. If two services share a database, they share a deployment unit, a failure domain, and a schema migration dependency. They are not separate services in any meaningful sense.

Testing across boundaries. Integration tests that require multiple services running become slow and brittle. Contract tests (Pact, schema validation) are more sustainable. Define the API contract explicitly and test both sides independently.

Observability gaps. In a monolith, a stack trace shows you the full call path. Across services, you need distributed tracing, correlated logs, and service-level dashboards. Budget time for this infrastructure before extraction, not after.

// Minimum observability: propagate trace context on every cross-service call
async function callBillingService(
  path: string,
  body: unknown,
  ctx: RequestContext
): Promise<Response> {
  return fetch(`${BILLING_SERVICE_URL}${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Trace-Id": ctx.traceId,
      "X-Span-Id": crypto.randomUUID(),
      "X-Parent-Span-Id": ctx.spanId,
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(5000),
  });
}

A Decision Framework You Can Actually Use

When someone proposes extracting a service, run through this checklist:

  1. Can you name the specific problem this solves? Not a category (“scalability”) but a specific, current pain (“billing report generation consumes 90% of API server CPU during month-end, degrading checkout latency to 2s”).

  2. Is the module boundary already clean? If you need to untangle shared state and unclear ownership first, do that inside the monolith. Extraction adds complexity to an already complex refactor.

  3. Will the resulting services be independently deployable? If extracting billing still requires coordinated deploys with the order service because of shared schema or API contracts, you have not achieved independence. You have achieved overhead.

  4. Do you have the operational infrastructure? Service discovery, distributed tracing, CI/CD per service, independent monitoring. If these do not exist yet, the first extraction will be painful and slow.

  5. Is a single team willing to own this service end-to-end? Including on-call, schema migrations, capacity planning, and incident response. If ownership is unclear, the service will rot.

If you answer “no” to any of these, invest in making the answer “yes” before proceeding. Premature extraction is expensive to reverse. A well-structured monolith is a strong position. Defend it until the evidence for decomposition is clear, specific, and actionable.

Where to Start

If you are reading this and your monolith is a tangle of cross-cutting concerns, here is a practical sequence:

  1. Draw the dependency graph. Identify which modules call which. Find the clusters and the natural seams.
  2. Enforce module boundaries in code. Barrel exports, lint rules, code review discipline. No module reaches into another’s internals.
  3. Separate data access by module. Each module queries only its own tables. Shared read access goes through explicit interfaces.
  4. Extract the obvious candidate first. Pick the leaf module with the clearest ownership, the fewest inbound dependencies, and a team ready to own it.
  5. Use strangler fig. Incremental migration with rollback at every step. Shadow mode for reads. Canary rollout for writes. Parallel data validation before decommissioning.

The goal is not to have microservices. The goal is to have a system where teams can ship independently, subsystems can scale independently, and failures stay contained. Sometimes a modular monolith achieves that. Sometimes you need service boundaries. The architecture should follow the evidence, not the trend.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.