I have spent too many weekends untangling services that knew way too much about each other. Every time I changed a field name in one service, three others broke. That is the real cost of tight coupling: not elegance, but velocity. Here is what actually works for me.
Coupling is about knowledge, not code
Two services are coupled if one has to change when the other changes for reasons unrelated to its own job. It does not matter whether they share a process, a repo, or a database. What matters is how much each one knows about the other's internals.
The goal is not zero coupling. That is impossible. The goal is to depend on stable, intentional contracts rather than incidental details.
Talk through contracts, not internals
If service A imports a struct from service B, A now knows B's field names, types, and defaults. Rename a field and A breaks at compile time. That is honest coupling, but it is still coupling.
A better pattern is a narrow interface defined by the consumer:
type UserLookup interface {
EmailForID(ctx context.Context, id string) (string, error)
}
func SendReceipt(ctx context.Context, users UserLookup, orderID string) error {
// only knows about EmailForID
}
Service A declares what it needs. Service B satisfies it. Neither imports the other's model package. The contract is small enough to reason about and easy to fake in tests.
Version your messages
When services talk over the network, the payload is the contract. Add a version field from day one and never remove fields without a deprecation window.
{
"version": 2,
"order_id": "o_123",
"total_cents": 4200
}
Consumers should ignore unknown fields. Producers should never repurpose a field name. This one habit has saved me more incidents than any architecture diagram.
Prefer async events for cross-boundary work
Synchronous calls create runtime coupling. If the payment service is down, checkout is down. Events flip that: the order service publishes order.placed and moves on. Whoever cares subscribes.
# publisher
bus.publish("order.placed", {"order_id": order.id, "version": 1})
# subscriber, entirely separate deploy
def on_order_placed(event):
analytics.record(event["order_id"])
The publisher does not know or care who listens. That is the point. The tradeoff is eventual consistency and harder debugging, so use it where the workflow allows.
Share schemas, not classes
If you must share types, share a schema (JSON Schema, Protobuf, Avro) and generate code per language. Do not share a hand-written model library across services. Generated code from a versioned schema gives you type safety without a shared release train.
Watch for accidental coupling
The sneakiest coupling is not in the code:
- Shared database. Two services writing the same table is a distributed monolith. One service owns the table; others call its API or read a replica.
-
Shared config keys. A feature flag named
checkout_v2read by four services couples them to a rollout plan. - Shared retry logic. If A retries because B is flaky, A knows B's failure modes. Fix B or use a queue.
- Chatty calls. Ten sequential calls to build one page means the services are really one service wearing a trench coat.
A quick test
Before merging, ask: if I delete this service tomorrow, what breaks? If the answer is more than one upstream caller plus its own data, you probably have coupling to untangle. Another version: can I deploy these two services independently on a Friday? If not, why?
What I actually do
- Define consumer-owned interfaces.
- Version every message.
- Default to events across service boundaries.
- One writer per table.
- Deploy independently as the real test.
Loose coupling is not a one-time design decision. It is a habit you practice on every pull request. The teams I have seen do it well are not smarter; they just refuse to let one service peek into another's business.
Top comments (1)
The reframe at the top is the useful one: coupling is about knowledge, not topology. Plenty of teams decouple by putting a queue between two services and every consumer still parses the producer's internal event struct - the queue is just a network hop around a shared database of assumptions.
Consumer-defined interfaces work until the consumer list grows and each one declares a slightly different shape of the same capability; then you've traded N compile-time dependencies for N near-duplicate ports. We keep one canonical narrow interface per capability in a shared contracts package and the producer adapts once.