Why (when) to use them
- Independent deployability and scaling per domain.
- Smaller blast radius and clearer ownership when boundaries are correct.
Real costs
- Network and data consistency complexity replaces in‑process calls.
- Distributed observability, API versioning, and cross‑team coordination.
Practices that work
- Design around DDD bounded contexts; each service owns its data.
- Prefer asynchronous communication (events/queues) for workflows; use sync only for critical reads.
- Backward‑compatible contracts and explicit versioning.
- End‑to‑end tracing, correlation IDs, per‑service SLOs and budgets.
Data and consistency
- Avoid cross‑service transactions; use the outbox pattern and idempotent consumers.
- Sagas for long‑running workflows; design compensations explicitly.
- Keep aggregates small; expose read models via materialized views.
Anti‑patterns
- Microservices without automation (CI/CD, infra as code, service templates).
- Sharing a single database across services → tight coupling and lock‑step deploys.
Start modular, earn your split, and move state out of processes before slicing services.
Migration playbook
- Strangle the monolith at the edge (routing/anti‑corruption layer).
- Carve off a low‑risk bounded context with its own data store.
- Introduce contracts and CDC; backfill read models.
- Measure SLOs and error budgets before/after; iterate.
Code: outbox pattern (SQL + worker)
-- language-sql
-- In the same transaction as a domain change, write an outbox row
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = $1;
INSERT INTO outbox(id, aggregate_id, type, payload, created_at)
VALUES (gen_random_uuid(), $1, 'OrderPaid', to_jsonb($2::json), NOW());
COMMIT;
// language-typescript
// Worker polls outbox, publishes to a broker, marks as processed idempotently
type OutboxEvent = { id: string; type: string; payload: unknown }
async function handleOutbox(batch: OutboxEvent[]) {
for (const evt of batch) {
const already = await redis.setnx(`outbox:${evt.id}`, 1)
if (!already) continue // idempotent
await broker.publish(evt.type, evt.payload)
await db.query('DELETE FROM outbox WHERE id = $1', [evt.id])
}
}
Analogy
Microservices are like a team of small restaurants in a food court. Each shop specializes (bounded context) and has its own kitchen (database). The hallway (gateway/mesh) organizes orders and traffic. Sharing one kitchen leads to chaos.
Example scenario
- Start with a modular monolith. Extract the “billing” context when it stresses team boundaries. Give it an outbox and a queue; build read models in “orders” via CDC.
FAQ
- “How big should a service be?” Small enough to be owned by one team, big enough to avoid chatty cross‑service calls.
- “Do I need a mesh?” Not initially. Add it once you need mTLS, traffic shaping, and consistent telemetry at scale.
Try it (trace budget)
Define a target: 95% of checkout traces < 700ms with < 3 cross‑service hops. Alert if exceeded.