The tax rule is four lines and you cannot write a unit test for it. The class that owns it also creates a Stripe client from an environment variable and an SMTP session from a properties file, so testing “does 100 become 118” needs an API key, a network, and a mail server.
That is the Dependency Inversion Principle’s entire job description. SOLID Roadmap defined the letter in one sentence: policy depends on abstractions, and the details get handed in. This post does the move on the OrderProcessor from that post — the coupled version, the fix, the composition root, and where Spring fits.
The starting point: new inside the policy
Here is OrderProcessor after someone has already tidied it up. The SQL and the audit log are gone, the tax rule is a private method, and the class still constructs both of its collaborators:
public class OrderProcessor {
private final StripeGateway gateway;
private final SmtpEmailNotifier notifier;
public OrderProcessor() {
this.gateway = new StripeGateway(System.getenv("STRIPE_KEY"));
this.notifier = new SmtpEmailNotifier(Session.getInstance(smtpProps()));
}
public void process(Order order) {
BigDecimal payable = order.total().add(taxOn(order.total()));
StripeCharge charge = gateway.charge(order.id(), payable);
if (!charge.paid()) {
throw new PaymentFailedException(order.id(), charge.failureCode());
}
notifier.sendConfirmation(order.customerEmail(), order.id(), payable);
}
private BigDecimal taxOn(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.18"));
}
}
This reads fine. The coordination is clear, the method is short, and nobody would flag it in review for being long. The problem is not the shape of process — it is the two new calls above it.
What the new calls actually cost
Three separate costs, and only the first one is obvious.
The class cannot be tested without infrastructure. new OrderProcessor() reaches for STRIPE_KEY and an SMTP host before your test asserts anything. There is no argument you can pass to stop it.
The provider choice is welded in. A second payment provider means editing this class, because the type of the field is the provider. Same for a second notification channel.
The provider’s vocabulary leaks upward. StripeCharge, paid(), failureCode() are Stripe’s words. process now speaks them, so swapping providers is not a wiring change — it is a rewrite of the policy body.
The usual workaround makes it worse:
// Please don't: a test-only setter to defeat the constructor
public void setGateway(StripeGateway gateway) {
this.gateway = gateway;
}
A test-only mutator is a seam bolted on from the outside. It widens the public API of production code so that tests can undo a decision the constructor should never have made. The mocking-framework version of the same workaround — statically mocking the constructor — hides the coupling instead of removing it.
”Inverted” compared to what?
The name confuses people because nothing gets removed. OrderProcessor still needs a way to charge a card. What changes is which side of the dependency knows about the other:
Before: OrderProcessor ────────> StripeGateway ────> Stripe SDK, network
(policy) (detail)
After: OrderProcessor ────────> PaymentGateway <──── StripePaymentGateway ────> Stripe SDK
(policy) (abstraction) (detail)
The arrow from the detail now points up at the abstraction. That is the inversion: the volatile side depends on the stable side, not the reverse.
The consequence people skip: the interface belongs to the policy, not to the adapter. PaymentGateway is not “the common shape of payment providers” — it is the payment vocabulary OrderProcessor wants to be written against. Put it in the policy’s package and let each provider come to it.
The abstractions the policy wants
Two interfaces, both named for what the caller needs rather than how it happens:
public interface PaymentGateway {
PaymentResult charge(Order order, BigDecimal amount);
}
public interface OrderNotifier {
void orderConfirmed(Order order, BigDecimal amountCharged);
}
Part 1 called the second one EmailNotifier. OrderNotifier is the better name once the dependency is inverted, because “email” is a delivery mechanism — the policy only cares that the customer was told. The day SMS arrives, the interface does not change.
The result type belongs to the policy too, so no provider’s class appears in the signature:
public record PaymentResult(boolean paid, String reference, String failureCode) {
public static PaymentResult success(String reference) { return new PaymentResult(true, reference, null); }
public static PaymentResult declined(String code) { return new PaymentResult(false, null, code); }
}
Note: Returning a result object rather than throwing is a deliberate choice here — a declined card is an expected outcome of a payment call, not an exceptional one. Either design satisfies DIP; what matters is that the type is yours, so a provider swap cannot change the policy’s error handling.
The fix: constructor injection
OrderProcessor now asks for what it needs and never builds it:
public class OrderProcessor {
private final PaymentGateway payments;
private final OrderNotifier notifier;
public OrderProcessor(PaymentGateway payments, OrderNotifier notifier) {
this.payments = Objects.requireNonNull(payments);
this.notifier = Objects.requireNonNull(notifier);
}
public void process(Order order) {
BigDecimal payable = order.total().add(taxOn(order.total()));
PaymentResult result = payments.charge(order, payable);
if (!result.paid()) {
throw new PaymentFailedException(order.id(), result.failureCode());
}
notifier.orderConfirmed(order, payable);
}
private BigDecimal taxOn(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.18"));
}
}
The body of process barely moved. What changed is that the class is now honest about its dependencies: the constructor signature lists everything it needs, final fields guarantee they cannot be swapped later, and there is no provider name anywhere in the file.
Stripe’s vocabulary now stops at the adapter, which is the only class that translates:
public class StripePaymentGateway implements PaymentGateway {
private final StripeClient stripe;
public StripePaymentGateway(StripeClient stripe) {
this.stripe = stripe;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
StripeCharge charge = stripe.charge(order.id(), amount);
return charge.paid()
? PaymentResult.success(charge.id())
: PaymentResult.declined(charge.failureCode());
}
}
SmtpOrderNotifier does the same job for mail: it owns the Session, the from-address, and the subject line, and it implements orderConfirmed.
Note: Taking StripeGateway as a constructor parameter — the concrete class, injected — is dependency injection without dependency inversion. Your tests still need Stripe’s types on the classpath and the policy still speaks Stripe. Injection is the mechanism; the abstraction is the principle.
The composition root: plain main
Somebody has to call new on a Stripe client. DIP does not delete that line; it moves it to one place where knowing the details is the whole point:
public final class Application {
public static void main(String[] args) {
PaymentGateway payments = new StripePaymentGateway(
new StripeClient(System.getenv("STRIPE_KEY")));
OrderNotifier notifier = new SmtpOrderNotifier(
Session.getInstance(smtpProps()), System.getenv("MAIL_FROM"));
OrderProcessor processor = new OrderProcessor(payments, notifier);
new OrderHttpServer(processor).start(8080);
}
}
That block is the composition root: the single layer that knows every concrete type, reads configuration, and assembles the object graph. Everything below it takes collaborators as parameters. Switching providers is now an edit to this file and a new adapter class — OrderProcessor is not recompiled for a reason it does not care about.
The package layout makes the rule visible:
com.geekmonks.orders Order, OrderProcessor, PaymentGateway, OrderNotifier, PaymentResult
com.geekmonks.orders.stripe StripePaymentGateway -> imports orders + Stripe SDK
com.geekmonks.orders.smtp SmtpOrderNotifier -> imports orders + JavaMail
com.geekmonks.app Application -> imports everything, `new`s the adapters
The invariant worth enforcing: com.geekmonks.orders imports nothing from com.stripe, javax.mail, or its own sub-packages. One ArchUnit rule turns that into a failing build instead of a review comment:
@ArchTest
static final ArchRule policy_stays_free_of_providers =
noClasses().that().resideInAPackage("com.geekmonks.orders")
.should().dependOnClassesThat()
.resideInAnyPackage("com.stripe..", "javax.mail..", "com.geekmonks.orders.*");
What the tests look like now
The payoff is that the interesting logic is testable with objects you write by hand — no container, no mocking framework, no I/O:
class OrderProcessorTest {
private final RecordingNotifier notifier = new RecordingNotifier();
@Test
void chargesTotalPlusTax() {
StubGateway gateway = new StubGateway(PaymentResult.success("ch_1"));
new OrderProcessor(gateway, notifier).process(orderOf("100.00"));
assertEquals(0, new BigDecimal("118").compareTo(gateway.lastAmount()));
assertEquals(1, notifier.confirmations());
}
@Test
void doesNotNotifyWhenTheCardIsDeclined() {
StubGateway gateway = new StubGateway(PaymentResult.declined("card_declined"));
OrderProcessor processor = new OrderProcessor(gateway, notifier);
assertThrows(PaymentFailedException.class,
() -> processor.process(orderOf("100.00")));
assertEquals(0, notifier.confirmations());
}
}
StubGateway is about eight lines: it stores the amount it was handed and returns a canned PaymentResult. That second test — a declined charge must not send a confirmation email — was effectively unwritable before the refactor, and it is the kind of rule that quietly breaks in production.
Both tests run in milliseconds:
OrderProcessorTest > chargesTotalPlusTax() PASSED
OrderProcessorTest > doesNotNotifyWhenTheCardIsDeclined() PASSED
2 tests, 0 skipped -- 0.04s (no network, no SMTP, no keys)
Spring is one wiring option, not the principle
Everything above is plain Java. A DI container changes who runs the composition root, not the design. In Spring, the adapters get annotated and the policy’s constructor stays exactly as written:
@Component
class StripePaymentGateway implements PaymentGateway { /* as above */ }
@Component
class SmtpOrderNotifier implements OrderNotifier { /* as above */ }
@Service
class OrderProcessor {
private final PaymentGateway payments;
private final OrderNotifier notifier;
// single constructor -> Spring injects it, no @Autowired needed
OrderProcessor(PaymentGateway payments, OrderNotifier notifier) {
this.payments = payments;
this.notifier = notifier;
}
}
For third-party classes you cannot annotate — StripeClient itself — a @Bean method in a @Configuration class plays the role the main method did. That configuration class is the composition root; the container just calls it for you.
Notice what did not change: OrderProcessorTest still says new OrderProcessor(gateway, notifier). A class designed for DIP is testable with or without the container. That is the tell for whether the container is helping or hiding.
Two failure modes to name, because a container makes them easy:
@Serviceplusnew StripeClient(...)in the method body is still a DIP violation. The annotation does not inject anything you constructed yourself.@Autowiredon a private field gives you no constructor to call, so tests need reflection or a Spring context to build the object. Constructor injection keeps the dependency list readable and the fieldfinal.
Note: With two PaymentGateway beans on the classpath, Spring fails at startup with a no-unique-bean error until you add @Primary or @Qualifier. That is a wiring decision and it belongs in configuration — resolving it inside OrderProcessor with an if puts the provider choice back into the policy.
When DIP turns into ceremony
DIP is the letter most often over-applied, because “add an interface” always looks like progress:
- An interface per class.
OrderProcessorImplbehindOrderProcessor, with no second implementation and no test double asking for it, is two files where one would do. Abstract at the boundary you cross — payment, mail, storage — not at every layer inside your own code. - A factory to reach an injected object. If
OrderProcessorcallsGatewayFactory.get(), the dependency is not inverted, it is hidden. Static lookup is the same coupling with worse discoverability. - A service locator injected everywhere. Passing a container or a
Map<String, Object>of services means the constructor no longer tells you what the class needs. - A leaky abstraction.
PaymentGatewaywithsetStripeApiVersion(String)on it has one provider’s details in the shared type. Every other implementation now stubs a no-op — which is also an Interface Segregation problem. - Abstracting what will never vary. One mail provider, one country, one database, no pending change: an interface there is a lookup and a config key describing a constant.
The trigger stays the same as in Part 1: a real change is being blocked — a second provider, a channel swap, or a rule you cannot test. “I cannot unit-test this without a network” is by itself sufficient evidence, and it is the most common one.
Cheat sheet
Symptom `new` on infrastructure (SDK, SMTP, JDBC, HTTP client) inside a policy class
Cost unit tests need keys/network; provider welded in; provider vocabulary leaks up
Move take the collaborator as a constructor parameter, typed as an interface you own
Ownership interface lives in the policy's package; adapters depend on it, not the reverse
Wiring composition root -- main, a @Configuration class, or the DI container
Test hand-written stub/fake, no container, no static mocking
Not DIP injecting a concrete class; a factory or locator that hides `new`;
an interface per class with no second implementation
Do:
- Declare every collaborator in one constructor and keep the fields
final. - Name the interface after what the caller needs (
OrderNotifier), not the mechanism (SmtpSender). - Keep provider types (
StripeCharge,MimeMessage) inside the adapter and return your own types. - Push all
newon infrastructure to the composition root, and keep that root small and boring. - Enforce the import direction in the build if the policy package matters.
Don’t:
- Add a test-only setter to work around a constructor that builds its own dependencies.
- Inject a concrete class and call it dependency inversion.
- Reach for static or constructor mocking to test around a hard-coded
new. - Let the container become the design — annotations do not invert anything by themselves.
- Add an interface before there is a second implementation, a second provider, or a test that needs a double.
Wrap-up
Dependency Inversion is one mechanical move with a large payoff: stop constructing collaborators inside the class that uses them, take them as constructor parameters typed against interfaces you own, and let a composition root assemble the concretes. The new calls do not disappear — they collect in one file whose job is knowing details.
The evidence that it worked is not a diagram. It is that the tax rule and the “declined card sends no email” rule are now unit tests that run without a key, a network, or a mail server, and that a second provider is a new class plus one edited line in main. Spring, Guice, or Dagger can take over that wiring later; the design is already done by then.