DEV Community

Context First AI
Context First AI

Posted on

The Architecture Decision Framework: Monolith vs. Microservices for SaaS Products.

The Architecture Decision Framework: Monolith vs. Microservices for SaaS Products

Model: Opus 4.5 (Test Run)

I spent six months building microservices for a SaaS product that never found users. The architecture was sophisticated. The product failed anyway.

That experience taught me something important: architecture decisions should solve problems you actually have, not problems you imagine having someday.

The Mistake I Made

When I started my SaaS product, I'd been reading about Netflix's microservices, Uber's architecture, Amazon's distributed systems. These companies had figured something out, and I wanted to learn from them.

So I built microservices from day one:

services/
├── user-service/
│   ├── src/
│   ├── Dockerfile
│   └── package.json
├── auth-service/
│   ├── src/
│   ├── Dockerfile
│   └── package.json
├── payment-service/
│   ├── src/
│   ├── Dockerfile
│   └── package.json
└── notification-service/
    ├── src/
    ├── Dockerfile
    └── package.json
Enter fullscreen mode Exit fullscreen mode

Each service had its own database, its own deployment pipeline, its own monitoring.

Six months later, I had:

  • Four services that talked to each other constantly
  • Three-hour deployments
  • An application that crashed when any service went down
  • Zero paying customers

I'd optimized for scale I never reached because I never made the product useful enough.

What Monolithic Architecture Actually Means

When developers hear "monolith," they often picture spaghetti code. But a monolith is simply a single deployable unit—it can be well-organized or poorly organized, just like any codebase.

src/
├── modules/
│   ├── users/
│   │   ├── controllers/
│   │   ├── services/
│   │   ├── models/
│   │   └── index.ts
│   ├── billing/
│   │   ├── controllers/
│   │   ├── services/
│   │   ├── models/
│   │   └── index.ts
│   └── content/
│       ├── controllers/
│       ├── services/
│       ├── models/
│       └── index.ts
├── shared/
│   ├── middleware/
│   ├── utils/
│   └── database/
└── app.ts
Enter fullscreen mode Exit fullscreen mode

The benefits are tangible:

Development speed: Adding a feature that touches users, billing, and notifications? Make changes in one codebase. Run tests once. Deploy once.

// In a monolith, this is straightforward
async function createSubscription(userId: string, planId: string) {
  const user = await userModule.getUser(userId);
  const payment = await billingModule.processPayment(user, planId);
  await notificationModule.sendConfirmation(user.email, payment);
  return payment;
}
Enter fullscreen mode Exit fullscreen mode

Debugging: When something breaks, you follow the code path in one place. No distributed tracing. No correlating logs across services.

Onboarding: New developers clone one repo, run one setup command, and can understand the entire system by reading through it systematically.

When Microservices Make Sense

Microservices solve real problems—just not the problems most early-stage products have.

Organizational scaling: When you have 50+ developers, they can't all work in the same codebase efficiently. Different teams need to deploy independently.

Technical scaling**: When your search needs 10x the memory of your API, scaling them together wastes resources.

Compliance requirements: When PCI compliance demands your payment processing is isolated from everything else.

# Microservices make sense when you need this level of independence
services:
  user-service:
    replicas: 3
    resources:
      memory: 512Mi

  search-service:
    replicas: 10
    resources:
      memory: 8Gi

  payment-service:
    replicas: 2
    resources:
      memory: 256Mi
    network: isolated-pci
Enter fullscreen mode Exit fullscreen mode

The Hidden Costs

What the success stories don't emphasize: microservices add significant complexity.

Network failures: Every service call can fail. Your code needs to handle this gracefully.

// Microservices require defensive programming
async function getUser(userId: string) {
  try {
    const response = await fetch(`${USER_SERVICE_URL}/users/${userId}`, {
      timeout: 5000,
      retry: {
        attempts: 3,
        backoff: 'exponential'
      }
    });

    if (!response.ok) {
      // What do we do if user service is down?
      return getCachedUser(userId) || handleUserServiceUnavailable();
    }

    return response.json();
  } catch (error) {
    // Circuit breaker? Fallback? Fail fast?
    circuitBreaker.recordFailure('user-service');
    throw new ServiceUnavailableError('user-service');
  }
}
Enter fullscreen mode Exit fullscreen mode

Data consistency: In a monolith, you wrap related operations in a transaction. In microservices, you need sagas or eventual consistency.

// The same operation in microservices becomes complex
async function createSubscription(userId: string, planId: string) {
  const saga = new Saga();

  try {
    const user = await saga.step(
      () => userService.getUser(userId),
      () => {} // no rollback needed for read
    );

    const payment = await saga.step(
      () => billingService.processPayment(user, planId),
      () => billingService.refundPayment(payment.id)
    );

    await saga.step(
      () => notificationService.sendConfirmation(user.email, payment),
      () => {} // notifications are fire-and-forget
    );

    return payment;
  } catch (error) {
    await saga.rollback();
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Operational overhead: You need service discovery, API gateways, distributed tracing, centralized logging, per-service monitoring. Someone has to build and maintain all of this.

The Modular Monolith Pattern

Here's what I recommend for most teams: start with a modular monolith.

Single deployable application, but with strict module boundaries:

// modules/users/index.ts - The public interface
export interface UserModule {
  getUser(id: string): Promise<User>;
  createUser(data: CreateUserDTO): Promise<User>;
  updateUser(id: string, data: UpdateUserDTO): Promise<User>;
}

export const userModule: UserModule = {
  // Implementation uses internal services, repos, etc.
  // But other modules only see this interface
};
Enter fullscreen mode Exit fullscreen mode
// modules/billing/services/subscription.ts
import { userModule } from '../../users';

// Billing module uses the public interface
// NOT internal user module code
async function createSubscription(userId: string, planId: string) {
  const user = await userModule.getUser(userId);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Key rules:

  1. Modules communicate through public interfaces
  2. No cross-module database access
  3. No importing internal module code
  4. Each module could be extracted to a service

This gives you fast development now and clear extraction paths later.

Decision Framework

When should you use each pattern?

Start with monolith if:

  • Team size < 10 developers
  • Pre-product-market fit
  • Need to ship fast and iterate
  • Limited distributed systems experience

Consider modular monolith if:

  • Team size 10-30 developers
  • Product growing, need better organization
  • Want extraction flexibility for the future

Consider microservices if:

  • Team size 30+ developers
  • Clear organizational boundaries between teams
  • Different components have genuinely different scaling needs
  • Strong operational maturity (DevOps culture, monitoring, etc.)

The Bottom Line

The companies famous for microservices didn't start that way. Netflix was a monolith when it started streaming. Amazon's e-commerce was a monolith for years. They evolved to microservices when they had real problems that microservices solved.

Your 5-person startup probably doesn't have those problems yet.

Build for the problems you have. Ship fast. Iterate. Evolve your architecture when you have real reasons to evolve.

The best architecture is one that gets your product to users.

Top comments (0)