Writing

Monoliths, services and the cost of a network boundary

Separating an application into services can create valuable ownership, deployment and scaling boundaries, but every network boundary introduces latency, partial failure and operational cost.

A system begins with one primary deployable application.

APPLICATION
│
├── catalog
├── basket
├── inventory
├── ordering
└── payment

Its modules share one release lifecycle and commonly run in the same process, although the deployment may also include worker processes or scheduled jobs.

The components can call one another directly. When they share a database and transaction manager, related changes can participate in one local transaction. The application can be built, tested and released as one unit.

Then somebody draws a new diagram.

CATALOG SERVICE

BASKET SERVICE

INVENTORY SERVICE

ORDER SERVICE

PAYMENT SERVICE

The application has become a collection of services.

The boxes are smaller.

The arrows are more numerous.

The architecture diagram has acquired the stately confidence of a regional rail map.

But what has actually improved?

Perhaps catalog can now be deployed independently.

Perhaps inventory can scale separately.

Perhaps one team can own payment without coordinating every release with the rest of the application.

Those can be significant benefits.

The new design has also replaced ordinary in-process calls with remote interactions.

BEFORE

basket code
    │
    ▼
catalog function


AFTER

basket service
    │
    ▼
network
    │
    ▼
catalog service

The function call has gained:

  • serialisation;
  • network latency;
  • service discovery;
  • authentication;
  • timeouts;
  • retries;
  • partial failure;
  • versioned contracts;
  • remote observability.

The network boundary is not simply a tidier line between two responsibilities.

It is a new operating environment.

That leads to the question I want to use throughout this article:

What valuable boundary am I buying, and is it worth the distributed-systems cost?

A network boundary must buy something

A monolith is primarily a deployment boundary

The word monolith is often used as though it were a diagnosis.

It can conjure an application that is large, old, fragile, poorly tested and difficult to change.

But those are not required properties of a monolith.

A monolithic application is broadly one primary deployment and change boundary.

MONOLITHIC APPLICATION
│
├── user interface
├── application logic
├── domain modules
├── persistence access
└── supporting workers

It may run as one process, several application instances, or a small collection of workers released together. It may be small or large, well structured or tangled, modern or old, frequently deployed or rarely changed, and owned by one team or several.

A monolith can be badly designed.

A collection of services can also be badly designed, only with more network sockets.

Architecture quality, process topology and deployment topology are related but different properties.

Look for a concrete requirement

Suppose I move catalog into a separate process but still require:

  • the same release train
  • the same database
  • the same deployment window
  • the same team approval
  • the same scaling policy
  • the same shared internal library

I have introduced remote communication without gaining much autonomy.

DISTRIBUTED DEPLOYMENT

but

COORDINATED CHANGE

That is a poor exchange.

A useful network boundary should normally support at least one concrete requirement such as:

  • catalog must scale independently
  • payment requires a stricter security boundary
  • inventory has a separate availability requirement
  • one team needs independent release control
  • a capability must be reusable by several applications
  • failure must be isolated from the main process
  • the component requires a distinct runtime or resource profile

Without such a requirement, the network may be charging rent for an empty room.

A practical comparison

Concern Modular monolith Independent services
Deployment One application artifact Several independently deployable artifacts
Calls In-process Remote
Typical latency Lower Higher and more variable
Failure model Process-level failure Partial and network failure
Transactions Easier across modules sharing one database Usually local to each service
Cross-domain queries Local joins may be available Composition or derived views
Scaling Whole application commonly scales together Services may scale independently
Contract evolution Often one code change Must support version overlap
Debugging One process and call stack Correlation and tracing required
Security boundaries Primarily module and database controls Separate workload identities and network policy
Operational surface Smaller Larger
Team autonomy Possible, but one release unit can constrain it Stronger when ownership is genuine
Technology choice Usually shared runtime Can vary, with additional support cost
Local development Usually simpler Requires dependency strategy
Best fit Cohesive system with shared lifecycle Capabilities needing distinct lifecycles

The table does not select an architecture.

It shows where the cost moves.

Strong boundaries without distribution

One application can contain several real boundaries

Suppose the application contains these modules:

application
│
├── catalog
├── basket
├── inventory
└── order

Each module can expose a narrow application interface.

BASKET MODULE
    │
    │ catalog interface
    ▼
CATALOG MODULE

The catalog module can own:

  • product rules
  • product model
  • catalog tables
  • catalog migrations
  • supported operations

The basket module does not need to query catalog tables directly.

It uses an explicit interface:

type ProductReader interface {
    GetPurchasableProduct(
        ctx context.Context,
        productID ProductID,
    ) (PurchasableProduct, error)
}

The concrete implementation may still be an in-process call.

basket
  │
  ▼
Go interface
  │
  ▼
catalog implementation

That is a meaningful application boundary even though no network lies between the modules.

The important properties are:

  • catalog controls its model
  • basket depends on a supported contract
  • internal catalog details remain private
  • cross-module access is deliberate

This is often called a modular monolith.

It combines one deployment unit with explicit internal boundaries.

