DEV Community

Timevolt
Timevolt

Posted on

How I Learned to Stop Worrying and Love Dependency Injection

The Quest Begins (The "Why")

I still remember the first time I tried to add a new payment gateway to an e‑commerce app. The existing code looked like this:

class OrderService {
  private stripe = new StripeProcessor(); // hard‑coded!
  private paypal = new PayPalProcessor(); // also hard‑coded!

  charge(amount: number, method: 'stripe' | 'paypal') {
    if (method === 'stripe') {
      return this.stripe.pay(amount);
    }
    return this.paypal.pay(amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

Every time a new gateway appeared, I had to crack open OrderService, add another if, and instantiate a brand‑new class inside the service. The file grew like a monster, unit tests became a nightmare because I couldn’t swap out the processors without rewriting the service, and any change in one gateway threatened to break the whole thing. I felt like I was stuck in a boss fight where the boss kept gaining new attacks and I had no way to upgrade my gear.

That pain point was the dragon I needed to slay: tight coupling. I wanted a way to add new behaviors without tearing apart the existing code, and I wanted my tests to be fast, isolated, and deterministic.

The Revelation (The Insight)

The treasure I uncovered was Dependency Injection (DI)—the practice of giving a class its dependencies from the outside instead of letting it create them herself. Think of it as handing a knight a sword rather than forcing him to forge one in the middle of battle.

When a class receives its collaborators through its constructor (or a setter), three magical things happen:

  1. Loose coupling – the class no longer knows how to build its helpers, only what they do.
  2. Testability – you can inject mocks or fakes and verify behavior in isolation.
  3. Flexibility – swapping implementations is as easy as passing a different object; no code changes inside the class.

It sounded simple, but the impact was huge. Once I started injecting dependencies, adding a new payment method meant writing a new class that implemented the same interface and then registering it somewhere—no surgery on the core service.

Wielding the Power (Code & Examples)

The Trap: Hard‑Coded Dependencies

Here’s the “before” version again, with a few extra lines to show how quickly it spirals:

class OrderService {
  private stripe = new StripeProcessor();
  private paypal = new PayPalProcessor();
  private applePay = new ApplePayProcessor(); // ← another gateway added later

  charge(amount: number, method: 'stripe' | 'paypal' | 'applePay') {
    if (method === 'stripe') {
      return this.stripe.pay(amount);
    }
    if (method === 'paypal') {
      return this.paypal.pay(amount);
    }
    return this.applePay.pay(amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

What’s wrong?

  • Every new gateway forces a modification inside OrderService.
  • Unit tests must instantiate all those concrete classes, even if we only care about one path.
  • If StripeProcessor changes its constructor signature, we have to hunt down every place we new it.

The Victory: Constructor Injection

First, we define a small abstraction that all payment processors share:

interface PaymentProcessor {
  pay(amount: number): Promise<TransactionResult>;
}
Enter fullscreen mode Exit fullscreen mode

Each concrete processor implements this interface:

class StripeProcessor implements PaymentProcessor {
  async pay(amount: number) {
    // talk to Stripe API …
  }
}

class PayPalProcessor implements PaymentProcessor {
  async pay(amount: number) {
    // talk to PayPal API …
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the service receives its processor from the outside:

class OrderService {
  constructor(private processor: PaymentProcessor) {}

  async charge(amount: number) {
    return this.processor.pay(amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

Using it:

// Composition root (could be a DI container, or just manual wiring)
const stripe = new StripeProcessor();
const orderServiceStripe = new OrderService(stripe);
await orderServiceStripe.charge(42);

const paypal = new PayPalProcessor();
const orderServicePaypal = new OrderService(paypal);
await orderServicePaypal.charge(99);
Enter fullscreen mode Exit fullscreen mode

Why this feels like leveling up:

  • Adding a new gateway? Just create a new class that implements PaymentProcessor and pass it in. No touching OrderService.
  • Testing becomes a breeze:
class FakeProcessor implements PaymentProcessor {
  pay amount) { return Promise.resolve({ success: true }); }
}

const fake = new FakeProcessor();
const svc = new OrderService(fake);
// assert that svc.charge calls fake.pay …
Enter fullscreen mode Exit fullscreen mode
  • If the Stripe SDK updates its constructor, we only change the place where we instantiate StripeProcessor. The service stays blissfully unaware.

Common Pitfalls to Avoid

  1. Injecting too many things – If a constructor starts with a dozen parameters, it’s a sign the class may be doing too much. Consider splitting responsibilities or using a builder/factory.
  2. Using DI containers as a service locator – Avoid pulling dependencies from a static container inside the class; that hides the true dependencies and brings us back to tight coupling. Stick to constructor (or setter) injection where the class’s needs are explicit.

Why This New Power Matters

Adaching DI didn’t just tidy up my code—it changed the way I think about software design. I now start every new feature by asking, “What abstractions does this need?” instead of “What concrete classes should I instantiate here?” The codebase feels more like a set of interchangeable LEGO bricks than a tangled mess of wiring.

When I look back at that payment‑gateway nightmare, I can laugh. The same pattern that once felt like an extra step now feels like a superpower: I can swap implementations, test in isolation, and extend functionality without fear of breaking something far away. It’s the difference between wandering a dungeon with a rusty sword and wielding a blade that adapts to every enemy you meet.

Give it a try in your next project. Pick a class that creates its own dependencies, extract an interface, and inject those dependencies through the constructor. Watch how the code becomes lighter, the tests faster, and your confidence higher.

Your turn: What’s a place in your current code where you feel the pain of hard‑coded dependencies? Sketch out how you’d refactor it with DI, and share the before/after in the comments—I’d love to hear your victory stories!


P.S. Writing this felt like discovering the hidden warp pipe in Super Mario Bros.—suddenly the whole level opened up, and I could zip straight to the flag.

Top comments (0)