Microservices were supposed to make teams faster. For many teams, they did the opposite.
What started as a modern architectural upgrade quietly turned into an over-engineering trap.
This post explains what actually went wrong, why modular monoliths are winning again, and how to choose architecture based on reality—not trends.
Microservices promised:
In theory, this works — at massive scale with strong platform capabilities and mature DevOps. Most teams don't operate there.
Teams adopting microservices encountered:
The system became harder to understand than the business logic.
Every microservice adds its own:
Complexity grows multiplicatively, not linearly.
What used to be function calls becomes remote calls with:
Failures are now normal, not exceptional.
// Modular monolith
const price = Pricing.calculate(items);
// Microservices
const price = await withTimeout(
() => pricingService.calculate({ items }),
1000
);
A single user request may span:
Understanding behavior requires dashboards and traces, not just code & stack traces.
Each service consumes:
Cost often scales per service, not per feature.
Industry case studies (e.g., Prime Video) reported order-of-magnitude cost reductions when consolidating serverless microservices into a simpler, monolithic design.
A modular monolith is one deployable unit with strong internal boundaries (modules/packages) and clear ownership.
You keep modularity without paying network overhead.
| Dimension | Microservices | Modular Monolith | |---|---|---| | Communication | Remote network calls | In-process function calls | | Latency | Higher (ms + serialization) | Minimal (µs) | | Failure Modes | Cascading across services | Mostly local | | Debugging | Distributed tracing, dashboards | Stack traces, local logs | | Consistency | Sagas, outbox, eventual | ACID transactions | | Deployment | Many pipelines, versions | Single pipeline | | Infra Cost | Higher (per service tax) | Lower | | Best For | Org/scaling constraints | Dev speed & clarity |
Client
↓ (HTTP, TLS, latency)
API Gateway / WAF
↓
Service A Service B Service C
↓ ↓ ↓
DB A DB B DB C
↓
Response (after multiple hops)
Each arrow = network call + serialization + timeout + failure risk.
Client
↓ (HTTP)
Controller → Service Layer → Domain Module(s) → Database
↓
Response
Each arrow = an in-process function call (except HTTP and DB).
// order-service/createOrder.ts
import { withTimeout, retry, CircuitBreaker } from "./resilience";
import { authService, pricingService, paymentService } from "./clients";
const breaker = new CircuitBreaker({ failureThreshold: 5 });
export async function createOrder(reqBody: unknown) {
const { token, items, idempotencyKey, currency } = validate(reqBody);
// 1) AuthN/AuthZ
const user = await withTimeout(
() => retry(() => authService.verify(token), { retries: 2 }),
1200
);
// 2) Pricing
const price = await withTimeout(
() => retry(() => pricingService.calculate({ items, currency }), { retries: 2 }),
1000
);
// 3) Payment with circuit breaker
const payment = await breaker.exec(() =>
withTimeout(
() => paymentService.charge({
customerId: user.sub,
amount: price.total,
currency
}, { idempotencyKey }),
1800
)
);
// 4) Persist + publish event
const order = await saveOrder({ userId: user.sub, amount: price.total, items });
await publishEvent("order.created", { orderId: order.id, items });
return { orderId: order.id };
}
Key points: timeouts/retries/circuit breaking, idempotency, event publication, structured logs.
// app/order/createOrder.ts
import { db } from "../infra/db";
import { Outbox } from "../infra/outbox";
import { PaymentGateway } from "../payments/PaymentGateway";
import { Auth } from "../auth/Auth";
export async function createOrder(input: unknown) {
const { userId, items, currency, idempotencyKey } = validate(input);
Auth.ensureUserCanOrder(userId);
const price = Pricing.calculate(items, currency);
// First transaction: Check idempotency and create provisional order
const provisional = await db.transaction(async (tx) => {
const existing = await tx.order.findUnique({ where: { idempotencyKey } });
if (existing) return existing;
return await tx.order.create({
data: { userId, amount: price.total, currency, status: "PAYMENT_PENDING", idempotencyKey },
});
});
// If order already existed, return early
if (provisional.status === "PAID") {
return { orderId: provisional.id, reused: true };
}
// External side-effect (outside transaction)
const payment = await PaymentGateway.charge({
customerId: userId,
amount: price.total,
currency,
idempotencyKey
});
// Second transaction: Update order and publish event
await db.transaction(async (tx) => {
await tx.order.update({
where: { id: provisional.id },
data: { status: "PAID", paymentId: payment.id }
});
await Outbox.add(tx, "order.created", { orderId: provisional.id, items });
});
return { orderId: provisional.id };
}
Key points: single deployable unit, ACID transaction, DB-enforced idempotency, transactional outbox.
** Start simple, modularize hard, and split services only when the seams are obvious.**
Microservices aren't inherently bad — but they're often premature optimization.
For most teams, a well-architected modular monolith delivers:
Simple beats clever until scale proves otherwise.
Want to dive deeper?