A modular monolith keeps local simplicity

An in-process module call retains several local properties.

The caller and callee usually share:

  • one process lifetime
  • one memory space
  • one deployment
  • one local call stack
  • one runtime environment

The call may look like:

product, err := catalog.GetProduct(ctx, productID)

The function receives in-memory values.

It returns in-memory values.

The compiler can check much of the interface.

A debugger can follow the call directly.

A process crash affects both modules because they are part of one process.

A deployment replaces both modules because they are part of one artifact.

Those are real constraints.

They are also operational simplicity.

A modular monolith should not be treated as a temporary embarrassment awaiting enough containers to become respectable.

For many systems, it may be the appropriate architecture.

Not every boundary needs a service

Suppose two modules:

  • change together
  • share one invariant
  • are owned by one small team
  • scale together
  • fail together acceptably
  • have no distinct security requirement

Keeping them in one process may be the simpler design.

A module interface can still protect their internal responsibilities.

MODULE A
   │
   ▼
EXPLICIT INTERFACE
   │
   ▼
MODULE B

The network is not the only way to create discipline.

Repository rules, package visibility, database permissions, tests and code review can also preserve boundaries.

The question is whether the boundary needs independent runtime behaviour.

If not, a module may be enough.

What changes when a call becomes remote

A service creates a remote runtime boundary

Extracting catalog into a service creates a remote runtime boundary.

APPLICATION RUNTIME
│
└── basket module


CATALOG SERVICE
│
├── catalog runtime
└── one or more instances

The basket can no longer invoke catalog code directly.

It must send a request using a remote contract.

BASKET
   │
   │ GetProduct request
   ▼
NETWORK
   │
   ▼
CATALOG
   │
   │ GetProduct response
   ▼
NETWORK
   │
   ▼
BASKET

This boundary can permit:

  • independent deployment;
  • independent scaling;
  • separate failure and restart lifecycles, subject to shared infrastructure and dependency coupling;
  • a different technology where the benefit exceeds the support cost;
  • a separate security policy;
  • separate operational ownership.

Those are the reasons to introduce a service.

The network is not the purpose.

It is the mechanism through which the independence is enforced.

Compare:

product, err := catalog.GetProduct(ctx, productID)

with:

product, err := catalogClient.GetProduct(
    ctx,
    &catalogv1.GetProductRequest{
        ProductId: productID.String(),
    },
)

They look similar.

That resemblance is convenient.

It can conceal the larger path taken by the second call.

CALLER
  │
  ├── validate local request
  ├── serialise request
  ├── resolve destination
  ├── acquire or reuse connection
  ├── authenticate peer
  ├── transmit bytes
  │
  ▼
NETWORK
  │
  ▼
SERVER
  │
  ├── receive bytes
  ├── decode request
  ├── authenticate caller
  ├── authorise operation
  ├── run application logic
  ├── encode response
  └── transmit response

Every stage consumes time and can fail.

A remote procedure call may be written like a local call.

It cannot be designed like one.

An in-process call may involve:

  • function dispatch
  • memory access
  • local computation

A remote call adds:

  • serialisation
  • kernel networking
  • network traversal
  • proxy or load-balancer processing
  • server scheduling
  • deserialisation
  • response traversal

The exact cost varies.

A call between two processes on the same machine is different from a call across availability zones.

A tiny request is different from a large streamed response.

A warm connection is different from a new secure connection.

The important point is not one universal latency number.

It is that a remote call normally costs more than an ordinary local call, and that remote latency has a distribution.

Most calls may complete quickly.

A smaller number may take much longer.

User-facing operations are often shaped by those slower calls rather than by the median alone.

Suppose checkout calls services sequentially:

CHECKOUT
   │
   ▼
CATALOG
   │
   ▼
INVENTORY
   │
   ▼
PAYMENT
   │
   ▼
SHIPPING

If each call consumes time, the total path accumulates that latency.

catalog latency
+
inventory latency
+
payment latency
+
shipping latency
+
checkout work
=
customer response time

The calls may be individually acceptable.

Together they may create a slow user experience.

Some independent calls can run concurrently:

                 CHECKOUT
                    │
        ┌───────────┴───────────┐
        │                       │
        ▼                       ▼
    CATALOG                  SHIPPING
        │                       │
        └───────────┬───────────┘
                    ▼
                CONTINUE

Concurrency can shorten the critical path.

It does not remove the availability dependencies.

Checkout still needs both results before continuing.

An in-process component might offer:

  • GetProductName
  • GetProductPrice
  • GetProductImages
  • GetProductDimensions
  • GetProductCategory

Calling five local methods may be harmless.

Calling five remote methods may require five network round trips.

BASKET ──► CATALOG: GetName
BASKET ──► CATALOG: GetPrice
BASKET ──► CATALOG: GetImages
BASKET ──► CATALOG: GetDimensions
BASKET ──► CATALOG: GetCategory

The service boundary should expose an interface shaped for remote use.

For example, GetPurchasableProduct might return the coherent information required for the caller’s operation.

That does not mean creating one enormous endpoint containing the whole database.

