Skip to main content
Stripe SystemsStripe Systems
Software Development📅 January 9, 2026· 11 min read

Microservices vs Monolith — Making the Right Architecture Decision

✍️
Stripe Systems

The architecture decision between microservices and a monolith is not a technology choice — it is an organizational one. The right answer depends on your team size, your domain maturity, your operational capacity, and your tolerance for distributed systems complexity. Most teams that adopt microservices prematurely end up with a distributed monolith: all the coupling of a monolith plus the operational overhead of a distributed system, with none of the benefits of either.

This post lays out a framework for making that decision based on concrete engineering constraints rather than hype cycles.

When Monoliths Are the Correct Choice

A monolith is a single deployable unit. All your application code — HTTP handlers, business logic, data access — lives in one process and shares one database. This is not a limitation. It is a feature.

For startups and small teams (under 10 engineers), a monolith offers decisive advantages:

  • Simplified debugging. A stack trace spans the full request path. You attach a debugger and step through the code — no distributed tracing needed.
  • Atomic transactions. Your database handles consistency. An order placement that debits inventory and creates a shipment record either commits or rolls back. No saga orchestration, no compensating transactions.
  • Fast iteration speed. One repository, one build pipeline, one deployment. A junior engineer can ship a feature without understanding Kubernetes networking or service mesh configuration.
  • Straightforward testing. Integration tests run against a single process, not a Docker Compose file with six containers.

The monolith gets a bad reputation because of poorly structured monoliths — codebases where the payment module imports from inventory which imports from notifications in a tangled dependency graph. But that is a code organization problem, not an architecture problem. Microservices do not fix poor discipline; they make it more expensive.

The general rule: if your domain boundaries are still shifting — if you are still learning what your product is — a monolith lets you refactor cheaply. Extracting a service boundary that turns out to be wrong is far more costly than moving code between packages within a single application.

The Modular Monolith as Middle Ground

A modular monolith applies the boundary discipline of microservices within a single deployable unit. Each domain (orders, inventory, billing, notifications) lives in its own module with an explicitly defined interface. Modules communicate through public APIs — method calls or in-process events — not by reaching into each other's internals.

