You spent three years building a distributed system. Kubernetes clusters, service meshes, inter-service gRPC calls, a dedicated message broker — and your user base is 200 people, 40 of whom are your coworkers testing it.
The Architecture Trap
There's a well-worn path in software engineering that goes something like this: you read about how Netflix decomposed their monolith, you watch a conference talk about event-driven architecture, and suddenly your SaaS for managing yoga class bookings has seventeen services, a Kafka cluster, and three on-call rotations.
This isn't a hot take. It's a recurring diagnosis. Developers — especially good ones — are pattern-matchers. We see sophisticated solutions to hard problems and we want to apply them. The trouble is, microservices aren't a better way to build software. They're a specific solution to a specific set of scaling and team-coordination problems that most applications will never have.
Netflix had thousands of engineers and hundreds of millions of users before they went full microservices. Your B2B dashboard tool does not have that problem. It has a different problem: it needs to ship features, fast, without a distributed tracing setup just to debug why a user can't log in.
What a Monolith Actually Looks Like in 2024
The word "monolith" has been so thoroughly dragged through the mud that developers treat it like a slur. But a well-structured monolith isn't a big ball of mud — it's a modular application with clear internal boundaries that happens to deploy as a single unit.
Here's a straightforward Node.js/Express monolith structure that scales comfortably to hundreds of thousands of users:
// src/app.ts — entry point, clean and boring
import express from 'express';
import { userRouter } from './modules/users/user.routes';
import { billingRouter } from './modules/billing/billing.routes';
import { notificationRouter } from './modules/notifications/notification.routes';
import { db } from './lib/db';
const app = express();
app.use(express.json());
app.use('/api/users', userRouter);
app.use('/api/billing', billingRouter);
app.use('/api/notifications', notificationRouter);
app.listen(3000, () => {
console.log('Server running on port 3000');
});
export default app;
// src/modules/users/user.service.ts — a self-contained module
import { db } from '../../lib/db';
import { NotificationService } from '../notifications/notification.service';
import { BillingService } from '../billing/billing.service';
export class UserService {
private notificationService: NotificationService;
private billingService: BillingService;
constructor() {
this.notificationService = new NotificationService();
this.billingService = new BillingService();
}
async createUser(email: string, plan: string): Promise<{ id: string; email: string }> {
// All of this happens in one process, one transaction, one deployment.
// No network calls. No distributed transactions. No saga pattern.
const user = await db.user.create({
data: { email, plan, createdAt: new Date() },
});
await this.billingService.initializeBillingRecord(user.id, plan);
await this.notificationService.sendWelcomeEmail(user.email);
return { id: user.id, email: user.email };
}
async getUserById(id: string) {
return db.user.findUnique({ where: { id } });
}
}
Notice what's not here: no message queues to coordinate the billing and notification steps, no distributed tracing to figure out why the welcome email failed, no separate deployment pipelines per service. If something breaks, your stack trace tells you exactly where. Your rollback is one command.
The modules are still cleanly separated. If you ever genuinely need to extract NotificationService into its own service — because your notification volume is overwhelming the main app, or because a separate team owns it — you can. The internal boundaries are already there. You're paying none of the distributed systems tax until you actually need to.
The Real Costs Nobody Talks About
When someone pitches microservices, the conversation is usually about scalability and team autonomy. Fair points, eventually. But the costs are almost never foregrounded honestly:
Operational complexity compounds. Every service you add is a new thing that can fail in production, a new set of logs to aggregate, a new deployment pipeline to maintain. At five services you're fine. At twenty you need a platform engineering team whose entire job is keeping the infrastructure running for the product engineers.
Distributed transactions are genuinely hard. In the monolith example above, createUser runs billing and notification in sequence. If billing fails, you throw an error, nothing is committed, the user doesn't exist. In a microservices world, you've got eventual consistency, saga patterns, compensation transactions, and a whole new class of bugs where the user exists in one service but not another.
Local development gets painful fast. Running npm run dev is great. Running docker-compose up for twelve services, waiting for them all to be healthy, and then realizing service seven has a port conflict is a productivity graveyard. Junior devs especially get slowed down enormously.
Debugging requires infrastructure. In a monolith, you add a console.log and you're done. In microservices, you need distributed tracing (Jaeger, Zipkin, Datadog APM) just to follow a request through the system. That tooling is powerful, but it's also expensive and takes real time to set up correctly.
When Microservices Actually Make Sense
This isn't a microservices hit piece. There are legitimate reasons to decompose a system:
- Independent scaling needs. Your video transcoding workload needs 10x the compute of your API layer and runs on completely different hardware profiles.
- Team boundaries at scale. Once you have 50+ engineers, Conway's Law means your architecture should reflect your org structure. Separate teams genuinely benefit from deployment independence.
- Wildly different tech requirements. Your ML inference pipeline wants Python and GPUs. Your API wants Node. They genuinely shouldn't be the same process.
- Compliance isolation. Your payment processing code needs to live in a PCI-compliant environment that the rest of your app doesn't touch.
If none of these apply to you right now, you don't need microservices. You need a better-organized monolith and maybe a read replica on your database.
A Practical Migration Path That Isn't Premature
If you're starting fresh or refactoring, here's the approach that keeps your options open without paying distributed systems costs upfront:
- Build a modular monolith. Strong internal module boundaries, dependency injection, no circular imports between modules. Treat each module as if it could become a service, but don't make it one.
- Use a job queue early. Not Kafka — something like BullMQ backed by Redis. This handles async workloads (emails, webhooks, report generation) without splitting your codebase. One deployment, async processing.
- Watch your metrics, not your ambitions. Set up basic APM. Only split a service when you have concrete evidence that a specific part of the system is causing problems — response time degradation, resource contention, deployment coupling that's actually slowing teams down.
- Extract one service as an experiment before extracting many. The operational overhead of service number two is enormous relative to one. Service number five is marginal. Feel the actual cost before committing the architecture.
Ship the Thing
The best architecture is the one that lets you move fast now while not painting you into a corner for later. A well-structured monolith with clear module boundaries does exactly that. It's boring, it's pragmatic, and it ships.
At BLN Craft, we build dev tools for developers who want to move faster without setting their architecture on fire first. If you're tired of infrastructure getting in the way of building, come take a look.
Now go delete a Kubernetes cluster you didn't need.
Top comments (0)