It means designing around useful remote interactions rather than copying every internal method onto the network.

A process boundary should influence API granularity.

Partial failure, deadlines and uncertain outcomes

Inside one process, a fatal process failure usually affects every module together.

Across services, one component can fail while the others continue running.

BASKET

running


CATALOG

unavailable

This is useful isolation.

It also creates states in which the application is only partly functional.

The basket may be able to load an existing basket but unable to validate a newly added product.

The storefront may show account information while product search fails.

The architecture needs to decide:

  • Which functions remain available?
  • Which calls should fail quickly?
  • Can stale data be used?
  • Should work be queued?
  • What should the customer be told?
  • How will the failed dependency recover?

Partial failure is not simply a smaller outage.

It is a requirement for intentional degraded behaviour.

A local call normally ends when:

  • the function returns
  • the process crashes
  • the context is cancelled

A remote call may stop producing observable progress.

The caller cannot wait indefinitely.

request sent
      │
      ▼
no response
      │
      ▼
how long should caller wait?

The client needs a deadline or timeout.

That deadline should reflect:

  • how long the result remains useful
  • the complete caller deadline
  • expected service latency
  • remaining downstream work
  • retry policy

A timeout is not merely a networking option.

It is part of the service contract.

The caller is saying:

After this point, this response is no longer useful enough for me to continue waiting.

Suppose the order service calls payment.

ORDER
  │
  │ AuthorisePayment
  ▼
PAYMENT

The order service reaches its deadline.

It knows that no successful result arrived in time.

It may not know:

  • payment never received the request
  • payment rejected the request
  • payment authorised successfully
  • payment is still processing
  • the response was lost

The network boundary has introduced an ambiguous outcome.

That ambiguity requires:

  • idempotency
  • operation status
  • reconciliation
  • careful retry policy
  • explicit pending states

The cost of the remote call is not only latency.

It is the possibility that one side changes state while the other side remains uncertain.

A client can retry after a selected failure.

attempt 1
    │
    └── timeout

attempt 2
    │
    └── success

The second attempt may overcome a transient problem.

It may also repeat a state-changing operation whose first outcome is unknown.

CreateOrder attempt 1
    │
    └── order 5001 created,
        response lost


CreateOrder attempt 2
    │
    └── order 5002 created

Remote APIs need explicit retry semantics.

That can include:

  • safe retrieval operations
  • idempotent target-state updates
  • stable operation identifiers
  • deduplication records
  • bounded backoff
  • server retry guidance

A local method extraction did not require most of this.

The network boundary does.

A caller might think only about sending the request.

But a remote interaction has two journeys:

REQUEST

client ─────────► server


RESPONSE

client ◄───────── server

The request can succeed while the response fails.

The server can commit the operation while the caller reports an error.

This is why a client error does not always mean that the server did nothing.

The architecture needs business operation identity that survives the network attempt.

That identity may be carried through:

  • idempotency key
  • order ID
  • payment attempt ID
  • message ID
  • workflow ID

Without it, the system cannot easily distinguish another attempt at the same work from genuinely new work.

Services create contract and data boundaries

Contracts must survive version overlap

Inside one codebase, a developer might change:

type Product struct {
    ID    string
    Name  string
    Price int64
}

and update all callers in one commit.

Across independently deployed services, the producer and consumer may run different versions at the same time.

BASKET v3
    │
    ▼
CATALOG v2

The contract must tolerate that overlap.

Changes need rules for:

  • adding and removing fields;
  • changing data types;
  • changing field or status meaning;
  • changing defaults and error interpretation;
  • changing ordering or delivery guarantees;
  • changing whether success means accepted or completed;
  • introducing and retiring operations.

A machine-readable contract such as Protocol Buffers or OpenAPI helps define structure.

It does not decide compatibility policy.

The service owner must know which changes existing consumers can tolerate.

Suppose every service imports one shared package, company-domain-model.

It contains:

  • Product
  • Customer
  • Order
  • Payment
  • Address

Updating the package may require every service to upgrade together.

shared package v17
       │
       ├── catalog
       ├── basket
       ├── order
       └── payment

The services have separate processes but one shared internal model.

The network boundaries do not create much conceptual independence.

A shared package can be useful for:

  • generated contract code
  • small technical utilities
  • observability conventions

It becomes risky when it exports one universal business model that every service must adopt.

Service autonomy depends on limiting shared change, not merely creating separate repositories.

A service is independently deployable when its owner can release a compatible change without requiring simultaneous releases of every consumer and dependency.

That requires:

  • backward-compatible contracts
  • tolerant consumers
  • safe database migrations
  • independent build pipelines
  • independent configuration
  • independent rollback or roll-forward
  • operational ownership

Suppose catalog adds a new response field.

Old basket instances should continue working.

Suppose catalog stops sending an old field immediately.

Any basket version that still requires it will fail.

Independent deployment therefore relies on an overlap period:

producer supports old and new contract
        │
        ▼
consumers migrate
        │
        ▼
old contract retired later

The architecture trades coordinated deployment for compatibility work.

That can be a good trade.

It is not free.

