Skip to content

VOL 06 / CH 07 / LESSON 01

7.1 Understanding SRP, OCP, and DIP Through Change ​

Design principles help teams ask concrete questions when requirements evolve: which pieces of code will change together, where decisions should be isolated, and whether business rules are being driven by infrastructure.

Observe Cohesion and Coupling ​

Cohesion describes whether the elements within a module are centered around a single purpose; coupling describes how much knowledge and changes are propagated to other modules when one module changes.

The goal is not to achieve zero coupling (collaboration inherently introduces dependencies) but rather to make dependencies explicit, stable, and to bring code that changes for the same reason together, while separating code that changes for different reasons.

For example, consider reward rules in a competition. Operations staff adjust reward logic, finance personnel modify accounting and audit rules, and the platform team switches databases. If all these decisions are bundled into a single PrizeCalculator, any change by one role forces the entire team to re-understand the module’s structure.

SRP: Organize Modules Around a Single Source of Change ​

The Single Responsibility Principle is often summarized as "one reason for change." A more practical interpretation is that a module should serve a single, consistent role or concern, not make decisions for multiple, independent sources of change.

The Java 21 examples below use non-cash reward points. Save each public type in its own same-named .java file. The policy/service exercise is complete after assembling Reward, MatchResult, RewardTable, RewardPolicy, the two policies, RewardLedger, and RewardService. A real PostgreSQL adapter is outside this example. Start with the value types:

java
public record Reward(long points) {
    public Reward {
        if (points < 0) throw new IllegalArgumentException("points < 0");
    }
    public Reward multiply(int factor) {
        if (factor < 1) throw new IllegalArgumentException("factor < 1");
        return new Reward(Math.multiplyExact(points, factor));
    }
}

public record MatchResult(int rank) {
    public MatchResult {
        if (rank < 1) throw new IllegalArgumentException("rank < 1");
    }
}

public interface RewardTable {
    Reward rewardFor(int rank);
}
java
public final class RewardService {
    private final RewardPolicy policy;
    private final RewardLedger ledger;

    public RewardService(RewardPolicy policy, RewardLedger ledger) {
        this.policy = java.util.Objects.requireNonNull(policy, "policy");
        this.ledger = java.util.Objects.requireNonNull(ledger, "ledger");
    }

    public Reward grant(MatchResult result) {
        Reward reward = java.util.Objects.requireNonNull(
                policy.calculate(java.util.Objects.requireNonNull(result, "result")), "reward");
        ledger.record(reward);
        return reward;
    }
}

Here are three distinct concerns:

  • RewardService orchestrates a reward-granting use case;
  • RewardPolicy expresses tournament reward rules;
  • RewardLedger defines the recordkeeping port.

SRP does not mean "one method per class." If a group of methods maintains the same invariant and is driven by the same set of requirements, they are often more cohesive when grouped together in the same module. Turning each private function into its own class only increases navigation overhead.

To determine whether a split is warranted, examine the actual change history: do the two parts originate from different stakeholders, release at different paces, or require distinct testing or security reviews? If the answer to all these questions is no, then premature splitting may offer no real benefit.

OCP: Establishing Stable Boundaries Along Known Axes of Change ​

The Open–Closed Principle is often stated as "open for extension, closed for modification." It does not mean that no lines of existing code should be altered when adding new functionality. Configuration, wiring, and tests must account for new implementations. The key is to protect validated core strategies, allowing new changes to enter through well-defined extension points.

java
public interface RewardPolicy {
    Reward calculate(MatchResult result);
}

public final class RankedRewardPolicy implements RewardPolicy {
    private final RewardTable table;

    public RankedRewardPolicy(RewardTable table) {
        this.table = java.util.Objects.requireNonNull(table, "table");
    }

    @Override
    public Reward calculate(MatchResult result) {
        return java.util.Objects.requireNonNull(
                table.rewardFor(java.util.Objects.requireNonNull(result, "result").rank()), "reward");
    }
}

public final class HolidayRewardPolicy implements RewardPolicy {
    private final RewardPolicy base;
    private final int multiplier;

    public HolidayRewardPolicy(RewardPolicy base, int multiplier) {
        if (multiplier < 1) throw new IllegalArgumentException("multiplier < 1");
        this.base = java.util.Objects.requireNonNull(base, "base");
        this.multiplier = multiplier;
    }

    @Override
    public Reward calculate(MatchResult result) {
        return base.calculate(result).multiply(multiplier);
    }
}

