Back to Microservices Fundamentals
Q3Question 3 of 12

Challenges of microservices versus a monolithic application

Key Concepts
  • A monolith is one deployable unit with in-process calls and local ACID transactions, simple but scales only as one block.
  • Microservices split that unit into independently deployable services, each owning its own database and communicating over the network.
  • Distributed transactions are replaced by the Saga pattern with compensating actions and eventual consistency.
  • Partial failure and latency require timeouts, retries, circuit breakers, and bulkheads on every remote call.
  • Observability needs distributed tracing with a shared traceId because a single stack trace no longer tells the whole story.
  • Operational complexity multiplies: many pipelines, service discovery, config servers, versioned contracts, and container orchestration.
  • Microservices are justified only when teams need independent deployment and scaling; otherwise you get a distributed monolith.

Definition

Monolith is simple but scales as one block; microservices scale and deploy independently but pay with network failure, eventual consistency, and operational complexity.
  • Monolith – a single deployable unit where all modules share one process, one codebase, and one database, so calls are in-process and transactions are local ACID.
  • Microservices – independently deployable services, each owning its own database and communicating over the network.
  • Core trade-off – the split trades the simplicity of local calls and local transactions for independent scaling and deployment.
  • Distributed-system cost – network latency, partial failure, eventual consistency, data duplication, distributed tracing, and operational complexity.
  • Decision criterion – microservices are justified only when teams genuinely need independent deployment and scaling.

Imagine National Bank of India as one giant building where every clerk sits in the same hall. If Rajesh wants a transfer, one clerk walks three steps to the ledger desk and the cashier desk: fast, simple, and if the building is open everything works. That is a monolith. Now imagine the bank splits into separate buildings across the city: an Account building, a Payment building, a Notification building. Rajesh's transfer now needs a courier to carry forms between buildings. Couriers get stuck in traffic (network latency), sometimes a building is closed (partial failure), and you cannot lock all ledgers at once (no single transaction). You gained the ability to rebuild the Payment building without shutting the Account building, but you now have to manage a whole city.

Understanding the Concepts

What is a monolithic application?

A monolith is a single deployable unit where all modules share one process, one codebase, and one database. Every call between modules is a local, in-process method call, and every multi-step operation runs inside one ACID transaction. At National Bank of India, this means a single Spring Boot application handles accounts, payments, loans, and notifications together.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class MonolithTransferService {

    private final AccountRepository accountRepository;

    public MonolithTransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();
        from.debit(amount);
        to.credit(amount);
        accountRepository.save(from);
        accountRepository.save(to);
        // One transaction, one database, one stack trace.
    }
}
TEXT
Expected output (log):
Transfer of 50000.00 from SAV2001 to SAV2002 committed in single transaction.

Everything about this is simple: one transaction guarantees atomicity, one stack trace shows the full story, and one deploy ships everything.

What is a microservices architecture?

Microservices split the monolith into independently deployable services, each owning its own database. At National Bank of India, the Account Service owns the accounts database, the Payment Service owns the payments database, and the Notification Service owns the notifications database. They communicate over the network using REST, gRPC, or Kafka.

JAVA
package com.bank.payment;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;

@Service
public class PaymentService {

    private final RestTemplate restTemplate;

    public PaymentService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public void transfer(String fromId, String toId, BigDecimal amount) {
        // Network call to Account Service: debit
        restTemplate.postForEntity(
            "http://account-service/api/v1/accounts/" + fromId + "/debit",
            new DebitRequest(amount), Void.class);

        // Network call to Account Service: credit
        restTemplate.postForEntity(
            "http://account-service/api/v1/accounts/" + toId + "/credit",
            new CreditRequest(amount), Void.class);
    }
}
TEXT
Expected output (log):
Debit request sent to account-service for SAV2001.
Credit request sent to account-service for SAV2002.

What is the distributed transaction challenge?

In a monolith, a transfer is one @Transactional method: debit and credit commit or roll back together. Across services you cannot span a database transaction over the network, so you adopt 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 TransferSagaOrchestrator {

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

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

    public void executeTransfer(String fromId, String toId, BigDecimal amount) {
        try {
            accountClient.debit(fromId, amount);  // Step 1: local tx in Account Service
            accountClient.credit(toId, amount);    // Step 2: local tx in Account Service
            LOG.info("Transfer saga completed: " + fromId + " -> " + toId);
        } catch (Exception e) {
            // Compensating action: re-credit the sender
            accountClient.credit(fromId, amount);
            LOG.warning("Transfer saga compensated: re-credited " + fromId);
        }
    }
}
TEXT
Expected output (failure case):
WARNING: Transfer saga compensated: re-credited SAV2001

