Back to Decomposition & DDD
Q9Question 9 of 9

A critical service has grown to 300,000 lines: would you split it into microservices, and what would you evaluate first?

Key Concepts
  • Line count alone is not a reason to split; follow evidence about coupling, ownership, and blast radius.
  • Evaluate change coupling: modules that always change together should stay together.
  • Evaluate ownership: a split that creates an orphan nobody owns is worse than a module in a monolith.
  • Evaluate blast radius: does a failure in one capability take down unrelated capabilities?
  • The permanent microservices tax: network calls, retries, service discovery, distributed tracing, eventual consistency.
  • Most 'we need microservices' pain is actually 'we have no internal boundaries' pain -- fix with a modular monolith first.
  • Extract only the module with a proven independent need, along the seam you already built.

Definition

Line count is not a reason to split; split only where you have evidence of independent change, ownership, scaling, or failure needs that a stronger internal boundary cannot satisfy.
  • Change coupling -- modules that always change together in the same commits should not be split.
  • Ownership -- a split pays off only when it aligns a service with a team that owns it end to end.
  • Blast radius -- if a failure in one capability takes down unrelated capabilities, a split adds fault isolation.
  • Independent scaling -- if one capability needs to scale very differently, a split enables that.
  • Microservices tax -- network calls, retries, service discovery, tracing, eventual consistency; a permanent cost on every change and every incident.
  • Modular monolith -- most 'size' pain is actually 'no boundaries' pain; enforce internal module boundaries first.

National Bank of India runs one large branch. The question is not "it is big, so build three branches." The question is: which activities get in each other's way, who is responsible for what, and does a problem at the loan desk stop the transfer desk? Often the fix is to reorganize the one building into departments with their own doors. You only open a separate branch when a department truly needs its own building.

Understanding the Concepts

Would you split a 300K-line service into microservices?

Not because of the line count. Size tells you the service is big; it does not tell you where the boundaries are or whether a network boundary would help. The decision must follow evidence.

JAVA
package com.bank.architecture;

public class SplitDecision {
    public static String evaluate(boolean changesIndependently,
                                  boolean hasOwner,
                                  boolean needsIndependentScaling,
                                  boolean failureAffectsOthers) {
        if (changesIndependently && hasOwner
                && (needsIndependentScaling || failureAffectsOthers)) {
            return "EXTRACT: proven independent need exists";
        }
        return "MODULAR MONOLITH: strengthen internal boundaries first";
    }

    public static void main(String[] args) {
        System.out.println(evaluate(true, true, true, true));   // extract
        System.out.println(evaluate(true, false, true, false)); // no owner
        System.out.println(evaluate(false, true, false, false)); // co-changes
    }
}
TEXT
Expected output:
EXTRACT: proven independent need exists
MODULAR MONOLITH: strengthen internal boundaries first
MODULAR MONOLITH: strengthen internal boundaries first

How do you measure change coupling?

Change coupling shows which files always change together in the same commits. If transfers and accounts always co-change, splitting them spreads one logical change across two deployables.

BASH
# Measure which files co-change in the same commits
git log --format=format: --name-only | sort | uniq -c | sort -rn | head -20
JAVA
package com.bank.architecture;

public class ChangeCouplingAnalysis {
    public static void main(String[] args) {
        // If transfers/ and accounts/ co-change in 80% of commits:
        int totalCommits = 500;
        int coChangeCommits = 400;
        double coupling = (double) coChangeCommits / totalCommits;
        System.out.printf("Change coupling: %.0f%%%n", coupling * 100);
        if (coupling > 0.5) {
            System.out.println("HIGH coupling: splitting these would spread changes");
            System.out.println("across two deployables. Keep together.");
        }
    }
}
TEXT
Expected output:
Change coupling: 80%
HIGH coupling: splitting these would spread changes
across two deployables. Keep together.

How do you evaluate ownership?

