Microservices: Building Applications as Independent, Communicating Services
A practical, capstone guide to microservices architecture — building applications as small, independently deployable services communicating over APIs and messages — covering service boundaries, communication patterns, data ownership, deployment, observability, and honest guidance on when this architecture is (and isn't) the right choice, drawing together nearly every guide in this series.
Table of Contents
- Introduction
- What Actually Defines a Microservice
- Finding Service Boundaries
- Communication Patterns: Synchronous
- Communication Patterns: Asynchronous
- Data Ownership: Database Per Service
- Maintaining Consistency Across Service Boundaries
- The API Gateway and Backend-for-Frontend Patterns
- Service Discovery
- Deployment and Packaging
- Observability Across Many Services
- Testing Strategies for Microservices
- Resilience Patterns
- Organizational Structure and Conway's Law
- When Microservices Are (and Aren't) the Right Choice
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Microservices architecture structures an application as a collection of small, independently deployable services, each owning a specific piece of business capability, communicating with each other over well-defined APIs and asynchronous messages rather than through shared in-process code or a shared database. This guide is deliberately a capstone — nearly every other guide in this series is a piece of the microservices puzzle: REST, gRPC, and GraphQL provide the synchronous communication layer; RabbitMQ, Kafka, and Azure Service Bus provide the asynchronous layer; Docker and Kubernetes provide the deployment and orchestration layer; SQL Server, PostgreSQL, and Cosmos DB/MongoDB provide the per-service data layer; and OpenTelemetry, Distributed Tracing, Structured Logging, and Health Checks provide the observability needed to actually operate the result. This guide is about how those pieces fit together into a coherent architecture, and — just as importantly — when they shouldn't be assembled this way at all.
Monolith: Microservices:
┌─────────────────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Orders │ │ Orders │ │Inventory │ │ Payments │
│ Inventory │ ────► │ Service │──│ Service │──│ Service │
│ Payments │ └──────────┘ └──────────┘ └──────────┘
│ (one process, one DB) │ │ own DB │ own DB │ own DB
└─────────────────────────┘ (communicate via APIs/messages, per Sections 3–4)
1. What Actually Defines a Microservice
Independent deployability is the defining property, not size
A "microservice" that can only be deployed together with three other services,
in a specific order, isn't actually independently deployable — regardless of how small its codebase is.
The word "micro" is genuinely misleading — service size (lines of code, number of endpoints) is not what makes something a microservice architecturally. The defining property is independent deployability: a team can change, test, and deploy one service without needing to coordinate a simultaneous deployment of any other service, and without that deployment requiring anyone else's service to change in lockstep. A "microservice" that violates this — where deploying Service A always requires also redeploying Service B — has most of microservices' operational costs (network calls, distributed data, deployment complexity) without the actual benefit that's supposed to justify them.
Owning a specific business capability
❌ "UserValidationService" — a technical layer, not a business capability
✅ "OrderService" — owns the complete business capability of order lifecycle management
A well-bounded microservice owns a genuine business capability end-to-end (data, logic, and API), not a narrow technical layer sliced horizontally out of a broader capability — this connects directly to Section 2's discussion of finding boundaries via domain modeling rather than technical layering.
Communicating only through well-defined interfaces
❌ Service A reaches directly into Service B's database
✅ Service A calls Service B's REST/gRPC API, or reacts to Service B's published events
Services never share a database or reach into each other's internal state directly (Section 5) — every interaction happens through the same kind of explicit, versioned, backward-compatible-by-discipline interfaces covered in this series' REST, gRPC, and Event-Driven Architecture guides, which is what actually preserves each service's independence over time.
2. Finding Service Boundaries
The most consequential decision in a microservices architecture
Where exactly to draw the line between one service and the next is, by a wide margin, the hardest and most consequential decision in this entire architectural style — get it wrong, and you end up with the "distributed monolith" anti-pattern covered in Section 15, where services are nominally separate processes but so tightly coupled in practice that they can't actually be changed or deployed independently.
Domain-Driven Design's bounded context as the standard technique
Bounded Context: "Order Management"
Owns: Order, OrderLineItem, OrderStatus
Doesn't own (references by ID only): Customer, Product
Bounded Context: "Inventory"
Owns: Product, StockLevel, Warehouse
Doesn't own: Order
A bounded context (from Domain-Driven Design) is a boundary within which a specific set of business concepts have one consistent, unambiguous meaning and model — "Product" might mean something subtly different to the Inventory service (a physical, stocked item with a warehouse location) than to the Catalog service (a marketing-facing listing with descriptions and images), and forcing both to share one single, universal "Product" model is a common source of accidental coupling and endless compromise in a monolith's shared data model. Microservice boundaries generally align well with bounded context boundaries — each service becomes the authoritative owner of its bounded context's concepts.
Boundaries drawn around business capabilities, not technical layers
❌ "Data Access Service", "Business Logic Service", "Validation Service"
(horizontal technical layers — every real operation needs all three, so nothing is independently deployable)
✅ "Order Service", "Inventory Service", "Payment Service"
(vertical business capabilities — each is a complete, independently useful slice)
Slicing services by technical layer (all the data access code in one service, all the business logic in another) virtually guarantees that any single meaningful business operation requires coordinated changes across multiple services — precisely the coupling independent deployability is meant to avoid. Vertical slices by business capability, each owning its own data access, logic, and API together, are what actually enable one team to ship a complete feature change without touching another team's service.
Starting broader and splitting later, rather than over-decomposing upfront
A genuinely common, hard-won lesson: it's considerably easier to split an overly broad service into two once real usage patterns and team boundaries reveal a natural seam, than it is to merge two overly narrow services back together once their APIs, data, and deployment pipelines have already diverged — Section 14 covers this "start simpler" guidance more fully, but it applies directly to service granularity specifically, not just to the monolith-vs-microservices decision as a whole.
3. Communication Patterns: Synchronous
When a synchronous call is genuinely the right choice
As covered in this series' Event-Driven Architecture guide, a direct call is appropriate when a caller genuinely needs an immediate answer before it can proceed — validating a payment before confirming an order to the user, checking real-time inventory before accepting a checkout.
REST for broadly compatible, cacheable, human-inspectable APIs
GET /inventory/products/42/stock → { "available": 15 }
As covered in this series' REST guide, REST's ubiquity, HTTP-native caching, and human-readability make it the right default for service-to-service calls that don't have extreme throughput or latency demands, and especially for anything a broader ecosystem (including external partners) might eventually need to consume.
gRPC for high-throughput, low-latency internal calls
service InventoryService {
rpc CheckStock (StockRequest) returns (StockResponse);
}
As covered in this series' gRPC guide, Protocol Buffers' compact binary format and HTTP/2 multiplexing make gRPC the better choice for high-volume internal service-to-service traffic where every millisecond and byte matters, and where both ends of the call are under the same organization's control (making the generated-client-and-server coupling gRPC requires an acceptable trade-off).
GraphQL as a gateway-layer aggregation pattern, not typically service-to-service
As covered in this series' GraphQL guide, GraphQL is most commonly deployed as a layer in front of several microservices (often literally a backend-for-frontend, Section 7) — aggregating and reshaping calls to multiple backend services into the single, client-driven query a UI needs — rather than as the protocol two backend microservices use to talk directly to each other.
The synchronous call chain risk
API Gateway → Order Service → Inventory Service → Pricing Service → Tax Service
(a single incoming request now depends on the availability and latency of FOUR downstream services)
Every synchronous hop in a chain adds both latency (each hop's time contributes additively to the total) and a new potential point of failure (any one service being down breaks the entire chain) — a long synchronous call chain is a common, genuine microservices anti-pattern, and it's worth actively looking for opportunities to shorten these chains via caching, data denormalization, or converting parts of the chain to asynchronous patterns (Section 4) where an immediate answer genuinely isn't required.
4. Communication Patterns: Asynchronous
When decoupling matters more than an immediate answer
As covered in this series' Event-Driven Architecture guide in depth, publishing an event and letting interested services react independently and asynchronously is the right choice whenever the publisher doesn't need to know the outcome immediately, and especially when multiple, potentially evolving-over-time services need to react to the same fact.
RabbitMQ for flexible routing between services
As covered in this series' RabbitMQ guide, RabbitMQ's exchange-based routing is well suited to microservices needing flexible, content-based or topic-based message distribution across a moderate number of services — a natural fit for the "one order-placed event, several independent reactions" pattern common in a well-decomposed microservices system.
Kafka for high-volume event streams and multiple independent consumer groups
As covered in this series' Kafka guide, Kafka's retained, replayable log is especially valuable in microservices architectures where multiple, entirely independent teams/services need to consume the same underlying event stream (order events feeding both a real-time fraud-detection service and a separate, slower analytics pipeline), each at their own pace, without needing to coordinate directly.
Azure Service Bus for managed, enterprise-feature-rich messaging
As covered in this series' Azure Service Bus guide, its built-in sessions, transactions, and dead-lettering reduce the amount of custom coordination logic a microservices team needs to hand-build for common enterprise messaging patterns, at the cost of being an Azure-specific choice.
The general guidance for choosing among them, restated for microservices specifically
The right choice depends on the same factors covered in each guide's respective comparison sections — flexible routing needs point toward RabbitMQ, high-volume replay needs point toward Kafka, managed-service simplicity within Azure points toward Service Bus — and, as emphasized throughout this series, many real microservices architectures use more than one, matched to the specific communication need of each particular interaction rather than standardizing on exactly one messaging technology for every purpose.
5. Data Ownership: Database Per Service
The core rule: no service reaches into another service's database directly
❌ OrderService's code directly queries InventoryService's database tables
✅ OrderService calls InventoryService's API, or reacts to InventoryService's published events
This is arguably the single most important structural rule in microservices architecture, and the one most often violated under deadline pressure — a shared database between two services silently recreates monolith-style coupling (a schema change in one service's tables can break another service that happens to query them directly) while incurring all the costs of a distributed system, with none of the independence benefit that's supposed to justify those costs.
Each service chooses its own data store, matched to its own needs
OrderService: SQL Server (per this series' SQL Server guide) — relational, transactional order data
ProductCatalog: Cosmos DB (per this series' Cosmos DB/MongoDB guide) — flexible, varied product attributes
SessionStore: Redis (per this series' Redis guide) — fast, ephemeral session data
AnalyticsEventStore: Kafka (per this series' Kafka guide) — retained, replayable event log
This is the "polyglot persistence" benefit microservices genuinely provide — every service in this series' database guides can be the right choice for a specific service's specific data shape and access pattern, rather than an entire monolithic application being forced to share one single database technology regardless of how well or poorly it fits each individual concern.
Duplication of data across services is normal and expected
OrderService stores: { customerId: 42, customerName: "Ada Lovelace" } ← a LOCAL, denormalized copy
CustomerService owns: the authoritative Customer record, including this same name field
This deliberately contradicts relational-database normalization instincts, and it's worth being explicit about why: OrderService storing a local copy of the customer's name (kept eventually consistent via the events covered in Section 6) avoids a synchronous call to CustomerService every time an order needs to display a customer's name — a direct, practical application of the event-carried state transfer pattern covered in this series' Event-Driven Architecture guide, applied specifically to cross-service data ownership.
6. Maintaining Consistency Across Service Boundaries
Why cross-service ACID transactions don't exist
As covered in depth in this series' Event-Driven Architecture guide, once "place an order" requires coordinated changes across OrderService's, InventoryService's, and PaymentService's separate databases, there's no single database transaction that can span all three — this is a direct, unavoidable consequence of the database-per-service rule from Section 5, not a limitation specific to any particular technology choice.
Sagas as the standard solution
OrderSaga:
1. OrderService: create order (pending)
2. InventoryService: reserve stock — compensating action: release stock
3. PaymentService: charge payment — compensating action: refund
4. OrderService: confirm order
As covered fully in this series' Event-Driven Architecture guide, a saga — a sequence of local transactions with explicit compensating actions for failure — is the standard pattern for achieving an overall "all or effectively nothing" outcome across multiple services' separate databases, whether implemented via choreography (each service reacting to the previous step's event) or orchestration (a dedicated coordinator explicitly driving the sequence).
Eventual consistency as an accepted, designed-for property
t=0ms: Order placed, OrderService's database updated immediately
t=50ms: InventoryService processes the OrderPlaced event, updates its own stock count
t=120ms: AnalyticsService processes the same event, updates its own dashboard data
Rather than treating the brief window where different services' views of "the current state of this order" are momentarily out of sync as a bug, microservices architectures explicitly design for eventual consistency — every service's local data will converge to a consistent view eventually, typically within milliseconds to seconds, but not necessarily instantaneously the way a single-database transaction guarantees. UX and business logic need to be designed with this genuine trade-off in mind, not built assuming the kind of immediate, universal consistency a monolith's single database would have provided for free.
The idempotency discipline this makes non-negotiable
As covered in this series' Event-Driven Architecture, RabbitMQ, Kafka, and Azure Service Bus guides, every messaging technology's default at-least-once delivery guarantee means every service reacting to cross-service events must be written assuming duplicate delivery is possible — this isn't optional hardening in a microservices architecture; it's a foundational correctness requirement given how central asynchronous messaging is to maintaining consistency across service boundaries.
7. The API Gateway and Backend-for-Frontend Patterns
The problem: clients shouldn't need to know about every individual service
❌ A mobile app makes 6 separate calls to 6 different microservices to render one screen
✅ A mobile app makes ONE call to an API Gateway, which internally calls the 6 services and aggregates the result
Exposing every individual microservice directly to external clients (a web frontend, a mobile app, third-party integrators) creates real problems: clients need to know the location and API of every service individually, every service needs its own public-facing authentication/authorization/rate-limiting logic duplicated, and a single UI screen needing data from several services means the client makes several separate round trips.
API Gateway: a single, unified entry point
Client → API Gateway → routes to → OrderService / InventoryService / PaymentService / ...
(also handles: authentication, rate limiting, request logging, TLS termination)
An API Gateway sits in front of the entire microservices system, providing one consistent entry point that handles cross-cutting concerns (the authentication and authorization patterns covered in this series' OAuth2/OpenID Connect, JWT Validation, and RBAC guides, along with rate limiting per this series' ASP.NET Core guide) centrally, and routes each incoming request to the appropriate backend service — clients interact with one coherent API surface, never needing direct knowledge of the internal service topology.
Backend-for-Frontend (BFF): a gateway tailored per client type
Mobile BFF: aggregates and reshapes data specifically for the mobile app's UI needs (smaller payloads, fewer round trips)
Web BFF: a separate aggregation layer, tailored to the web frontend's different UI needs
For systems serving genuinely different client types with meaningfully different data and interaction needs, a Backend-for-Frontend takes the API Gateway idea further — rather than one generic gateway trying to serve every client type equally well (and inevitably compromising for all of them), each client type gets its own dedicated aggregation layer, tailored specifically to that client's needs. GraphQL (per this series' GraphQL guide) is a particularly natural fit for implementing a BFF, letting each client request exactly the shape of data it needs from the underlying services the BFF aggregates.
The gateway itself needs the same deployment/observability discipline as any service
An API Gateway is itself a piece of critical infrastructure — it needs the health checks (per this series' Health Checks guide), the distributed tracing propagation (per this series' Distributed Tracing guide), and the same CI/CD discipline (per this series' CI/CD Pipelines guide) as every microservice behind it; a gateway that's poorly observed or poorly deployed becomes a single point of failure for the entire system precisely because every request now flows through it.
8. Service Discovery
The problem: services need to find each other, and their locations change
In a containerized, autoscaled microservices deployment (per this series' Docker and Kubernetes/Helm guides), a specific service's instances are constantly being created, destroyed, and rescheduled — hardcoding a specific instance's IP address anywhere in another service's configuration would break the moment that instance is replaced.
Kubernetes Services as built-in, DNS-based service discovery
http://inventory-service.default.svc.cluster.local/stock/42
As covered in this series' Kubernetes/Helm guide, a Kubernetes Service provides a stable DNS name and virtual IP in front of a dynamically changing set of pods — for microservices deployed on Kubernetes, this is typically all the service discovery needed, with no separate service registry to operate.
Client-side vs. server-side discovery, for non-Kubernetes deployments
Server-side (a load balancer/gateway resolves the target): the client just calls a stable, well-known address
Client-side (the calling service itself resolves the target): the client queries a registry (Consul, Eureka) directly
Outside of Kubernetes's built-in mechanism, dedicated service registries (HashiCorp Consul, Netflix Eureka) provide the equivalent capability — services register themselves on startup, and calling services (or an intermediary load balancer) query the registry to resolve a logical service name to a currently-healthy instance's actual address, mirroring the same underlying need Kubernetes Services solve natively within its own ecosystem.
Service discovery and health checks are directly connected
As covered in this series' Health Checks guide, service discovery mechanisms typically only route traffic to instances currently passing their readiness check — the two concerns (finding available instances, and confirming those instances are actually ready) work together, not as separate, independent systems.
9. Deployment and Packaging
Containers as the standard packaging unit
As covered in this series' Docker guide in depth, packaging each microservice as a container image is the standard approach — it guarantees the exact same artifact runs consistently from a developer's laptop through CI and into production, and it's the deployment unit every orchestration platform covered in this series (Kubernetes, ECS, Azure Container Apps) is built around.
Kubernetes as the common orchestration layer for many services
As covered in this series' Kubernetes/Helm guide, running dozens of independently-deployable microservices is precisely the scenario Kubernetes was built to manage — scheduling, scaling, networking, and rolling updates for each service independently, with Helm (per the same guide) packaging each service's full set of Kubernetes objects into a versioned, installable, per-service unit.
Independent CI/CD pipelines per service
Each microservice's repository (or its section of a monorepo) has its OWN pipeline:
build → test → package (Docker image) → deploy — independently of every other service's pipeline
As covered in this series' CI/CD Pipelines and GitHub Actions/Azure DevOps guides, genuine independent deployability (Section 1's defining property) requires each service to have its own build-test-deploy pipeline, triggered independently — a shared, monolithic pipeline that builds and deploys every microservice together on every change reintroduces exactly the deployment coupling microservices are meant to eliminate.
GitOps for consistent, auditable multi-service deployment
As covered in this series' GitOps guide, with potentially dozens of independently-deployed services, having Git as the single, consistently-enforced source of truth for what's actually running in each environment — reconciled automatically by Argo CD or Flux — becomes considerably more valuable than in a single-application deployment, precisely because manually tracking "what version of which of our 30 services is currently in production" becomes genuinely difficult without it.
10. Observability Across Many Services
Why this is where microservices architecture's operational cost is most acutely felt
Every guide in this series' observability trio — Structured Logging, Distributed Tracing, and Prometheus/Grafana, unified under OpenTelemetry — exists in large part because of the specific challenges microservices introduce: a single logical request now spans many independent processes, each with its own logs, and reconstructing "what actually happened" requires deliberate, consistent instrumentation across every one of them.
Distributed tracing as close to mandatory, not optional, at real microservices scale
As covered in this series' Distributed Tracing guide, once request fan-out spans more than a handful of services, manual log correlation across services becomes genuinely impractical — this is precisely the threshold at which distributed tracing stops being a nice-to-have and becomes necessary infrastructure for operating the system at all.
Correlation across both synchronous and asynchronous boundaries
As covered in this series' Event-Driven Architecture, OpenTelemetry, and Distributed Tracing guides, a microservices system built with both synchronous (REST/gRPC) and asynchronous (RabbitMQ/Kafka/Service Bus) communication needs trace context and correlation IDs propagated consistently across every boundary type it actually uses — a gap in propagation at any one boundary silently breaks the ability to reconstruct the full story of a request that crosses it.
Health checks as the fast, automated layer beneath deep observability
As covered in this series' Health Checks guide, with many independently-deployed and independently-scaled services, the liveness/readiness distinction and per-service health endpoints become the automated, real-time signal every orchestrator (Section 9) depends on to keep the overall system healthy, with the deeper diagnostic tools (traces, logs, metrics) doing the actual root-cause investigation once a health signal indicates something's wrong.
11. Testing Strategies for Microservices
The testing pyramid, adapted for service boundaries
Unit tests: within a single service, no network calls — fast, the majority of tests
Integration tests: a single service against its real dependencies (its own database, via Testcontainers)
Contract tests: verify a service's API matches what its consumers actually expect
End-to-end tests: a small number of critical paths through the FULL deployed system
This directly extends the testing pyramid covered in this series' CI/CD Pipelines guide, with contract tests as a distinctly important addition specific to microservices — verifying that a service's API (or event schema, per this series' Kafka guide's Schema Registry discussion) genuinely matches what its actual consumers expect, without needing every consumer's full application running to verify it.
Consumer-driven contract testing
InventoryService's consumers (OrderService, ReportingService) each publish an explicit,
automated expectation of the InventoryService API/event shape they depend on
InventoryService's own CI pipeline runs ALL of these consumer expectations as part of its test suite,
catching a breaking change before it's ever deployed
As referenced in this series' Event-Driven Architecture guide, this pattern (tools like Pact implement it concretely) catches breaking changes to a service's public interface before they reach production, without requiring a full, expensive end-to-end test environment spinning up every consuming service simultaneously — a genuinely valuable middle ground between fast, isolated unit tests and slow, brittle, full-system end-to-end tests.
Why full end-to-end tests should be few and deliberately chosen
Standing up every microservice together for an end-to-end test is slow, expensive, and — because it depends on the availability and correct behavior of every single service simultaneously — inherently more flaky than any individual service's own test suite; the standard guidance is a small number of end-to-end tests covering only the most critical, cross-cutting user journeys (placing an order end-to-end, say), with the bulk of confidence coming from well-tested individual services plus contract tests verifying their interfaces align correctly.
12. Resilience Patterns
Why resilience matters more, not less, in a microservices architecture
A monolith has one process to keep running; a microservices system has many independent processes, any one of which can fail independently — without deliberate resilience patterns, a failure in one non-critical service can cascade into a much broader outage, precisely the opposite of the fault-isolation microservices are often assumed to provide automatically (it doesn't come for free; it has to be designed for).
Circuit breakers
// Using a library like Polly
var circuitBreakerPolicy = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(exceptionsAllowedBeforeBreaking: 5, durationOfBreak: TimeSpan.FromSeconds(30));
A circuit breaker stops calling a downstream service that's already failing repeatedly, failing fast locally instead of continuing to send requests (and wait for timeouts) against something that's clearly not responding — this both protects the calling service from wasting resources on doomed calls and reduces load on the already-struggling downstream service, giving it room to recover rather than being hit with continued traffic throughout its outage.
Retries with backoff, and their interaction with idempotency
var retryPolicy = Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
As covered in this series' Background Services guide, retries with exponential backoff handle transient failures gracefully — but every retried call needs to be genuinely safe to repeat, connecting directly to the idempotency discipline covered throughout this series' messaging and Event-Driven Architecture guides; a non-idempotent operation retried after a timeout (where the original request may have actually succeeded server-side despite the client-side timeout) risks a duplicate side effect.
Bulkheads: isolating failure to prevent it from spreading
Thread pool / connection pool dedicated to calls to Service A, SEPARATE from the pool used for Service B
→ Service A being slow/unresponsive can't exhaust the resources needed to keep calling Service B successfully
Named after a ship's watertight compartments, a bulkhead isolates the resources (connection pools, thread pools) used to call one dependency from the resources used to call another — without this isolation, one slow or failing downstream service can exhaust a shared resource pool, degrading calls to entirely unrelated, otherwise-healthy services as a side effect.
Graceful degradation, connecting back to Health Checks' Degraded status
As covered in this series' Health Checks guide, designing a service to continue functioning (in a reduced capacity) when a non-critical dependency is unavailable — rather than failing the entire request — is the same graceful-degradation principle applied at the level of individual request handling rather than the service's overall reported health status.
13. Organizational Structure and Conway's Law
Conway's Law, briefly
"Organizations which design systems... are constrained to produce designs which are copies of the communication structures of these organizations" — a system's architecture tends to mirror the team structure that built it, whether deliberately or not.
Why this matters directly for microservices
One team owning OrderService end-to-end (its API, its database, its deployment pipeline)
can move fast, independently, without needing cross-team coordination for most changes
Three different teams jointly owning "the Order system" (one for the API, one for the database,
one for the deployment pipeline) recreates coordination overhead INSIDE a single service boundary
Microservices architecture works best when service boundaries (Section 2) align with team boundaries — a team that owns a service completely (its code, its data, its deployment, its on-call responsibility) is genuinely empowered to move at the independent pace microservices are meant to enable; a service split across multiple teams' ownership, or a team responsible for many, loosely-related services, tends to erode the actual benefit even if the technical architecture looks correctly decomposed on a diagram.
"You build it, you run it"
The common pairing of microservices architecture with a "you build it, you run it" operational model (the team that writes a service is also the team that's on-call for it, using the health checks and observability tooling covered in this series' respective guides) is not incidental — it's a direct, deliberate application of Conway's Law, aligning the incentive to build a genuinely reliable, well-observed service with the team that actually has the context and authority to do so.
14. When Microservices Are (and Aren't) the Right Choice
The honest cost side of the ledger
Every pattern covered in this guide — sagas for cross-service consistency, distributed tracing to reconstruct a single request's journey, circuit breakers and bulkheads for resilience, contract testing, per-service CI/CD pipelines, service discovery — exists specifically to manage complexity that a well-structured monolith simply doesn't have in the first place. None of this is free, and adopting microservices without a genuine need for the specific benefits (independent deployability, independent scaling, polyglot technology choices, team autonomy) means taking on substantial complexity for little corresponding gain.
"Monolith first" as widely-endorsed, hard-won guidance
A genuinely common, respected pattern among experienced practitioners: start with a well-structured modular monolith — a single deployable application, but internally organized into clean, well-bounded modules (mirroring the bounded-context boundaries from Section 2) — and only split specific modules out into independent microservices once there's a concrete, demonstrated need (a module needing independent scaling, a team needing genuine independent deployment cadence, a module needing a fundamentally different technology). This defers the real cost of distribution until it's actually justified, while preserving the option to split cleanly later, since well-bounded modules within a monolith are already most of the way toward well-bounded microservices.
Signals that genuinely justify microservices
- Different parts of the system have genuinely different scaling needs — a checkout path needing to handle 100x the load of an admin reporting dashboard.
- Multiple teams need genuine independent deployment cadence — one team shipping daily, another shipping monthly, without either blocking the other.
- Different parts of the system have genuinely different technology needs — a data-science-heavy recommendation engine benefiting from a different language/runtime than the rest of the system.
- Organizational scale has reached the point where a single, shared deployable is itself the bottleneck — a large engineering organization all committing to one monolith, with build times, test suite duration, and deployment coordination becoming the actual limiting factor on how fast anyone can ship anything.
Signals that microservices are premature or unjustified
- A small team, a system with modest and fairly uniform scaling needs, and no genuine organizational pressure toward independent deployment — in this case, a modular monolith almost certainly delivers more value per unit of engineering effort than a distributed system would.
- Adopting microservices primarily because it's perceived as the "modern" or "correct" default, rather than because a specific, identified problem microservices solve is actually present.
15. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| The "distributed monolith" — services that are separate processes but can't be deployed independently | All the operational cost of distribution, none of the independence benefit | Verify genuine independent deployability, per Section 1's defining property, not just separate codebases |
| Shared database across services | Recreates monolith-style coupling with distributed-system overhead added on top | Every service owns its own data store; communicate via APIs/events only |
| Slicing services by technical layer instead of business capability | Every real feature requires coordinated cross-service changes | Slice vertically by bounded context/business capability |
| Long synchronous call chains across many services | Compounding latency and cascading failure risk | Shorten chains via caching/denormalization; convert non-immediate-answer needs to async |
| No idempotency discipline for cross-service messaging | At-least-once delivery causes real duplicate side effects | Design every event/message handler to be safely repeatable |
| Adopting microservices without a genuine, identified need | Substantial complexity cost with no corresponding benefit | Start with a modular monolith; split out services only when a specific need is demonstrated |
| No distributed tracing/correlation as service count grows | "What actually happened for this request" becomes unanswerable | Instrument distributed tracing and correlation IDs before service count makes it unavoidable, not after |
| Service boundaries misaligned with team boundaries | Coordination overhead re-appears inside what should be independent services | Align service ownership with team ownership, per Conway's Law |
| Full end-to-end tests as the primary testing strategy | Slow, expensive, inherently flaky across many independently-failing services | Rely primarily on per-service tests plus contract tests; keep E2E tests few and deliberate |
Quick Reference Table
| Concept | Where it's covered in depth elsewhere in this series |
|---|---|
| Synchronous communication | REST, gRPC, GraphQL guides |
| Asynchronous communication | RabbitMQ, Kafka, Azure Service Bus, Event-Driven Architecture, Pub/Sub Patterns guides |
| Data ownership and consistency | Event-Driven Architecture guide (sagas, outbox), SQL Server/PostgreSQL/Cosmos DB/Redis guides |
| Authentication/authorization at the gateway and per-service | OAuth2/OpenID Connect, JWT Validation, RBAC/Policy-Based Authorization guides |
| Packaging and deployment | Docker, Kubernetes/Helm guides |
| CI/CD per service | CI/CD Pipelines, GitHub Actions, Azure DevOps, GitOps guides |
| Cloud compute for hosting services | Azure Compute, AWS Compute guides |
| Infrastructure provisioning | Terraform/Bicep guide |
| Observability across services | OpenTelemetry, Distributed Tracing, Structured Logging, Prometheus/Grafana guides |
| Health and readiness signaling | Health Checks guide |
| Cost management at scale | Cloud Cost Optimization guide |
| Secrets across many services | Secret Management guide |
| Security | OWASP Top 10 guide |
Conclusion
Microservices architecture is, in a real sense, the sum of nearly every guide in this series applied together: independently deployable services, communicating through the REST/gRPC/GraphQL and RabbitMQ/Kafka/Service Bus patterns, each owning its own data store, packaged as containers and orchestrated by Kubernetes, deployed through independent CI/CD pipelines governed by GitOps discipline, secured through OAuth2/OIDC and RBAC, and made operable at all through distributed tracing, structured logging, and health checks. None of these pieces is optional if you're genuinely doing microservices at real scale — they're the necessary infrastructure for managing the complexity this architectural style deliberately introduces in exchange for independent deployability, independent scaling, and team autonomy.
The single most important thing this guide can leave you with is the honest cost side of that trade: microservices solve specific, real problems — organizational scale, genuinely divergent scaling needs, genuine need for independent deployment cadence — and they impose substantial, real complexity in exchange. A well-structured modular monolith, deferring that complexity until a concrete need actually justifies it, remains the better starting point for the majority of systems and teams, with the clean internal module boundaries of that monolith serving as the natural, low-regret path toward microservices later, if and when the specific problems this architecture solves actually materialize.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the service boundary that turned out to be exactly right — or the one you wish you'd drawn differently from the start.
Top comments (0)