Private data makes autonomy real

Suppose catalog and basket are separate services but share tables.

CATALOG SERVICE ─────┐
                     ├────► products table
BASKET SERVICE ──────┘

Basket can query catalog data directly.

A catalog schema change can break basket.

Catalog cannot evolve its persistence independently.

The process boundary exists.

The data boundary does not.

A stronger service boundary looks like:

BASKET SERVICE
      │
      │ catalog contract
      ▼
CATALOG SERVICE
      │
      ▼
CATALOG DATABASE

Catalog owns:

  • product meaning
  • product rules
  • catalog schema
  • catalog migrations
  • supported read and write contracts

Private ownership does not require a separate physical database server for every service. It can be enforced through separate credentials, schemas, databases or servers, provided other services cannot treat the owner’s persistence as their own interface.

This allows the service to change its internal storage without turning its table layout into a public interface.

It also removes easy local joins and cross-module transactions.

The independence is real because the cost is real.

Inside one monolithic database, checkout might execute:

BEGIN
  │
  ├── create order
  ├── reduce inventory
  ├── create payment record
  └── commit

If order, inventory and payment own separate databases, one ordinary local transaction cannot protect all three.

ORDER DATABASE

INVENTORY DATABASE

PAYMENT DATABASE

The workflow can reach intermediate states:

  • order created, but inventory not reserved;
  • inventory reserved, but payment declined;
  • payment authorised, but the order response was lost.

The design now needs:

  • workflow state
  • retries
  • idempotency
  • compensation
  • reconciliation
  • event publication
  • operational visibility

This is one of the most important costs of a network and data boundary.

A service does not merely move a function.

It can turn a local transaction into a distributed business process.

A monolithic database can perform:

SELECT
    orders.order_id,
    customers.name,
    payments.status
FROM orders
JOIN customers
    ON customers.customer_id = orders.customer_id
JOIN payments
    ON payments.order_id = orders.order_id;

With service-owned data, those records live behind different owners.

Possible replacements include:

  • API composition
  • derived read model
  • event-built projection
  • reporting pipeline
  • analytical warehouse

Each introduces trade-offs around:

  • latency
  • availability
  • staleness
  • storage duplication
  • rebuild procedures

The service boundary creates autonomy by removing direct shared access.

It also removes conveniences provided by one local relational model.

A serious design acknowledges both sides of that exchange.

Autonomy, scaling and failure isolation

Scaling boundaries must match real workloads

Suppose product search receives ten times more traffic than checkout.

In one deployable application:

APPLICATION INSTANCES
│
├── catalog
├── basket
├── order
└── payment

Scaling the application adds capacity for every module, even if only catalog needs it.

A separate catalog service can scale independently:

CATALOG
│
├── instance 1
├── instance 2
├── instance 3
├── instance 4
└── instance 5


ORDER
│
├── instance 1
└── instance 2

That can improve resource efficiency.

It can also allow catalog to use a different:

  • CPU allocation
  • memory profile
  • autoscaling policy
  • cache design
  • deployment frequency

Independent scaling is valuable when the workload difference is real.

If every service always scales together, the separate deployment units may not be buying much.

A monolithic application can also run several instances.

LOAD BALANCER
      │
      ├──► application instance 1
      ├──► application instance 2
      └──► application instance 3

That may be sufficient for many workloads.

The inefficiency appears when one capability dominates resource use or needs a different scaling model.

Before extracting a service for scaling, I should ask:

  • Which component consumes the resource?
  • Can expensive work move to a background queue or a separate worker pool?
  • Can one module be optimised or given tighter resource limits?
  • Can caching or database workload separation reduce the pressure?
  • Can the monolith scale horizontally at acceptable cost?
  • Does extraction create enough savings to repay its complexity?

“Microservices scale” is true in the same broad way that several lorries can carry furniture.

The useful question is whether this particular sofa required a fleet.

Process isolation does not guarantee capability isolation

In one process, an unrecovered fault in one module can terminate the entire application.

catalog defect
      │
      ▼
application process crashes
      │
      ▼
basket, order and payment disappear too

A separate catalog process can fail without terminating order.

  • catalog unavailable
  • order still running

That is valuable isolation.

But order may depend synchronously on catalog.

If so, order is alive but unable to complete important operations.

PROCESS IS HEALTHY

BUSINESS CAPABILITY IS NOT

Failure isolation requires more than process separation.

It may also require:

  • optional dependencies
  • cached or replicated read data
  • degraded modes
  • asynchronous handoff
  • careful critical-path design

The process boundary contains some failures.

The dependency graph determines how far their effects travel.

This isolation is limited by shared dependencies. Separate processes may still fail together when they depend on the same database cluster, identity provider, network, region or deployment platform.

A service boundary isolates only the failures that the new runtime and dependency design genuinely separate.

Suppose catalog slows down.

Basket waits.

Checkout waits on basket.

The storefront waits on checkout.

CATALOG SLOW
     │
     ▼
BASKET WAITS
     │
     ▼
CHECKOUT WAITS
     │
     ▼
CUSTOMER WAITS