A split pays off when it aligns with a single team that owns the service end to end: code, deployment, data, on-call. If no team would clearly own the extracted service, the split creates an orphan.

JAVA
package com.bank.architecture;

public class OwnershipCheck {
    public static void main(String[] args) {
        String module = "fraud-scoring";
        boolean hasDedicatedTeam = true;
        boolean teamOwnsOnCall   = true;
        boolean teamOwnsData     = true;

        if (hasDedicatedTeam && teamOwnsOnCall && teamOwnsData) {
            System.out.println(module + ": clear ownership. Split is viable.");
        } else {
            System.out.println(module + ": no clear owner. Split creates an orphan.");
        }
    }
}
TEXT
Expected output:
fraud-scoring: clear ownership. Split is viable.

How do you evaluate blast radius?

If a runaway report generation exhausts the heap and takes down transfers, that is real fault coupling a split could fix. But if capabilities are already isolated by threading or resource limits, a split buys little.

JAVA
package com.bank.architecture;

public class BlastRadiusCheck {
    public static void main(String[] args) {
        // Scenario: report generation causes OOM, takes down transfer processing
        boolean reportOomAffectsTransfers = true;
        if (reportOomAffectsTransfers) {
            System.out.println("Blast radius: report failure kills transfers.");
            System.out.println("Splitting report-generation isolates the fault.");
        }
    }
}
TEXT
Expected output:
Blast radius: report failure kills transfers.
Splitting report-generation isolates the fault.

What is the microservices tax?

Every network boundary imports a permanent tax. This tax is paid on every change and every incident, not once.

| In-Process (Monolith) | Cross-Service (Microservices) |
|---|---|
| Method call | Network call (slow, can fail, can timeout) |
| Local transaction | Saga or 2PC |
| Stack trace | Distributed tracing |
| Direct reference | Service discovery |
| One deploy | Coordinated or independent deploys |

JAVA
package com.bank.architecture;

public class MicroservicesTax {
    public static void main(String[] args) {
        System.out.println("Method call       --> Network call (latency, failure, timeout)");
        System.out.println("Local transaction --> Saga (eventual consistency)");
        System.out.println("Stack trace       --> Distributed tracing (correlation IDs)");
        System.out.println("Direct reference  --> Service discovery (registry lookup)");
        System.out.println("One deploy        --> Multiple pipelines to coordinate");
    }
}
TEXT
Expected output:
Method call       --> Network call (latency, failure, timeout)
Local transaction --> Saga (eventual consistency)
Stack trace       --> Distributed tracing (correlation IDs)
Direct reference  --> Service discovery (registry lookup)
One deploy        --> Multiple pipelines to coordinate

What is the modular monolith alternative?

A modular monolith enforces boundaries inside one deployable. Each module exposes only a public interface; architecture tests prevent reaching into internals. This captures most of the organizational benefit with none of the distributed-systems tax.

JAVA
package com.bank.transfers;

import java.math.BigDecimal;

// Public interface: the only way other modules interact with transfers
public interface TransferModule {
    TransferResult execute(String fromAccountId, String toAccountId, BigDecimal amount);
}
JAVA
package com.bank.architecture;

import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

public class ModuleBoundaryTest {
    // Prevent accounts module from reaching into transfers internals
    @ArchTest
    static final ArchRule moduleBoundary = noClasses()
        .that().resideInAPackage("..accounts..")
        .should().dependOnClassesThat().resideInAPackage("..transfers.internal..");
}
TEXT
Expected behavior:
ArchUnit test FAILS if accounts code imports transfers.internal classes.
Boundary enforced at compile time, not runtime.

What are common mistakes?

The most common mistake is splitting because 300K lines "feels too big," without evidence that the split solves a real problem.

JAVA
package com.bank.architecture;

