Ten years ago, if you asked a software architect "what is the right architecture for my application?", the most common answer was "start with a monolith, split it when you have to." It was practical advice. Today, the answer is more nuanced - not because monolithic architecture stopped working, but because the tools and patterns for distributed systems have matured dramatically.
In my career across a global investment bank, a healthcare analytics firm,a financial services firm, and my own products, I have lived through this evolution. I have built monoliths, migrated them to microservices, and watched the industry swing from one extreme to the other. This post captures what I have learned about when and how to make that transition.
What you will learn: Why monoliths are still the right starting point · When microservices actually make sense · Domain-driven design as the decomposition guide · Communication protocols: REST, gRPC, event streaming · The Strangler Fig migration pattern · Data management: database-per-service, event sourcing · Serverless, service mesh, and the future · A decision framework for choosing your architecture
1. The Monolith: Where Every System Starts
The monolith gets a bad reputation, but it is the correct default for almost every new project. A well-structured monolith - with clear module boundaries, a clean domain model, and a single deployment unit - is faster to build, easier to test, and simpler to operate than any distributed alternative.
The problems with monoliths are not architectural - they are organizational. As your team grows, code ownership blurs. Deployments become coupling nightmares. A change to one feature requires QA-regressing the entire application.
Diagram 1: When a monolith makes sense vs. when it breaks
2. When Microservices Actually Help
Microservices are not a scaling strategy - they are an organizational strategy. They exist to let different teams work independently. The technical benefits (independent scaling, fault isolation, technology diversity) are secondary to the primary goal: team autonomy.
You should consider microservices when you have:
- Multiple teams that need to ship independently on different schedules.
- Distinct business domains with clear boundaries (e.g., user management, payments, analytics).
- Different scaling requirements - one part of the system needs 100x more compute than another.
// A decision framework for microservices
function shouldAdoptMicroservices(context: {
teamSize: number;
distinctDomains: number;
scalingDifferential: number; // ratio of heaviest to lightest workload
}): "monolith" | "modules" | "services" {
if (context.teamSize < 8 && context.distinctDomains < 3) {
return "monolith"; // You are not ready yet
}
if (context.scalingDifferential > 10) {
return "services"; // Heavy vs light workloads need separation
}
return "modules"; // Well-structured modules in a monorepo
}3. Domain-Driven Design: Finding the Service Boundaries
The hardest part of microservices is deciding where to draw the boundaries. Get it wrong, and you end up with distributed big ball of mud - services that are so tightly coupled they might as well be a monolith, but with network latency between every call.
Domain-driven design (DDD) provides the answer. Instead of designing services around technical layers (controllers, services, repositories), design them around business domains:
Diagram 2: DDD service boundaries in a trading system
Identifying Bounded Contexts
// Before: services organized by technical layers
// (wrong - no business meaning)
services/
controllers/ # All the controllers
services/ # All the business logic
repositories/ # All the data access
// After: services organized by bounded contexts
// (right - each has its own domain language)
services/
trade-processing/ # "trade", "confirmation", "settlement"
risk-management/ # "position", "var", "exposure"
reporting/ # "report", "regulation", "audit"
// Each bounded context owns its data, its language, its model.
// No shared database. No shared entity classes.
// Communication through events, not shared models.4. Communication Protocols: Picking the Right Channel
Once you have services, they need to talk to each other. The choice of communication protocol is one of the most consequential decisions you will make:
Diagram 3: Communication protocol tradeoffs
REST: The Universal Interface
// POST /api/trades - Create a new trade
// (used by the frontend and external systems)
{
"trade_id": "IRS-2025-03-15-0042",
"counterparty": "BANK_A",
"notional_amount": 50000000.00,
"currency": "GBP",
"maturity_date": "2030-06-15"
}Event-Driven: The De-coupler
For cross-service communication, events are more decoupled than direct calls. When a trade is settled, the Trade Processing service emits an event:
// Event emitted by Trade Processing
{
"event_type": "trade.settled",
"producer": "trade-processing-service",
"timestamp": "2025-03-15T14:30:00Z",
"payload": {
"trade_id": "IRS-2025-03-15-0042",
"settlement_status": "confirmed"
}
}
// The Risk Management service subscribes to "trade.settled"
// → recalculates VaR
// The Reporting service subscribes to "trade.settled"
// → generates EMIR report
// Neither service knows about the other.
// New subscribers can be added without changing the producer.5. Migration: The Strangler Fig Pattern
The hardest migration I have done was at a global investment bank, moving a post-trade processing system from a monolith to microservices. The system could not go down - every minute of downtime meant delayed settlements and regulatory risk.
The Strangler Fig pattern let us replace the monolith piece by piece:
Diagram 4: The Strangler Fig migration
// Step 1: Create the new service alongside the monolith
// The new service handles one bounded context (e.g., Reporting)
// Step 2: Route traffic through a proxy
// Use a feature flag to send specific requests to the new service
if (featureFlags.isEnabled("reporting-service")) {
return await reportingService.handle(request);
} else {
return await monolith.handle(request);
}
// Step 3: Monitor and validate
// Compare outputs from old and new systems in production
async function validateMigration(request) {
const newResult = await reportingService.handle(request);
const oldResult = await monolith.handle(request);
if (!deepEqual(newResult, oldResult)) {
await alertOncall("Migration divergence detected", { newResult, oldResult });
}
return newResult; // Trust the new service
}
// Step 4: Route ALL traffic to the new service
// Step 5: Delete the old code6. Data Management: Database-Per-Service
In a monolith, every module accesses the same database. In microservices, each service owns its data. No other service queries its database directly. This is non-negotiable - it is what makes services independently deployable.
Event Sourcing for Audit Trails
In financial systems, you cannot just update a record - you need to know what the value was before and after, who changed it, and when. Event sourcing stores every state change as an immutable event:
// Every state change is an event - never deleted, never updated
events = [
{ type: "trade.created", data: { id: "A", amount: 100 }, timestamp: "T1" },
{ type: "trade.amended", data: { id: "A", amount: 150 }, timestamp: "T2" },
{ type: "trade.cancelled", data: { id: "A" }, timestamp: "T3" },
]
// Current state = replay all events
function getCurrentState(events) {
return events.reduce((state, event) => {
if (event.type === "trade.created") return { ...state, ...event.data, status: "active" };
if (event.type === "trade.amended") return { ...state, ...event.data };
if (event.type === "trade.cancelled") return { ...state, status: "cancelled" };
return state;
}, {});
}
// Audit query: what was the state at T2?
function getStateAt(events, timestamp) {
return getCurrentState(events.filter(e => e.timestamp <= timestamp));
}7. The Future: Serverless and Service Mesh
Two trends are shaping the next generation of distributed systems:
Diagram 5: The evolving stack
The winning architecture for most systems today is not "pure microservices" or "pure monolith" - it is a hybrid. A core monolith or set of services for the critical path (where consistency and latency matter), with serverless functions for event handlers, webhooks, and batch jobs.
8. Summary: What to Remember
- Start with a monolith. It is the fastest, simplest, and most reliable architecture for new projects. Do not adopt microservices until you have the team and domain complexity that justifies them.
- Use domain-driven design to find service boundaries. Services should align with business domains (Trade Processing, Risk Management, Reporting), not technical layers (Controllers, Services, Repositories).
- The Strangler Fig pattern is the only safe way to migrate a live system. Never rewrite the entire monolith at once - replace it one bounded context at a time.
- Database-per-service is non-negotiable. Shared databases create hidden coupling that destroys the independence microservices are supposed to provide.
- Choose protocols by context: REST for external APIs, gRPC for high-performance internal calls, event streaming for cross-service integration. Using the wrong protocol creates accidental complexity.