DEV Community

Puneet Gupta
Puneet Gupta

Posted on • Originally published at pg-blogs.netlify.app

Designing for Change: Boundaries, Contracts, and Dependency Inversion in Java

Introduction

Most Java systems don't fail because a single class was written badly. They fail because the boundaries between classes were drawn in the wrong place — a database library leaks into business logic, a payment provider's SDK shows up in twelve unrelated files, and changing one third-party dependency means touching half the codebase.

Designing for change is not about predicting the future correctly. It's about isolating what varies so that when it inevitably does vary — a new payment provider, a swapped database, a different message broker — the blast radius is one adapter, not the whole system.

This post covers the practical mechanics: dependency inversion, ports-and-adapters, and making illegal states impossible to construct — plus where to stop.


Coupling, Cohesion, and the Cost of Guessing Wrong

Two forces pull against each other in every design:

  • Coupling — how much one module knows about another's internals. Low coupling means you can change a module without rippling changes elsewhere.
  • Cohesion — how tightly a module's responsibilities belong together. High cohesion means a class has one clear reason to change.

The classic mistake is coupling business logic directly to volatile, external things — HTTP clients, ORMs, cloud SDKs — because that's the fastest way to get something working:

// Tightly coupled: OrderService cannot exist without Stripe's SDK
public class OrderService {
    private final StripeClient stripeClient = new StripeClient(System.getenv("STRIPE_KEY"));

