Services and Boundaries
Monolith versus microservices, where to draw the lines, and the machinery services need that a monolith does not
Splitting an application into services is an organisational decision with technical consequences. It is also the most commonly over-applied idea in system design.
Start With the Monolith
A monolith is the correct default
One deployable application with a clear internal module structure is simpler in every dimension: one deploy, one log stream, real transactions, function calls instead of network calls, and no distributed debugging.
Most systems never need more. Splitting early means paying all the costs of distribution while the boundaries are still wrong — and wrong boundaries are far more expensive to fix once they are network calls.
Split when a specific pressure demands it, not because services sound more serious.
| Real reasons to split | Not reasons to split |
|---|---|
| Teams block each other on deploys | "Microservices are modern" |
| One component needs very different scaling | The codebase feels large |
| One component needs different hardware (GPU) | Wanting to try a new language |
| A component needs stricter isolation | It looks better on a diagram |
What Changes When You Split
You lose transactions, and that is the biggest loss
In a monolith, "take payment and reserve stock" is one transaction — atomic, guaranteed.
Across services it cannot be. You need a saga: a sequence of local transactions each with a compensating undo, and a window where the system is genuinely inconsistent.
Before splitting, ask which operations currently rely on a single transaction. Those are the boundaries that will hurt most, and they are the ones to keep together.
Where to Draw the Lines
The classic mistake is splitting by technical layer. The right split is by business capability.
| Wrong — by layer | Right — by capability |
|---|---|
api-service | orders |
business-logic-service | payments |
database-service | inventory |
Layer-based splits mean almost every feature touches every service, so nothing can be deployed or scaled independently. You have added network calls and gained nothing.
Two tests for a good boundary
Can it be deployed alone? If shipping a change to orders always requires deploying payments too, they are one service pretending to be two.
Does it own its data? If two services read the same tables, they are coupled through the database and cannot evolve separately — the worst kind of coupling, because it is invisible in the code.
The Machinery Services Need
Splitting creates problems a monolith never had, each requiring a component.
| Problem | Component |
|---|---|
| Clients would need to know every service | API gateway — one entry point, routes inward |
| Services move and scale, so addresses change | Service discovery — a registry to look up |
| Every call needs retries, timeouts, mTLS | Service mesh, or a shared client library |
| A failure spans several services' logs | Distributed tracing — one id follows the request |
| Config differs per service and environment | Central configuration |
API gateway
The single door into the system. It handles the concerns every service would otherwise reimplement:
Client → Gateway → authentication
rate limiting
request routing
response aggregation
→ servicesKeep business logic out of the gateway
It is tempting to let the gateway do "just a little" orchestration. Over time it accumulates rules from every team, becomes the thing nobody dares deploy, and turns into a distributed monolith with a single point of failure.
The gateway routes, authenticates and limits. It should not know what an order is.
Talking Between Services
| Style | Shape | Suits |
|---|---|---|
| Synchronous (REST, gRPC) | Call and wait | The caller needs the answer now |
| Asynchronous (queue, event log) | Publish and move on | Work that can happen later |
Prefer asynchronous where the answer is not needed immediately
Every synchronous call couples the caller's availability to the callee's. Chain four services at 99.9% each and you are at 99.6% — see reliability.
Publishing an event instead means the caller succeeds even if the consumer is down; the work happens when it recovers.
"Order placed" as an event, consumed independently by shipping, analytics and email, is more robust and easier to extend than the orders service calling three others in turn.
The chatty-call trap
GET /orders → 1 call
for each of 50 orders:
GET /users/{id} → 50 callsFifty network round trips where a monolith did one join. Fix it by batching (GET /users?ids=1,2,3), by having the caller cache, or by accepting some duplicated data inside the service that needs it.
Duplicating a user's display name into the orders service feels wrong after years of normalisation. Across a service boundary it is often correct — it removes a synchronous dependency and the data rarely changes.
Versioning and Compatibility
Services deploy independently, which means two versions run at once during every rollout.
| Change | Safe? |
|---|---|
| Add an optional field | Yes |
| Add a new endpoint | Yes |
| Remove a field | No — version it |
| Rename a field | No — add the new one, migrate, then remove |
| Make an optional field required | No |
Expand then contract
To change something safely across independently deployed services:
- Expand — add the new field alongside the old; write both.
- Migrate — move every consumer to the new field.
- Contract — once nothing reads the old field, remove it.
Three deploys instead of one. It is the only way to change a shared contract without a coordinated outage, and it is the same discipline needed for database migrations.
Related Reading
- Consistency and CAP — sagas, and the transactions you gave up
- Reliability — why chained dependencies reduce availability
- Queues and Async Work — event-driven communication
- APIs and Rate Limiting — gateway responsibilities