Skip to content

7.2 Replaceable Contracts, Interface Isolation, and Composition

After replacing an old implementation with a new one, the compiler may not report any errors, but the behavior of the calling code can still change. You must clearly document the contract behind the abstraction.

Having an interface does not guarantee safe replacement of its implementation. Callers depend on method names, preconditions, postconditions, failure semantics, and state evolution. LSP and ISP help verify that abstractions behave honestly; composition and the principle of least knowledge limit structural exposure between objects.

LSP: Subtypes Must Preserve the Properties That Callers Can Rely On

The core of the Liskov Substitution Principle is behavioral subtyping: if a caller only knows about a parent type or interface, then replacing an object with any of its subtypes must not invalidate any previously sound reasoning about correctness.

Suppose a payment port promises that repeated deductions with the same idempotency key will not result in a second financial change.

java
public interface PaymentGateway {
    /**
     * For the same idempotencyKey Returns the same business result on repeated calls
     * And at most one fund deduction occurs.
     */
    PaymentResult charge(
            String idempotencyKey,
            Money amount,
            PaymentMethod method);
}

Even if a subtype passes type checking, it cannot safely replace another implementation if it ignores the idempotency key, this breaks the caller's assumption about safe retries.

Common checks include:

  • Subtypes must not impose stricter preconditions than the parent;
  • Subtypes must not provide weaker postconditions than the parent;
  • The parent's invariants must be preserved;
  • Observable properties such as failure types, idempotency, ordering, and state history must not be unexpectedly altered.

The pattern "all implementations throw UnsupportedOperationException for unsupported parameters" typically indicates that the interface's promise is too broad, or that these implementations do not belong to the same replaceable type family.

Testing Implementation Families with Shared Contracts

java
interface PaymentGatewayContract {
    PaymentGateway gateway();

    @org.junit.jupiter.api.Test
    default void repeatedKeyDoesNotChargeTwice() {
        PaymentGateway gateway = gateway();
        Money amount = Money.usd("20.00");

        PaymentResult first = gateway.charge("order-7", amount, testMethod());
        PaymentResult second = gateway.charge("order-7", amount, testMethod());

        org.junit.jupiter.api.Assertions.assertEquals(first, second);
        assertSingleDebit("order-7", amount);
    }

    PaymentMethod testMethod();
    void assertSingleDebit(String key, Money amount);
}

Memory mocks, sandbox adapters, and real provider tests can all reuse the same contract. While the test cannot formally prove all LSP requirements, it effectively transforms key semantics from comments into continuously verified behavior.

ISP: Split Interfaces Based on Caller Needs

The Interface Segregation Principle is not about having fewer methods in interfaces. Instead, it means that a caller should not be forced to depend on capabilities they don’t need or can’t reasonably implement.

java
public interface TournamentQueries {
    TournamentView find(TournamentId id);
    Page<TournamentView> search(TournamentFilter filter, PageRequest page);
}

public interface TournamentCommands {
    TournamentId create(CreateTournament command);
    void close(TournamentId id);
}

public interface TournamentAudit {
    List<AuditEntry> history(TournamentId id);
}

A query client doesn’t need to depend on administrative commands; a regular repository adapter doesn’t need to implement audit export just because it’s part of a large interface. Interface boundaries should align with the roles of clients and their consistent contracts, rather than splitting each method into a separate file alone.

Overly fine-grained splitting can scatter related concepts across many shallow interfaces, forcing the caller to manage complex compositions. If a set of operations is consistently used together by the same client and maintains shared invariants, keeping them in a single, deeper interface is often clearer and more maintainable.

Composition First, to Enable Independent Replacement of Behavior

When inheritance is used for both code reuse and type relationships, it tends to bind these two goals together. Composition, on the other hand, allows objects to choose collaborators at runtime or during assembly:

