Skip to content

Low Level DesignSOLID principles

Dependency inversion principle

The dependency inversion principle (DIP) through three real examples, from a signup email to a payment provider to a billing job built as ports and adapters, with live demos, class diagrams and code in five languages.

Mid-level20 min read

The dependency inversion principle has two parts:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

“High-level” means the rules the business cares about: who can sign up, how a checkout is paid, when a subscription renews. “Low-level” means the details used to carry them out: SMTP, the Stripe SDK, a SQL table, the system clock. Left alone, code grows the obvious way, with the rules calling the details directly, so every change to a detail reaches the rules.

The inversion is about who owns the interface. The rules declare what they need, in their own words. The details implement it. So the source code arrow changes direction. It used to point from the rules down to the details. Now it points from the details up to the rules. Most explanations skip that part, and it’s the part this page focuses on. Pick a level below. The rest of the page is shared.

The idea in one picture
before: rules depend on details SignupService new SmtpEmailClient(...) SmtpEmailClient change SMTP, and signup changes after: details depend on the rules signup owns both SignupService WelcomeSender "welcome this person", in signup's own words SmtpWelcomeSender implements the arrow now points up, toward the rules
Scroll or pinch to zoom, drag to pan
Before, signup creates and calls an SMTP client. After, signup owns a WelcomeSender interface, and the SMTP class depends on signup by implementing it.
The general shape

builds

builds

Policy

+doBusinessThing()

«interface»

Port

+whatPolicyNeeds()

Adapter

-vendorClient

FakeForTests

CompositionRoot

+main()

builds

builds

Policy

+doBusinessThing()

«interface»

Port

+whatPolicyNeeds()

Adapter

-vendorClient

FakeForTests

CompositionRoot

+main()

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Three things make it work:

  • A port owned by the policy. It lives next to the rules, in the rules’ package, and says what they need. Nothing in it mentions a vendor.
  • Adapters that implement the port. Each one translates between the port and one real detail: an SDK, a database, the clock. Tests get their own small adapters.
  • A composition root. One place, usually main or the app’s startup code, creates the adapters and hands them to the policy. It is the only code that knows every concrete class.
Easy

A signup welcome email

  • Spotting a policy that builds its details
  • An interface the policy owns
  • Handing details in

A sign-up service registers new users and sends them a welcome email. The service checks for duplicate addresses, which is the rule worth testing.

SignupService.javasignup_service.pySignupService.cssignup_service.gosignup-service.ts
import java.util.HashMap;
import java.util.Map;
// A low-level detail: talks SMTP to a mail server.
class SmtpEmailClient {
private final String host;
SmtpEmailClient(String host, int port) { this.host = host; }
void send(String to, String subject, String body) {
System.out.println("SMTP " + host + ": \"" + subject + "\" to " + to);
}
}
// High-level policy: the rules for signing up.
class SignupService {
private final Map<String, String> users = new HashMap<>();
// flaw: the policy creates its own detail. Signup now depends on SMTP, a host name and a
// port, so it can't be tested without a mail server and can't send any other way.
private final SmtpEmailClient email = new SmtpEmailClient("smtp.example.com", 587);
void signUp(String address, String name) {
if (users.containsKey(address)) throw new IllegalArgumentException("already registered");
users.put(address, name);
email.send(address, "Welcome, " + name, "Thanks for joining.");
}
}
class Main {
public static void main(String[] args) {
new SignupService().signUp("ada@example.com", "Ada");
// A unit test of signUp would do exactly this: connect to a real mail server.
}
}

The service builds its own SMTP client, with a host and port written into it. A unit test of the duplicate check sends a real email, or fails without a mail server. Switching to an email API, or sending a welcome SMS, means editing the service that holds the sign-up rules.

Signup declares what it needs: something that can welcome this person. The SMTP code implements that, and is handed in from outside.

Easy: signup owns WelcomeSender, SMTP implements it

SignupService

-users

+signUp(address, name)

«interface»

WelcomeSender

+sendWelcome(address, name)

SmtpWelcomeSender

-host

RecordingWelcomeSender