The key constraints:

  • No cross-module database access. The orders module queries order data through its own repository layer. It does not join against the inventory table directly. If it needs inventory data, it calls the inventory module's public interface.
  • Explicit dependency direction. Module dependencies form a directed acyclic graph. If orders depends on inventory, inventory must not depend on orders. Circular dependencies are treated as build failures.
  • Internal event bus. Modules communicate asynchronously through an in-process event system (e.g., Spring's ApplicationEventPublisher, MediatR in .NET, or a simple observer pattern). This decouples modules without introducing a message broker.

Shopify is the canonical example. Their Rails monolith serves millions of merchants. Rather than splitting into microservices, they enforced module boundaries within a single application using Packwerk, a tool that declares public interfaces and dependency rules per component. Multiple teams work concurrently without stepping on each other, while retaining the operational simplicity of a single deployment.

The modular monolith preserves your option to extract services later. When a module genuinely needs independent scaling, you already have the clean interface boundary. The extraction becomes a mechanical exercise rather than a months-long untangling effort.

When Microservices Justify Their Complexity

Microservices make sense when you have concrete, measurable problems that a monolith cannot solve:

Independent Scaling Requirements

Your search indexing consumes 16 GB of RAM and benefits from vertical scaling, but your checkout flow is CPU-bound and benefits from horizontal scaling across many small instances. In a monolith, you scale both together. With separate services, you size each workload independently. This matters when the cost differential is significant — when you are running dozens of instances and the memory overhead of co-locating unrelated workloads adds up.

Polyglot Persistence

Your product catalog is a natural fit for a document store (MongoDB, DynamoDB). Your social graph is best served by a graph database (Neo4j). Your transaction ledger requires strict ACID guarantees (PostgreSQL). Forcing all of these into a single relational database creates friction — impedance mismatch between your data model and your storage engine. Separate services let each domain choose the storage technology that fits its access patterns.

Team Autonomy at Scale

Once you pass roughly 50 engineers, coordination costs dominate. Two teams waiting on each other for a shared deployment window burns more productivity than the overhead of maintaining separate services. Microservices give teams ownership of their deployment pipeline, their on-call rotation, and their release cadence. The billing team ships daily; the analytics team ships weekly. Neither blocks the other.

Different Deployment Cadences

A payment processing service that changes quarterly (due to compliance review) should not share a deployment pipeline with a recommendation engine that ships multiple times per day. Coupling their release cycles artificially constrains one or both teams.

If none of these conditions apply, microservices will cost you more than they save.

Operational Overhead — What You Are Actually Signing Up For

Teams that adopt microservices often underestimate the operational tax. Here is a concrete accounting of what a production microservices deployment requires.

Distributed Tracing

A single user request now traverses multiple services. When that request fails or slows down, you need to reconstruct its path. This requires a tracing system — Jaeger, Zipkin, or the vendor-agnostic OpenTelemetry SDK. Every service must propagate trace context (typically via W3C Trace Context headers) and emit spans to a collector. You need storage (Elasticsearch, Cassandra, or Grafana Tempo) and dashboards to query traces by latency percentile, error rate, or trace ID.

This is not optional. Without distributed tracing, debugging a production issue across six services becomes guesswork.

Service Mesh

As your service count grows, cross-cutting concerns multiply: mutual TLS between services, traffic shaping (canary deployments, percentage-based routing), circuit breaking, retry policies, and per-service observability. A service mesh — Istio or Linkerd — handles these at the infrastructure layer so application code does not need to.

Istio deploys an Envoy sidecar proxy alongside each service instance, routing all traffic through the proxy for mTLS enforcement, metrics collection, and traffic policy application. Linkerd takes a lighter-weight approach with its Rust-based proxy, trading configurability for lower resource consumption.

The tradeoff: a service mesh adds latency (typically 1–3ms per hop), memory overhead per pod (50–100 MB for the sidecar), and a significant learning curve.

Eventual Consistency and CAP Theorem Implications

In a monolith with a single database, you get linearizable reads and serializable transactions by default. In a microservices architecture with database-per-service, you lose this. The CAP theorem dictates that under network partitions (inevitable in distributed systems), you choose between consistency and availability. Most microservices architectures choose availability, accepting windows where services have divergent views of the data.

This is not theoretical. A customer might see an order confirmation while the inventory service has not yet decremented stock. A cancelled subscription might still generate an invoice if the event has not propagated. Your system and your business processes must tolerate and reconcile these inconsistencies.

Network Latency and Failure Modes

In-process method calls take nanoseconds and do not fail due to network partitions. HTTP calls between services take milliseconds, can timeout, return 503s, or silently drop. Every service-to-service call is a failure point that does not exist in a monolith.

You need circuit breakers (Resilience4j in Java, Polly in .NET) to prevent cascade failures — when service B is down, service A should fail fast rather than exhausting its thread pool. You need retry logic with exponential backoff and jitter to handle transient failures without thundering herd effects. Bulkhead patterns isolate failures so a slow dependency does not degrade unrelated functionality.

Deployment Complexity

Each service needs its own CI/CD pipeline, Docker image, Kubernetes manifests or Helm charts, resource requests and limits, and horizontal pod autoscaler configuration. Multiply by 20 services and the infrastructure management becomes substantial.

GitOps tools like ArgoCD or Flux help by declaratively reconciling desired state (in Git) with cluster state. But someone must write and maintain those manifests, manage secrets rotation across services (Vault, Sealed Secrets), and define network policies controlling which services can communicate.

If you do not have at least one engineer dedicated to platform and infrastructure, you are not ready for microservices.

Migration Strategies

If your monolith has genuinely outgrown its architecture, here are proven patterns for incremental migration.

Strangler Fig Pattern

Named after the strangler fig tree that gradually envelops its host, this pattern routes traffic incrementally from the monolith to new services. You place a routing layer (an API gateway or reverse proxy like Nginx, Kong, or AWS API Gateway) in front of the monolith. New endpoints are implemented in the new service. Existing endpoints are migrated one at a time — route /api/v2/orders to the new orders service while /api/v2/inventory still hits the monolith.

The critical advantage: you can roll back any individual endpoint migration without affecting others. Each migration is a small, reversible change.

Branch by Abstraction

When you need to extract a capability that is deeply integrated into the monolith — say, the notification system that is called from dozens of places — introduce an interface (or abstraction layer) within the monolith. All call sites are updated to use the interface. Initially, the implementation behind the interface is the existing monolith code. Then you build a new implementation that calls the external notification service. You switch over by changing the implementation binding, not by changing every call site. If the new service has problems, you switch back.

Martin Fowler documented this pattern extensively, and it remains one of the safest approaches for extracting tightly coupled functionality.

Anti-Corruption Layer

When integrating a new service with a legacy monolith, the new service should not adopt the monolith's data model. An anti-corruption layer (ACL) translates between the two — mapping the monolith's representation (perhaps deeply nested XML from a SOAP API) to the new service's clean domain objects.

This prevents legacy design decisions from leaking into new code. The ACL is explicit, testable, and replaceable — when the monolith is retired, you remove the ACL and wire the new service to its replacement data source.

Data Management Patterns

Data ownership is the hardest problem in microservices. Get this wrong and you have a distributed monolith — services that cannot deploy independently because they share a database.

Database per Service vs Shared Database

Database per service gives each service full control over its schema, its migration schedule, and its storage technology. The orders service can add a column without coordinating with the billing team. The tradeoff is that you lose cross-service joins. Queries that previously joined orders and customers in SQL now require API calls or data duplication.

Shared database preserves join capability and transactional consistency but reintroduces coupling. Schema changes require coordination. One team's migration can break another team's queries. In practice, a shared database with per-service schema ownership (separate schemas within one database instance, with enforced access rules) can be a pragmatic compromise for early migration stages, but it is not a long-term target.

Saga Pattern for Distributed Transactions

Without a shared database, you cannot use ACID transactions across services. The saga pattern replaces a single transaction with a sequence of local transactions, each published as an event or coordinated by an orchestrator. If any step fails, compensating transactions undo the previous steps.

Choreography-based sagas use events. The orders service publishes OrderCreated. The payment service listens, processes payment, and publishes PaymentCompleted. The inventory service listens and decrements stock. Each service reacts independently. This is loosely coupled but hard to reason about as steps grow — debugging requires reconstructing the event chain across services and topics.

Orchestration-based sagas use a central coordinator (a workflow engine like Temporal or Camunda). The orchestrator calls each step explicitly and handles failures. Easier to understand and debug, but introduces a coordination point that must be durable — if it crashes mid-saga, it must resume from its last checkpoint.

Choose choreography for simple, few-step workflows where the event flow is obvious. Choose orchestration for complex, multi-step transactions where visibility and error handling matter more than loose coupling.

CQRS — Command Query Responsibility Segregation

CQRS separates the write model (commands that change state) from the read model (queries that return data). The write side processes commands through domain logic and persists events or state changes. The read side maintains denormalized projections optimized for specific query patterns.

When CQRS is warranted: Read and write patterns have fundamentally different performance characteristics. The read side needs denormalized views across multiple aggregates expensive to compute at query time. The write side has complex domain logic with invariants that do not map to a read-optimized schema.

When CQRS is over-engineering: The application is standard CRUD where reads and writes operate on the same data shape. A small team would rather spend time on product features than maintaining two data models and a synchronization mechanism. Adding CQRS to a system that does not need it doubles your data layer complexity for no measurable benefit.

Event Sourcing as a Complement to CQRS

Event sourcing stores state as a sequence of immutable events rather than as a mutable current-state row. Instead of an orders table with an order_status column, you have an event log: OrderPlaced, PaymentReceived, OrderShipped, OrderDelivered. The current state is derived by replaying the event sequence.

Event sourcing pairs naturally with CQRS: events produced by the write side are projected into read-optimized views. It provides a complete audit trail, enables temporal queries (what was the state of this order at 3 PM last Tuesday?), and supports rebuilding read models when query requirements change.

The cost is significant. Event schema evolution is hard — once persisted, an event's schema is immutable, requiring upcasting or versioned event types for changes. Event replay slows as the log grows, demanding snapshotting strategies. Developers must think in event streams rather than current state — a genuine cognitive shift.

Event sourcing fits domains where auditability and temporal queries are first-class requirements: financial systems, regulatory compliance, collaborative editing. For a typical web application, a relational database with an audit log table is simpler and sufficient.

Making the Decision

Start with a monolith. Structure it well — enforce module boundaries, define explicit interfaces between domains, keep your dependency graph clean. When you have specific, measurable problems that a monolith cannot solve, extract the service that addresses that problem. Migrate incrementally. Invest in observability before decomposition.

The goal is not microservices. The goal is a system that your team can develop, deploy, and operate effectively. For most teams, for most products, that system is a well-structured monolith.

Ready to discuss your project?

Get in Touch →
← Back to Blog

More Articles