Threads, connections and request slots accumulate.

Timeouts trigger retries.

Retry traffic increases catalog load.

One degraded service can create a cascading failure across otherwise healthy processes.

The network boundary can isolate process crashes while spreading latency and resource exhaustion through synchronous dependencies.

Resilience requires:

  • deadlines
  • bounded concurrency
  • load shedding
  • retry limits
  • backoff
  • circuit breaking
  • capacity planning

Those mechanisms become part of the service architecture.

Independent deployment requires compatible change

Independent deployments reduce the size of each release.

They also create many possible version combinations.

  • catalog v5
  • basket v3
  • inventory v7
  • order v4
  • payment v2

The system must work across supported combinations.

A rollout can temporarily produce:

  • old caller → new server
  • new caller → old server

Compatibility should exist in both directions where the rollout requires it.

Deployment strategy may involve:

  • rolling updates
  • canary releases
  • feature flags
  • backward-compatible schema changes
  • traffic shifting

The smaller artifact reduces one blast radius.

The number of interactions increases the compatibility surface.

A service containing one capability can be changed without redeploying unrelated code.

A catalog release does not replace the payment process.

That can provide:

  • smaller change sets
  • clearer ownership
  • faster rollback
  • targeted scaling
  • reduced unrelated failure exposure

This benefit becomes meaningful when the service is genuinely deployable and operable independently.

If every release still requires:

  • the same approval board
  • the same deployment window
  • all services to use one branch
  • all databases to migrate together

the organisation has retained coordinated delivery while multiplying artifacts.

The deployment boundary should match the change boundary.

Operating a service estate

Every service is an operational responsibility

One application deployment might require:

  • one build pipeline
  • one runtime configuration
  • one deployment
  • one set of health checks
  • one log stream
  • one alerting model

Ten services may require:

  • ten buildable artifacts
  • ten deployment definitions
  • ten runtime identities
  • ten configuration sets
  • ten health models
  • ten capacity profiles
  • many dashboards and alerts

Shared platform tooling can reduce the repeated effort.

It cannot remove the need to understand each service.

Every service needs answers to:

  • How is it built?
  • How is it deployed?
  • How is it configured?
  • How does it authenticate?
  • What are its dependencies?
  • What does healthy mean?
  • How is it scaled?
  • How is it rolled back?
  • Who responds when it fails?

A service is not only code with an HTTP port.

It is an operational responsibility.

A team can operate one or two services with bespoke scripts and manual knowledge.

As the service count grows, repeated operational work becomes significant.

Useful platform capabilities include:

  • standard build pipelines
  • deployment templates
  • service identity
  • secret delivery
  • logging and metrics
  • distributed tracing
  • health conventions
  • service discovery
  • configuration management
  • local development support

These provide a paved path for common service requirements.

Without them, every service team invents:

  • its own deployment
  • its own telemetry format
  • its own retry policy
  • its own certificate handling
  • its own alerting

The cost of the network boundary then repeats manually across the organisation.

A microservice architecture is partly an application architecture and partly an operational platform commitment.

Observability must reconstruct the distributed call stack

Inside one process, one request may produce one local log sequence.

Across services:

  • gateway log
  • checkout log
  • inventory log
  • payment log
  • database log

The timestamps may differ slightly.

Entries may arrive out of order.

One service may log a timeout while another logs success.

To reconstruct the interaction, the system needs:

  • request IDs
  • correlation IDs
  • trace context
  • structured fields
  • central collection
  • consistent clocks

Without these, debugging becomes:

  • search five systems
  • guess which entries belong together
  • compare timestamps
  • discover one service says “failed”
  • while another says “committed”

The network boundary creates an observability boundary.

Tracing and correlation are not decorative production extras.

They are how the distributed call stack is reconstructed.

Each service can report:

  • request rate
  • error rate
  • latency
  • resource usage

It should also report dependency behaviour:

  • outbound call rate
  • dependency latency
  • timeouts
  • retry count
  • circuit state
  • connection-pool usage

A checkout service may appear slow because its own code is slow, because payment is slow, or because inventory retries are consuming the deadline.

The boundary needs measurements on both sides.

Otherwise every service reports:

I spent most of the request waiting politely for somebody else.

A service can be:

  • running
  • ready to receive traffic
  • able to reach its database
  • able to serve selected functions
  • unable to reach one optional dependency

These are different conditions.

A health check used to restart a process should not fail merely because one downstream service is unavailable.

Restarting healthy callers can increase the incident.

A readiness check may remove an instance that cannot serve its intended traffic.

A business-level availability signal may report that checkout is degraded because payment is unavailable.

The network boundary creates several kinds of health:

PROCESS HEALTH

Can this process continue running?


INSTANCE READINESS

Should new traffic be sent here?


DEPENDENCY HEALTH

Can required remote operations complete?


CAPABILITY HEALTH

Can the customer journey succeed?

One green endpoint cannot summarise the entire graph.

Security and discovery become execution concerns

Inside one process, modules share one process identity and memory boundary.

