Loose coupling is one of those things everyone agrees with until they have to do it. Then it turns into a mess of interfaces, event buses, and six layers of indirection for a feature that just sends an email.
I've built both kinds of systems: tightly coupled ones that were fast to write and painful to change, and over-abstracted ones that were painful to write and still painful to change. Here's what I've settled on.
Coupling isn't binary
The question isn't "are these services coupled?" It's "what changes together, and how often?"
Two services are tightly coupled if a change in one forces a coordinated change in the other. If they share a database schema, a deploy, or a data format, they're coupled. The goal isn't zero coupling. It's coupling that matches the rate of change. Things that change together should be coupled together.
Share contracts, not implementations
The cheapest win is making sure services talk through a stable shape, not through each other's internals.
# Bad: consumer knows the producer's DB columns
user = db.query("SELECT id, email, created_at FROM users WHERE id = %s", uid)
# Better: producer exposes a contract
class UserService:
def get_user(self, uid: str) -> User:
...
The User type is the contract. When the producer adds a phone column, the consumer doesn't care. When the producer changes how it stores users, the consumer still doesn't care.
This sounds obvious, but I've seen plenty of services reach into each other's tables because "it's faster than adding an endpoint." It is faster. Once.
Prefer async messages for cross-service work
Synchronous HTTP calls chain failures. If service A calls B calls C, then C's downtime is A's downtime. Async messaging breaks that chain.
# Instead of calling the billing service directly:
publish("order.created", {"order_id": order.id, "total": order.total})
# The billing service subscribes and handles it on its own time.
The tradeoff is real: you lose the immediate response, you need idempotency, and debugging gets harder. But the services no longer need to be up at the same time, and the producer doesn't need to know who consumes the event.
Use sync calls when you genuinely need an answer right now. Use events when you're announcing something happened.
Version your contracts from day one
Every event and API payload will change. Decide now how.
- Additive changes are safe: new optional fields, new event types.
- Breaking changes need a new version:
order.created.v2or a new endpoint. - Never repurpose a field.
totalmeaning "pre-tax" then "post-tax" is how you get silent bugs.
A consumer that ignores unknown fields is a consumer you can evolve. Make that a rule in your code review checklist.
Don't share a database
This is the single biggest source of hidden coupling. Two services on one schema can never be deployed independently, never be scaled independently, and never have clear ownership. Every migration becomes a negotiation.
If you're stuck here, you don't have to rewrite everything. Extract one table at a time. Put an API in front of it, migrate readers, then migrate writers. It's slow, but it's reversible.
Where people overdo it
Loose coupling has a cost. Sometimes it's not worth paying.
- Don't split a service until you have a reason. A modular monolith with clear module boundaries is easier to keep loosely coupled than three services sharing a database.
- Don't add an event bus for something that happens once a day and has one consumer.
- Don't wrap every internal function in an interface "for testability." Test the behavior, not the abstraction.
I've seen teams spend a quarter building a message broker to avoid one function call. The function call was fine.
A practical test
Before you ship a change, ask: if I deploy this service alone, does anything else break?
If yes, you're coupled. That's not always wrong. But you should know it, write it down, and decide whether it's the coupling you want.
Loose coupling isn't a goal you finish. It's a property you maintain, like performance or readability. The teams that do it well aren't the ones with the most abstractions. They're the ones who know exactly where their seams are.
Top comments (0)