Architecture15 minute read

The Monolith Renaissance

Why we're moving back to monoliths (and you should too)

Zev Uhuru
Engineering Research
February 8, 2024

I know what you're thinking. "Another engineer advocating for monoliths? What year is this, 2010?" But hear me out. After spending three years in microservices hell, we made the controversial decision to consolidate back into a monolith. The results? 50% fewer bugs, 80% faster deployments, and a team that actually enjoys their work again.

This isn't a blanket condemnation of microservices—they have their place. But for many teams, especially those under 50 engineers, the monolith might be the better choice. Here's why.

The Microservices Mirage

We started our microservices journey with the best intentions. We had read all the Netflix and Uber engineering blogs. We understood the benefits: independent deployments, technology diversity, team autonomy. What we didn't anticipate was the operational complexity.

Our Microservices Reality:

  • 23 services for what used to be 3 modules
  • 18 repositories to maintain and coordinate
  • 45 deployments per week (most were rollbacks)
  • 3 hours average to trace a bug across services

The breaking point came during a seemingly simple feature request: "Can users see their order history on the profile page?" This required coordinating changes across 6 services, 4 teams, and 2 sprint cycles. What should have been a 2-day task became a 3-week ordeal.

The Great Consolidation

The decision to move back to a monolith wasn't made lightly. We spent months planning the migration, identifying service boundaries that made sense, and figuring out how to maintain the benefits of our microservices architecture while eliminating the pain points.

typescript
// Before: Service-to-service communication nightmare
class OrderService {
  async createOrder(userId: string, items: CartItem[]) {
    // Call user service to validate user
    const user = await this.userService.getUser(userId);
    
    // Call inventory service to check stock
    const availability = await this.inventoryService.checkStock(items);
    
    // Call payment service to process payment
    const payment = await this.paymentService.charge(user, total);
    
    // Call notification service to send confirmation
    await this.notificationService.sendOrderConfirmation(user, order);
    
    // Each call can fail, timeout, or return stale data
    // Debugging requires tracing across 5 different services
  }
}

// After: Simple, traceable, fast
class OrderService {
  async createOrder(userId: string, items: CartItem[]) {
    return await this.db.transaction(async (tx) => {
      const user = await tx.users.findById(userId);
      const inventory = await tx.inventory.reserveItems(items);
      const payment = await tx.payments.charge(user, total);
      const order = await tx.orders.create({ user, items, payment });
      
      // Queue notification for async processing
      await this.queue.add('order-confirmation', { orderId: order.id });
      
      return order;
    });
  }
}

Deployment Frequency: Before vs After

The Modern Monolith

Our new monolith isn't the monoliths of old. We've learned from our microservices experience and built something that gives us the best of both worlds: the simplicity of a monolith with the modularity lessons learned from microservices.

Modular Monolith Architecture

Domain Modules: Users, Orders, Inventory, Payments
Shared Infrastructure: Database, Queue, Cache, Auth
API Layer: GraphQL gateway with module-specific resolvers
Event System: Internal pub/sub for loose coupling
typescript
// Clean module boundaries within the monolith
export class UserModule {
  constructor(
    private db: Database,
    private events: EventBus,
    private cache: Cache
  ) {}

  async createUser(userData: CreateUserRequest): Promise<User> {
    const user = await this.db.users.create(userData);
    
    // Publish event for other modules to react
    await this.events.publish('user.created', { 
      userId: user.id, 
      email: user.email 
    });
    
    return user;
  }
}

// Other modules can listen without tight coupling
export class NotificationModule {
  constructor(private events: EventBus) {
    this.events.subscribe('user.created', this.sendWelcomeEmail);
    this.events.subscribe('order.completed', this.sendOrderConfirmation);
  }
}

The Results

Complexity Reduction

50%
Fewer Bugs
80%
Faster Deployments
90%
Less Debugging Time

When Monoliths Make Sense

Monoliths aren't always the right choice, but they're right more often than the industry admits. Here's when you should seriously consider a monolith:

  • Team size under 50: You don't have the operational overhead to manage dozens of services
  • Rapid feature development: Cross-service changes slow you down more than they help
  • Shared data models: If your services share 80% of their data, they should probably be one service
  • Limited DevOps resources: Managing 20+ services requires dedicated platform teams

"The best architecture is the one that lets your team move fast and sleep well at night. For most teams, that's a well-designed monolith, not a distributed system."

The Path Forward

If you're struggling with microservices complexity, you're not alone. The path back to a monolith doesn't mean abandoning everything you've learned. Take the best practices—modular design, event-driven architecture, proper testing—and apply them within a single deployable unit.

The monolith renaissance isn't about going backwards. It's about choosing the right tool for the job, and for many teams, that tool is a well-architected, modern monolith.