Across services, every remote interaction needs decisions about:

  • server identity
  • client identity
  • transport encryption
  • authentication
  • authorisation
  • credential rotation
  • certificate trust
  • request provenance

The catalog service should know whether the caller is:

  • the storefront
  • the basket service
  • an operator
  • an unknown process

Authentication answers:

Who is calling?

Authorisation answers:

Is that caller allowed to perform this operation?

The network boundary can provide useful security separation.

It also creates more identities, credentials and policies to manage.

Separate services do not automatically create least privilege.

They create the opportunity and obligation to define it.

An in-process call knows where its target code lives.

A remote client needs to locate a reachable service endpoint.

logical service name
        │
        ▼
service discovery
        │
        ▼
one or more endpoints
        │
        ▼
connection

The endpoint set may change because of:

  • deployment
  • autoscaling
  • restart
  • failover
  • maintenance

The client or a load balancer must select an endpoint.

It may need to react when an endpoint becomes unhealthy.

This introduces:

  • name resolution
  • load balancing
  • connection lifecycle
  • endpoint caching
  • health information

A local module call does not need a control plane to discover its neighbour.

A service call often does.

Testing and local development need deliberate seams

A modular monolith can test several modules together in one process.

test process
│
├── catalog
├── basket
└── order

A service system needs several kinds of evidence:

Test level Purpose
Unit Exercise isolated business rules
Component Exercise one service with controlled dependencies
Contract Check declared producer and consumer expectations
Integration Exercise databases, brokers and network protocols
End to end Exercise selected user journeys across deployed services

The number of possible combinations grows quickly.

End-to-end tests alone can become slow, fragile, difficult to diagnose and expensive to maintain.

Strong local tests and contract tests reduce dependence on one enormous test environment. They do not prove complete semantic compatibility, production-data compatibility, latency and capacity behaviour, failure handling, or every end-to-end workflow.

The service boundary creates test seams.

It also creates more things requiring integration evidence.

One monolithic process may start with:

make run

A service architecture might require:

  • catalog
  • basket
  • inventory
  • order
  • payment
  • message broker
  • several databases
  • gateway
  • observability components

A developer working on basket should not always need to operate the entire production topology locally.

Possible approaches include:

  • run one service with fakes
  • use contract-compatible stubs
  • provide a smaller local composition
  • connect to selected shared development dependencies
  • record and replay representative responses

The production architecture and the developer workflow need to be designed together.

A system that can be deployed independently but cannot be understood independently has only completed half of the work.

Teams and coherent service boundaries

Ownership can justify a service boundary

A service can give one team end-to-end responsibility for a capability.

CATALOG TEAM
│
├── product model
├── catalog API
├── catalog database
├── deployment
├── monitoring
└── incident response

The team can make local decisions without waiting for every other application team.

This can reduce communication overhead where the organisation is large enough to need parallel delivery.

But splitting one small team across many services can have the opposite effect.

Each developer must understand:

  • several repositories
  • several deployment pipelines
  • several local environments
  • several operational surfaces

The architecture should reflect actual team and ownership needs.

Creating a service per imagined future team can leave today’s team operating tomorrow’s org chart alone.

Suppose two teams disagree about one shared business capability.

Placing a network API between them does not automatically clarify:

  • who owns the rule
  • which meaning is authoritative
  • how changes are prioritised
  • who supports incidents

The service may turn organisational ambiguity into an API dispute.

A healthy boundary needs:

  • clear capability ownership
  • contract responsibility
  • operational responsibility
  • change process
  • consumer communication

Technology can enforce a decided boundary.

It cannot decide the organisation’s responsibilities by itself.

Size should follow behaviour and invariants

The term microservice encourages size questions.

  • How many lines?
  • How many endpoints?
  • How many tables?
  • How many engineers?

There is no useful universal number.

A service should be small enough to represent a coherent capability with understandable ownership.

It should be large enough to preserve the rules and data that belong together.

TOO BROAD

unrelated capabilities
forced release coordination
large ownership surface


TOO NARROW

chatty calls
split invariants
distributed transactions
excess operational overhead

The useful boundary follows:

  • business behaviour
  • data ownership
  • transactional invariants
  • change cadence
  • team responsibility

A service with five endpoints can be badly bounded.

A service with fifty endpoints can still represent one coherent capability.

Suppose the database has:

  • orders
  • order_lines
  • addresses
  • order_status_history

Creating one service per table produces:

  • order service
  • order-line service
  • address service
  • status-history service

Now creating one order may require several remote calls.

ORDER SERVICE
    │
    ├──► ORDER-LINE SERVICE
    ├──► ADDRESS SERVICE
    └──► HISTORY SERVICE

A business operation that previously used one transaction has become distributed coordination.

The services are individually small.

The system is not simpler.

These records may belong to one order aggregate and one transaction boundary.

ORDER SERVICE
│
├── orders
├── order lines
├── accepted address
└── status history

Decomposition should follow coherent behaviour rather than the visual neatness of one noun per box.

A distributed monolith pays both sets of costs

A distributed monolith has separate services but requires coordinated behaviour resembling one tightly coupled application.