You trade strong consistency for eventual consistency and must design every step to be undoable.

What is the partial failure and latency challenge?

A local method call never "half works," but a network call can time out, be refused, or hang. You must wrap every remote call with a timeout, a retry, and a circuit breaker.

JAVA
package com.bank.resilience;

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.logging.Logger;

@Service
public class ResilientAccountClient {

    private static final Logger LOG = Logger.getLogger(ResilientAccountClient.class.getName());

    @CircuitBreaker(name = "accountService", fallbackMethod = "debitFallback")
    @Bulkhead(name = "accountService")
    public void debit(String accountId, BigDecimal amount) {
        // Remote call to Account Service with timeout
        // restTemplate.postForEntity(...)
        LOG.info("Debit successful for " + accountId);
    }

    public void debitFallback(String accountId, BigDecimal amount, Throwable t) {
        LOG.warning("Account Service down. Queuing debit for " + accountId);
        // Queue for retry when service recovers
    }
}
TEXT
Expected output (circuit open):
WARNING: Account Service down. Queuing debit for SAV2001

When the Account Service is down, the circuit breaker opens and the Payment Service fails fast instead of piling up threads.

What is the data management challenge?

Database-per-service means the Payment Service cannot join against the Account Service's tables. It either calls the service or keeps a local read-model copy kept fresh by events.

JAVA
package com.bank.readmodel;

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

@Component
public class AccountReadModelUpdater {

    private static final Logger LOG = Logger.getLogger(AccountReadModelUpdater.class.getName());
    private final LocalAccountRepository localRepo;

    public AccountReadModelUpdater(LocalAccountRepository localRepo) {
        this.localRepo = localRepo;
    }

    @KafkaListener(topics = "account-events", groupId = "payment-service")
    public void onAccountEvent(AccountEvent event) {
        localRepo.upsert(event.getAccountId(), event.getHolderName(), event.getBalance());
        LOG.info("Read model updated for account: " + event.getAccountId());
    }
}
TEXT
Expected output:
INFO: Read model updated for account: SAV2001

This causes data duplication and the need to reconcile stale copies.

What is the observability challenge?

A single request now crosses the gateway, payment, account, and notification services. A plain stack trace is useless. You propagate a single traceId with Micrometer and view the waterfall in Zipkin or Jaeger.

JAVA
package com.bank.tracing;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.logging.Logger;

@RestController
public class TransferController {

    private static final Logger LOG = Logger.getLogger(TransferController.class.getName());

    @PostMapping("/api/v1/transfers")
    public void initiateTransfer() {
        // traceId is auto-injected by Micrometer Tracing
        LOG.info("traceId=abc123 Transfer initiated at gateway");
        // Calls to payment-service, account-service, notification-service
        // all carry the same traceId in W3C traceparent header
    }
}
DIAGRAM
Expected output (Zipkin waterfall):
traceId=abc123  gateway          0ms  ────────────────────── 250ms
traceId=abc123  payment-service  5ms  ─────────────── 180ms
traceId=abc123  account-service  10ms ──────── 120ms
traceId=abc123  notification     185ms ── 240ms

What is the operational complexity challenge?

Many services mean many pipelines, service discovery, config servers, versioned contracts, and container orchestration. Each service needs its own CI/CD pipeline, health checks, and log aggregation.

TEXT
Monolith operational footprint:
  1 repo, 1 pipeline, 1 database, 1 deploy

Microservices operational footprint:
  5+ repos, 5+ pipelines, 5+ databases
  + Service Registry (Eureka/Consul)
  + Config Server (Spring Cloud Config)
  + API Gateway (Spring Cloud Gateway)
  + Container orchestration (Kubernetes)
  + Distributed tracing (Zipkin/Jaeger)
  + Log aggregation (ELK/Loki)
  + Monitoring (Prometheus + Grafana)

How is monolith different from microservices? (comparison table)

The differences are clearest in a side-by-side table.

| Aspect | Monolith | Microservices |
|---|---|---|
| Deployment | One unit, all or nothing | Each service deploys independently |
| Database | One shared database | Database per service |
| Communication | In-process method calls | Network calls (REST, gRPC, Kafka) |
| Transactions | Local ACID | Saga with eventual consistency |
| Scaling | Scale the whole application | Scale individual services |
| Failure | One bug can crash everything | Partial failure; circuit breakers needed |
| Debugging | One stack trace | Distributed tracing with traceId |
| Team structure | One team, shared codebase | Team per service, independent pipelines |
| Complexity | Low operational, high coupling | High operational, low coupling |

When should you use microservices?

