Skip to content

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:

docker-compose.yml
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:

  1. Payment service logs: Error calling user-service to verify account
  2. User service logs: Error calling auth-service to verify session
  3. 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:

services/payment-service/src/handlers.js
// Payment service needs to call user service
async 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:

src/app.js
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.js

Each module has the same structure as a microservice, but they’re all in one deployable unit:

src/modules/users/users.service.js
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:

docker-compose.yml
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:

docs/architecture-decisions.md
// 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 boundary

These are real needs, not premature optimization.

How It Works

The modular monolith pattern provides separation without distribution:

  1. Module boundaries: Each module has its own controller, service, repository pattern. This is the same structure as a microservice.

  2. Shared infrastructure: Database, cache, and config are shared. No cross-service network calls.

  3. In-process communication: Modules call each other directly. Events are published in-process.

  4. Single deployment: One artifact to build, test, and deploy. Rolling back is trivial.

  5. 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:

src/modules/users/users.service.js
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:

  1. Velocity improved dramatically: What took hours (debugging, deploying) now took minutes.

  2. Operational burden dropped: One deployment, one set of logs, one database to manage.

  3. Bug fixing became tractable: Stack traces show the full call chain. No more guessing which service failed.

  4. Development was enjoyable again: I spent time building features instead of managing infrastructure.

  5. 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