Warning signs include:

  • services must be deployed together
  • services share one database schema
  • one request crosses many services
  • one shared domain package changes everywhere
  • local development requires the entire estate
  • one service cannot start without all others
  • a minor feature touches many repositories
  • failures propagate across the whole call chain

The architecture pays for:

  • networking
  • deployment complexity
  • observability
  • service discovery
  • remote failure

while retaining:

  • coordinated releases
  • shared internals
  • tight change coupling

This is the least attractive combination.

The solution is not always more services.

It may be:

  • merge services
  • redraw ownership boundaries
  • remove synchronous dependencies
  • give services private data
  • stabilise contracts

Distribution should increase meaningful autonomy.

Extracting services safely

Extract when a proven boundary needs a new lifecycle

One repository and one process can make changes across related functionality straightforward.

  • change catalog model
  • update basket caller
  • migrate database
  • run test suite
  • deploy one artifact

When the domain is still being discovered, this can be valuable.

Boundaries often move while the team learns:

  • which rules belong together
  • which data changes together
  • which capabilities have distinct lifecycles
  • which dependencies are genuinely optional

Extracting services too early can freeze guesses into network contracts.

Changing an internal interface is usually easier than coordinating a remote contract and data migration.

A monolith can support rapid learning, provided it is kept modular enough to reveal potential boundaries.

As an application and organisation grow, one deployment unit can create:

  • large test suites
  • release contention
  • unclear ownership
  • high deployment blast radius
  • coupled scaling
  • slow build times
  • frequent merge conflicts

Several teams may need to coordinate changes to one artifact.

A small catalog change may require redeploying payment code.

The application may still be logically modular, but the deployment unit has become a bottleneck.

This is a stronger reason to extract a service:

A proven boundary needs an independent lifecycle that the monolith can no longer provide efficiently.

The service is introduced to solve an observed coupling problem.

Not to make the architecture resemble a fashionable diagram.

Choose a coherent candidate

A good candidate often has:

  • clear business ownership
  • a stable vocabulary
  • private data
  • a small supported interface
  • distinct change cadence
  • distinct scaling or security needs
  • limited synchronous dependencies

For example, product-image processing may be a useful early extraction.

APPLICATION
    │
    │ image job
    ▼
IMAGE PROCESSOR

It may have:

  • CPU-heavy workload
  • asynchronous operation
  • separate scaling
  • few business transactions with the core application

By contrast, separating orders from order lines is likely to cut through one consistency boundary.

The easiest component to draw as a box is not necessarily the safest component to extract.

Migration moves authority as well as code

Suppose catalog begins inside the monolith.

MONOLITH
│
├── catalog
├── basket
└── order

The target is:

MONOLITH
│
├── basket
└── order

CATALOG SERVICE

The transition may require:

  • define catalog contract
  • separate catalog model
  • move ownership of catalog tables
  • route selected reads to the service
  • migrate writes
  • keep old and new paths compatible temporarily
  • remove old code and access

During migration, both paths may exist.

Ownership must remain explicit:

One component should remain the authoritative writer for each fact at every stage of the migration.

Shadow reads, comparison and change-data capture may help validate the new path. Uncoordinated dual writes create two failure paths and uncertain authority.

If both the monolith and the new service can change the same facts independently, the migration creates two authorities.

Extraction is a data and responsibility migration, not only a code movement.

During extraction, the monolith may access the service through an adapter.

BASKET MODULE
    │
    ▼
CATALOG ADAPTER
    │
    ▼
CATALOG SERVICE

That bridge should use the service’s supported contract.

A dangerous shortcut is:

  • new catalog service
  • plus
  • monolith still reading catalog tables directly

Now the application depends on both:

  • the service API
  • the service’s internal storage

The new boundary cannot evolve.

A transitional dependency needs a removal plan.

Otherwise temporary architecture develops tenure.

Rather than replacing the complete monolith at once, capabilities can move gradually.

REQUESTS
   │
   ▼
ROUTING LAYER
   │
   ├──► existing monolith
   └──► extracted service

One capability is redirected at a time.

The monolith continues serving the remaining functions.

This can reduce the size of each migration.

It also creates a period in which:

  • old and new architectures coexist
  • routing must be correct
  • data ownership is changing
  • observability spans both environments

Incremental migration reduces one kind of risk.

It does not remove the need for careful boundary and data design.

A practical decision framework

Questions before introducing a service

Before introducing a service, I should be able to answer:

Concern Question
Capability What coherent business capability will this service own?
Data Which facts and invariants belong to it?
Lifecycle Does it genuinely need independent deployment?
Scale Does it have a distinct traffic or resource profile?
Failure Which failures will the boundary isolate, and which dependencies remain shared?
Transactions Which local transaction will become a workflow?
Queries Which easy joins will become composition or derived data?
Contract Can structure and meaning evolve without coordinated releases?
Operations Who will deploy, monitor and support it?
Platform Are identity, telemetry, delivery and discovery capabilities available?
Recovery How will uncertain and partially completed operations be reconciled?
Alternative Could a module, worker or internal resource boundary solve the current problem without a network boundary?

If the answers are vague, the service boundary is probably being proposed before its purpose is understood.

A possible furniture-store boundary

Suppose the first version of the furniture store is a modular monolith.

BFSTORE APPLICATION
│
├── catalog module
├── basket module
├── order module
├── inventory module
└── payment integration

Each module owns its internal model and tables.

The application deploys as one process.

This allows the domain to develop without turning every changing assumption into a remote contract.

Later, evidence appears:

  • catalog reads dominate traffic
  • product search needs independent scaling
  • payment requires tighter access controls
  • image processing consumes substantial CPU
  • notification work should happen asynchronously
  • different teams own catalog and fulfilment

These are concrete pressures.

The system can then choose different responses.

Extract image processing

CATALOG
   │
   │ image job
   ▼
MESSAGE SYSTEM
   │
   ▼
IMAGE PROCESSOR

Why:

  • distinct workload
  • asynchronous operation
  • clear input and output
  • independent scaling

Extract payment

ORDER
  │
  │ payment operation
  ▼
PAYMENT SERVICE

Why:

  • clear security boundary
  • external provider integration
  • specialised lifecycle and auditing

Costs:

  • ambiguous network outcomes
  • idempotency
  • reconciliation
  • distributed checkout workflow

Keep order and order lines together

ORDER MODULE OR SERVICE
│
├── order
├── order lines
├── accepted delivery address
└── status history

Why:

  • one coherent model
  • shared invariants
  • one transaction boundary

The result is neither “everything monolithic” nor “everything a microservice”.

It is a system whose boundaries are paid for where their benefits are visible.

The mental model I am keeping

My old model was:

MONOLITH

simple but bad


MICROSERVICES

complex but modern

The new model is:

MODULE BOUNDARY
│
├── explicit ownership
├── private model
├── supported interface
└── shared deployment and runtime lifecycle


NETWORK SERVICE BOUNDARY
│
├── explicit ownership
├── private model and data
├── versioned remote contract
├── independent deployment
└── independent runtime lifecycle

The service boundary can buy:

  • deployment autonomy
  • scaling autonomy
  • failure containment, subject to shared dependencies
  • security isolation
  • team ownership
  • selective technology freedom where it justifies the support cost

The invoice contains:

  • latency
  • partial failure
  • timeouts
  • retries
  • ambiguous outcomes
  • distributed transactions
  • eventual consistency
  • contract evolution
  • service discovery
  • workload identity
  • distributed observability
  • operational overhead

So the decision is not:

Is a monolith or a service architecture better?

The useful question is:

Which capability needs an independent lifecycle strongly enough to justify a network boundary?

Sometimes the answer will be:

catalog service

Sometimes:

payment service

Sometimes:

not yet

And sometimes:

never, because a well-protected module is the correct boundary

A network boundary is one of the strongest architectural boundaries available.

It can enforce autonomy that package conventions alone may struggle to preserve across large organisations.

It can also transform one understandable application into a collection of individually healthy processes that cannot complete a customer order together.

The boundary should therefore arrive with a job.

It should isolate a capability, enable a lifecycle or solve a measured constraint.

Without that job, the network is not architecture.

It is distance.

References and further reading

Microservice architecture

Martin Fowler and James Lewis: Microservices Describes the microservice architectural style, including independently deployable services, organisation around business capabilities, decentralised data management and infrastructure automation.

Martin Fowler: Microservice Trade-Offs Examines the benefits and costs of service boundaries, including strong modularity, independent deployment, distribution, eventual consistency and operational complexity.

Martin Fowler: Microservice Prerequisites Highlights operational capabilities that should exist before adopting a large microservice estate, including rapid provisioning, monitoring and deployment automation.

Starting and evolving system boundaries

Martin Fowler: Monolith First Presents the argument for beginning with a monolith while a domain is still being understood, then extracting services as stable boundaries and independent-lifecycle needs emerge.

Zhamak Dehghani: How to break a Monolith into Microservices Describes incremental decomposition of a monolith by business capability and the importance of decoupling code and data deliberately.

Martin Fowler: Strangler Fig Application Describes replacing an existing system gradually by routing selected functionality to new implementations rather than performing one complete replacement.

Service-owned data

Microsoft: Data sovereignty per microservice Explains why an autonomous service owns its domain logic and data, and describes the loss of local joins and single ACID transactions when data is distributed across services.

HTTP service contracts

RFC 9110: HTTP Semantics Defines HTTP methods, status semantics, content, safe and idempotent requests, and the meaning of application responses.

RFC 9112: HTTP/1.1 Defines HTTP/1.1 messaging, connection management and incomplete-message behaviour.

These specifications supersede RFC 7230 and RFC 7231, which were current when the original article was written.

RPC lifecycle

gRPC: Core concepts, architecture and lifecycle Introduces gRPC service definitions, unary and streaming calls, metadata, deadlines, cancellation and the client and server views of an RPC lifecycle.

gRPC and Deadlines Explains why remote calls require deadlines and why a client can stop waiting before a server reaches its own conclusion about the call.