+sent

SignupService

-users

+signUp(address, name)

«interface»

WelcomeSender

+sendWelcome(address, name)

SmtpWelcomeSender

-host

RecordingWelcomeSender

+sent

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
SignupService.javarecording_welcome_sender.pySignupService.cssignup_service.gorecording-welcome-sender.ts
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// The abstraction belongs to signup and is named in signup's words: "welcome this person".
// It says nothing about SMTP, so any way of sending can stand behind it.
interface WelcomeSender {
void sendWelcome(String address, String name);
}
// High-level policy. It depends on the abstraction it owns, and is handed an implementation.
class SignupService {
private final Map<String, String> users = new HashMap<>();
private final WelcomeSender welcome;
SignupService(WelcomeSender welcome) { this.welcome = welcome; }
void signUp(String address, String name) {
if (users.containsKey(address)) throw new IllegalArgumentException("already registered");
users.put(address, name);
welcome.sendWelcome(address, name);
}
}
// The detail now depends on the policy's interface: the arrow points up, toward the rules.
class SmtpWelcomeSender implements WelcomeSender {
private final String host;
SmtpWelcomeSender(String host, int port) { this.host = host; }
public void sendWelcome(String address, String name) {
System.out.println("SMTP " + host + ": \"Welcome, " + name + "\" to " + address);
}
}
// A test double: records instead of sending. Possible only because signup doesn't build its own.
class RecordingWelcomeSender implements WelcomeSender {
final List<String> sent = new ArrayList<>();
public void sendWelcome(String address, String name) { sent.add(address); }
}
class Main {
public static void main(String[] args) {
// Production wiring: the one place that knows SMTP is used.
new SignupService(new SmtpWelcomeSender("smtp.example.com", 587)).signUp("ada@example.com", "Ada");
// A unit test: no mail server, and it can check what would have been sent.
RecordingWelcomeSender recorder = new RecordingWelcomeSender();
new SignupService(recorder).signUp("grace@example.com", "Grace");
System.out.println("test saw: " + recorder.sent);
}
}

Send three requests through both designs:

A change request arrives
Signup builds its SMTP client
  • SignupServicesign-up rules, and builds its mailer
  • SmtpEmailClient

Signup owns WelcomeSender
  • SignupServicesign-up rules only
  • WelcomeSenderowned by signup
  • SmtpWelcomeSender
  • main()the composition root

Pick a change request to see which classes it touches in each design.

  • The interface is named for the need, not the tool. WelcomeSender.sendWelcome could be backed by SMTP, an email API, an SMS gateway or a queue. EmailClient.send(host, port, ...) could only ever be SMTP.
  • new moved out. The sign-up service no longer creates anything it depends on. That single change is what makes tests and swaps possible.
  • The composition root is allowed to know everything. Somebody has to pick SMTP. Keeping that choice in main means it’s made once, in the open, alongside the configuration.
Interviewer

How would you unit-test this sign-up service without sending emails?

What they are testing

Whether you see that the real problem is the new inside the service. And whether you fix it with an interface the service owns, passed into the constructor. All in under five minutes. The red flag is mocking the SMTP library’s internals, or adding an isTest flag to the service.

Say this out loud

Signup shouldn’t build its own mailer. I’d give it a WelcomeSender interface it owns, pass the SMTP version in from main, and pass a recorder in from the test.

Medium

Swapping a payment provider

  • A port in domain words
  • Adapters that translate errors
  • What DIP does not make cheaper

An online shop charges cards through Stripe. Finance wants a second provider for European customers, and the Stripe SDK is about to change versions. Checkout code is the most sensitive code the shop has.