public class CommonMistakes {
    public static void main(String[] args) {
        // WRONG: splitting because 300K lines "feels too big"
        // Now every feature change requires coordinating 5 services
        System.out.println("WRONG: split because big --> 5 services, 5 pipelines,");
        System.out.println("       5 on-call rotations for what was one module");

        // CORRECT: establish boundaries internally, measure coupling,
        // extract only the capability with a proven need
        System.out.println("CORRECT: modular monolith first, measure, extract only");
        System.out.println("         the module with proven independent need");
    }
}
TEXT
Expected output:
WRONG: split because big --> 5 services, 5 pipelines,
       5 on-call rotations for what was one module
CORRECT: modular monolith first, measure, extract only
         the module with proven independent need

What should a senior engineer know?

The mark of seniority is knowing when to merge back, not just when to split.

  • Start by establishing boundaries internally with a modular monolith.
  • Measure change coupling from version-control history before proposing a split.
  • Extract only when there is a concrete need: independent deployment, scaling, fault isolation, or separate ownership.
  • The extraction is cheap if the internal boundary already exists.
  • If you cannot separate the data, the services are split along the wrong boundaries; consider merging back.
JAVA
package com.bank.architecture;

public class SeniorDecision {
    public static void main(String[] args) {
        System.out.println("Step 1: Enforce internal module boundaries (ArchUnit)");
        System.out.println("Step 2: Measure change coupling from git history");
        System.out.println("Step 3: Identify modules with independent owner + scaling need");
        System.out.println("Step 4: Extract ONLY that module, along the existing seam");
        System.out.println("Step 5: If data cannot separate cleanly, merge back");
    }
}
TEXT
Expected output:
Step 1: Enforce internal module boundaries (ArchUnit)
Step 2: Measure change coupling from git history
Step 3: Identify modules with independent owner + scaling need
Step 4: Extract ONLY that module, along the existing seam
Step 5: If data cannot separate cleanly, merge back

Visual Overview

DIAGRAM
  Decision Flow

  300K lines --> Is it big? YES
       │
       ▼
  Do modules change independently?  ─── NO ──► Modular Monolith
       │ YES                                    (enforce boundaries)
       ▼
  Is there a clear owner per module? ─── NO ──► Fix ownership first
       │ YES
       ▼
  Does one module need independent   ─── NO ──► Modular Monolith
  scaling or fault isolation?
       │ YES
       ▼
  Extract THAT module only
  (along the seam you already built)

  Modular Monolith
  ┌────────────────────────────────────────┐
  │  One Deployable                        │
  │  ┌──────────┐  ┌──────────┐  ┌──────┐ │
  │  │Transfers │  │ Accounts │  │Fraud │ │
  │  │ public   │  │ public   │  │public│ │
  │  │ internal │  │ internal │  │inter.│ │
  │  └──────────┘  └──────────┘  └──────┘ │
  │  ArchUnit: no cross-internal access    │
  └────────────────────────────────────────┘

Key Takeaway

Line count is not a reason to split a 300K-line service into microservices. The decision must follow evidence about change coupling, ownership, blast radius, and independent scaling needs, because splitting converts in-process calls into network calls and imports a permanent tax of retries, tracing, discovery, and eventual consistency.

Before splitting, evaluate four things. Change coupling: modules that co-change should stay together. Ownership: a split without a clear owner creates an orphan. Blast radius: does one capability's failure take down unrelated capabilities? Independent scaling: does one capability need fundamentally different resources?

At National Bank of India, Pankaj found that transfers and accounts co-changed in 80% of commits, so splitting them would have spread every feature change across two deployables. Instead, he enforced internal boundaries with ArchUnit tests and extracted only the fraud-scoring module, which had a dedicated team, bursty CPU needs, and no data coupling to the rest.

Remember one sentence: do not split because of line count; evaluate change coupling, ownership, blast radius, and scaling needs, fix boundaries internally first with a modular monolith, and extract only the specific capability with a proven independent need.

---

Share
Use ← → arrow keys to navigate