SOLID Principles Series #5
Robert C. Martin’s definition has two parts: “A. High-level modules should not depend on low-level modules. Both should depend on abstractions.” “B. Abstractions should not depend on details. Details should depend on abstractions.” That sounds academic until you see what it actually means.
High-level modules are the classes that contain your business logic, the rules that make your application what it is. Low-level modules are the infrastructure details: which payment provider you use, which database you connect to, which email service sends your messages.
DIP says: your business logic should never know about those details. Both sides should talk through an abstraction, typically an interface. That way, you can swap Stripe for PayPal, MySQL for Postgres, or a real service for a mock in tests, without touching a single line of business code.
What Is the Dependency Inversion Principle?
DIP is the fifth and final SOLID principle. It inverts the traditional dependency direction. Without DIP, high-level code imports and instantiates low-level code directly. The business logic depends on the infrastructure. With DIP, both depend on an interface that lives in the business layer. The infrastructure adapts to the business, not the other way around.
The word “inversion” is the key. In a naive architecture, the dependency arrow points downward: business logic depends on database, on payment SDK, on email client. DIP flips that arrow. The infrastructure depends on an abstraction that the business logic defines. That’s the inversion.
The Problem in Code
A PaymentService handles charging customers. The team uses Stripe, so the service creates a StripeClient directly:
// PaymentService.java - tightly coupled to Stripe
public class PaymentService {
private final StripeClient stripeClient;
public PaymentService() {
// hardcoded dependency on Stripe
this.stripeClient = new StripeClient("sk_live_abc123");
}
public void charge(Order order) {
stripeClient.createCharge(
order.getTotalAmount(),
order.getCurrency(),
order.getCustomerPaymentToken()
);
order.markAsPaid();
}
public void refund(Order order) {
stripeClient.createRefund(order.getPaymentId());
order.markAsRefunded();
}
}
This works. Payments go through, customers are charged. Then three things happen:
- Testing. You want to unit test
PaymentService. Butnew StripeClient()in the constructor means every test hits the real Stripe API. You either pay for test charges or you skip the test entirely. - New provider. The business wants to add PayPal. But
PaymentServiceis welded to Stripe. Adding PayPal means opening this class, adding conditions, and risking existing payments. - Environment differences. In production you charge real cards. In staging you want a sandbox. In tests you want a fake. The same class, three different behaviours, and no way to switch without editing the code.
The Solution, Step by Step
1. Define the abstraction in the business layer
The interface describes what the business needs from a payment provider, without mentioning any specific one. This interface lives in the business package, not in the infrastructure package. That’s the inversion: the business defines the contract, the infrastructure implements it.
// PaymentGateway.java - the business defines the contract
public interface PaymentGateway {
void charge(double amount, String currency, String paymentToken);
void refund(String paymentId);
}
2. Implement the interface for each provider
Each payment provider gets its own class that adapts the provider’s SDK to the business interface. Stripe details stay in StripeGateway. PayPal details stay in PayPalGateway.
// StripeGateway.java - infrastructure adapts to business contract
public class StripeGateway implements PaymentGateway {
private final StripeClient stripeClient;
public StripeGateway(StripeClient stripeClient) {
this.stripeClient = stripeClient;
}
@Override
public void charge(double amount, String currency, String paymentToken) {
stripeClient.createCharge(amount, currency, paymentToken);
}
@Override
public void refund(String paymentId) {
stripeClient.createRefund(paymentId);
}
}
// PayPalGateway.java - same contract, different provider
public class PayPalGateway implements PaymentGateway {
private final PayPalSdk payPalSdk;
public PayPalGateway(PayPalSdk payPalSdk) {
this.payPalSdk = payPalSdk;
}
@Override
public void charge(double amount, String currency, String paymentToken) {
payPalSdk.createOrder(amount, currency);
payPalSdk.capturePayment(paymentToken);
}
@Override
public void refund(String paymentId) {
payPalSdk.issueRefund(paymentId);
}
}
3. Rewrite PaymentService to depend on the abstraction
PaymentService no longer knows about Stripe or PayPal. It takes a PaymentGateway through its constructor and delegates to it. The business logic is now completely independent of the payment infrastructure.
// PaymentService.java - depends on abstraction, not details
public class PaymentService {
private final PaymentGateway gateway;
// dependency is injected, not created
public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
public void charge(Order order) {
gateway.charge(
order.getTotalAmount(),
order.getCurrency(),
order.getCustomerPaymentToken()
);
order.markAsPaid();
}
public void refund(Order order) {
gateway.refund(order.getPaymentId());
order.markAsRefunded();
}
}
4. Wire it up per environment
The decision of which provider to use moves to the composition root, the place where you assemble your application at startup. Business logic never touches it.
// Application wiring - one place, three environments
// Production
PaymentGateway gateway = new StripeGateway(new StripeClient("sk_live_abc123"));
PaymentService payments = new PaymentService(gateway);
// Staging
PaymentGateway sandbox = new StripeGateway(new StripeClient("sk_test_xyz789"));
PaymentService payments = new PaymentService(sandbox);
// Tests
PaymentGateway fake = new FakePaymentGateway(); // in-memory, no network
PaymentService payments = new PaymentService(fake);
5. Testing becomes trivial
With the dependency inverted, testing PaymentService requires zero infrastructure. No Stripe API, no network, no sandbox. Just a fake implementation that records what was called:
// FakePaymentGateway.java - test double
public class FakePaymentGateway implements PaymentGateway {
private final List<String> charges = new ArrayList<>();
private final List<String> refunds = new ArrayList<>();
@Override
public void charge(double amount, String currency, String paymentToken) {
charges.add(paymentToken + ": " + amount + " " + currency);
}
@Override
public void refund(String paymentId) {
refunds.add(paymentId);
}
public List<String> getCharges() { return charges; }
public List<String> getRefunds() { return refunds; }
}
// PaymentServiceTest.java - clean, fast, no infrastructure
@Test
void shouldChargeAndMarkAsPaid() {
FakePaymentGateway fake = new FakePaymentGateway();
PaymentService service = new PaymentService(fake);
Order order = new Order(100.0, "USD", "tok_123");
service.charge(order);
assertEquals(1, fake.getCharges().size());
assertTrue(order.isPaid());
}
Why This Actually Works
Business logic is portable. PaymentService doesn’t import anything from Stripe’s SDK. If tomorrow the team migrates to Adyen, you write an AdyenGateway, change the wiring, and the business logic never knows. No changes to PaymentService, no risk to existing payment flows.
Tests run in milliseconds. Before DIP, testing meant hitting a real API or setting up an elaborate mock server. Now you inject a fake, run assertions on what it recorded, and you’re done. The tests are fast, deterministic, and independent of network or third-party availability.
The connection to the rest of SOLID. If this pattern looks familiar, it should. In the Strategy article we swapped payment algorithms at runtime. In the OCP article we added shipping providers without modifying existing code. In the ISP article we split fat interfaces into focused ones. DIP is the principle that ties them all together: the reason those patterns work is that they depend on abstractions, not on concrete implementations. Without DIP, none of the other principles hold up in practice.
When to Invert and When to Keep It Simple
Invert the dependency when:
- Your class creates infrastructure objects directly (new StripeClient, new MySQLConnection).
- You can’t test a class without standing up external services.
- Swapping a provider requires editing business logic.
Keep it simple when:
- The dependency is a utility that will never change (String, List, LocalDate).
- You have a small application with one environment and no tests that need isolation.
- Adding an interface creates a 1:1 wrapper with no realistic second implementation.
DIP is not about wrapping everything in interfaces. It’s about recognising where a concrete dependency is limiting your code and introducing an abstraction where it actually earns its place.
Wrapping Up
The original PaymentService worked. It charged customers, it processed refunds. The problem only surfaced when the team needed to test it, add a second provider, or run it in a different environment. DIP is the principle that prevents that kind of lock-in by making sure business logic never depends on infrastructure details directly.
That’s all five SOLID principles covered. SRP keeps classes focused. OCP makes them extensible. LSP keeps inheritance honest. ISP keeps interfaces lean. And DIP keeps the dependency arrows pointing the right way. They work best together, and understanding one makes the others clearer.
How tightly coupled is your current project? Drop a comment below. I’m curious whether you’ve done a full DIP refactor or if you’re still in the “new SomeClient()” phase. There’s no shame in it, most projects start that way. The interesting part is deciding when the abstraction becomes worth it.