Back to Decomposition & DDD
Q6Question 6 of 9

Decomposing a monolith into microservices

Key Concepts
  • Start by finding bounded contexts using DDD, not by cutting code along technical layers.
  • Decompose by business capability (Payments, Loans, Accounts) so each service owns a coherent domain.
  • Use the Strangler Fig pattern: route one capability at a time to a new service, never a big-bang rewrite.
  • Splitting the shared database is the hardest part: replace joins with API calls or event-driven read models.
  • Local transactions become Sagas with compensating actions and eventual consistency.
  • Extract the module with fewest dependencies first for an early, low-risk win.
  • Decompose only where teams genuinely need independent deployment; be willing to merge back if a boundary fails.

Definition

Decompose along DDD bounded contexts, give each service a private database, extract one module at a time with the Strangler Fig, and expect the hard parts to be the shared database and the shift from local transactions to sagas.
  • Bounded contexts – use Domain-Driven Design to identify areas with their own language and rules; each becomes a candidate service.
  • Business capability – decompose by what the business does (Payments, Loans) not by technical layer (Controllers, DAOs).
  • Strangler Fig – extract one capability at a time while the monolith keeps running; never a big-bang rewrite.
  • Database split – give each service its own database; replace cross-service joins with API calls or event-driven read models.
  • Transactions – local ACID transactions become Sagas with compensating actions and eventual consistency.

Picture National Bank of India as one huge building where accounts, payments, loans, and notifications all share the same giant ledger room. Decomposing it is like moving each department into its own building with its own ledger, one department at a time, while the bank stays open the whole time. You start by drawing clear department boundaries (bounded contexts), give each its own books (database-per-service), and move the easiest, least-connected department first.

Understanding the Concepts

How do you find boundaries for decomposition?

Start by finding boundaries, not by cutting code. Use Domain-Driven Design to identify bounded contexts: areas of the business with their own language and rules.

DIAGRAM
National Bank of India bounded contexts:

  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
  │   Account    │  │   Payment    │  │     Loan     │
  │  Context     │  │   Context    │  │   Context    │
  │              │  │              │  │              │
  │ - Balance    │  │ - Transfer   │  │ - Principal  │
  │ - Holder     │  │ - Settlement │  │ - EMI        │
  │ - KYC        │  │ - Reversal   │  │ - Sanction   │
  └──────────────┘  └──────────────┘  └──────────────┘

  ┌──────────────┐  ┌──────────────┐
  │ Notification │  │    Fraud     │
  │  Context     │  │   Context    │
  │              │  │              │
  │ - SMS        │  │ - Scoring    │
  │ - Email      │  │ - Rules      │
  │ - Push       │  │ - Alerts     │
  └──────────────┘  └──────────────┘

Each bounded context becomes a candidate service.
Decompose by business capability, NOT by technical layer.

Why not decompose by technical layer?

Splitting by technical layer (Controllers, Services, DAOs) gives you a distributed monolith where every feature still spans every service.

JAVA
package com.bank.antipattern;

// WRONG: Decompose by technical layer.
// Every transfer touches ALL three services.
public class WrongDecomposition {
    // controller-service -> handles HTTP
    // business-service   -> handles logic
    // dao-service         -> handles database
    // Result: every feature change touches all three.
    // This is a distributed monolith.
}

// CORRECT: Decompose by business capability.
public class CorrectDecomposition {
    // payment-service -> owns transfer logic + its own DB
    // account-service -> owns balance + its own DB
    // Each feature lives in ONE service.
}

How does the Strangler Fig pattern work?

Use the Strangler Fig pattern: put a gateway in front, route one capability to the new service, leave everything else on the monolith.

JAVA
package com.bank.gateway;

