Skip to main content

When to Split a Microservice and When to Keep It Together

You have a monolithic .NET service handling customer orders, inventory checks, and email notifications. A colleague suggests splitting it into three microservices. Another argues the split will triple operational overhead without delivering business value. Both are probably right, but neither answer helps you decide what to do Monday morning.

After modernizing multiple legacy .NET systems and operating microservices architectures on both AWS and Azure, I've learned that the decision to split a service is less about embracing distributed systems and more about matching architectural boundaries to the operational consequences your team can actually handle. The wrong split creates alert fatigue, deployment dependencies, and debugging sessions that span five repositories. The right boundary reduces coupling, enables independent deployments, and makes production incidents easier to isolate.

Start With the Deployment Boundary, Not the Domain Model

The first question is not whether order processing and email sending belong to different bounded contexts. The first question is whether your team needs to deploy them independently. If the email service changes twice a year and order processing changes weekly, a boundary makes sense. If both change together every sprint because they share validation logic, state transitions, and error handling, splitting them creates coordination work without reducing risk.

I've seen teams split services prematurely because a diagram looked clean, then spend months synchronizing releases, managing backward compatibility, and writing integration tests that fail in CI but pass in production. The operational cost of managing multiple deployment pipelines, container images, and runtime environments is real. If you cannot articulate why independent deployments reduce risk or speed up delivery, the boundary is probably premature.

A useful heuristic: if two components share more than one database transaction, evolve together during feature development, or fail together in production, they probably belong in the same deployment unit. Microservices are a deployment strategy, not a code organization philosophy.

When a Boundary Is Worth the Operational Trade

There are clear signals that justify the split. One is scale asymmetry. If your reporting query service needs four instances with 8 GB of memory but your write API needs two instances with 512 MB, running them in the same process wastes resources and complicates scaling policies. Separate deployments let you tune autoscaling, memory limits, and instance types independently.

Another is failure isolation. If a background job processing invoices can crash without affecting real-time order creation, that boundary protects customer-facing uptime. I've built systems where a single slow external API call in a background worker would exhaust the thread pool and bring down the entire application. Moving that work into a separate service with its own retry logic, timeout configuration, and circuit breaker meant the main API stayed responsive even when the external dependency failed.

Team ownership is the third valid reason. If two teams deploy code on different schedules, own different SLAs, and respond to different on-call alerts, sharing a deployment artifact creates unnecessary coordination. Separate services with clear API contracts reduce cross-team blocking and make ownership explicit.

The Hidden Cost of Distributed Calls

Every network boundary you introduce replaces a method call with an HTTP request, a serialization step, a potential timeout, and a failure mode. What was a deterministic function invocation becomes a distributed transaction with retries, idempotency requirements, and observability gaps. If splitting a service turns one operation into three sequential API calls, you've just tripled your median latency and created three new points of failure.

Distributed tracing helps, but only if you instrument every boundary and correlate trace IDs across services. Logs become harder to search because a single user request now generates log entries in three different CloudWatch log groups or Application Insights workspaces. Debugging a production issue requires correlating timestamps, request IDs, and deployment versions across multiple repositories.

I've worked on systems where every database query went through a dedicated data-access microservice. The abstraction looked elegant in architecture reviews, but every page load required a dozen round trips. The team spent more time tuning HTTP client timeouts and retry policies than they saved by isolating database logic. The boundary added latency and operational complexity without reducing coupling or improving deployability.

Design the Contract Before You Split the Code

If you decide a boundary is worth creating, define the API contract first. Write the client interface, the request and response models, and the error cases before you move a single line of implementation code. A well-designed contract makes the split reversible. A poorly designed one locks you into a distributed architecture that is harder to operate and impossible to merge back without rewriting consumers.

Use versioned REST APIs or gRPC with Protocol Buffers. Avoid sharing database schemas, internal domain models, or message queue formats directly. The moment two services depend on the same table structure, you've created a hidden deployment dependency that will break during the next schema migration.

Make failure handling explicit. If the email service is down, does the order service return a 500 error, queue the email for later, or return a 200 and log a warning? There is no universal right answer, but the decision must be deliberate and documented. I've debugged production incidents where a transient downstream timeout caused upstream retries that amplified load and turned a small failure into a cascading outage.

Common Pitfalls

  • Splitting too early: Creating microservices before you understand the domain leads to misaligned boundaries, frequent cross-service changes, and eventual rewrites. Build the monolith first, identify the natural seams, then extract services when operational pressure justifies the complexity.
  • Ignoring observability during the split: If you cannot trace a request across service boundaries or correlate logs by user session, distributed debugging becomes guesswork. Instrument trace IDs, structured logging, and health checks before the first deployment.
  • Sharing databases across service boundaries: If two services write to the same tables, they are not independently deployable. Schema changes require synchronized releases, breaking the primary benefit of microservices.
  • Underestimating network failure modes: Local method calls do not time out, retry, or require circuit breakers. Distributed calls do. If your error handling assumes every dependency responds in under 100 milliseconds, production will teach you otherwise.
  • Creating one service per table: Data model entities are not service boundaries. A service should own a cohesive capability, not a single database table. CRUD wrappers add latency without reducing coupling.

The Honest Takeaway

Microservices solve specific operational problems: independent deployability, heterogeneous scaling, and failure isolation. If you do not have those problems, the architecture creates complexity without delivering value. Start with a well-factored modular monolith, deploy it with CI/CD, and split services only when deployment coupling, scale asymmetry, or team ownership boundaries create measurable friction.

When you do split, design the contract first, instrument observability from day one, and avoid sharing databases. A bad boundary is harder to fix than a monolith that is too large. The goal is not a beautiful architecture diagram. The goal is a system your team can deploy confidently, debug quickly, and operate sustainably.

Comments