Checkout.javacheckout.pyCheckout.cscheckout.gocheckout.ts
// A stand-in for the payment provider's SDK. Its types and error codes are Stripe's, not ours.
class StripeException extends RuntimeException {
final String code;
StripeException(String code) { super(code); this.code = code; }
}
record StripeCharge(String id, String status) {}
class StripeClient {
StripeClient(String apiKey) {}
StripeCharge createCharge(long amount, String currency, String source) {
if (source.equals("tok_declined")) throw new StripeException("card_declined");
return new StripeCharge("ch_1", "succeeded");
}
}
// flaw: checkout's rules are written in Stripe's language: its client, its currency strings,
// its charge type and its error codes. Moving to another provider, or adding a second one for
// another country, means editing checkout. Testing it needs Stripe's test mode.
class Checkout {
private final StripeClient stripe = new StripeClient("sk_test_123");
String pay(String orderId, long amountCents, String cardToken) {
try {
StripeCharge charge = stripe.createCharge(amountCents, "usd", cardToken);
return "paid " + orderId + " (" + charge.id() + ")";
} catch (StripeException e) {
if (e.code.equals("card_declined")) return "declined: ask for another card";
throw e;
}
}
}
class Main {
public static void main(String[] args) {
Checkout checkout = new Checkout();
System.out.println(checkout.pay("o1", 1_250, "tok_visa"));
System.out.println(checkout.pay("o2", 1_250, "tok_declined"));
}
}

Checkout speaks Stripe: Stripe’s client, its "usd" strings, its charge object and its error code card_declined. Adding a second provider puts if statements for two SDKs inside checkout, and an SDK upgrade is an edit to checkout.

Checkout declares a PaymentGateway port, with results in its own words: Paid or Declined. A StripeGateway adapter is the only class that knows Stripe. It translates the request into Stripe’s terms, and Stripe’s answers and errors back into checkout’s results.

Medium: checkout owns the port, adapters translate

translates

Checkout

+pay(orderId, cents, cardToken) : String

«interface»

PaymentGateway

+charge(orderId, cents, cardToken) : PaymentResult

«sealed»

PaymentResult

Paid

+reference

Declined

+reason

StripeGateway

FakeGateway

StripeClient

+createCharge(amount, currency, source)

translates

Checkout

+pay(orderId, cents, cardToken) : String

«interface»

PaymentGateway

+charge(orderId, cents, cardToken) : PaymentResult

«sealed»

PaymentResult

Paid

+reference

Declined

+reason

StripeGateway

FakeGateway

StripeClient

+createCharge(amount, currency, source)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
StripeGateway.javacheckout.pyStripeGateway.cscheckout.gocheckout.ts
// ---- Checkout's side: the rules, and the port they own ----
// Results in checkout's own words. A declined card is an expected outcome, not an exception.
sealed interface PaymentResult permits Paid, Declined {}
record Paid(String reference) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
// The port. Checkout decided what it needs from any payment provider, and nothing more.
interface PaymentGateway {
PaymentResult charge(String orderId, long amountCents, String cardToken);
}
class Checkout {
private final PaymentGateway payments;
Checkout(PaymentGateway payments) { this.payments = payments; }
String pay(String orderId, long amountCents, String cardToken) {
return switch (payments.charge(orderId, amountCents, cardToken)) {
case Paid p -> "paid " + orderId + " (" + p.reference() + ")";
case Declined d -> "declined: ask for another card";
};
}
}
// ---- The provider's side: the SDK, unchanged ----
class StripeException extends RuntimeException {
final String code;
StripeException(String code) { super(code); this.code = code; }
}
record StripeCharge(String id, String status) {}
class StripeClient {
StripeClient(String apiKey) {}
StripeCharge createCharge(long amount, String currency, String source) {
if (source.equals("tok_declined")) throw new StripeException("card_declined");
return new StripeCharge("ch_1", "succeeded");
}
}
// ---- The adapter: the only class that knows both sides ----
// It translates checkout's request into Stripe's, and Stripe's answers back into checkout's
// results. Moving to another provider means writing another adapter like this one.
class StripeGateway implements PaymentGateway {
private final StripeClient stripe;
StripeGateway(StripeClient stripe) { this.stripe = stripe; }
public PaymentResult charge(String orderId, long amountCents, String cardToken) {
try {
return new Paid(stripe.createCharge(amountCents, "usd", cardToken).id());
} catch (StripeException e) {
if (e.code.equals("card_declined")) return new Declined("card declined");
throw e; // anything else is a real failure: let it surface
}
}
}
// For tests: decides outcomes from the token, with no network.
class FakeGateway implements PaymentGateway {
public PaymentResult charge(String orderId, long amountCents, String cardToken) {
return cardToken.equals("tok_declined") ? new Declined("test decline") : new Paid("fake-" + orderId);
}
}
class Main {
public static void main(String[] args) {
Checkout live = new Checkout(new StripeGateway(new StripeClient("sk_test_123")));
System.out.println(live.pay("o1", 1_250, "tok_visa"));
System.out.println(live.pay("o2", 1_250, "tok_declined"));
Checkout tested = new Checkout(new FakeGateway());
System.out.println(tested.pay("o3", 1_250, "tok_visa"));
}
}

