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 1000+ 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.
Why Clients Trust 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 →
You Might Also Like
- Microservices Development
- Technical Debt Management
- Software Development Process
- Startup CTO Responsibilities
External Resources
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
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.