java
public final class RewardService {
    private final RewardPolicy rewardPolicy;
    private final EligibilityPolicy eligibilityPolicy;
    private final RewardLedger ledger;

    // constructor omitted
}

RewardService Having separate reward and eligibility strategies, these behaviors can be tested and replaced independently. It avoids the "combination explosion" that arises from inheriting HolidayRewardService, RankedRewardService, and HolidayRankedRewardService.

Composition also carries costs: more objects, more assembly overhead, and indirect method calls. If the inheritance relationship genuinely expresses a stable, replaceable type, and if the parent class contract is clear and extension points are well-controlled, inheritance remains a valid choice. The key question isn't whether the relationship is syntactically "is-a," but whether the subclass fully adheres to the behavioral contract.

Separate Implementation from Interface Inheritance

Implementing an interface means "I fulfill this contract"; inheriting from a concrete base class adds reusability of its state and template workflows. The latter introduces stronger coupling: a child class may become dependent on the parent's protected APIs, method invocation order, or overridable methods.

When designing extensible base classes, clearly define:

  • Which methods are allowed to be overridden and which are not;
  • Whether overridable methods are called during construction;
  • The sequence and error semantics of template methods;
  • Which invariants the subclass must maintain;
  • Binary and source code compatibility commitments.

If these details cannot be clearly articulated, prefer final classes with explicit collaborators.

The Principle of Least Knowledge Does Not Prohibit Chained Calls

Dangerous navigation often exposes the internal object graph structure to callers:

java
player.getAccount().getWallet().getCurrency().getCode();

The caller now knows the structure of Player, Account, Wallet, and Currency, any change in intermediate relationships propagates outward. Instead, the actual business logic should be delegated to the object that possesses the relevant knowledge:

java
player.canReceive(reward);

But builder.withName("arena").withRegion("ap").build() does not necessarily violate the principle of least knowledge; if the methods in the chain continuously operate on the same fluent abstraction and do not penetrate deeply into unfamiliar object graphs, the coupling characteristics are fundamentally different.

Avoid creating numerous forwarding methods just to eliminate the dot notation. The goal is to place business decisions at the boundaries where data and invariants reside, not to pursue the shortest possible call chain.

Packaging Encodes Decisions, Not Fields

java
public final class Registration {
    private Status status;

    public void confirm(PaymentReceipt receipt) {
        if (status != Status.PENDING) {
            throw new IllegalStateException("registration is not pending");
        }
        if (!receipt.registrationId().equals(id())) {
            throw new IllegalArgumentException("receipt belongs to another registration");
        }
        status = Status.CONFIRMED;
    }
}

Setting a field to private and generating arbitrary setters only hides the memory layout. To truly encapsulate domain decisions, the object must enforce valid state transitions.

When Principles Conflict, Return to Change and Risk

SOLID principles can create tension:

  • Adding strategy interfaces to satisfy OCP increases the cost of understanding the current codebase;
  • Splitting interfaces to meet ISP may introduce complications in assembly and consistency across interfaces;
  • Introducing ports to satisfy DIP may offer no benefit for a small, stable script;
  • Splitting functionality into modules to meet SRP can actually make cross-module transactions and collaboration more complex.

The decision should be grounded in actual change frequency, failure cost, team boundaries, and test feedback. Start with the simplest and clearest implementation, and refactor boundaries only when evidence of change emerges. This approach is more reliable than treating each of the five principles as a static code-checking rule.

Design Review Questions

  1. Which roles will drive changes to this code?
  2. Which change axis warrants a stable extension point?
  3. Has business strategy introduced specific technical details?
  4. Does each implementation adhere to the same failure, idempotency, and state contract?
  5. Do callers depend on operations they don’t need?
  6. Is inheritance expressing substitutable types, or is it merely for code reuse?
  7. Does the call chain leak internal object graphs?
  8. Does the new abstraction resolve known risks, or does it merely increase file count?

References

Built with VitePress | Software Systems Atlas