import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class StranglerFigRoutes {

    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder) {
        return builder.routes()
            // Phase 2: Payment extracted to new service
            .route("payment-new", r -> r
                .path("/api/v1/payments/**")
                .uri("lb://payment-service"))
            // Everything else still hits the monolith
            .route("monolith-fallback", r -> r
                .path("/api/**")
                .uri("http://monolith:8080"))
            .build();
    }
}
TEXT
Strangler Fig phases:
Phase 1: /api/** -> monolith
Phase 2: /api/v1/payments/** -> payment-service (new)
         /api/**            -> monolith
Phase 3: /api/v1/accounts/** -> account-service (new)
         /api/**             -> monolith
Phase N: monolith deleted

How do you split the shared database?

Splitting the database is the hardest part. When Payment moves out, it can no longer join the accounts table.

JAVA
package com.bank.payment;

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.logging.Logger;

// Payment Service keeps a local read model of account data
// Updated by consuming account-events from Kafka.
@Component
public class AccountReadModel {

    private static final Logger LOG = Logger.getLogger(AccountReadModel.class.getName());
    private final LocalAccountCacheRepository cacheRepo;

    public AccountReadModel(LocalAccountCacheRepository cacheRepo) {
        this.cacheRepo = cacheRepo;
    }

    @KafkaListener(topics = "account-events", groupId = "payment-service")
    public void onAccountEvent(AccountEvent event) {
        cacheRepo.upsert(event.getAccountId(), event.getHolderName());
        LOG.info("Local read model updated: " + event.getAccountId());
    }
}
TEXT
Before (monolith):
  SELECT p.*, a.holder_name
  FROM payments p JOIN accounts a ON p.from_account_id = a.id

After (microservices):
  Option 1: Call account-service API (synchronous)
  Option 2: Consume account-events -> local read model (async, preferred)
  NEVER: Direct SELECT against account-service's database.

How do transactions change?

A monolith transfer is one @Transactional method. Across services, you use the Saga pattern with compensating actions.

JAVA
package com.bank.saga;

import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.logging.Logger;

@Service
public class TransferSaga {

    private static final Logger LOG = Logger.getLogger(TransferSaga.class.getName());
    private final AccountClient accountClient;

    public TransferSaga(AccountClient accountClient) {
        this.accountClient = accountClient;
    }

    public void execute(String fromId, String toId, BigDecimal amount) {
        try {
            accountClient.debit(fromId, amount);  // Step 1: local tx
            accountClient.credit(toId, amount);   // Step 2: local tx
            LOG.info("Transfer saga completed: " + fromId + " -> " + toId);
        } catch (Exception e) {
            accountClient.credit(fromId, amount);  // Compensation
            LOG.warning("Transfer saga compensated: re-credited " + fromId);
        }
    }
}
TEXT
Before: @Transactional -> debit + credit in one atomic operation
After:  Saga -> debit, then credit, compensate on failure
Trade-off: eventual consistency instead of strong ACID.

What is the extraction order?

Extract the module with the fewest dependencies first for a low-risk early win.

TEXT
Dependency analysis:
  Notification: 0 inbound deps (nothing calls it)
  Fraud:        1 inbound dep (Payment calls it)
  Payment:      2 inbound deps (Gateway, Account)
  Account:      3 inbound deps (Payment, Loan, Fraud)

Extraction order:
  1. Notification (fewest deps, easiest)
  2. Fraud (one caller, clear boundary)
  3. Payment (moderate deps)
  4. Account (most deps, hardest, extract last)

What are common mistakes in decomposition?

The most common mistake is splitting too early or too finely.

JAVA
package com.bank.antipattern;

// WRONG: Premature split. Unclear domain, frequent cross-service changes.
public class PrematureSplit {
    // Split before understanding boundaries -> distributed monolith
    // Every feature change requires coordinated deploys
}

// CORRECT: Start as a well-modularized monolith, extract when boundaries are proven.
public class ModularMonolith {
    // Modules communicate through interfaces, not shared state.
    // When a module needs independent deploy -> extract to service.
}

What should a senior engineer know?

The senior-level nuance is restraint. Decompose only where teams genuinely need independent deployment or scaling. Extract incrementally. Be willing to stop or merge back if a boundary creates coordination without autonomy.

Visual Overview

DIAGRAM
  Strangler Fig Migration:

  Phase 1: All in monolith
  ┌─────────────────────────────────────┐
  │  Gateway                             │
  │    │                                 │
  │    ▼                                 │
  │  ┌───────────────────────────────┐  │
  │  │       MONOLITH                │  │
  │  │  Account│Payment│Notification │  │
  │  │         │       │             │  │
  │  │      ┌──┴───────┴──┐          │  │
  │  │      │  Shared DB  │          │  │
  │  │      └─────────────┘          │  │
  │  └───────────────────────────────┘  │
  └─────────────────────────────────────┘

  Phase 3: Notification and Payment extracted
  ┌─────────────────────────────────────┐
  │  Gateway                             │
  │    │                                 │
  │    ├──► Payment Service (new)        │
  │    │      │                          │
  │    │    Payment DB                   │
  │    │                                 │
  │    ├──► Notification Service (new)   │
  │    │      │                          │
  │    │    Notif DB                     │
  │    │                                 │
  │    └──► Monolith (Account only)      │
  │           │                          │
  │         Account DB                   │
  └─────────────────────────────────────┘

Key Takeaway

When decomposing a monolith, start by finding boundaries using Domain-Driven Design, not by cutting code. At National Bank of India, I identify bounded contexts like Account, Payment, Loan, and Notification, and decompose by business capability rather than by technical layer. I never do a big-bang rewrite: I use the Strangler Fig pattern to extract one module at a time while the monolith keeps running.

The hardest challenge is always the shared database. When Payment moves out, it gets its own database and can no longer join the accounts table. I replace that join with an API call or a local read model kept fresh by events. Local transactions become Sagas with compensating actions, trading strong ACID for eventual consistency.

The senior-level point is restraint. I extract the module with the fewest dependencies first for an early win. I decompose only where teams genuinely need independent deployment or scaling. And I am willing to merge a service back if the boundary turns out to create coordination without autonomy.

Remember one sentence: decompose along DDD bounded contexts, extract one module at a time with the Strangler Fig, give each service a private database, and expect the hard parts to be splitting the shared database and replacing local transactions with Sagas.

---

Share
Use ← → arrow keys to navigate