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.
- Read first
- Interface segregation
- Patterns
- Adapter and facade
The dependency inversion principle has two parts:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- 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 shape
Section titled “The shape”- 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
mainor the app’s startup code, creates the adapters and hands them to the policy. It is the only code that knows every concrete class.
Worked examples
Section titled “Worked examples”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.
Without the principle
Section titled “Without the principle”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. }}# A low-level detail: talks SMTP to a mail server.class SmtpEmailClient: def __init__(self, host: str, port: int) -> None: self._host = host
def send(self, to: str, subject: str, body: str) -> None: print(f'SMTP {self._host}: "{subject}" to {to}')
# High-level policy: the rules for signing up.class SignupService: def __init__(self) -> None: self._users: dict[str, str] = {} # 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. self._email = SmtpEmailClient("smtp.example.com", 587)
def sign_up(self, address: str, name: str) -> None: if address in self._users: raise ValueError("already registered") self._users[address] = name self._email.send(address, f"Welcome, {name}", "Thanks for joining.")
if __name__ == "__main__": SignupService().sign_up("ada@example.com", "Ada") # A unit test of sign_up would do exactly this: connect to a real mail server.using System;using System.Collections.Generic;
// A low-level detail: talks SMTP to a mail server.class SmtpEmailClient{ private readonly string _host; public SmtpEmailClient(string host, int port) => _host = host; public void Send(string to, string subject, string body) => Console.WriteLine($"SMTP {_host}: \"{subject}\" to {to}");}
// High-level policy: the rules for signing up.class SignupService{ private readonly Dictionary<string, string> _users = new(); // 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 readonly SmtpEmailClient _email = new("smtp.example.com", 587);
public void SignUp(string address, string name) { if (_users.ContainsKey(address)) throw new ArgumentException("already registered"); _users[address] = name; _email.Send(address, $"Welcome, {name}", "Thanks for joining."); }}
static class Program{ static void Main() { new SignupService().SignUp("ada@example.com", "Ada"); // A unit test of SignUp would do exactly this: connect to a real mail server. }}package main
import ( "errors" "fmt")
// SmtpEmailClient is a low-level detail: it talks SMTP to a mail server.type SmtpEmailClient struct{ host string }
func NewSmtpEmailClient(host string, port int) *SmtpEmailClient { return &SmtpEmailClient{host} }
func (c *SmtpEmailClient) Send(to, subject, body string) { fmt.Printf("SMTP %s: %q to %s\n", c.host, subject, to)}
// SignupService is the high-level policy: the rules for signing up.type SignupService struct { users map[string]string email *SmtpEmailClient}
// NewSignupService builds its own detail.// flaw: 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.func NewSignupService() *SignupService { return &SignupService{users: map[string]string{}, email: NewSmtpEmailClient("smtp.example.com", 587)}}
func (s *SignupService) SignUp(address, name string) error { if _, ok := s.users[address]; ok { return errors.New("already registered") } s.users[address] = name s.email.Send(address, "Welcome, "+name, "Thanks for joining.") return nil}
func main() { NewSignupService().SignUp("ada@example.com", "Ada") // A unit test of SignUp would do exactly this: connect to a real mail server.}// A low-level detail: talks SMTP to a mail server.class SmtpEmailClient { constructor( private readonly host: string, private readonly port: number, ) {} send(to: string, subject: string, body: string): void { console.log(`SMTP ${this.host}: "${subject}" to ${to}`); }}
// High-level policy: the rules for signing up.class SignupService { private readonly users = new Map<string, string>(); // 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 readonly email = new SmtpEmailClient("smtp.example.com", 587);
signUp(address: string, name: string): void { if (this.users.has(address)) throw new Error("already registered"); this.users.set(address, name); this.email.send(address, `Welcome, ${name}`, "Thanks for joining."); }}
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.
With DIP
Section titled “With DIP”Signup declares what it needs: something that can welcome this person. The SMTP code implements that, and is handed in from outside.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
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); }}from typing import Protocol
# 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.class WelcomeSender(Protocol): def send_welcome(self, address: str, name: str) -> None: ...
# High-level policy. It depends on the abstraction it owns, and is handed an implementation.class SignupService: def __init__(self, welcome: WelcomeSender) -> None: self._users: dict[str, str] = {} self._welcome = welcome
def sign_up(self, address: str, name: str) -> None: if address in self._users: raise ValueError("already registered") self._users[address] = name self._welcome.send_welcome(address, name)
# The detail now depends on the policy's interface: the arrow points up, toward the rules.class SmtpWelcomeSender: def __init__(self, host: str, port: int) -> None: self._host = host
def send_welcome(self, address: str, name: str) -> None: print(f'SMTP {self._host}: "Welcome, {name}" to {address}')
# A test double: records instead of sending. Possible only because signup doesn't build its own.class RecordingWelcomeSender: def __init__(self) -> None: self.sent: list[str] = []
def send_welcome(self, address: str, name: str) -> None: self.sent.append(address)
if __name__ == "__main__": # Production wiring: the one place that knows SMTP is used. SignupService(SmtpWelcomeSender("smtp.example.com", 587)).sign_up("ada@example.com", "Ada")
# A unit test: no mail server, and it can check what would have been sent. recorder = RecordingWelcomeSender() SignupService(recorder).sign_up("grace@example.com", "Grace") print("test saw:", recorder.sent)using System;using System.Collections.Generic;
// 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 IWelcomeSender{ void SendWelcome(string address, string name);}
// High-level policy. It depends on the abstraction it owns, and is handed an implementation.class SignupService{ private readonly Dictionary<string, string> _users = new(); private readonly IWelcomeSender _welcome; public SignupService(IWelcomeSender welcome) => _welcome = welcome;
public void SignUp(string address, string name) { if (_users.ContainsKey(address)) throw new ArgumentException("already registered"); _users[address] = name; _welcome.SendWelcome(address, name); }}
// The detail now depends on the policy's interface: the arrow points up, toward the rules.class SmtpWelcomeSender : IWelcomeSender{ private readonly string _host; public SmtpWelcomeSender(string host, int port) => _host = host; public void SendWelcome(string address, string name) => Console.WriteLine($"SMTP {_host}: \"Welcome, {name}\" to {address}");}
// A test double: records instead of sending. Possible only because signup doesn't build its own.class RecordingWelcomeSender : IWelcomeSender{ public List<string> Sent { get; } = new(); public void SendWelcome(string address, string name) => Sent.Add(address);}
static class Program{ static void Main() { // 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. var recorder = new RecordingWelcomeSender(); new SignupService(recorder).SignUp("grace@example.com", "Grace"); Console.WriteLine($"test saw: [{string.Join(", ", recorder.Sent)}]"); }}package main
import ( "errors" "fmt")
// WelcomeSender 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. In Go it would sit in// the signup package, next to the code that uses it.type WelcomeSender interface { SendWelcome(address, name string)}
// SignupService is the high-level policy. It depends on the abstraction it owns, and is handed// an implementation.type SignupService struct { users map[string]string welcome WelcomeSender}
func NewSignupService(welcome WelcomeSender) *SignupService { return &SignupService{users: map[string]string{}, welcome: welcome}}
func (s *SignupService) SignUp(address, name string) error { if _, ok := s.users[address]; ok { return errors.New("already registered") } s.users[address] = name s.welcome.SendWelcome(address, name) return nil}
// SmtpWelcomeSender is the detail. It now fits the policy's interface: the arrow points up,// toward the rules.type SmtpWelcomeSender struct{ host string }
func NewSmtpWelcomeSender(host string, port int) SmtpWelcomeSender { return SmtpWelcomeSender{host} }
func (s SmtpWelcomeSender) SendWelcome(address, name string) { fmt.Printf("SMTP %s: %q to %s\n", s.host, "Welcome, "+name, address)}
// RecordingWelcomeSender is a test double: it records instead of sending. Possible only because// signup doesn't build its own.type RecordingWelcomeSender struct{ Sent []string }
func (r *RecordingWelcomeSender) SendWelcome(address, name string) { r.Sent = append(r.Sent, address) }
func main() { // Production wiring: the one place that knows SMTP is used. NewSignupService(NewSmtpWelcomeSender("smtp.example.com", 587)).SignUp("ada@example.com", "Ada")
// A unit test: no mail server, and it can check what would have been sent. recorder := &RecordingWelcomeSender{} NewSignupService(recorder).SignUp("grace@example.com", "Grace") fmt.Println("test saw:", recorder.Sent)}// 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 { sendWelcome(address: string, name: string): void;}
// High-level policy. It depends on the abstraction it owns, and is handed an implementation.class SignupService { private readonly users = new Map<string, string>(); constructor(private readonly welcome: WelcomeSender) {}
signUp(address: string, name: string): void { if (this.users.has(address)) throw new Error("already registered"); this.users.set(address, name); this.welcome.sendWelcome(address, name); }}
// The detail now depends on the policy's interface: the arrow points up, toward the rules.class SmtpWelcomeSender implements WelcomeSender { constructor( private readonly host: string, private readonly port: number, ) {} sendWelcome(address: string, name: string): void { console.log(`SMTP ${this.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 { readonly sent: string[] = []; sendWelcome(address: string, name: string): void { this.sent.push(address); }}
// 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.const recorder = new RecordingWelcomeSender();new SignupService(recorder).signUp("grace@example.com", "Grace");console.log("test saw:", recorder.sent);Send three requests through both designs:
- SignupServicesign-up rules, and builds its mailer
- SmtpEmailClient
- EmailApiClientnewHTTP email API
- SignupServicesign-up rules only
- WelcomeSenderowned by signup
- SmtpWelcomeSender
- main()the composition root
- ApiWelcomeSendernewHTTP email API
- RecordingWelcomeSendernewfor tests
Pick a change request to see which classes it touches in each design.
What to notice
Section titled “What to notice”- The interface is named for the need, not the tool.
WelcomeSender.sendWelcomecould be backed by SMTP, an email API, an SMS gateway or a queue.EmailClient.send(host, port, ...)could only ever be SMTP. newmoved 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
mainmeans it’s made once, in the open, alongside the configuration.
How would you unit-test this sign-up service without sending emails?
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.
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.
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.
Without the principle
Section titled “Without the principle”// 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")); }}from dataclasses import dataclass
# A stand-in for the payment provider's SDK. Its types and error codes are Stripe's, not ours.class StripeError(Exception): def __init__(self, code: str) -> None: super().__init__(code) self.code = code
@dataclass(frozen=True)class StripeCharge: id: str status: str
class StripeClient: def __init__(self, api_key: str) -> None: pass
def create_charge(self, amount: int, currency: str, source: str) -> StripeCharge: if source == "tok_declined": raise StripeError("card_declined") return 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: def __init__(self) -> None: self._stripe = StripeClient("sk_test_123")
def pay(self, order_id: str, amount_cents: int, card_token: str) -> str: try: charge = self._stripe.create_charge(amount_cents, "usd", card_token) return f"paid {order_id} ({charge.id})" except StripeError as e: if e.code == "card_declined": return "declined: ask for another card" raise
if __name__ == "__main__": checkout = Checkout() print(checkout.pay("o1", 1_250, "tok_visa")) print(checkout.pay("o2", 1_250, "tok_declined"))using System;
// A stand-in for the payment provider's SDK. Its types and error codes are Stripe's, not ours.class StripeException : Exception{ public string Code { get; } public StripeException(string code) : base(code) => Code = code;}
record StripeCharge(string Id, string Status);
class StripeClient{ public StripeClient(string apiKey) { } public StripeCharge CreateCharge(long amount, string currency, string source) => source == "tok_declined" ? throw new StripeException("card_declined") : 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 readonly StripeClient _stripe = new("sk_test_123");
public string Pay(string orderId, long amountCents, string cardToken) { try { var charge = _stripe.CreateCharge(amountCents, "usd", cardToken); return $"paid {orderId} ({charge.Id})"; } catch (StripeException e) when (e.Code == "card_declined") { return "declined: ask for another card"; } }}
static class Program{ static void Main() { var checkout = new Checkout(); Console.WriteLine(checkout.Pay("o1", 1_250, "tok_visa")); Console.WriteLine(checkout.Pay("o2", 1_250, "tok_declined")); }}package main
import "fmt"
// StripeError, StripeCharge and StripeClient stand in for the payment provider's SDK.// Its types and error codes are Stripe's, not ours.type StripeError struct{ Code string }
func (e *StripeError) Error() string { return e.Code }
type StripeCharge struct{ ID, Status string }
type StripeClient struct{}
func NewStripeClient(apiKey string) *StripeClient { return &StripeClient{} }
func (c *StripeClient) CreateCharge(amount int64, currency, source string) (StripeCharge, error) { if source == "tok_declined" { return StripeCharge{}, &StripeError{"card_declined"} } return StripeCharge{"ch_1", "succeeded"}, nil}
// Checkout's rules are written in Stripe's language.// flaw: 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.type Checkout struct{ stripe *StripeClient }
func NewCheckout() Checkout { return Checkout{NewStripeClient("sk_test_123")} }
func (c Checkout) Pay(orderID string, amountCents int64, cardToken string) (string, error) { charge, err := c.stripe.CreateCharge(amountCents, "usd", cardToken) if se, ok := err.(*StripeError); ok && se.Code == "card_declined" { return "declined: ask for another card", nil } if err != nil { return "", err } return fmt.Sprintf("paid %s (%s)", orderID, charge.ID), nil}
func main() { checkout := NewCheckout() for _, p := range [][2]string{{"o1", "tok_visa"}, {"o2", "tok_declined"}} { msg, _ := checkout.Pay(p[0], 1_250, p[1]) fmt.Println(msg) }}// A stand-in for the payment provider's SDK. Its types and error codes are Stripe's, not ours.class StripeError extends Error { constructor(readonly code: string) { super(code); }}
interface StripeCharge { id: string; status: string;}
class StripeClient { constructor(apiKey: string) {} createCharge(amount: number, currency: string, source: string): StripeCharge { if (source === "tok_declined") throw new StripeError("card_declined"); return { id: "ch_1", status: "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 readonly stripe = new StripeClient("sk_test_123");
pay(orderId: string, amountCents: number, cardToken: string): string { try { const charge = this.stripe.createCharge(amountCents, "usd", cardToken); return `paid ${orderId} (${charge.id})`; } catch (e) { if (e instanceof StripeError && e.code === "card_declined") return "declined: ask for another card"; throw e; } }}
const checkout = new Checkout();console.log(checkout.pay("o1", 1_250, "tok_visa"));console.log(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.
With DIP
Section titled “With DIP”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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// ---- 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")); }}from dataclasses import dataclassfrom typing import Protocol
# ---- 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.@dataclass(frozen=True)class Paid: reference: str
@dataclass(frozen=True)class Declined: reason: str
PaymentResult = Paid | Declined
# The port. Checkout decided what it needs from any payment provider, and nothing more.class PaymentGateway(Protocol): def charge(self, order_id: str, amount_cents: int, card_token: str) -> PaymentResult: ...
class Checkout: def __init__(self, payments: PaymentGateway) -> None: self._payments = payments
def pay(self, order_id: str, amount_cents: int, card_token: str) -> str: match self._payments.charge(order_id, amount_cents, card_token): case Paid(reference): return f"paid {order_id} ({reference})" case Declined(): return "declined: ask for another card"
# ---- The provider's side: the SDK, unchanged ----
class StripeError(Exception): def __init__(self, code: str) -> None: super().__init__(code) self.code = code
@dataclass(frozen=True)class StripeCharge: id: str status: str
class StripeClient: def __init__(self, api_key: str) -> None: pass
def create_charge(self, amount: int, currency: str, source: str) -> StripeCharge: if source == "tok_declined": raise StripeError("card_declined") return 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: def __init__(self, stripe: StripeClient) -> None: self._stripe = stripe
def charge(self, order_id: str, amount_cents: int, card_token: str) -> PaymentResult: try: return Paid(self._stripe.create_charge(amount_cents, "usd", card_token).id) except StripeError as e: if e.code == "card_declined": return Declined("card declined") raise # anything else is a real failure: let it surface
# For tests: decides outcomes from the token, with no network.class FakeGateway: def charge(self, order_id: str, amount_cents: int, card_token: str) -> PaymentResult: return Declined("test decline") if card_token == "tok_declined" else Paid(f"fake-{order_id}")
if __name__ == "__main__": live = Checkout(StripeGateway(StripeClient("sk_test_123"))) print(live.pay("o1", 1_250, "tok_visa")) print(live.pay("o2", 1_250, "tok_declined"))
tested = Checkout(FakeGateway()) print(tested.pay("o3", 1_250, "tok_visa"))using System;
// ---- 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.abstract record PaymentResult;record Paid(string Reference) : PaymentResult;record Declined(string Reason) : PaymentResult;
// The port. Checkout decided what it needs from any payment provider, and nothing more.interface IPaymentGateway{ PaymentResult Charge(string orderId, long amountCents, string cardToken);}
class Checkout{ private readonly IPaymentGateway _payments; public Checkout(IPaymentGateway payments) => _payments = payments;
public string Pay(string orderId, long amountCents, string cardToken) => _payments.Charge(orderId, amountCents, cardToken) switch { Paid p => $"paid {orderId} ({p.Reference})", Declined => "declined: ask for another card", _ => throw new InvalidOperationException("unknown result"), };}
// ---- The provider's side: the SDK, unchanged ----
class StripeException : Exception{ public string Code { get; } public StripeException(string code) : base(code) => Code = code;}
record StripeCharge(string Id, string Status);
class StripeClient{ public StripeClient(string apiKey) { } public StripeCharge CreateCharge(long amount, string currency, string source) => source == "tok_declined" ? throw new StripeException("card_declined") : 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 : IPaymentGateway{ private readonly StripeClient _stripe; public StripeGateway(StripeClient stripe) => _stripe = stripe;
public PaymentResult Charge(string orderId, long amountCents, string cardToken) { try { return new Paid(_stripe.CreateCharge(amountCents, "usd", cardToken).Id); } catch (StripeException e) when (e.Code == "card_declined") { return new Declined("card declined"); // anything else is a real failure: let it surface } }}
// For tests: decides outcomes from the token, with no network.class FakeGateway : IPaymentGateway{ public PaymentResult Charge(string orderId, long amountCents, string cardToken) => cardToken == "tok_declined" ? new Declined("test decline") : new Paid($"fake-{orderId}");}
static class Program{ static void Main() { var live = new Checkout(new StripeGateway(new StripeClient("sk_test_123"))); Console.WriteLine(live.Pay("o1", 1_250, "tok_visa")); Console.WriteLine(live.Pay("o2", 1_250, "tok_declined"));
var tested = new Checkout(new FakeGateway()); Console.WriteLine(tested.Pay("o3", 1_250, "tok_visa")); }}package main
import ( "errors" "fmt")
// ---- Checkout's side: the rules, and the port they own ----
// PaymentResult is checkout's own word for the outcome. A declined card is an expected outcome,// not an error.type PaymentResult interface{ isPaymentResult() }
type Paid struct{ Reference string }type Declined struct{ Reason string }
func (Paid) isPaymentResult() {}func (Declined) isPaymentResult() {}
// PaymentGateway is the port. Checkout decided what it needs from any payment provider, and// nothing more. A real failure (network, bad key) is still an error.type PaymentGateway interface { Charge(orderID string, amountCents int64, cardToken string) (PaymentResult, error)}
type Checkout struct{ payments PaymentGateway }
func (c Checkout) Pay(orderID string, amountCents int64, cardToken string) (string, error) { result, err := c.payments.Charge(orderID, amountCents, cardToken) if err != nil { return "", err } switch r := result.(type) { case Paid: return fmt.Sprintf("paid %s (%s)", orderID, r.Reference), nil case Declined: return "declined: ask for another card", nil } return "", errors.New("unknown result")}
// ---- The provider's side: the SDK, unchanged ----
type StripeError struct{ Code string }
func (e *StripeError) Error() string { return e.Code }
type StripeCharge struct{ ID, Status string }
type StripeClient struct{}
func NewStripeClient(apiKey string) *StripeClient { return &StripeClient{} }
func (c *StripeClient) CreateCharge(amount int64, currency, source string) (StripeCharge, error) { if source == "tok_declined" { return StripeCharge{}, &StripeError{"card_declined"} } return StripeCharge{"ch_1", "succeeded"}, nil}
// ---- The adapter: the only type that knows both sides ----
// StripeGateway 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.type StripeGateway struct{ stripe *StripeClient }
func (g StripeGateway) Charge(orderID string, amountCents int64, cardToken string) (PaymentResult, error) { charge, err := g.stripe.CreateCharge(amountCents, "usd", cardToken) var se *StripeError if errors.As(err, &se) && se.Code == "card_declined" { return Declined{"card declined"}, nil } if err != nil { return nil, err // anything else is a real failure: let it surface } return Paid{charge.ID}, nil}
// FakeGateway is for tests: it decides outcomes from the token, with no network.type FakeGateway struct{}
func (FakeGateway) Charge(orderID string, amountCents int64, cardToken string) (PaymentResult, error) { if cardToken == "tok_declined" { return Declined{"test decline"}, nil } return Paid{"fake-" + orderID}, nil}
func main() { live := Checkout{StripeGateway{NewStripeClient("sk_test_123")}} for _, p := range [][2]string{{"o1", "tok_visa"}, {"o2", "tok_declined"}} { msg, _ := live.Pay(p[0], 1_250, p[1]) fmt.Println(msg) }
tested := Checkout{FakeGateway{}} msg, _ := tested.Pay("o3", 1_250, "tok_visa") fmt.Println(msg)}// ---- 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.type PaymentResult = { kind: "paid"; reference: string } | { kind: "declined"; reason: string };
// The port. Checkout decided what it needs from any payment provider, and nothing more.interface PaymentGateway { charge(orderId: string, amountCents: number, cardToken: string): PaymentResult;}
class Checkout { constructor(private readonly payments: PaymentGateway) {}
pay(orderId: string, amountCents: number, cardToken: string): string { const result = this.payments.charge(orderId, amountCents, cardToken); switch (result.kind) { case "paid": return `paid ${orderId} (${result.reference})`; case "declined": return "declined: ask for another card"; } }}
// ---- The provider's side: the SDK, unchanged ----
class StripeError extends Error { constructor(readonly code: string) { super(code); }}
interface StripeCharge { id: string; status: string;}
class StripeClient { constructor(apiKey: string) {} createCharge(amount: number, currency: string, source: string): StripeCharge { if (source === "tok_declined") throw new StripeError("card_declined"); return { id: "ch_1", status: "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 { constructor(private readonly stripe: StripeClient) {}
charge(orderId: string, amountCents: number, cardToken: string): PaymentResult { try { return { kind: "paid", reference: this.stripe.createCharge(amountCents, "usd", cardToken).id }; } catch (e) { if (e instanceof StripeError && e.code === "card_declined") return { kind: "declined", reason: "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 { charge(orderId: string, amountCents: number, cardToken: string): PaymentResult { return cardToken === "tok_declined" ? { kind: "declined", reason: "test decline" } : { kind: "paid", reference: `fake-${orderId}` }; }}
const live = new Checkout(new StripeGateway(new StripeClient("sk_test_123")));console.log(live.pay("o1", 1_250, "tok_visa"));console.log(live.pay("o2", 1_250, "tok_declined"));
const tested = new Checkout(new FakeGateway());console.log(tested.pay("o3", 1_250, "tok_visa"));Send three requests through both designs. The third is the honest one:
- Checkoutpayment rules, in Stripe terms
- StripeClientthe vendor SDK
- Checkoutpayment rules, in its own terms
- PaymentGateway + PaymentResultowned by checkout
- StripeGatewaytranslates to and from Stripe
- FakeGatewayfor tests
- StripeClientthe vendor SDK
- AdyenGatewaynewtranslates to and from Adyen
Pick a change request to see which classes it touches in each design.
What to notice
Section titled “What to notice”- The port speaks the domain.
Declinedis checkout’s word. Stripe’scard_declinedcode 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.
We use Stripe today and might add a second provider next year. How do you structure the payment code?
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.
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.
Without the principle
Section titled “Without the principle”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(); }}from dataclasses import dataclass, replacefrom datetime import date
@dataclass(frozen=True)class Subscription: customer: str card_token: str monthly_cents: int renews_on: date status: str
def next_month(d: date) -> date: return d.replace(year=d.year + d.month // 12, month=d.month % 12 + 1)
# Low-level details: a SQL table and a payment SDK, simplified.class SqlSubscriptionTable: def __init__(self, url: str) -> None: self.rows: list[Subscription] = []
def select_due(self, day: date) -> list[Subscription]: return [s for s in self.rows if s.status == "active" and s.renews_on <= day]
def update(self, s: Subscription) -> None: self.rows = [s if r.customer == s.customer else r for r in self.rows]
class StripeClient: def create_charge(self, amount: int, currency: str, source: str) -> bool: return source != "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: def __init__(self, table: SqlSubscriptionTable) -> None: self._table = table self._stripe = StripeClient()
def run(self) -> None: today = date.today() # flaw: hidden input; which day it is depends on when it runs renewed = failed = 0 for s in self._table.select_due(today): if self._stripe.create_charge(s.monthly_cents, "usd", s.card_token): self._table.update(replace(s, renews_on=next_month(s.renews_on))) renewed += 1 else: self._table.update(replace(s, status="past_due")) failed += 1 print(f"renewed {renewed}, failed {failed}")
if __name__ == "__main__": table = SqlSubscriptionTable("postgres://billing") table.rows.append(Subscription("ada", "tok_visa", 900, date(2026, 11, 1), "active")) table.rows.append(Subscription("bob", "tok_declined", 900, date(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. RenewalJob(table).run()using System;using System.Collections.Generic;using System.Linq;
record Subscription(string Customer, string CardToken, long MonthlyCents, DateOnly RenewsOn, string Status);
// Low-level details: a SQL table and a payment SDK, simplified.class SqlSubscriptionTable{ public List<Subscription> Rows { get; } = new(); public SqlSubscriptionTable(string url) { } public List<Subscription> SelectDue(DateOnly day) => Rows.Where(s => s.Status == "active" && s.RenewsOn <= day).ToList(); public void Update(Subscription s) { var i = Rows.FindIndex(r => r.Customer == s.Customer); Rows[i] = s; }}
class StripeClient{ public bool CreateCharge(long amount, string currency, string source) => source != "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 readonly SqlSubscriptionTable _table; private readonly StripeClient _stripe = new(); public RenewalJob(SqlSubscriptionTable table) => _table = table;
public void Run() { var today = DateOnly.FromDateTime(DateTime.Today); // flaw: hidden input; depends on when it runs int renewed = 0, failed = 0; foreach (var s in _table.SelectDue(today)) { if (_stripe.CreateCharge(s.MonthlyCents, "usd", s.CardToken)) { _table.Update(s with { RenewsOn = s.RenewsOn.AddMonths(1) }); renewed++; } else { _table.Update(s with { Status = "past_due" }); failed++; } } Console.WriteLine($"renewed {renewed}, failed {failed}"); }}
static class Program{ static void Main() { var table = new SqlSubscriptionTable("postgres://billing"); table.Rows.Add(new Subscription("ada", "tok_visa", 900, new DateOnly(2026, 11, 1), "active")); table.Rows.Add(new Subscription("bob", "tok_declined", 900, new DateOnly(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(); }}package main
import ( "fmt" "time")
type Subscription struct { Customer, CardToken string MonthlyCents int64 RenewsOn time.Time Status string}
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
// SqlSubscriptionTable and StripeClient are low-level details, simplified.type SqlSubscriptionTable struct{ Rows []Subscription }
func (t *SqlSubscriptionTable) SelectDue(d time.Time) []Subscription { var due []Subscription for _, s := range t.Rows { if s.Status == "active" && !s.RenewsOn.After(d) { due = append(due, s) } } return due}
func (t *SqlSubscriptionTable) Update(s Subscription) { for i, r := range t.Rows { if r.Customer == s.Customer { t.Rows[i] = s } }}
type StripeClient struct{}
func (StripeClient) CreateCharge(amount int64, currency, source string) bool { return source != "tok_declined"}
// RenewalJob is the high-level policy: the rules for renewing subscriptions.// 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.type RenewalJob struct { table *SqlSubscriptionTable stripe StripeClient}
func (j RenewalJob) Run() { today := time.Now().UTC() // flaw: hidden input; which day it is depends on when it runs renewed, failed := 0, 0 for _, s := range j.table.SelectDue(today) { if j.stripe.CreateCharge(s.MonthlyCents, "usd", s.CardToken) { s.RenewsOn = s.RenewsOn.AddDate(0, 1, 0) j.table.Update(s) renewed++ } else { s.Status = "past_due" j.table.Update(s) failed++ } } fmt.Printf("renewed %d, failed %d\n", renewed, failed)}
func main() { table := &SqlSubscriptionTable{} table.Rows = append(table.Rows, Subscription{"ada", "tok_visa", 900, day(2026, 11, 1), "active"}, Subscription{"bob", "tok_declined", 900, day(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. RenewalJob{table: table}.Run()}// Dates are ISO strings ("2026-11-01") so they compare and print simply.interface Subscription { readonly customer: string; readonly cardToken: string; readonly monthlyCents: number; readonly renewsOn: string; readonly status: "active" | "past_due";}
const nextMonth = (iso: string): string => { const d = new Date(`${iso}T00:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); return d.toISOString().slice(0, 10);};
// Low-level details: a SQL table and a payment SDK, simplified.class SqlSubscriptionTable { rows: Subscription[] = []; constructor(url: string) {} selectDue(day: string): Subscription[] { return this.rows.filter((s) => s.status === "active" && s.renewsOn <= day); } update(s: Subscription): void { this.rows = this.rows.map((r) => (r.customer === s.customer ? s : r)); }}
class StripeClient { createCharge(amount: number, currency: string, source: string): boolean { return source !== "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 readonly stripe = new StripeClient(); constructor(private readonly table: SqlSubscriptionTable) {}
run(): void { const today = new Date().toISOString().slice(0, 10); // flaw: hidden input; depends on when it runs let renewed = 0; let failed = 0; for (const s of this.table.selectDue(today)) { if (this.stripe.createCharge(s.monthlyCents, "usd", s.cardToken)) { this.table.update({ ...s, renewsOn: nextMonth(s.renewsOn) }); renewed++; } else { this.table.update({ ...s, status: "past_due" }); failed++; } } console.log(`renewed ${renewed}, failed ${failed}`); }}
const table = new SqlSubscriptionTable("postgres://billing");table.rows.push( { customer: "ada", cardToken: "tok_visa", monthlyCents: 900, renewsOn: "2026-11-01", status: "active" }, { customer: "bob", cardToken: "tok_declined", monthlyCents: 900, renewsOn: "2026-11-01", status: "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.
With DIP, all the way: ports and adapters
Section titled “With DIP, all the way: ports and adapters”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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
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()); }}from dataclasses import dataclass, replacefrom datetime import datefrom typing import Protocol
# ======== core: the billing rules, and the ports they own. Imports nothing from below. ========
def next_month(d: date) -> date: return d.replace(year=d.year + d.month // 12, month=d.month % 12 + 1)
@dataclass(frozen=True)class Subscription: customer: str card_token: str monthly_cents: int renews_on: date status: str
def renewed(self) -> "Subscription": return replace(self, renews_on=next_month(self.renews_on), status="active")
def past_due(self) -> "Subscription": return replace(self, status="past_due")
# Ports: what the rules need from the outside world, in the rules' words.class Subscriptions(Protocol): def due_on(self, day: date) -> list[Subscription]: ... def save(self, s: Subscription) -> None: ...
class Payments(Protocol): def charge(self, customer: str, card_token: str, cents: int) -> bool: ...
# Time is an input like any other. Making it a port makes "renewal day" something a test can set.class Clock(Protocol): def today(self) -> date: ...
# 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: def __init__(self, subscriptions: Subscriptions, payments: Payments, clock: Clock) -> None: self._subscriptions = subscriptions self._payments = payments self._clock = clock
def run(self) -> str: renewed = failed = 0 for s in self._subscriptions.due_on(self._clock.today()): if self._payments.charge(s.customer, s.card_token, s.monthly_cents): self._subscriptions.save(s.renewed()) renewed += 1 else: self._subscriptions.save(s.past_due()) failed += 1 return f"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: def __init__(self) -> None: self.rows: list[Subscription] = []
def due_on(self, day: date) -> list[Subscription]: return [s for s in self.rows if s.status == "active" and s.renews_on <= day]
def save(self, s: Subscription) -> None: self.rows = [s if r.customer == s.customer else r for r in self.rows]
class StripeClient: def create_charge(self, amount: int, currency: str, source: str) -> bool: return source != "tok_declined"
class StripePayments: def __init__(self) -> None: self._stripe = StripeClient()
def charge(self, customer: str, card_token: str, cents: int) -> bool: return self._stripe.create_charge(cents, "usd", card_token)
class SystemClock: def today(self) -> date: return date.today()
# A test adapter: a clock that says what the test needs.@dataclass(frozen=True)class FixedClock: day: date
def today(self) -> date: return self.day
# ======== main: the composition root, the one place that knows every adapter ========
if __name__ == "__main__": # Production would be: RenewSubscriptions(SqlSubscriptions(url), StripePayments(), SystemClock())
# A test of renewal day, run on any day: subs = InMemorySubscriptions() subs.rows.append(Subscription("ada", "tok_visa", 900, date(2026, 11, 1), "active")) subs.rows.append(Subscription("bob", "tok_declined", 900, date(2026, 11, 1), "active")) subs.rows.append(Subscription("cy", "tok_visa", 900, date(2026, 12, 1), "active"))
job = RenewSubscriptions(subs, StripePayments(), FixedClock(date(2026, 11, 1))) print(job.run()) # renewed 1, failed 1 for s in subs.rows: print(f"{s.customer}: {s.status}, renews {s.renews_on}")using System;using System.Collections.Generic;using System.Linq;
// ======== core: the billing rules, and the ports they own. References nothing below. ========
record Subscription(string Customer, string CardToken, long MonthlyCents, DateOnly RenewsOn, string Status){ public Subscription Renewed() => this with { RenewsOn = RenewsOn.AddMonths(1), Status = "active" }; public Subscription PastDue() => this with { Status = "past_due" };}
// Ports: what the rules need from the outside world, in the rules' words.interface ISubscriptions{ List<Subscription> DueOn(DateOnly day); void Save(Subscription s);}
interface IPayments{ bool 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.// (.NET 8 ships one: System.TimeProvider.)interface IClock{ DateOnly 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 readonly ISubscriptions _subscriptions; private readonly IPayments _payments; private readonly IClock _clock; public RenewSubscriptions(ISubscriptions subscriptions, IPayments payments, IClock clock) { _subscriptions = subscriptions; _payments = payments; _clock = clock; }
public string Run() { int renewed = 0, failed = 0; foreach (var s in _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 : ISubscriptions{ public List<Subscription> Rows { get; } = new(); public List<Subscription> DueOn(DateOnly day) => Rows.Where(s => s.Status == "active" && s.RenewsOn <= day).ToList(); public void Save(Subscription s) { var i = Rows.FindIndex(r => r.Customer == s.Customer); Rows[i] = s; }}
class StripeClient{ public bool CreateCharge(long amount, string currency, string source) => source != "tok_declined";}
class StripePayments : IPayments{ private readonly StripeClient _stripe = new(); public bool Charge(string customer, string cardToken, long cents) => _stripe.CreateCharge(cents, "usd", cardToken);}
class SystemClock : IClock{ public DateOnly Today() => DateOnly.FromDateTime(DateTime.Today);}
// A test adapter: a clock that says what the test needs.record FixedClock(DateOnly Day) : IClock{ public DateOnly Today() => Day;}
// ======== main: the composition root, the one place that knows every adapter ========
static class Program{ static void Main() { // Production would be: new RenewSubscriptions(new SqlSubscriptions(url), new StripePayments(), new SystemClock())
// A test of renewal day, run on any day: var subs = new InMemorySubscriptions(); subs.Rows.Add(new Subscription("ada", "tok_visa", 900, new DateOnly(2026, 11, 1), "active")); subs.Rows.Add(new Subscription("bob", "tok_declined", 900, new DateOnly(2026, 11, 1), "active")); subs.Rows.Add(new Subscription("cy", "tok_visa", 900, new DateOnly(2026, 12, 1), "active"));
var job = new RenewSubscriptions(subs, new StripePayments(), new FixedClock(new DateOnly(2026, 11, 1))); Console.WriteLine(job.Run()); // renewed 1, failed 1 foreach (var s in subs.Rows) Console.WriteLine($"{s.Customer}: {s.Status}, renews {s.RenewsOn:yyyy-MM-dd}"); }}package main
import ( "fmt" "time")
// ======== core: the billing rules, and the ports they own. Imports nothing from below. ========// (In a real project this is its own package, and the adapters import it.)
type Subscription struct { Customer, CardToken string MonthlyCents int64 RenewsOn time.Time Status string}
func (s Subscription) Renewed() Subscription { s.RenewsOn, s.Status = s.RenewsOn.AddDate(0, 1, 0), "active" return s}
func (s Subscription) PastDue() Subscription { s.Status = "past_due"; return s }
// Ports: what the rules need from the outside world, in the rules' words.type Subscriptions interface { DueOn(day time.Time) []Subscription Save(s Subscription)}
type Payments interface { Charge(customer, cardToken string, cents int64) bool}
// Clock makes time an input like any other, so "renewal day" is something a test can set.type Clock interface { Today() time.Time}
// RenewSubscriptions is 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.type RenewSubscriptions struct { Subscriptions Subscriptions Payments Payments Clock Clock}
func (r RenewSubscriptions) Run() string { renewed, failed := 0, 0 for _, s := range r.Subscriptions.DueOn(r.Clock.Today()) { if r.Payments.Charge(s.Customer, s.CardToken, s.MonthlyCents) { r.Subscriptions.Save(s.Renewed()) renewed++ } else { r.Subscriptions.Save(s.PastDue()) failed++ } } return fmt.Sprintf("renewed %d, failed %d", renewed, failed)}
// ======== adapters: implement the ports. They depend on core; core never depends on them. ========
// InMemorySubscriptions keeps the example runnable, and is the one tests use. In production a// SqlSubscriptions adapter runs these as queries.type InMemorySubscriptions struct{ Rows []Subscription }
func (m *InMemorySubscriptions) DueOn(d time.Time) []Subscription { var due []Subscription for _, s := range m.Rows { if s.Status == "active" && !s.RenewsOn.After(d) { due = append(due, s) } } return due}
func (m *InMemorySubscriptions) Save(s Subscription) { for i, r := range m.Rows { if r.Customer == s.Customer { m.Rows[i] = s } }}
type StripeClient struct{}
func (StripeClient) CreateCharge(amount int64, currency, source string) bool { return source != "tok_declined"}
type StripePayments struct{ stripe StripeClient }
func (p StripePayments) Charge(customer, cardToken string, cents int64) bool { return p.stripe.CreateCharge(cents, "usd", cardToken)}
type SystemClock struct{}
func (SystemClock) Today() time.Time { return time.Now().UTC() }
// FixedClock is a test adapter: a clock that says what the test needs.type FixedClock struct{ Day time.Time }
func (c FixedClock) Today() time.Time { return c.Day }
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
// ======== main: the composition root, the one place that knows every adapter ========
func main() { // Production would be: RenewSubscriptions{SqlSubscriptions{db}, StripePayments{}, SystemClock{}}
// A test of renewal day, run on any day: subs := &InMemorySubscriptions{Rows: []Subscription{ {"ada", "tok_visa", 900, day(2026, 11, 1), "active"}, {"bob", "tok_declined", 900, day(2026, 11, 1), "active"}, {"cy", "tok_visa", 900, day(2026, 12, 1), "active"}, }}
job := RenewSubscriptions{subs, StripePayments{}, FixedClock{day(2026, 11, 1)}} fmt.Println(job.Run()) // renewed 1, failed 1 for _, s := range subs.Rows { fmt.Printf("%s: %s, renews %s\n", s.Customer, s.Status, s.RenewsOn.Format("2006-01-02")) }}// ======== core: the billing rules, and the ports they own. Imports nothing from below. ========
// Dates are ISO strings ("2026-11-01") so they compare and print simply.interface Subscription { readonly customer: string; readonly cardToken: string; readonly monthlyCents: number; readonly renewsOn: string; readonly status: "active" | "past_due";}
const nextMonth = (iso: string): string => { const d = new Date(`${iso}T00:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); return d.toISOString().slice(0, 10);};
const renewed = (s: Subscription): Subscription => ({ ...s, renewsOn: nextMonth(s.renewsOn), status: "active" });const pastDue = (s: Subscription): Subscription => ({ ...s, status: "past_due" });
// Ports: what the rules need from the outside world, in the rules' words.interface Subscriptions { dueOn(day: string): Subscription[]; save(s: Subscription): void;}
interface Payments { charge(customer: string, cardToken: string, cents: number): boolean;}
// Time is an input like any other. Making it a port makes "renewal day" something a test can set.interface Clock { today(): string;}
// 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 { constructor( private readonly subscriptions: Subscriptions, private readonly payments: Payments, private readonly clock: Clock, ) {}
run(): string { let ok = 0; let failed = 0; for (const s of this.subscriptions.dueOn(this.clock.today())) { if (this.payments.charge(s.customer, s.cardToken, s.monthlyCents)) { this.subscriptions.save(renewed(s)); ok++; } else { this.subscriptions.save(pastDue(s)); failed++; } } return `renewed ${ok}, 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 { rows: Subscription[] = []; dueOn(day: string): Subscription[] { return this.rows.filter((s) => s.status === "active" && s.renewsOn <= day); } save(s: Subscription): void { this.rows = this.rows.map((r) => (r.customer === s.customer ? s : r)); }}
class StripeClient { createCharge(amount: number, currency: string, source: string): boolean { return source !== "tok_declined"; }}
class StripePayments implements Payments { private readonly stripe = new StripeClient(); charge(customer: string, cardToken: string, cents: number): boolean { return this.stripe.createCharge(cents, "usd", cardToken); }}
class SystemClock implements Clock { today(): string { return new Date().toISOString().slice(0, 10); }}
// A test adapter: a clock that says what the test needs.class FixedClock implements Clock { constructor(private readonly day: string) {} today(): string { return this.day; }}
// ======== main: the composition root, the one place that knows every adapter ========
// Production would be: new RenewSubscriptions(new SqlSubscriptions(url), new StripePayments(), new SystemClock())
// A test of renewal day, run on any day:const subs = new InMemorySubscriptions();subs.rows.push( { customer: "ada", cardToken: "tok_visa", monthlyCents: 900, renewsOn: "2026-11-01", status: "active" }, { customer: "bob", cardToken: "tok_declined", monthlyCents: 900, renewsOn: "2026-11-01", status: "active" }, { customer: "cy", cardToken: "tok_visa", monthlyCents: 900, renewsOn: "2026-12-01", status: "active" },);
const job = new RenewSubscriptions(subs, new StripePayments(), new FixedClock("2026-11-01"));console.log(job.run()); // renewed 1, failed 1for (const s of subs.rows) console.log(`${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:
- 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.
What to notice
Section titled “What to notice”- Time is a dependency.
LocalDate.now()inside a rule is a hidden input, just like a database call. MakingClocka port is what turned “renewal day” into something a test can set. Java hasjava.time.Clockand .NET 8 hasTimeProviderfor 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.
FixedClockand 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.
DIP, DI and IoC
Section titled “DIP, DI and IoC”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.
This billing job has no tests and runs every night. How would you restructure it?
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.
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.
Tradeoffs
Section titled “Tradeoffs”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.
Commonly taught wrong
Section titled “Commonly taught wrong”Commonly confused with
Section titled “Commonly confused with”| 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. |
Where it shows up
Section titled “Where it shows up”On this site:
- Parking lot: pricing and spot assignment sit behind interfaces the lot owns.
- Vending machine:
Hardwareis a port the machine owns, and the motors and coin return are adapters behind it. - Rate limiter: the limiter depends on a
Bucketinterface, 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.
Interview questions
Section titled “Interview questions”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 |
Further reading
Section titled “Further reading”- 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
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.