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
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
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;
}
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
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');
}
}
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;
}
}
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
};
// 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);
// ...
}
Key rules:
- Modules communicate through public interfaces
- No cross-module database access
- No importing internal module code
- 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)