Should a Solo Developer Use Microservices or Monolith? (Real Experience)
Purpose
This post shares my experience building microservices as a solo developer and explains why a modular monolith is almost always the better choice for small teams.
Environment
- Node.js 20.x
- Express.js 4.18.x
- Docker 24.x
- PostgreSQL 16.x
- Redis 7.x
- Kubernetes 1.28 (for the microservices nightmare)
The Problem
I read all the blog posts about microservices. Netflix, Amazon, Uber - they all use microservices, so I should too, right? I was building a SaaS product and wanted to “do it right” from the start.
So I built 4 separate services for an application with 50 users:
services/ auth-service/ # Authentication (Node.js + Express) user-service/ # User management (Node.js + Express) payment-service/ # Stripe integration (Node.js + Express) notification-service/ # Email + push (Node.js + Express)Each service had its own:
- Docker container
- Database (4 separate PostgreSQL instances)
- Redis cache
- Deployment pipeline
- Monitoring setup
This is what my docker-compose looked like:
version: '3.8'services: auth-service: build: ./services/auth ports: ["3001:3001"] depends_on: [postgres-auth, redis] environment: DATABASE_URL: postgres://auth@postgres-auth:5432/auth
user-service: build: ./services/users ports: ["3002:3002"] depends_on: [postgres-users, redis] environment: DATABASE_URL: postgres://users@postgres-users:5432/users
payment-service: build: ./services/payments ports: ["3003:3003"] depends_on: [postgres-payments, redis] environment: DATABASE_URL: postgres://payments@postgres-payments:5432/payments
notification-service: build: ./services/notifications ports: ["3004:3004"] depends_on: [postgres-notifications, redis]
postgres-auth: image: postgres:16 volumes: [pg-auth:/var/lib/postgresql/data]
postgres-users: image: postgres:16 volumes: [pg-users:/var/lib/postgresql/data]
postgres-payments: image: postgres:16 volumes: [pg-payments:/var/lib/postgresql/data]
postgres-notifications: image: postgres:16 volumes: [pg-notifications:/var/lib/postgresql/data]
redis: image: redis:7
volumes: pg-auth: pg-users: pg-payments: pg-notifications:Four databases for 50 users. The complexity was crushing me.
The Debugging Nightmare
One day, a user reported they couldn’t complete a purchase. The error was vague: “Something went wrong.”
I started tracing:
- Payment service logs:
Error calling user-service to verify account - User service logs:
Error calling auth-service to verify session - Auth service logs:
Error calling notification-service to send verification
The actual error? Notification service was down because I forgot to restart it after a config change. But tracing the issue across 4 services took hours.
Each service call added network latency and potential failure points:
// Payment service needs to call user serviceasync function processPayment(userId, amount) { // Network call 1: Verify user exists const userResponse = await fetch(`http://user-service:3002/users/${userId}`); if (!userResponse.ok) { throw new Error('User service unavailable'); // What caused this? } const user = await userResponse.json();
// Network call 2: Check user permissions via auth service const authResponse = await fetch(`http://auth-service:3001/verify/${userId}`); if (!authResponse.ok) { throw new Error('Auth service unavailable'); // Or this? }
// Network call 3: Send confirmation via notification service await fetch('http://notification-service:3004/send', { method: 'POST', body: JSON.stringify({ userId, type: 'payment' }) });
// Finally, the actual payment logic return stripe.charges.create({ amount, customer: user.stripeCustomerId });}Any one of those network calls could fail. I needed:
- Circuit breakers
- Retry logic with exponential backoff
- Distributed tracing
- Log aggregation across services
- Service discovery
- API versioning
All for 50 users.
The Solution
I consolidated everything into a modular monolith:
import express from 'express';import { authRoutes } from './modules/auth/auth.routes.js';import { userRoutes } from './modules/users/users.routes.js';import { paymentRoutes } from './modules/payments/payments.routes.js';import { notificationRoutes } from './modules/notifications/notification.routes.js';
const app = express();
app.use('/api/auth', authRoutes);app.use('/api/users', userRoutes);app.use('/api/payments', paymentRoutes);app.use('/api/notifications', notificationRoutes);
export default app;Modular Monolith Structure
The key is clean module boundaries without distributed complexity:
src/ modules/ auth/ auth.controller.js auth.service.js auth.repository.js auth.routes.js users/ users.controller.js users.service.js users.repository.js users.routes.js payments/ payments.controller.js payments.service.js stripe.integration.js payments.routes.js notifications/ notifications.service.js email.service.js notifications.routes.js shared/ middleware/ utils/ config/ database.js app.jsEach module has the same structure as a microservice, but they’re all in one deployable unit:
import { UserRepository } from './users.repository.js';import { EventPublisher } from '../../shared/events.js';
export class UserService { constructor( private userRepo = new UserRepository(), private events = new EventPublisher() ) {}
async createUser(data) { // Single transaction, no distributed coordination const user = await this.userRepo.create(data);
// In-process event publishing (simple) this.events.publish('user.created', user);
return user; }
async getUser(id) { // Direct call, no network latency return this.userRepo.findById(id); }}Compare this to the microservice version:
- No HTTP calls between modules
- No network failures
- No circuit breakers needed
- Single database transaction
- Direct function calls with stack traces
Deployment Simplicity
My docker-compose went from 100+ lines to this:
version: '3.8'services: app: build: . ports: ["3000:3000"] depends_on: [postgres, redis]
postgres: image: postgres:16 volumes: [pg-data:/var/lib/postgresql/data]
redis: image: redis:7
volumes: pg-data:One database. One application. One deployment pipeline.
When I Actually Need Microservices
I’ve since learned to recognize actual signals for microservices:
// Signal 1: One module needs different scaling characteristics// Example: Image processing is CPU-heavy, everything else is IO-bound// Solution: Extract just that module as a worker service
// services/image-processor/// Dockerfile// worker.js // Consumes from queue, no HTTP endpoint needed// queue.js // RabbitMQ or SQS
// Signal 2: Different technology requirements// Example: ML inference needs Python, rest of app is Node.js
// services/ml-inference/// requirements.txt// model.pkl// predict.py // Python service
// Signal 3: Regulatory isolation// Example: Payment data needs separate compliance boundaryThese are real needs, not premature optimization.
How It Works
The modular monolith pattern provides separation without distribution:
-
Module boundaries: Each module has its own controller, service, repository pattern. This is the same structure as a microservice.
-
Shared infrastructure: Database, cache, and config are shared. No cross-service network calls.
-
In-process communication: Modules call each other directly. Events are published in-process.
-
Single deployment: One artifact to build, test, and deploy. Rolling back is trivial.
-
Future extraction path: When a module genuinely needs to be a service, the clean boundaries make extraction straightforward.
Common Mistakes
I made several mistakes that led to my microservices disaster:
1. Following Big Tech blog posts
Netflix has 1000+ engineers. They need microservices because teams need independent deployment. As a solo developer, I have none of those constraints.
2. Premature optimization for scale
I built for millions of users before I had 100. The complexity cost was immediate, but the scale benefits never materialized.
3. Underestimating operational complexity
Each service needs:
- Monitoring
- Logging
- Deployment pipeline
- Database migrations
- Error tracking
- Performance profiling
Four services means four of everything. That’s a full-time DevOps role.
4. Ignoring the debugging cost
When something breaks, I need to trace across services. Without distributed tracing infrastructure (Jaeger, Zipkin), debugging is guesswork.
5. Forgetting about data consistency
In my monolith, a user creation transaction is atomic:
await database.transaction(async (tx) => { const user = await tx.users.create(data); await tx.preferences.create({ userId: user.id }); await tx.audit.log({ action: 'user_created', userId: user.id }); return user;});In microservices, this requires:
- Saga pattern
- Compensating transactions
- Eventually consistent state
- Complex failure handling
Why This Matters
Consolidating to a monolith changed everything:
-
Velocity improved dramatically: What took hours (debugging, deploying) now took minutes.
-
Operational burden dropped: One deployment, one set of logs, one database to manage.
-
Bug fixing became tractable: Stack traces show the full call chain. No more guessing which service failed.
-
Development was enjoyable again: I spent time building features instead of managing infrastructure.
-
Costs decreased: One small server instead of a Kubernetes cluster.
Summary
In this post, I shared my experience building microservices for 50 users and why I consolidated to a monolith. The key insight is that complexity is the real enemy for solo developers.
Microservices make sense when:
- You have 10+ developers with clear service ownership
- Specific modules need independent scaling
- Different modules need different tech stacks
- You have dedicated DevOps resources
For solo developers and small teams:
- Start with a modular monolith
- Create clean module boundaries
- Wait for real scaling problems
- Extract services only when you have concrete needs
My 50 users never needed microservices. Your startup probably doesn’t either.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments