Why your microservices are more tightly coupled than your monolith

By Alejandro Ríos · 21 July 202614 views

A senior engineer at a mid-size fintech joins a team that migrated from a monolith to microservices eighteen months ago. She is told the new architecture is more maintainable. Each team owns their service. Deployments are independent. The coupling problem is solved.

On her third day she traces a payment failure. The payment service calls the account service for balance. The account service calls the user service for account ownership. The user service calls the identity service to validate a session token. All four calls happen synchronously on the request path. A 200ms p99 on the payment service requires all four downstream services to respond within 50ms each — and one of them is deployed on the same three-node cluster as the others.

The monolith they replaced had none of this. Function calls were microseconds. Errors were local. The "coupling" was visible in import statements and could be refactored. The coupling in the microservice version is invisible until it fails.

This is not an unusual story. It is the default outcome of microservice architectures that are not designed with coupling as a first-class concern.

What coupling actually means in a distributed system

Coupling is a measure of how much one component must know about another to function correctly. In a monolith, the clearest signal of coupling is the import graph. If module A imports module B, A is coupled to B's interface. If B's interface changes, A must change too. This is visible, statically analyzable, and refactorable.

In a distributed system, coupling takes different forms — and several of them are invisible until the system is under load.

Temporal coupling means A cannot function when B is unavailable. A synchronous HTTP call from A to B is temporal coupling. When B's p99 rises to 2 seconds, A's p99 rises to at least 2 seconds. When B is down, A cannot complete the operation that depends on it. The dependency exists at runtime, not at compile time — so no static analysis tool finds it.

Data coupling means A and B share a data contract. If A reads a field in B's response and B renames the field, A breaks. Unlike function signatures in a monolith, this contract is enforced only at runtime, in production, when the mismatch surfaces.

Deployment coupling means A and B must be deployed in a specific order. If A's new version requires B's new field before B's new version is deployed, the deployment window is constrained. Teams that believed they had independent deployments discover the dependency when a deployment fails.

Logical coupling means changes to A frequently accompany changes to B — not because of interface dependencies, but because they implement two sides of the same concept. In a codebase, logical coupling shows up as commits that always touch both services. In production, it shows up as a broken feature that required updating two services but only one was deployed.

A monolith that is well-structured has explicit coupling at the module interface level and no other kinds. A microservice architecture that is not designed to manage coupling has all four kinds, and three of them are invisible in the source code.

Where coupling migrates when you decompose a monolith

The coupling in a monolith does not disappear when services are extracted. It migrates — and in most cases it migrates to a form that is harder to observe and manage.

A payment flow in the monolith might call a balance check function, a fraud check function, and a notification function. The coupling is explicit: the payment module imports the balance module, the fraud module, and the notification module. A developer reading the payment module can see exactly what it depends on.

After extraction to services, the same dependencies exist — but they are now service calls over the network. The payment service calls the account service, the risk service, and the notification service. The dependencies are still there. Now they are latency-sensitive, failure-sensitive, and invisible to static analysis.

The teams that own the account, risk, and notification services may not know that payment depends on them. There is no interface file to discover. There is no import statement to trace. The dependency is expressed as a URL in a configuration file and an HTTP call in a handler method. It becomes visible when the risk service has an incident and the payment service starts failing.

This is the key insight that most migration projects miss: extracting a module into a service does not reduce its coupling to other modules. It changes the nature of the coupling from compile-time to runtime. Runtime coupling is harder to detect, harder to test, and more expensive to recover from.

The three patterns that introduce the most coupling

Synchronous request chains

The most common form of tight coupling in microservice architectures is synchronous service-to-service calls on the request path. Each call introduces a latency dependency and a failure dependency.

Request → Service A (50ms)
            → Service B (80ms)
                → Service C (120ms)
                    → Service D (60ms)
Total p50: 310ms
Total p99: (p99 of each × 4) = easily 2-4 seconds

Every service in the chain must be available for the request to succeed. Every service's latency adds to the end-to-end latency. The chain creates temporal coupling between all four services even though their teams deployed them independently and believe they are decoupled.

The alternative is to pre-compute or cache the data that downstream services need, eliminating the synchronous call. If the payment service needs account balance, the account service can maintain a read-optimized projection that the payment service queries directly — or push balance update events that the payment service consumes asynchronously to maintain its own read model. The coupling becomes eventual rather than synchronous.

Shared databases

Services that share a database are more tightly coupled than a well-structured monolith. The interface between them is a schema — an implicit contract that no tooling enforces and no test framework validates. When the account team adds a NOT NULL constraint to a column that the payment service reads, the payment service fails at runtime with no warning at build time.

-- Account service migration — team believes this is safe
ALTER TABLE accounts ADD COLUMN kyc_verified_at TIMESTAMP NOT NULL DEFAULT NOW();

-- Payment service query — breaks silently when accounts without kyc_verified_at exist
SELECT balance, kyc_verified_at FROM accounts WHERE user_id = ?
-- If payment service model expects the field and it's missing in some rows,
-- the failure is in the payment service ORM, not the migration

Shared database coupling is common in organizations that extracted services from a monolith without changing the data layer. The services look decoupled — they have separate codebases, separate deployments, separate teams. They are more tightly coupled than the monolith because the shared database is now an undocumented interface between multiple codebases, none of which has full visibility into the others' usage.

Synchronous event buses

An event bus that delivers events synchronously and blocks the publisher until consumers acknowledge creates the same coupling as direct service calls. If the payment service publishes a payment.completed event and waits for the notification service to acknowledge before returning, the payment service's availability is bounded by the notification service's availability.

# Tight coupling disguised as an event bus
def process_payment(payment: Payment) -> None:
    save_to_db(payment)
    # This is a synchronous call with retry semantics, not event-driven architecture
    result = event_bus.publish_and_wait(
        "payment.completed",
        payload=payment.to_dict(),
        timeout=5.0,
        require_ack=True
    )
    if not result.acknowledged:
        raise PaymentEventDeliveryError("notification service did not acknowledge")

A true event bus is fire-and-forget at the producer. The payment service writes the event and returns success to the caller. The notification service consumes the event from the queue asynchronously. The payment service does not care whether the notification service is available at the moment of payment completion — the event is durable, and the notification will arrive when the service comes back online.

How to measure coupling in a live system

The coupling in a microservice architecture is measurable even when it is not visible in source code.

Fan-out per request. For each user-facing request, count how many downstream service calls it triggers. A request that triggers one downstream call is loosely coupled. A request that triggers five sequential downstream calls is tightly coupled regardless of how the architecture diagram is drawn.

Correlated deployment frequency. Track which services are deployed together. If the payment service and account service are always deployed within 24 hours of each other, they are logically coupled. The deployment correlation is a signal that the services implement two sides of a shared concept that changes together.

Shared failure correlation. Track which services' error rates rise together. If the account service and payment service have correlated error spikes — not caused by infrastructure — they are temporally coupled. The correlation shows up in metrics before it is visible in the architecture.

Contract test coverage. Count how many service-to-service contracts are covered by consumer-driven contract tests. Uncovered contracts are coupling that has no automated enforcement and will only be discovered in production.

What genuinely loose coupling looks like

A service is genuinely loosely coupled when it can be deployed, restarted, and scaled independently of other services — and when its availability and performance are not directly affected by the availability and performance of other services.

The characteristics that enable this:

Asynchronous communication by default. The payment service writes a payment record and publishes an event to a durable queue. The account service, notification service, and analytics service consume the event independently. The payment service does not know or care which services consume its events or when. Adding a new consumer requires no change to the payment service.

Data ownership without shared storage. Each service owns its data. Other services get the data they need through events or read-only APIs, not by querying the same database. The payment service maintains its own view of account balances, updated by consuming account balance events. It does not query the account database directly.

Tolerant reader pattern for API contracts. Services that call each other ignore unknown fields in responses. They do not fail when a response contains additional fields. They use the fields they need and discard the rest. This allows producers to add fields without coordinating with consumers.

# Brittle reader — fails when response contains unexpected fields
@dataclass
class AccountResponse:
    account_id: str
    balance: Decimal
    # Will fail if account service adds any new field to the response

account = AccountResponse(**response.json())  # Fails with unexpected 'kyc_tier' field

# Tolerant reader — uses what it needs, ignores the rest
account_data = response.json()
account_id = account_data["account_id"]
balance = Decimal(account_data["balance"])
# Other fields are ignored — adding them to the response requires no consumer change

Circuit breakers at synchronous boundaries. For cases where synchronous calls are necessary, circuit breakers prevent a slow downstream service from cascading into a caller outage. When the circuit is open, the caller handles the degraded state locally rather than waiting for a timeout on every request.

Common mistakes teams make when addressing coupling

Adding an API gateway without fixing synchronous chains. A gateway adds routing and authentication in front of all services but does not change the fact that the payment service still synchronously calls three downstream services on every request. The gateway reduces external coupling; it does not reduce internal coupling.

Treating event-driven as synonymous with decoupled. Teams that introduce Kafka or RabbitMQ sometimes assume the event bus automatically decouples their services. If producers wait for consumer acknowledgment, or if consumers rely on event ordering that the bus does not guarantee, the coupling has changed form but not decreased.

Schema sharing through shared libraries. Packaging the common data contracts into a shared library that all services depend on creates a form of deployment coupling. Updating the shared schema library requires updating every service that imports it before any service can use the new schema. The coupling is now at the package level rather than the database level, but it still constrains independent deployment.

Versioning APIs incorrectly. Teams that deploy breaking API changes and then update consumers in parallel have deployment coupling, even if they call it "versioning." True versioning means v1 and v2 of an API coexist until all consumers have migrated at their own pace. Until then, deployments are still coupled.

The organizational dimension

Coupling in microservices has an organizational mirror. Conway's Law — systems mirror the communication structure of the organizations that build them — runs in both directions. Tightly coupled teams produce tightly coupled services. Tightly coupled services require tightly coupled teams to change safely.

A payment team that cannot release without coordinating with the account team and the notification team has recreated the coordination overhead of a monolith without its advantages. In a monolith, the coordination is visible as a shared codebase and a shared release process. In a microservice architecture, the coordination is invisible until a release fails because a service was deployed in the wrong order.

The structural fix is the same in both cases: establish clear ownership boundaries, define the contracts between teams explicitly, and enforce those contracts with automated tests. In a monolith, this means module interfaces and no cross-module database access. In a microservice architecture, this means consumer-driven contract tests, event schemas with versioning, and no shared databases.

The goal of a microservice architecture is not the elimination of coupling. Coupling between components is inevitable in any system that has multiple components that collaborate. The goal is to make coupling explicit, to prefer asynchronous over synchronous coupling, and to enforce boundaries in ways that fail at build time rather than at 3am in production.

A service architecture that has achieved this looks markedly different from a monolith. The failure modes are isolated. The deployment boundaries are real. The teams are genuinely independent. None of this comes from extracting services from a monolith. It comes from redesigning the coupling structure — and that work is separate from, and more consequential than, the extraction itself. Teams that treat the extraction as the end of the migration, rather than the beginning of the coupling discipline, will spend the next year discovering in production what a good architect would have caught on the whiteboard.

Comments

No comments yet. Be the first!

Sign in to leave a comment.