Software Architecture Patterns: Choosing Between Monolith, Microservices, and Modular Monolith
Software architecture patterns in 2026 — monolith vs microservices vs modular monolith, when each applies, bounded contexts, anti-patterns, and how to evolve ar
Software Architecture Patterns: Choosing Between Monolith, Microservices, and Modular Monolith
"Should we use microservices?" is one of the most common architectural questions. The answer almost always: not yet, or not as many as you think. The modular monolith — clean internal boundaries within a single deployable unit — is the right starting architecture for the vast majority of products.
This guide covers the honest trade-offs, the patterns that work at each scale, and the signals that tell you when to evolve.
The Monolith — Not a Dirty Word
A monolith is a single deployable unit. The entire application — web layer, business logic, database access — ships and runs together.
Advantages:
- Simplest possible deployment (one thing to deploy, monitor, debug)
- No network latency between components
- Transactions across all data are trivial (ACID within a single database)
- Onboarding is straightforward — one codebase, one deployment
- Debugging is easy — full stack trace in one place
Real examples: Stack Overflow (monolith serving 5M+ concurrent users), Basecamp (monolith, profitable at scale), Shopify (started as monolith, selective extraction over 15 years)
The reason monoliths get a bad reputation: they become monoliths without internal structure — every component talks to every other component, and changes anywhere break things everywhere. That's a big ball of mud, not a well-structured monolith.
The Modular Monolith
A modular monolith has clear internal module boundaries enforced at the code level. Modules communicate through defined interfaces — not direct database queries across module boundaries.
// Module structure: one folder per bounded context
// src/
// modules/
// orders/
// OrderService.ts
// OrderRepository.ts
// types.ts
// index.ts ← Only public API of the module
// billing/
// BillingService.ts
// index.ts
// inventory/
// InventoryService.ts
// index.ts
// notifications/
// NotificationService.ts
// index.ts
// orders/index.ts — explicit public API (everything else is private)
export { OrderService } from './OrderService';
export type { Order, CreateOrderDTO } from './types';
// OrderRepository is NOT exported — internal implementation detail
// billing/BillingService.ts — depends on order public API only
import { OrderService } from '../orders'; // ✅ Uses public interface
// import { OrderRepository } from '../orders/OrderRepository'; // ❌ Never direct internal access
Enforce boundaries with ESLint:
// .eslintrc.json
{
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["*/orders/OrderRepository", "*/orders/internal/*"],
"message": "Access orders module only through its public index.ts"
}
]
}]
}
}
Database boundaries — the harder problem:
-- Option 1: Schema-per-module (same database, logical separation)
CREATE SCHEMA orders;
CREATE SCHEMA billing;
CREATE SCHEMA inventory;
-- orders.orders table belongs to orders module
-- billing.invoices table belongs to billing module
-- Cross-module joins go through services, not direct SQL
-- Option 2: Shared schema with ownership convention
-- orders_* tables owned by orders module
-- billing_* tables owned by billing module
-- No cross-module direct queries (enforced by code review)
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Microservices — When Appropriate
Microservices decompose the application into independently deployable services, each owning its own data.
Monolith:
[Web] → [Orders] → [Billing] → [DB]
↓
[Inventory]
Microservices:
[API Gateway]
↓
[Order Service] ←→ [Billing Service] ←→ [Inventory Service]
[Orders DB] [Billing DB] [Inventory DB]
Real advantages of microservices:
- Independent scaling: High-traffic search service scales independently from low-traffic admin service
- Independent deployment: Billing team deploys 10× per day without coordinating with other teams
- Technology isolation: Search service uses Elasticsearch; rest of system uses PostgreSQL
- Fault isolation: Failing recommendation service doesn't take down checkout
Microservices costs that are often underestimated:
- Network latency between services (5–50ms per hop)
- Distributed transactions require saga pattern or eventual consistency
- Debugging across service boundaries requires distributed tracing
- Deployment complexity multiplies with service count
- Testing requires service mocks or a full integration environment
- API versioning between services — internal APIs must be backward compatible
The microservices trap: Teams adopt microservices to enable independent team ownership, but then create tight coupling between services anyway (synchronous HTTP calls everywhere, shared databases). This produces the worst of both worlds.
Domain-Driven Design (DDD) Basics
DDD provides the vocabulary for thinking about module and service boundaries.
Bounded Context: A clear boundary within which a model is consistent. "Order" in the sales context means something different from "Order" in the fulfillment context.
Sales bounded context: Fulfillment bounded context:
Order { Order {
id id
customerId warehouseId
lineItems[] pickingListId
totalValue shippingAddress
discountCode items[]
salesRepId estimatedShipDate
} }
Aggregate: A cluster of objects treated as a single unit for data changes. The aggregate root is the only entry point.
// OrderLine is part of the Order aggregate
// You never directly create/update an OrderLine — you go through Order
class Order {
private _id: string;
private _lines: OrderLine[] = [];
private _status: OrderStatus = 'draft';
addLine(productId: string, quantity: number, unitPrice: number): void {
if (this._status !== 'draft') {
throw new DomainError('Cannot add lines to a confirmed order');
}
this._lines.push(new OrderLine(productId, quantity, unitPrice));
}
confirm(): DomainEvent[] {
if (this._lines.length === 0) {
throw new DomainError('Cannot confirm empty order');
}
this._status = 'confirmed';
return [new OrderConfirmed(this._id, this.total())];
}
total(): number {
return this._lines.reduce((sum, line) => sum + line.lineTotal(), 0);
}
}
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
When to Extract a Microservice
Not every module should become a service. Extract when you have a specific reason:
| Reason | Example |
|---|---|
| Dramatically different scaling needs | ML inference service needs GPU; rest doesn't |
| Different deployment cadence | ML models retrain daily; web app deploys weekly |
| Different tech requirements | Legacy C++ pricing engine stays separate from Node.js services |
| Independent team ownership | Platform team owns infra service; product team owns feature services |
| Compliance isolation | Payment processing must be PCI-DSS isolated |
Not valid reasons to extract:
- "It would be cleaner" (cleanliness is achievable within a modular monolith)
- "Microservices are more scalable" (a well-structured monolith scales to billions of users)
- "Team X wants to own service Y" (team structure alone doesn't justify the distributed systems overhead)
Architecture Evolution Roadmap
Stage 1 (0–PMF): Monolith
- One codebase, one deployment, one database
- Focus: shipping features, validating product
- Don't think about services yet
Stage 2 (PMF–$5M ARR): Modular Monolith
- Add module boundaries to the monolith
- Extract truly independent services (e.g., ML inference, email sending)
- Database schema per module
- Focus: maintainability, team organization
Stage 3 ($5M–$20M ARR): Selective Extraction
- Extract 2–5 services that have specific reasons for extraction
- Keep the modular monolith as core
- Focus: independent scaling for high-traffic components
Stage 4 ($20M+ ARR): Service Mesh
- Multiple services, each with clear ownership
- Kubernetes or ECS with service discovery
- Focus: team autonomy, compliance isolation
Anti-Patterns
Distributed monolith: Services that must be deployed together because they're tightly coupled. The worst of both worlds.
Nano-services: A service for every function (UserEmailService, UserPasswordService, UserProfileService). Creates coordination overhead with no corresponding benefit.
Premature extraction: Extracting a service before you understand the domain boundaries. Results in wrong boundaries that are expensive to change.
Shared database between services: Services sharing a database table. Eliminates all benefits of service separation.
Clean Architecture in a Monolith
// Clean architecture layers (each layer only depends inward)
// Domain layer (innermost — no external dependencies)
class Order {
// Pure business logic, no framework dependencies
confirm(): Result<OrderConfirmed, ValidationError> { ... }
}
// Application layer (orchestrates domain objects)
class ConfirmOrderUseCase {
constructor(
private readonly orderRepo: OrderRepository, // Interface only
private readonly eventBus: EventBus, // Interface only
) {}
async execute(orderId: string): Promise<void> {
const order = await this.orderRepo.findById(orderId);
const events = order.confirm();
await this.orderRepo.save(order);
await this.eventBus.publishAll(events);
}
}
// Infrastructure layer (implements interfaces — Postgres, Redis, etc.)
class PostgresOrderRepository implements OrderRepository {
async findById(id: string): Promise<Order> { ... }
async save(order: Order): Promise<void> { ... }
}
// Interface layer (HTTP controllers, CLI, etc.)
app.post('/orders/:id/confirm', async (req, res) => {
await confirmOrderUseCase.execute(req.params.id);
res.status(200).json({ confirmed: true });
});
This structure means your business logic is testable without a database, without HTTP, without Redis — just pure TypeScript/Python.
Working With Viprasol
We design software architectures for products at every stage — from startup monolith design through modular extraction and selective microservice decomposition.
→ Architecture review →
→ Software Development Services →
→ IT Consulting Services →
See Also
- Microservices Development
- Technical Debt Management
- Software Development Process
- Startup CTO Responsibilities
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.