    public void checkout(Order order) {
        stripeClient.charges().create(order.totalCents(), "usd");
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

This compiles, ships, and works — until you need to add a second payment provider, or write a test without hitting the network, or the SDK does a breaking major-version bump. Every one of those becomes a rewrite of OrderService itself, not an isolated change.

The fix is not "add an abstraction for everything." It's to abstract specifically at the seams that are likely to vary: I/O, third-party services, storage. A pure computation with no external dependency (tax calculation, discount rules) usually doesn't need an interface at all — that would be an abstraction with no second implementation and no real caller-side benefit, just extra indirection to read through.


Dependency Inversion: Program to an Interface

The Dependency Inversion Principle says high-level policy (the order workflow) should not depend on low-level detail (a specific payment SDK). Both should depend on an abstraction owned by the high-level side:

// The core defines the contract it needs — no import of any SDK
public interface PaymentGateway {
    PaymentResult charge(Money amount, String customerReference);
}
Enter fullscreen mode Exit fullscreen mode
public record Money(long amountMinorUnits, String currencyCode) {
    public Money {
        if (amountMinorUnits < 0) {
            throw new IllegalArgumentException("amount cannot be negative: " + amountMinorUnits);
        }
        if (currencyCode == null || currencyCode.length() != 3) {
            throw new IllegalArgumentException("currencyCode must be a 3-letter ISO code: " + currencyCode);
        }
    }
}

public record PaymentResult(boolean approved, String providerReference) {}
Enter fullscreen mode Exit fullscreen mode

A concrete adapter implements the interface and is the only place that imports the third-party SDK:

public final class StripePaymentGateway implements PaymentGateway {
    private final StripeClient client;

    public StripePaymentGateway(StripeClient client) {
        this.client = client;
    }

    @Override
    public PaymentResult charge(Money amount, String customerReference) {
        var charge = client.charges().create(amount.amountMinorUnits(), amount.currencyCode());
        return new PaymentResult(charge.isSuccessful(), charge.id());
    }
}
Enter fullscreen mode Exit fullscreen mode

The order workflow now depends only on the interface, injected through its constructor:

public final class OrderService {
    private final PaymentGateway paymentGateway;

    // Constructor injection: the dependency is explicit, required, and immutable
    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public PaymentResult checkout(Order order) {
        var amount = new Money(order.totalMinorUnits(), order.currencyCode());
        return paymentGateway.charge(amount, order.customerId());
    }
}
Enter fullscreen mode Exit fullscreen mode

OrderService compiles and runs with zero knowledge of Stripe, a competitor's SDK, or a fake used in tests. Swapping providers means writing one new adapter class — OrderService never changes.

A framework like Spring can wire new StripePaymentGateway(...) into OrderService's constructor automatically via @Bean/@Autowired, but nothing above requires it — this is plain constructor injection, and you could wire it by hand in a main method just as validly. The design decision (depend on the interface) is independent of whether a DI container does the wiring.


Ports and Adapters: The Domain Core Stays Framework-Free

Zoom out from a single dependency and the same idea structures the whole application: a domain core in the center that defines ports (interfaces) for everything it needs from the outside world, and adapters at the edges that implement those ports against real infrastructure.

// Port — owned by the domain, expresses what the domain needs, not how storage works
public interface OrderRepository {
    Optional<Order> findById(String orderId);
    void save(Order order);
}
Enter fullscreen mode Exit fullscreen mode
// Domain core: depends only on the port, knows nothing about SQL or JDBC
public final class OrderProcessor {
    private final OrderRepository repository;
    private final PaymentGateway paymentGateway;

    public OrderProcessor(OrderRepository repository, PaymentGateway paymentGateway) {
        this.repository = repository;
        this.paymentGateway = paymentGateway;
    }

    public PaymentResult processPayment(String orderId) {
        var order = repository.findById(orderId)
            .orElseThrow(() -> new NoSuchElementException("no order with id " + orderId));
        var result = paymentGateway.charge(
            new Money(order.totalMinorUnits(), order.currencyCode()), order.customerId());
        if (result.approved()) {
            repository.save(order.markPaid());
        }
        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

An in-memory adapter makes the core trivially testable, with no database and no test containers:

public final class InMemoryOrderRepository implements OrderRepository {
    private final Map<String, Order> store = new HashMap<>();

    @Override
    public Optional<Order> findById(String orderId) {
        return Optional.ofNullable(store.get(orderId));
    }

    @Override
    public void save(Order order) {
        store.put(order.orderId(), order);
    }
}
Enter fullscreen mode Exit fullscreen mode
@Test
void processPayment_marksOrderPaid_whenChargeApproved() {
    var repository = new InMemoryOrderRepository();
    repository.save(new Order("ord-1", "cust-1", 1999, "USD", false));
    var processor = new OrderProcessor(repository, (amount, ref) -> new PaymentResult(true, "px_1"));

    var result = processor.processPayment("ord-1");

    assertTrue(result.approved());
    assertTrue(repository.findById("ord-1").orElseThrow().paid());
}
Enter fullscreen mode Exit fullscreen mode

Notice the test passes a lambda as the PaymentGateway — since it's a single-method interface, no mocking framework is required for this seam. A production adapter (JdbcOrderRepository, backed by a real DataSource) implements the same OrderRepository port and is wired in only at the application's composition root (main, or a Spring @Configuration class) — never referenced by name inside OrderProcessor.

        ┌─────────────────────────────┐
        │        Domain Core          │
        │   OrderProcessor, Order,    │
        │   Money  (no I/O imports)   │
        └───────────┬─────────────────┘
                     │ depends on (ports)
        ┌────────────┴─────────────┐
        │ OrderRepository           │  PaymentGateway
        └───┬───────────────┬──────┘
            │               │
   InMemoryOrderRepository  StripePaymentGateway   (adapters, at the edges)
   JdbcOrderRepository
Enter fullscreen mode Exit fullscreen mode

Make Invalid States Unrepresentable

The Money record above already does this: it is impossible to construct a Money with a negative amount or a malformed currency code, because the compact constructor validates on every construction path — there is no way to get an instance into an invalid state later. Compare that to a class with a public setter for amount that some far-away code path forgets to validate.

Sealed hierarchies extend the same idea to state transitions. An order shouldn't be payable twice, and a cancelled order shouldn't be payable at all — encode that in the type instead of a boolean flag and scattered if checks:

public sealed interface OrderStatus permits Pending, Paid, Cancelled {}
public record Pending() implements OrderStatus {}
public record Paid(String providerReference) implements OrderStatus {}
public record Cancelled(String reason) implements OrderStatus {}

public PaymentResult processPayment(Order order, PaymentGateway gateway) {
    return switch (order.status()) {
        case Paid p -> throw new IllegalStateException("order already paid: " + p.providerReference());
        case Cancelled c -> throw new IllegalStateException("order cancelled: " + c.reason());
        case Pending p -> gateway.charge(order.amount(), order.customerId());
    };
}
Enter fullscreen mode Exit fullscreen mode

The switch over a sealed interface is exhaustive — the compiler rejects the code if a fourth OrderStatus variant is ever added and this method isn't updated. A boolean isPaid field offers no such guarantee; it's easy to leave a call site unhandled.


When Not to Do This

Every interface, every port, every extra layer has a cost: more files to navigate, more indirection to trace through when reading the code, and a real risk of premature abstraction — building a seam for a second implementation that never arrives.

Don't introduce a PaymentGateway-style interface for:

  • A single, stable, in-house utility with no plausible second implementation and no test-isolation need.
  • Pure logic (tax rules, string formatting) that has no I/O and nothing to swap.
  • A prototype or spike where the entire point is to learn something before committing to a design.

The signal that a seam is earning its keep is concrete: you have (or clearly will have) a second implementation, or the boundary is exactly the kind of volatile I/O this post is about (a paid third-party service, a datastore, a queue). Abstracting "in case we need it later" without either of those is speculative generality — it adds a layer of indirection for a change that may never come, and makes the one implementation you do have harder to read for no offsetting benefit.


Practical Takeaways

Practice Why it matters
Depend on interfaces at I/O boundaries, not concrete SDKs Swapping a provider touches one adapter, not the core
Inject dependencies through the constructor Dependencies are explicit, required, and easy to fake in tests
Keep the domain core free of framework/SDK imports The core stays portable and fast to test
Give every port an in-memory adapter for tests Unit tests run with no network, no database
Validate in constructors (records, compact constructors) Invalid objects simply cannot exist
Model state transitions with sealed types, not booleans The compiler enforces exhaustive handling
Don't abstract a seam with no second implementation Premature abstraction costs more than it saves

Designing for change is a bet on where things will vary, not a guarantee against all future work. Spend the abstraction budget at the boundaries most likely to move — third-party services, storage, transport — and leave the stable, purely internal logic simple and direct.

Top comments (0)