7.2 Substitutability, Interface Segregation, 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.
public interface PaymentGateway {
/**
* Within the documented account/operation scope and retention window,
* equal keys and arguments return the same result with at most one debit.
*/
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.
These are project interface fragments: Money, PaymentMethod, and PaymentResult must come from the payment model; they are not the earlier Money exercise unchanged. Define amount equality, tenant/merchant scope, key retention, behavior after expiry, and concurrent requests. This teaching contract rejects reuse of a retained key with different arguments via IllegalArgumentException and performs no further debit. Provider adapters must map their API semantics to that contract; copying a key into an HTTP header alone is insufficient.
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.
An operation may legitimately be optional if the parent contract says so. Java Collection, for example, documents optional mutations that may throw UnsupportedOperationException. LSP is violated when a subtype withdraws a guarantee the parent actually made, not whenever that exception appears.
Testing Implementation Families with Shared Contracts
interface PaymentGatewayContract {
PaymentGateway gateway();
PaymentMethod testMethod();
String freshKey();
void assertSingleDebit(String key, Money amount);
@org.junit.jupiter.api.Test
default void repeatedKeyDoesNotChargeTwice() {
PaymentGateway gateway = gateway();
Money amount = Money.usd("20.00");
PaymentMethod method = testMethod();
String key = freshKey();
PaymentResult first = gateway.charge(key, amount, method);
PaymentResult second = gateway.charge(key, amount, method);
org.junit.jupiter.api.Assertions.assertEquals(first, second);
assertSingleDebit(key, amount);
}
@org.junit.jupiter.api.Test
default void repeatedKeyWithDifferentAmountIsRejected() {
PaymentGateway gateway = gateway();
Money amount = Money.usd("20.00");
PaymentMethod method = testMethod();
String key = freshKey();
gateway.charge(key, amount, method);
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
() -> gateway.charge(key, Money.usd("21.00"), method));
assertSingleDebit(key, amount);
}
}An in-memory Fake and provider sandbox adapters can reuse this contract. While the test cannot formally prove all LSP requirements, it effectively transforms key semantics from comments into continuously verified behavior.
JUnit 5 discovers default test methods only through a concrete class implementing this interface. Its gateway must use isolated sandbox state, freshKey must produce a new key for each case, and assertSingleDebit must inspect the same gateway ledger or provider sandbox rather than echo the returned result. The tests reuse one PaymentMethod instance and cover sequential replay plus changed-amount rejection. They do not establish safety across concurrent requests, crashes, key expiry, or real-money settlement; test those boundaries separately.
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.
These Java interface declarations require the project-specific IDs, views, paging types, and commands shown in their signatures; save public interfaces separately and import java.util.List for TournamentAudit.
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:
This is an alternative RewardService class declaration to 7.1, with a project-defined EligibilityPolicy. Its constructor demonstrates composition; it does not implement reward granting.
public final class RewardService {
private final RewardPolicy rewardPolicy;
private final EligibilityPolicy eligibilityPolicy;
private final RewardLedger ledger;
public RewardService(RewardPolicy rewardPolicy,
EligibilityPolicy eligibilityPolicy, RewardLedger ledger) {
this.rewardPolicy = java.util.Objects.requireNonNull(rewardPolicy, "rewardPolicy");
this.eligibilityPolicy = java.util.Objects.requireNonNull(eligibilityPolicy, "eligibilityPolicy");
this.ledger = java.util.Objects.requireNonNull(ledger, "ledger");
}
}RewardService has separate reward and eligibility strategies that 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:
player.getAccount().getWallet().getCurrency().getCode();The caller now knows the structure of Player, Account, Wallet, and Currency. A change in an intermediate relationship can propagate outward. Instead, the actual business logic should be delegated to the object that possesses the relevant knowledge:
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.
Encapsulation Protects Decisions as Well as Fields
import java.util.Objects;
public final class Registration {
public enum Status { PENDING, CONFIRMED }
public record PaymentReceipt(String registrationId) {
public PaymentReceipt {
Objects.requireNonNull(registrationId, "registrationId");
}
}
private final String id;
private Status status = Status.PENDING;
public Registration(String id) {
this.id = Objects.requireNonNull(id, "id");
}
public String id() { return id; }
public Status status() { return status; }
public void confirm(PaymentReceipt receipt) {
if (status != Status.PENDING) {
throw new IllegalStateException("registration is not pending");
}
Objects.requireNonNull(receipt, "receipt");
if (!receipt.registrationId().equals(id)) {
throw new IllegalArgumentException("receipt belongs to another registration");
}
status = Status.CONFIRMED;
}
}Save this complete Java 21 example as Registration.java. A wrong registration ID or null receipt leaves a pending object unchanged; a valid receipt confirms it, and a second confirmation is rejected by this local contract. PaymentReceipt here is only a shape for the transition example. Its issuer, payment success, amount, and currency must already have been validated at a trusted boundary. The object is thread-confined; persistence-level concurrency and idempotent callbacks require additional coordination.
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
- Which roles will drive changes to this code?
- Which change axis warrants a stable extension point?
- Has business strategy introduced specific technical details?
- Does each implementation adhere to the same failure, idempotency, and state contract?
- Do callers depend on operations they don’t need?
- Is inheritance expressing substitutable types, or is it merely for code reuse?
- Does the call chain leak internal object graphs?
- Does the new abstraction resolve known risks, or does it merely increase file count?