Send three requests through both designs. The third is the honest one:

A change request arrives
Checkout calls Stripe directly
  • Checkoutpayment rules, in Stripe terms
  • StripeClientthe vendor SDK

Checkout owns PaymentGateway
  • Checkoutpayment rules, in its own terms
  • PaymentGateway + PaymentResultowned by checkout
  • StripeGatewaytranslates to and from Stripe
  • FakeGatewayfor tests
  • StripeClientthe vendor SDK

Pick a change request to see which classes it touches in each design.

  • The port speaks the domain. Declined is checkout’s word. Stripe’s card_declined code never leaves the adapter. If it did, the port would be a Stripe interface with a different name.
  • Adapters translate errors, too. An expected outcome (a declined card) becomes a result. A real failure (a bad API key, the network) still surfaces as an error. Deciding which is which is the adapter’s main job.
  • The port has to fit more than one provider. Design it from what checkout needs, then check it against a second real provider. A port that only fits Stripe will leak on the second one.
  • Changing the port is expensive. Every adapter implements it. Keep it small and stable, and expect real cost when the rules need something new.
Interviewer

We use Stripe today and might add a second provider next year. How do you structure the payment code?

What they are testing

Whether you put a payment port in checkout’s terms in front of the SDK. Whether you map vendor errors into domain results, in an adapter. And whether you test checkout with a fake. Strong answers also mention idempotency keys and webhooks. They admit the port will need to fit things like 3-D Secure. The red flag is an interface that copies Stripe’s method signatures.

Hard

Ports and adapters for a billing job

  • The clock as a dependency
  • Source dependencies pointing inward
  • DIP vs DI vs IoC

A subscription business renews customers every month. A nightly job finds subscriptions due today, charges them, and either moves the renewal date forward or marks the account past due. This is the code that earns the company’s revenue, and it has never had a test.

RenewalJob.javarenewal_job.pyRenewalJob.csrenewal_job.gorenewal-job.ts
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
record Subscription(String customer, String cardToken, long monthlyCents, LocalDate renewsOn, String status) {}
// Low-level details: a SQL table and a payment SDK, simplified.
class SqlSubscriptionTable {
final List<Subscription> rows = new ArrayList<>();
SqlSubscriptionTable(String url) {}
List<Subscription> selectDue(LocalDate day) {
return rows.stream().filter(s -> s.status().equals("active") && !s.renewsOn().isAfter(day)).toList();
}
void update(Subscription s) { rows.replaceAll(r -> r.customer().equals(s.customer()) ? s : r); }
}
class StripeClient {
boolean createCharge(long amount, String currency, String source) { return !source.equals("tok_declined"); }
}
// High-level policy: the rules for renewing subscriptions. This is what the business pays for.
// flaw: it reaches straight down into the database, the payment SDK and the system clock.
// The arrows point from the rules to the details, so a change in any detail reaches the rules,
// and the rules can't be run, or tested, without all of them.
class RenewalJob {
private final SqlSubscriptionTable table;
private final StripeClient stripe = new StripeClient();
RenewalJob(SqlSubscriptionTable table) { this.table = table; }
void run() {
LocalDate today = LocalDate.now(); // flaw: hidden input; which day it is depends on when it runs
int renewed = 0, failed = 0;
for (Subscription s : table.selectDue(today)) {
if (stripe.createCharge(s.monthlyCents(), "usd", s.cardToken())) {
table.update(new Subscription(s.customer(), s.cardToken(), s.monthlyCents(), s.renewsOn().plusMonths(1), "active"));
renewed++;
} else {
table.update(new Subscription(s.customer(), s.cardToken(), s.monthlyCents(), s.renewsOn(), "past_due"));
failed++;
}
}
System.out.println("renewed " + renewed + ", failed " + failed);
}
}
class Main {
public static void main(String[] args) {
SqlSubscriptionTable table = new SqlSubscriptionTable("postgres://billing");
table.rows.add(new Subscription("ada", "tok_visa", 900, LocalDate.of(2026, 11, 1), "active"));
table.rows.add(new Subscription("bob", "tok_declined", 900, LocalDate.of(2026, 11, 1), "active"));
// What this prints depends on today's date. Testing "bob's card fails on renewal day"
// means waiting for renewal day, or changing the computer's clock.
new RenewalJob(table).run();
}
}

The job reaches into a SQL table, a payment SDK and the system clock. The clock is the subtle one: the output depends on the day it runs. Testing “bob’s card fails on renewal day” means waiting for renewal day. The rules can’t run without a database and a payment account, so they are tested by running the job in production and watching.

Apply the same move to every detail the rules touch. The core declares three ports, Subscriptions, Payments and Clock, and the use case depends only on them. Adapters implement the ports, and the core imports nothing from the adapters. This arrangement is called ports and adapters, or hexagonal architecture. Clean Architecture and onion architecture are close relatives built on the same rule.

Hard: the use case and its ports

RenewSubscriptions

+run() : String

Subscription

+customer

+renewsOn

+status

+renewed() : Subscription

+pastDue() : Subscription

«interface»

Subscriptions

+dueOn(day) : List<Subscription>

+save(Subscription)

«interface»

Payments

+charge(customer, cardToken, cents) : bool

«interface»

Clock

+today() : Date

InMemorySubscriptions

StripePayments

SystemClock

FixedClock

RenewSubscriptions

+run() : String

Subscription

+customer

+renewsOn

+status

+renewed() : Subscription

+pastDue() : Subscription

«interface»

Subscriptions

+dueOn(day) : List<Subscription>

+save(Subscription)

«interface»

Payments

+charge(customer, cardToken, cents) : bool

«interface»

Clock

+today() : Date

InMemorySubscriptions

StripePayments

SystemClock