Microservices are justified when teams genuinely need independent deployment and scaling. If you do not need these, you end up with a distributed monolith that has all the operational pain and none of the benefit.

JAVA
package com.bank.decision;

/**
 * Decision framework for monolith vs microservices.
 *
 * Choose MONOLITH when:
 * - Small team (fewer than 10 developers)
 * - Early-stage product with unclear boundaries
 * - Simple domain with few bounded contexts
 * - Speed of initial development is the priority
 *
 * Choose MICROSERVICES when:
 * - Multiple teams need independent deployment
 * - Components have very different scaling needs
 * - Domain is well-understood with clear bounded contexts
 * - Organization can afford the operational investment
 */
public class ArchitectureDecisionRecord {
    // This is a documentation class, not runnable code.
    // The decision is organizational, not technical.
}

What are common mistakes when adopting microservices?

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

JAVA
package com.bank.antipattern;

// WRONG: Distributed monolith. Two services that always deploy together.
public class WrongTransferService {
    // This service calls LimitCheckService synchronously for EVERY transfer.
    // LimitCheckService shares the same database.
    // They always deploy at the same time.
    // This is NOT microservices. This is a monolith with network hops.
}

// CORRECT: Keep tightly coupled logic in one service.
public class CorrectTransferService {
    // Limit checking is a method inside TransferService.
    // One database, one deploy, one transaction.
    // Split only when a separate team needs independent ownership.
}
TEXT
Distributed monolith symptoms:
- Services always deploy together
- Shared database between services
- Synchronous chains where A cannot work without B
- Every feature change touches multiple services

What should a senior engineer know?

A senior engineer understands that microservices are an organizational pattern, not a technical one. The real test is whether a service can be deployed alone without a meeting. Size is a distraction: a tiny service that must always ship with another is a distributed monolith, while a larger service that releases on its own schedule is a proper microservice.

Visual Overview

DIAGRAM
  ┌─────────────────────────────────────────────────┐
  │                   MONOLITH                       │
  │                                                  │
  │  ┌──────────┐  ┌──────────┐  ┌──────────────┐  │
  │  │ Accounts │  │ Payments │  │ Notification │  │
  │  │  Module  │──│  Module  │──│    Module     │  │
  │  └──────────┘  └──────────┘  └──────────────┘  │
  │         │           │             │             │
  │         └───────────┴─────────────┘             │
  │                     │                            │
  │              ┌──────┴──────┐                    │
  │              │ One Database │                    │
  │              └─────────────┘                    │
  └─────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────┐
  │              MICROSERVICES                       │
  │                                                  │
  │  ┌────────────┐  REST  ┌─────────────┐          │
  │  │  Account   │◄──────►│   Payment   │          │
  │  │  Service   │        │   Service   │          │
  │  └─────┬──────┘        └──────┬──────┘          │
  │        │                      │                  │
  │  ┌─────┴──────┐        ┌──────┴──────┐          │
  │  │ Account DB │        │ Payment DB  │          │
  │  └────────────┘        └─────────────┘          │
  │                                                  │
  │        Kafka: account-events                     │
  │  ┌─────────────┐                                │
  │  │Notification │                                │
  │  │  Service    │                                │
  │  └──────┬──────┘                                │
  │  ┌──────┴──────┐                                │
  │  │ Notif DB    │                                │
  │  └─────────────┘                                │
  └─────────────────────────────────────────────────┘

Key Takeaway

A monolith is a single deployable unit where all modules share one process and one database, so a transfer from Rajesh to Priya at National Bank of India is just one local transaction and one stack trace tells you everything. It is simple to build, test, and debug, and it is the right choice for a small team or an early-stage product. The problem is that it scales only as one block, every change redeploys the whole thing, and one memory leak can take down the entire application.

Microservices split that unit into independently deployable services, each owning its own database and talking over the network. The biggest challenge is that there is no distributed transaction across services, so you use the Saga pattern with compensating actions and accept eventual consistency. The second challenge is partial failure: a local method call never half-works, but a network call can time out or hang, so you wrap every remote call with a timeout, a retry, and a circuit breaker. The third challenge is observability: one request now crosses three or four services, so you propagate a single traceId and view the waterfall in Zipkin. The fourth is operational complexity: many services mean many pipelines, service discovery, config servers, and container orchestration.

The honest senior-level point is that microservices are only worth this cost when teams genuinely need independent deployment and scaling. If they do not, you end up with a distributed monolith that has all the operational pain and none of the benefit.

Remember one sentence: microservices trade the simplicity of in-process calls and local transactions for independent scaling and deployment, and you pay with distributed transactions, partial failure, observability, and operational complexity.

---

Share
Use ← → arrow keys to navigate