The microservices vs monolith debate has matured significantly. In 2026, the answer is not 'microservices are always better' but 'it depends on your context.' This guide helps you make the right architectural decision based on your team, scale, and requirements.
Quick Decision Framework
- Start with a monolith if: Small team (<10), new product, unclear domains, need to ship fast
- Consider microservices if: Multiple teams, clear domain boundaries, different scaling needs, polyglot requirements
The Modern Modular Monolith
A well-structured monolith with clear module boundaries offers most benefits of microservices without the operational complexity. This is often the right starting point.
// Modular Monolith Structure
// src/
// ├── modules/
// │ ├── users/
// │ │ ├── api/ # HTTP handlers
// │ │ ├── domain/ # Business logic
// │ │ ├── infrastructure/# DB, external services
// │ │ └── index.ts # Public API
// │ ├── orders/
// │ ├── payments/
// │ └── inventory/
// ├── shared/
// │ ├── kernel/ # Shared domain concepts
// │ └── infrastructure/ # Shared utilities
// └── main.ts
// modules/users/index.ts - Public API
export { UserService } from './domain/UserService';
export type { User, CreateUserDTO } from './domain/types';
// Only export what other modules need
// modules/users/domain/UserService.ts
import { db } from '../infrastructure/database';
import { EventBus } from '@/shared/infrastructure/EventBus';
export class UserService {
constructor(
private eventBus: EventBus
) {}
async createUser(dto: CreateUserDTO): Promise<User> {
// Domain logic
const user = await db.user.create({
data: {
email: dto.email,
name: dto.name,
passwordHash: await hashPassword(dto.password),
},
});
// Publish domain event (other modules subscribe)
await this.eventBus.publish({
type: 'user.created',
payload: { userId: user.id, email: user.email },
});
return user;
}
async getUser(id: string): Promise<User | null> {
return db.user.findUnique({ where: { id } });
}
}
// modules/orders/domain/OrderService.ts
import { UserService } from '@/modules/users';
import { EventBus } from '@/shared/infrastructure/EventBus';
export class OrderService {
constructor(
private userService: UserService,
private eventBus: EventBus
) {
// Subscribe to events from other modules
this.eventBus.subscribe('user.created', this.handleUserCreated.bind(this));
}
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
// Cross-module communication through public API
const user = await this.userService.getUser(userId);
if (!user) {
throw new Error('User not found');
}
const order = await db.order.create({
data: {
userId,
items: { create: items },
status: 'pending',
},
});
await this.eventBus.publish({
type: 'order.created',
payload: { orderId: order.id, userId },
});
return order;
}
private async handleUserCreated(event: DomainEvent) {
// React to user creation (e.g., send welcome offer)
console.log('New user created:', event.payload.userId);
}
}Microservices Architecture
When you have clear domain boundaries, independent scaling needs, or multiple teams, microservices become valuable. Here's a production-ready setup.
// User Service - Standalone microservice
// services/user-service/src/index.ts
import express from 'express';
import { createClient } from 'redis';
import { Kafka } from 'kafkajs';
const app = express();
const redis = createClient({ url: process.env.REDIS_URL });
const kafka = new Kafka({
clientId: 'user-service',
brokers: [process.env.KAFKA_BROKER!],
});
const producer = kafka.producer();
const consumer = kafka.consumer({ groupId: 'user-service-group' });
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy', service: 'user-service' });
});
// API endpoints
app.post('/users', async (req, res) => {
try {
const user = await createUser(req.body);
// Publish event to Kafka
await producer.send({
topic: 'user-events',
messages: [{
key: user.id,
value: JSON.stringify({
type: 'USER_CREATED',
payload: user,
timestamp: new Date().toISOString(),
}),
}],
});
// Cache user data
await redis.setEx(`user:${user.id}`, 3600, JSON.stringify(user));
res.status(201).json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to create user' });
}
});
app.get('/users/:id', async (req, res) => {
// Check cache first
const cached = await redis.get(`user:${req.params.id}`);
if (cached) {
return res.json(JSON.parse(cached));
}
const user = await getUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Cache for next request
await redis.setEx(`user:${user.id}`, 3600, JSON.stringify(user));
res.json(user);
});
// Consume events from other services
async function startConsumer() {
await consumer.connect();
await consumer.subscribe({ topics: ['order-events'], fromBeginning: false });
await consumer.run({
eachMessage: async ({ topic, message }) => {
const event = JSON.parse(message.value!.toString());
if (event.type === 'ORDER_COMPLETED') {
// Update user's order count
await updateUserStats(event.payload.userId);
}
},
});
}
startConsumer();
app.listen(3000);// API Gateway / BFF Pattern
// services/api-gateway/src/index.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import rateLimit from 'express-rate-limit';
import { expressjwt } from 'express-jwt';
const app = express();
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
});
app.use(limiter);
// JWT authentication
app.use(
expressjwt({
secret: process.env.JWT_SECRET!,
algorithms: ['HS256'],
}).unless({ path: ['/auth/login', '/auth/register', '/health'] })
);
// Service discovery (simplified - use Consul/etcd in production)
const services = {
users: process.env.USER_SERVICE_URL || 'http://user-service:3000',
orders: process.env.ORDER_SERVICE_URL || 'http://order-service:3001',
payments: process.env.PAYMENT_SERVICE_URL || 'http://payment-service:3002',
};
// Route to services
app.use(
'/api/users',
createProxyMiddleware({
target: services.users,
changeOrigin: true,
pathRewrite: { '^/api/users': '/users' },
onProxyReq: (proxyReq, req) => {
// Forward user context
if ((req as any).auth) {
proxyReq.setHeader('X-User-ID', (req as any).auth.sub);
}
},
})
);
app.use(
'/api/orders',
createProxyMiddleware({
target: services.orders,
changeOrigin: true,
pathRewrite: { '^/api/orders': '/orders' },
})
);
// Aggregation endpoint (BFF pattern)
app.get('/api/dashboard', async (req, res) => {
const userId = (req as any).auth.sub;
try {
// Fetch from multiple services in parallel
const [userRes, ordersRes, statsRes] = await Promise.all([
fetch(`${services.users}/users/${userId}`),
fetch(`${services.orders}/orders?userId=${userId}&limit=5`),
fetch(`${services.orders}/orders/stats?userId=${userId}`),
]);
const [user, orders, stats] = await Promise.all([
userRes.json(),
ordersRes.json(),
statsRes.json(),
]);
// Return aggregated response
res.json({
user,
recentOrders: orders,
stats,
});
} catch (error) {
res.status(500).json({ error: 'Failed to load dashboard' });
}
});
app.listen(8080);Service Communication Patterns
// Synchronous: REST with Circuit Breaker
import CircuitBreaker from 'opossum';
const userServiceBreaker = new CircuitBreaker(
async (userId: string) => {
const response = await fetch(`${USER_SERVICE_URL}/users/${userId}`);
if (!response.ok) throw new Error('User service error');
return response.json();
},
{
timeout: 3000, // 3 second timeout
errorThresholdPercentage: 50,
resetTimeout: 30000, // Try again after 30 seconds
}
);
userServiceBreaker.fallback(() => ({
id: 'unknown',
name: 'Unknown User',
cached: true,
}));
userServiceBreaker.on('open', () => {
console.log('Circuit breaker opened - user service degraded');
});
async function getUser(userId: string) {
return userServiceBreaker.fire(userId);
}
// Asynchronous: Event-Driven with Kafka
import { Kafka, Partitioners } from 'kafkajs';
const kafka = new Kafka({
clientId: 'order-service',
brokers: [process.env.KAFKA_BROKER!],
});
const producer = kafka.producer({
createPartitioner: Partitioners.DefaultPartitioner,
});
// Publish event
async function publishOrderCreated(order: Order) {
await producer.send({
topic: 'order-events',
messages: [{
key: order.id,
value: JSON.stringify({
type: 'ORDER_CREATED',
payload: order,
metadata: {
timestamp: new Date().toISOString(),
correlationId: order.correlationId,
service: 'order-service',
},
}),
headers: {
'content-type': 'application/json',
},
}],
});
}
// Consume events with exactly-once semantics
const consumer = kafka.consumer({ groupId: 'payment-service-group' });
async function startConsumer() {
await consumer.connect();
await consumer.subscribe({ topics: ['order-events'] });
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
const event = JSON.parse(message.value!.toString());
// Idempotency check
const processed = await redis.get(`processed:${message.offset}`);
if (processed) {
console.log('Event already processed, skipping');
return;
}
try {
if (event.type === 'ORDER_CREATED') {
await processPayment(event.payload);
}
// Mark as processed
await redis.setEx(`processed:${message.offset}`, 86400, '1');
} catch (error) {
console.error('Failed to process event:', error);
// Dead letter queue
await producer.send({
topic: 'order-events-dlq',
messages: [{ value: message.value }],
});
}
},
});
}Migration Strategy
Migrating from monolith to microservices should be gradual. The Strangler Fig pattern allows incremental migration without big-bang rewrites.
// Strangler Fig Pattern Implementation
// 1. Start by routing all traffic through a facade
class ServiceFacade {
private featureFlags: FeatureFlagService;
async getUser(userId: string): Promise<User> {
// Check if user service is enabled for this request
if (await this.featureFlags.isEnabled('use-user-microservice', { userId })) {
// Route to new microservice
return this.callUserMicroservice(userId);
}
// Fall back to monolith
return this.callMonolithUserAPI(userId);
}
async createOrder(dto: CreateOrderDTO): Promise<Order> {
// Gradual rollout: 10% to new service
if (await this.featureFlags.isEnabled('use-order-microservice', {
percentage: 10,
})) {
return this.callOrderMicroservice(dto);
}
return this.callMonolithOrderAPI(dto);
}
private async callUserMicroservice(userId: string): Promise<User> {
const response = await fetch(`${USER_SERVICE_URL}/users/${userId}`);
return response.json();
}
private async callMonolithUserAPI(userId: string): Promise<User> {
// Call the existing monolith endpoint
return this.monolith.users.findById(userId);
}
}
// 2. Data synchronization during migration
class DataSyncService {
async syncUserData() {
// Dual-write pattern: write to both old and new
const monolithUsers = await this.monolith.users.findAll();
for (const user of monolithUsers) {
await fetch(`${USER_SERVICE_URL}/users/sync`, {
method: 'POST',
body: JSON.stringify(user),
});
}
}
// Event-driven sync
async handleUserCreatedInMonolith(event: UserCreatedEvent) {
// Replicate to microservice
await fetch(`${USER_SERVICE_URL}/users`, {
method: 'POST',
body: JSON.stringify(event.user),
});
}
}
// 3. Verification before cutover
class MigrationVerifier {
async verifyDataConsistency(): Promise<VerificationResult> {
const monolithUsers = await this.monolith.users.findAll();
const microserviceUsers = await this.fetchAllFromMicroservice();
const discrepancies: string[] = [];
for (const mUser of monolithUsers) {
const msUser = microserviceUsers.find(u => u.id === mUser.id);
if (!msUser) {
discrepancies.push(`Missing user: ${mUser.id}`);
continue;
}
if (JSON.stringify(mUser) !== JSON.stringify(msUser)) {
discrepancies.push(`Data mismatch for user: ${mUser.id}`);
}
}
return {
consistent: discrepancies.length === 0,
discrepancies,
monolithCount: monolithUsers.length,
microserviceCount: microserviceUsers.length,
};
}
}Decision Matrix
Architecture Decision Matrix
Team Size:
- 1-10 developers: Modular Monolith
- 10-50 developers: Consider microservices for specific domains
- 50+ developers: Microservices likely beneficial
Product Maturity:
- MVP/Startup: Monolith (speed > scalability)
- Growth: Modular monolith with clear boundaries
- Scale: Extract services where needed
Domain Complexity:
- Simple CRUD: Monolith
- Complex domains: Domain-driven design, consider services per bounded context
Scaling Needs:
- Uniform: Monolith scales fine
- Varied: Microservices for different scaling profiles
Conclusion
The best architecture depends on your context. Start with a well-structured modular monolith, establish clear boundaries, and extract services only when the benefits outweigh the complexity. Remember: you can always extract services later, but merging them back is much harder.
Need help with your architecture decisions? Contact Jishu Labs for expert system design consulting and implementation guidance.
About Michael Torres
Michael Torres is the Backend Lead at Jishu Labs specializing in distributed systems architecture. He has helped companies scale from monoliths to microservices and back.