FixedClock

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
RenewSubscriptions.javarenew_subscriptions.pyRenewSubscriptions.csrenew_subscriptions.gorenew-subscriptions.ts
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
// ======== core: the billing rules, and the ports they own. Imports nothing from below. ========
record Subscription(String customer, String cardToken, long monthlyCents, LocalDate renewsOn, String status) {
Subscription renewed() { return new Subscription(customer, cardToken, monthlyCents, renewsOn.plusMonths(1), "active"); }
Subscription pastDue() { return new Subscription(customer, cardToken, monthlyCents, renewsOn, "past_due"); }
}
// Ports: what the rules need from the outside world, in the rules' words.
interface Subscriptions {
List<Subscription> dueOn(LocalDate day);
void save(Subscription s);
}
interface Payments {
boolean charge(String customer, String cardToken, long cents);
}
// Time is an input like any other. Making it a port makes "renewal day" something a test can set.
interface Clock {
LocalDate today();
}
// The use case. It depends only on ports, so it runs the same in production, in tests and in a
// one-off script, and none of the details below can change it.
class RenewSubscriptions {
private final Subscriptions subscriptions;
private final Payments payments;
private final Clock clock;
RenewSubscriptions(Subscriptions subscriptions, Payments payments, Clock clock) {
this.subscriptions = subscriptions;
this.payments = payments;
this.clock = clock;
}
String run() {
int renewed = 0, failed = 0;
for (Subscription s : subscriptions.dueOn(clock.today())) {
if (payments.charge(s.customer(), s.cardToken(), s.monthlyCents())) {
subscriptions.save(s.renewed());
renewed++;
} else {
subscriptions.save(s.pastDue());
failed++;
}
}
return "renewed " + renewed + ", failed " + failed;
}
}
// ======== adapters: implement the ports. They depend on core; core never depends on them. ========
// In production a SqlSubscriptions adapter runs these as queries. This in-memory one keeps the
// example runnable, and is the one tests use.
class InMemorySubscriptions implements Subscriptions {
final List<Subscription> rows = new ArrayList<>();
public List<Subscription> dueOn(LocalDate day) {
return rows.stream().filter(s -> s.status().equals("active") && !s.renewsOn().isAfter(day)).toList();
}
public void save(Subscription s) { rows.replaceAll(r -> r.customer().equals(s.customer()) ? s : r); }
}
class StripeClient {
boolean createCharge(long amount, String currency, String source) { return !source.equals("tok_declined"); }
}
class StripePayments implements Payments {
private final StripeClient stripe = new StripeClient();
public boolean charge(String customer, String cardToken, long cents) {
return stripe.createCharge(cents, "usd", cardToken);
}
}
class SystemClock implements Clock {
public LocalDate today() { return LocalDate.now(); }
}
// A test adapter: a clock that says what the test needs.
record FixedClock(LocalDate day) implements Clock {
public LocalDate today() { return day; }
}
// ======== main: the composition root, the one place that knows every adapter ========
class Main {
public static void main(String[] args) {
// Production would be: new RenewSubscriptions(new SqlSubscriptions(url), new StripePayments(), new SystemClock())
// A test of renewal day, run on any day:
InMemorySubscriptions subs = new InMemorySubscriptions();
subs.rows.add(new Subscription("ada", "tok_visa", 900, LocalDate.of(2026, 11, 1), "active"));
subs.rows.add(new Subscription("bob", "tok_declined", 900, LocalDate.of(2026, 11, 1), "active"));
subs.rows.add(new Subscription("cy", "tok_visa", 900, LocalDate.of(2026, 12, 1), "active"));
RenewSubscriptions job = new RenewSubscriptions(subs, new StripePayments(), new FixedClock(LocalDate.of(2026, 11, 1)));
System.out.println(job.run()); // renewed 1, failed 1
for (Subscription s : subs.rows) System.out.println(s.customer() + ": " + s.status() + ", renews " + s.renewsOn());
}
}

The class diagram shows who calls whom. The more important picture is which packages import which. At run time, calls still flow from the job down to the database. In the source code, every import points inward, toward the rules:

Who imports whom, after the change

main
(composition root)

adapters
InMemorySubscriptions, StripePayments,
SystemClock, FixedClock

core
RenewSubscriptions, Subscription,
ports: Subscriptions, Payments, Clock

Stripe SDK, database driver

main
(composition root)

adapters
InMemorySubscriptions, StripePayments,
SystemClock, FixedClock

core
RenewSubscriptions, Subscription,
ports: Subscriptions, Payments, Clock

Stripe SDK, database driver

Scroll or pinch to zoom, drag to pan
  • Color key
  • Wiring and outside code
  • Adapters
  • The core: rules and ports

Before the change the arrows ran the other way: RenewalJob imported the SQL table, the Stripe SDK and the system clock. Now nothing points out of the core.

  • Time is a dependency. LocalDate.now() inside a rule is a hidden input, just like a database call. Making Clock a port is what turned “renewal day” into something a test can set. Java has java.time.Clock and .NET 8 has TimeProvider for exactly this.
  • The core has no imports from outside. The use case and its ports compile without the database driver or the Stripe SDK on the classpath. Teams often enforce this with a build rule, or an architecture test, because it wears away quietly.
  • Run-time flow and source dependencies are different things. The job still ends up calling the database. What changed is which code knows about which. That’s the whole meaning of “inversion”.
  • Tests use adapters too. FixedClock and the in-memory store are ordinary implementations of the ports, not mocks of a library. They are small enough to trust.
  • Not every class needs a port. Subscription.renewed() is plain logic with no outside world in it, so it stays concrete and is called directly.

These three are taught together and often confused. They are related but answer different questions:

