Why Coupling Sneaks Up on You
Every project starts clean. You split code into modules, services, or microservices, and everything feels decoupled. Then three months later, you find a UserService that imports OrderService to check if a user has orders, and OrderService imports UserService to fetch the user's email. You've built a tangled web without noticing.
Coupling isn't just about imports. It's about how much one service needs to know about another's internals. The goal is to reduce that knowledge, not to eliminate all communication.
The Contract Principle
The simplest mental model: every service exposes a contract, and other services only depend on that contract, never on the implementation. Think of it like a restaurant menu. You order "chicken soup," not "boil a chicken for 40 minutes with carrots."
In code, this means defining clear interfaces or API boundaries. If you're in a monolith, that's a class interface. If you're in microservices, that's a REST or message queue endpoint.
Here's a classic violation:
class OrderService:
def __init__(self, user_service):
self.user_service = user_service
def create_order(self, user_id, items):
user = self.user_service.get_user(user_id)
if user.credit_score < 600:
raise ValueError("Low credit")
# ... create order
The OrderService knows about credit_score, a property of the user's internal model. If the user model changes, this breaks.
A better contract:
class OrderService:
def __init__(self, user_service):
self.user_service = user_service
def create_order(self, user_id, items):
if not self.user_service.can_place_order(user_id):
raise ValueError("User cannot order")
# ... create order
Now OrderService only knows can_place_order, a semantic capability, not the internals. The UserService decides how to evaluate that.
Use Events for Side Effects
Sometimes a service needs to react to changes in another service, but not immediately. That's a perfect case for events.
Instead of OrderService calling EmailService.send_invoice(order), have OrderService publish an order_created event. EmailService subscribes to that event. Now neither service knows about the other.
# OrderService
from events import publish
def create_order(...):
# ... save order
publish("order_created", {"order_id": order.id})
# EmailService
from events import subscribe
@subscribe("order_created")
def send_invoice(data):
# ... send email using data
This is a huge win for testability too. You can test OrderService without mocking EmailService. Just assert that the event was published.
Beware of Shared Models
A common trap is sharing database models or DTOs across services. In a microservice world, that's a direct violation of bounded context. Each service should own its data representation.
If two services share the same User class, any change to that class forces both services to redeploy. Instead, each service defines its own view of the user. OrderService might have CustomerSnapshot with just id and name. That's enough to display on an order.
Even in a monolith, you can keep separate internal models and map between them. It's extra code, but it pays off when requirements diverge.
Dependency Injection Helps
Constructor injection is the easiest way to see your dependencies. If a service has five dependencies in its constructor, that's a smell. It's probably doing too much.
class CheckoutService:
def __init__(self, order_repo, user_service, inventory_service, payment_gateway, email_service):
# ...
Can you reduce that? Maybe CheckoutService can delegate to smaller services, each with one dependency.
Also, inject interfaces, not concrete classes. In Python, that's just type hints, but it forces you to think about what methods you actually need.
The Rule of Three
Don't abstract prematurely. If you have one place that uses a service, direct calls are fine. When a second place needs it, consider a shared interface. When a third appears, refactor to a contract.
This prevents over-engineering. You don't need an event bus for a two-service monolith. You don't need a message queue if you can just call a function.
Practical Steps to Decouple
- List your dependencies. For each service, write down what other services it imports or calls. Look for cycles.
- Ask "what does the caller need?" Not "what can the callee provide?" Define the minimal interface.
- Replace direct calls with events for async needs. If a side effect isn't part of the primary flow, make it an event.
- Use integration tests at the contract level. Test that a service respects its contract, not that it works with a specific implementation.
When Tight Coupling Is Fine
Not everything needs to be decoupled. If two services are deployed together, change together, and have the same lifecycle, they're really one service. Splitting them adds complexity without benefit.
For example, UserService and AuthService often share so much domain logic that separating them is artificial. Keep them together until you have a concrete reason to split.
Final Thought
Loosely coupled services are easier to test, evolve, and reason about. But they come at a cost: more interfaces, more mapping, more events. The trick is to find the sweet spot for your project. Start simple, and refactor when coupling actually hurts.
Remember, the goal isn't to have zero dependencies. It's to have dependencies that are explicit, stable, and narrow. That's what keeps your codebase healthy as it grows.
Top comments (0)