The default should be a monolith — here's why
The software industry spent a decade hyping microservices, and the pendulum is swinging back. Amazon, Netflix, and Google run microservices because they have thousands of engineers, complex organizational structures, and traffic patterns that require independent scaling. Most applications — including many that serve millions of users — don't have these constraints.
A monolith is simpler to develop (no network calls between services), simpler to test (run the whole app locally), simpler to debug (one log stream, one trace), simpler to deploy (one artifact), and simpler to operate (one monitoring target). Start there and migrate when you have evidence that a monolith is causing a specific, measurable problem.
The real cost of microservices
Every microservice boundary you introduce adds:
- Network latency — A function call takes nanoseconds. A network call takes milliseconds. At 10 services in a chain, you've added 10–100ms of minimum latency.
- Partial failure modes — What happens when service B returns a 503? You need circuit breakers, retry logic, fallback behavior. In a monolith, a function can't "be down".
- Distributed transactions — ACID transactions across service boundaries require sagas or two-phase commit. Neither is simple.
- Distributed tracing — A request that crosses 5 services requires trace context propagation and a distributed tracing system to understand.
- Per-service everything — Deployment pipeline, monitoring dashboard, alert rules, oncall runbook, scaling policy. Multiply by the number of services.
- Data consistency — Each service owns its database. Joining data across services requires either API calls, event-driven eventual consistency, or a reporting database.
These costs are real and ongoing. The benefits — independent deployability, independent scalability, team autonomy — are also real, but they only pay off at a certain organizational and traffic scale. That scale is much higher than most teams think.
When microservices make sense
There are legitimate reasons to decompose. The key is having a specific, measurable problem:
- Team coordination friction — 20+ engineers constantly stepping on each other in the same codebase, with deployment conflicts blocking releases multiple times per week
- Wildly different scaling requirements — A video transcoding component needs to scale 100x during evenings while your API needs 2x. In a monolith you'd be scaling the entire app for one use case.
- Different reliability requirements — Your payment processing cannot be affected by your analytics service going down. Hard process isolation is the right tool.
- Different technology requirements — A computer vision model that must run Python can reasonably be a separate service from your Node.js API.
- Compliance boundaries — PCI DSS requires that cardholder data be isolated. This sometimes genuinely requires service separation.
The modular monolith: the best starting point
The modular monolith gives you clean architecture and the option to extract services later, without paying the operational cost upfront. The key rule: modules communicate through explicit interfaces, never through direct database access or function calls that bypass the module boundary.
src/
modules/
users/
index.ts // Public interface — what other modules can use
users.service.ts // Business logic — private
users.repo.ts // Database access — private
users.router.ts // HTTP routes — private
users.events.ts // Events this module emits
users.types.ts // Types exported in the public interface
billing/
index.ts
billing.service.ts
billing.repo.ts
billing.router.ts
notifications/
index.ts
notifications.service.ts
lib/
event-bus.ts // In-process event emitter for inter-module communication
db.ts
app.ts// modules/users/index.ts — public interface only
export { UserService } from './users.service';
export type { User, CreateUserInput } from './users.types';
// users.repo.ts is NOT exported — it's an internal implementation detail
// modules/billing/billing.service.ts — communicates through the event bus
import { eventBus } from '@/lib/event-bus';
eventBus.on('user.created', async ({ userId }) => {
await billingService.createBillingAccount(userId);
});
// ✗ Don't do this — direct database access across module boundaries
import { prisma } from '@/lib/db';
const user = await prisma.user.findUnique(...); // billing module shouldn't query users table directlyWhen you do extract a service: the strangler fig pattern
Don't attempt a big-bang rewrite. Use the strangler fig pattern: run the new service alongside the monolith, route traffic gradually, and remove the monolith code once the service is proven stable.
// Step 1: Add a feature flag to route to new service
async function getUser(userId: string): Promise<User> {
if (featureFlags.useUserService) {
return userServiceClient.getUser(userId);
}
return userRepository.findById(userId); // Monolith code path
}
// Step 2: Gradually enable the flag (10% → 50% → 100%)
// Step 3: Remove monolith code path once stable at 100%The Netflix/Amazon fallacy
The most common microservices antipattern is copying architecture from companies that operate at 1000x your scale. Netflix has hundreds of dedicated platform engineers maintaining their service mesh, observability stack, and deployment infrastructure. That's the cost of their architecture at scale. At your scale, those same engineers would be building features.
Copy principles, not architectures. The principles microservices encode — loose coupling, high cohesion, independent deployability — can all be applied in a well-structured monolith. The architecture follows naturally when your organization genuinely needs it.