Term Answers In this example
Dependency inversion (DIP) Which way do source dependencies point? The core owns Clock; SystemClock depends on the core.
Dependency injection (DI) How does an object get its dependencies? They’re passed into RenewSubscriptions’s constructor.
Inversion of control (IoC) Who drives the flow? A framework or scheduler calls your code, instead of your code calling it.
DI container Who does the wiring? Optional. Here main does it by hand, in a few lines.

You can do DI without DIP: inject a concrete StripeClient and the rules still depend on Stripe. And you can do DIP without a container: this page never uses one.

Interviewer

This billing job has no tests and runs every night. How would you restructure it?

What they are testing

Whether you find every hidden dependency, including the clock, turn each into a port the use case owns, and wire adapters in one composition root. Senior answers keep the domain logic (renewed()) concrete, mention enforcing the dependency direction, and explain DI vs DIP without being asked. The red flag is “add a DI framework” as the answer.

Say this out loud

The renewal rules shouldn’t know about SQL, Stripe or the system clock. I’d give the use case three ports it owns, implement them as adapters, and wire them in main. Then a test can use an in-memory store and a fixed date.

Invert a dependency when:

  • It talks to the outside world: a network service, a database, files, the clock, randomness.
  • It is likely to change or be replaced: a vendor, a framework, a provider per region.
  • The code that uses it holds rules worth testing on their own.

Depend directly when the thing is stable and part of your language or platform: collections, strings, Math, your own value objects and domain logic. Wrapping List in an interface protects you from nothing.

Idea Looks like DIP because The difference
Dependency injection Same initials, same constructors DI is how objects receive dependencies. DIP is which way the source dependency points.
Adapter Adapters implement the ports Adapter is the pattern that does the translation. DIP is the reason the port exists, and who owns it.
Open/closed Both put an interface in front of variation OCP is about adding variants without edits. DIP is about the rules not depending on details at all, even when there is one variant.
Interface segregation Both say interfaces belong to clients ISP is about the size and shape of the interface. DIP is about which side owns it and which way the arrow points.

On this site:

  • Parking lot: pricing and spot assignment sit behind interfaces the lot owns.
  • Vending machine: Hardware is a port the machine owns, and the motors and coin return are adapters behind it.
  • Rate limiter: the limiter depends on a Bucket interface, not on a particular algorithm or store.

In real systems, you’ll see it in java.time.Clock and .NET’s TimeProvider, and in Go’s habit of declaring interfaces where they are used. Also in Python’s typing.Protocol, in hexagonal architecture, and in every test suite that swaps a real database for an in-memory one.

Interviewer asksWhat exactly is inverted?

The direction of the source code dependency. Normally the rules import the details. After the inversion, the rules define an interface and the details import it to implement it. Calls at run time still go from rules to details.

Interviewer asksWhere should the interface live?

With the code that uses it, in its package, named in its words. If it lives with the implementation, the rules still depend on the implementation’s package, and nothing was inverted.

Interviewer asksHow is DIP different from dependency injection?

DIP decides which way dependencies point. DI is a way of delivering them, usually through a constructor. You can inject a concrete class and violate DIP, and you can follow DIP with a hand-written main and no container.

Interviewer asksWhy would you make the clock a dependency?

Because “now” is an input. Rules that read the system clock behave differently depending on when they run, which makes them hard to test and hard to reason about. A clock port lets tests pick the day, and lets production use the real time.

Decision We chose We gave up
Who owns the interface The rules, in their own words Reusing the vendor’s types
Who creates the details The composition root, once Classes that build what they need
Vendor errors Translated to domain results in the adapter Handling vendor codes where they happen
Time A port, like any other input Calling now() wherever it’s needed
What gets a port The outside world, and what varies An interface for every class
  • Interface segregation, for the shape of the ports
  • SOLID overview, for how the five principles fit together
  • Robert C. Martin, “The Dependency Inversion Principle”, C++ Report (1996)
  • Alistair Cockburn, Hexagonal architecture (2005)
  • Mark Seemann, Dependency Injection: Principles, Practices, and Patterns (2019), for the composition root