"A reward rule that varies by event type" is a clear axis of change, making a strategy interface both meaningful and practical. New holiday rules can combine existing strategies without requiring modifications to the core accounting logic.

However, if the system currently has only a single fixed formula and no alternative rules or foreseeable changes, a straightforward function may be clearer. Reserving arbitrary extension points for every piece of code can turn current complexity into imagined future convenience.

Extension Points Must Include a Complete Contract ​

An interface signature specifies types but leaves important semantics unstated. RewardPolicy must also define:

  • Whether negative values or empty rewards are allowed;
  • Whether the computation must be deterministic;
  • Whether it reads the current time or external state;
  • How overflow, rounding, and currency are handled;
  • Whether the order of strategy execution affects the outcome.

For this exercise, valid results have rank >= 1; policies are deterministic and return a non-null Reward with points >= 0. Multipliers are positive integers, and overflow raises ArithmeticException before the ledger call. There is no currency conversion or rounding because these are integral non-cash points. A table that returns null violates the contract; requireNonNull detects that violation. A base reward of 100 with multiplier 2 yields 200, while a base value of Long.MAX_VALUE with multiplier 2 must fail without recording a reward.

If the implementation must secretly know the caller’s execution order, the extension point merely distributes if across multiple classes, without actually eliminating the core risk.

DIP: Let Source Code Dependencies Point to Strategies ​

The Dependency Inversion Principle focuses on the direction of source code dependencies. High-level business strategies should not depend on specific implementations like database drivers, HTTP SDKs, or message clients. Instead, the capabilities required by the business are expressed through stable ports. Infrastructure components implement those ports.

text
domain/application
  RewardService -> RewardPolicy
                -> RewardLedger
                         ^
                         |
infrastructure  PostgresRewardLedger
java
public interface RewardLedger {
    void record(Reward reward);
}

PostgresRewardLedger in the diagram must implement persistence and transaction participation before it can satisfy RewardLedger. An empty record body would silently discard rewards. Source dependencies point toward the port even though the runtime call travels from RewardService to the adapter; wiring does not reverse every runtime arrow.

Interfaces should typically reside in the location that best expresses their contract. For persistence capabilities required by use cases, it's natural for the application or domain layer to define the port. However, general abstractions provided by third-party libraries may not belong in the domain layer. The key point is that business logic should not be shaped or constrained by the changeable implementation details.

Dependency Injection Is Just a Wiring Mechanism ​

Constructor injection makes dependencies explicit, replaceable, and testable, yet using a DI framework doesn't automatically mean adhering to the Dependency Inversion Principle (DIP). If business classes still directly depend on JpaRepository, vendor-specific DTOs, or a global service locator, the direction of dependency remains unchanged.

Assembly happens at the edge. This complete method-body exercise uses a List-backed ledger Fake and can run in a main method after compiling the types above:

java
RewardTable rewardTable = rank -> new Reward(rank == 1 ? 100 : 20);
RewardPolicy policy = new HolidayRewardPolicy(
        new RankedRewardPolicy(rewardTable), 2);
var recorded = new java.util.ArrayList<Reward>();
RewardLedger ledger = recorded::add;
RewardService service = new RewardService(policy, ledger);
System.out.println(service.grant(new MatchResult(1)).points()); // 200
System.out.println(recorded.size()); // 1

Core objects only see contracts, and the entry layer decides which concrete implementations to use. This Fake is thread-confined and not durable. Repeating grant records again; SRP, OCP, and DIP do not provide idempotency or transaction atomicity. Real reward granting needs a business operation identity and a ledger contract that states retry and commit behavior.

Don't Assign an Interface to Every Class ​

It's typically unnecessary to add an interface in these situations:

  • Pure value objects and immutable data structures;
  • Stable, local implementations with a single, clear semantics;
  • Abstraction that doesn't reduce the information callers need to know;
  • The sole justification being "maybe we'll have a second implementation later."

The cost of abstraction includes naming, navigation, assembly, testing stubs, and maintaining contracts. It's often more reliable to first expose real changes at the boundaries, then extract stable interfaces later, rather than guessing at design upfront.

How the Three Principles Work Together ​

text
SRP: Identify distinct sources of change and draw clear boundaries of responsibility
OCP: Provide controlled extension along frequently occurring, known axes of change
DIP: Let specific technical dependencies rely on these stable strategies, not the other way around

The next section verifies whether these abstractions are truly interchangeable, whether interfaces align with caller expectations, and addresses inheritance, composition, and navigation within object graphs.

References ​

Built with VitePress | Software Systems Atlas