Backend & APIs15 min read1,541 words

Microservices vs Monolith in 2026: When to Use Each Architecture

Compare microservices and monolithic architectures for modern applications. Learn when to choose each approach, migration strategies, and practical implementation patterns.

MT

Michael Torres

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.

typescript
// 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.

typescript
// 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);
typescript
// 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

typescript
// 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.

typescript
// 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.

MT

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.

Related Articles

Ready to Build Your Next Project?

Let's discuss how our expert team can help bring your vision to life.

Top-Rated
Software Development
Company

Ready to Get Started?

Get consistent results. Collaborate in real-time.
Build Intelligent Apps. Work with Jishu Labs.

SCHEDULE MY CALL