Clean Code

Decoupling Components: Best Practices for Loose Coupling

I needed to swap a payment provider. One class change. It took two weeks. The payment logic was tangled into the order service, the notification system, a...

16 Apr 2024

Decoupling Components: Best Practices for Loose Coupling

I needed to swap a payment provider. One class change. It took two weeks. The payment logic was tangled into the order service, the notification system, and three different controllers. Everything depended on everything.

That's tight coupling. It means you can't change one thing without breaking five others.

What loose coupling actually means

Loose coupling is about how much one component knows about another. In a loosely coupled system, components talk through contracts — interfaces, events, or well-defined APIs. They don't care about each other's internals.

Think of a power outlet. Your laptop doesn't know how the electrical grid works. It just needs the right plug shape. That's a contract.

Why it matters

You can replace parts. Swap a database, change a provider, rewrite a module — without rewriting everything that touches it.

You can test in isolation. Mock the interface, test the component. No need to spin up the entire system.

You can scale teams. Different teams own different components. As long as the contracts hold, they can work independently.

You can evolve independently. One component can be refactored, optimized, or rewritten without coordinating changes across the entire system.

How to decouple in TypeScript

Program to interfaces. Don't depend on concrete classes. Depend on the shape of the data or behavior you need.

Typescript
interface PaymentGateway {
  charge(amount: number, currency: string): Promise<Receipt>;
}

class StripeGateway implements PaymentGateway {
  async charge(amount: number, currency: string): Promise<Receipt> {
    // Stripe-specific implementation
  }
}

class OrderService {
  constructor(private gateway: PaymentGateway) {}

  async placeOrder(order: Order): Promise<void> {
    await this.gateway.charge(order.total, order.currency);
  }
}

OrderService doesn't know about Stripe. It knows about PaymentGateway. Tomorrow you can pass a PayPalGateway and nothing changes.

Use dependency injection. Pass dependencies in. Don't let components create their own. This makes swapping and testing trivial.

Separate concerns. A component that handles business logic shouldn't also handle HTTP routing, logging, and database queries. Each concern gets its own layer.

Communicate through events. Instead of component A calling component B directly, emit an event. Let B subscribe. Now A doesn't even know B exists.

The trade-off

Loose coupling adds indirection. You have more files, more interfaces, more wiring. It takes longer to trace a request through the system because the path isn't hardcoded — it's configured.

That indirection is the price of flexibility. For small scripts or prototypes, tight coupling is fine. For production systems that evolve over years, loose coupling pays for itself many times over.

The question isn't "should I decouple?" It's "how much coupling can I afford?"