Hard lesson 04

Strategy Pattern & Dependency Inversion

An interface, two implementations, constructor injection, and still no Strategy and no inversion. These two ideas are related in a precise way that most write-ups blur, and the blur is expensive: it produces codebases full of interfaces that cost maintenance and buy nothing. This page draws the line sharply enough to settle an argument in a code review.

Level Intermediate → Advanced Stack Java 17 · Spring Verified against production code

The claim under test

"We use the Strategy pattern here." It is one of the most frequently made and least frequently earned claims in enterprise Java, and unlike most architectural disputes it is decidable in about forty seconds. You do not need to agree on taste. You need to look at two things.

javaThe two tests, side by side
// Test 1 fails: the field names a concrete class.
private final RayCastingCollisionAlgorithm rayCasting;

// Test 1 passes: the field names the abstraction. This class cannot know,
// and has no way to ask, which implementation it was handed.
private final CollisionAlgorithm collisionAlgorithm;

// Test 2 fails: the Context is branching on the runtime type of its input.
if (area instanceof Polygon p) { return rayCasting.contains(p, position); }

// Test 2 passes: one call, dispatched by the JVM rather than by this class.
return collisionAlgorithm.contains(area, position);

Both tests are about where knowledge lives. In a real Strategy, the knowledge of which algorithm applies lives outside the Context: in the caller, in a factory, in configuration, in the wiring. Inside the Context there is one call and no opinion.

GoF Strategy, structurally

Strategy comes from the Gang of Four, or GoF: the four authors of Design Patterns, published in 1994. Their definition is short enough to hold in one hand, and every clause of it is load-bearing.

Strategy is a behavioral design pattern that lets you define a family of algorithms, put each of them into separate classes, and make their objects interchangeable.

Note the three obligations packed into one sentence: a family (so, more than one), separate classes (so, not branches in a method), and interchangeable objects (so, a common supertype the user of them can hold). The same page is explicit about the Context's field and about who does the choosing:

The original class, called context, must have a field for storing a reference to one of the strategies.

… The context isn't responsible for selecting an appropriate algorithm for the job. Instead, the client passes the desired strategy to the context.

Wikipedia states the consequence of that field being typed as the interface, which is the whole reason the pattern is worth the classes it costs:

Context refers to the Strategy interface for performing an algorithm (strategy.algorithm()), which makes Context independent of how an algorithm is implemented.

Wikipedia: Strategy pattern en.wikipedia.org/wiki/Strategy_pattern
Context − strategy: Strategy holds the interface: never a concrete type, never an instanceof test strategy «interface» Strategy + execute(Input): Output ConcreteStrategyA + execute(Input) ConcreteStrategyB + execute(Input)
Solid open arrow = association: the Context has a Strategy, typed as the interface. Dashed line with a hollow triangle = realisation: each concrete class implements it. Remove either concrete class and the Context still compiles; add a third and the Context still does not change. That last property is the pattern.
The four requirements, none optional

(1) A Strategy interface. (2) More than one ConcreteStrategy: a family of one is not a family. (3) A Context whose field is typed as the interface. (4) Selection performed outside the Context, so that adding a strategy never edits it. Miss (3) and you have coupled code with extra files. Miss (4) and you have conditional dispatch.

Dependency Inversion: what "inversion" actually means

Robert Martin's principle is usually quoted in its first half and forgotten in its second. Both halves matter, and it is the second one that makes it a principle rather than a style preference:

High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).

Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

Wikipedia: Dependency inversion principle en.wikipedia.org/wiki/Dependency_inversion_principle

Read literally, that is satisfiable by any interface anywhere. It is not. The word being inverted names something specific, and the same article says exactly what:

…the conventional dependency relationships established from high-level, policy-setting modules to low-level, dependency modules are reversed…

In a direct application of dependency inversion, the abstracts are owned by the higher/policy layers. This architecture groups the higher/policy components and the abstractions that define lower services together in the same package.

Wikipedia: Dependency inversion principle en.wikipedia.org/wiki/Dependency_inversion_principle

The abstraction is owned by the consumer. That is the sentence almost every tutorial skips, and without it the Dependency Inversion Principle (DIP) degenerates into "use interfaces". The interface is not a neutral object floating between two modules. It is a statement of what the high-level module requires, written in the high-level module's vocabulary, and it belongs in the high-level module's package. The implementation then reaches upward to satisfy it. Martin Fowler catalogued the mechanic separately:

Defines an interface in a separate package from its implementation.

… This way a client that needs the dependency to the interface can be completely unaware of the implementation.

Martin Fowler, Separated Interface (P of EAA) martinfowler.com/eaaCatalog/separatedInterface.html
Before · the dependency points down geofencing GeofencingEvaluationService depends on geofencing.algorithm RayCastingCollisionAlgorithm a concrete class, named directly No abstraction The policy module names a concrete class, so it cannot be reused, retargeted or tested without dragging the algorithm along. After · the abstraction moves into the high-level package geofencing · owns the abstraction GeofencingEvaluationService uses «interface» CollisionAlgorithm depends on geofencing.algorithm RayCasting CollisionAlgorithm HaversineDistance CollisionAlgorithm Ownership inverted The interface lives in the package that consumes it. The algorithm package now depends upward, on terms the high-level module chose.
Both arrows carry the same label; only the direction changed. What moved is not the code but the package boundary. In the lower half the interface sits inside the high-level package, so the compile-time dependency now crosses that boundary upward. Draw the same picture with the interface left in geofencing.algorithm and every arrow still points down: that is indirection, not inversion, and it is the diagram most codebases are actually shaped like.
Strategy is DIP with a job to do

The two are not siblings. Strategy is a concrete application of DIP: the Context is the high-level module, the Strategy interface is the abstraction it owns, and each ConcreteStrategy is a detail depending on it. DIP is the general rule about the direction of source-code dependencies; Strategy is what it looks like when the thing being inverted is an algorithm and you want to swap it.

DI is not DIP, and neither is IoC

Three terms, routinely used as synonyms, describing three different things: dependency injection (DI), the Dependency Inversion Principle (DIP), and inversion of control (IoC). Fowler had to coin one of them precisely because the existing name was too broad:

As a result I think we need a more specific name for this pattern. Inversion of Control is too generic a term, and thus people find it confusing. As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection.

TermWhat it governsSatisfied byCan be present without the others?
IoC

IoC

Inversion of control. The framework decides when your code runs and calls it, instead of your code driving the sequence and calling the framework.

Who calls whom: the framework calls you Any framework lifecycle, template method, callback Yes
DI

DI

Dependency injection. An object is handed the collaborators it needs, usually as constructor parameters, instead of creating them itself.

How an object receives its collaborators A constructor parameter, container optional Yes, and usually is
DIP

DIP

The Dependency Inversion Principle. Source dependencies point towards an abstraction that the consumer owns, so the high-level code does not depend on low-level detail.

Which direction source dependencies point An abstraction owned by the consumer Yes, new satisfies it fine

The row that surprises people is the last one. A hand-written class that takes an interface in its constructor and is instantiated with new obeys DIP completely and uses no container at all. Conversely, and this is the shape of the production defect further down, a class can be a perfectly wired Spring @Service using textbook constructor injection and still violate DIP on every line, because what it is injected with are concrete types.

The "cosmetic DIP" smell

Here is the pattern to learn to see: an interface PaymentService, one implementation PaymentServiceImpl, both in package …payment, the interface existing because a mocking library was easier to satisfy that way. Every DIP box is ticked and nothing has been inverted. The consumer still depends on a type shipped by the implementer; move the implementation and the interface moves with it; add a second implementation and you discover the interface was shaped around the first one's internals.

QuestionEarns its placeNoise
Which package holds the interface? The consumer's The implementation's
Who chose its method names? The caller's vocabulary Mirrors the impl, method for method
What does the second implementation look like? Named and plausible: a test double at a real boundary counts Cannot be described
What breaks if you delete it? A boundary the domain must not cross One import line
Still fine with one implementation

An interface at a true boundary does DIP even if production ships one impl, because the second implementation is the test double and it is a real, permanent one:

  • Clock: time is an external service
  • Filesystem, object storage, outbound HTTP
  • Message broker, mail gateway, payment provider
  • Anything whose real version is slow, non-deterministic, or costs money
Noise everywhere else

OrderServiceImpl beside OrderService, in the same package, with no boundary between them. Mockito can mock the concrete class; the interface buys nothing and costs a second file, a second name and a permanent indirection in every jump-to-definition.

The tell is that you cannot name the second implementation, and neither can anyone else on the team.

The "one implementation" test is about naming, not counting

A single implementation is not automatically a smell, and this matters for the evidence below. LinearSplitStrategy has no sibling in the codebase, yet it is not cosmetic: linear names one member of a documented family of R-Tree node-splitting algorithms, with quadratic and R*-tree splits as the others, and the interface is owned by the tree that consumes it. PaymentServiceImpl names nothing; the suffix is an admission that no second member was ever conceived. Ask what the name distinguishes. If it distinguishes nothing, the interface is decoration.

What this looked like in production

Verified in one service, written start to finish by the author

A geofencing service I wrote start to finish contained, roughly two hundred lines apart, a clean use of the pattern and a broken one. They are worth studying together, because the pair is the actual lesson. Both were mine. The defect described below has since been repaired, using the implementation shown further down this page.

The clean instance. A hand-written R-Tree spatial index holds its node-splitting policy behind an interface:

  • The field is private final SplitStrategy splitStrategy: the interface, never the implementation.
  • It is constructor-injected, with a null guard, by plain Java. No container is involved, which is a neat demonstration that DIP is a source-dependency property, not a framework feature.
  • LinearSplitStrategy is the concrete implementation, and the tree contains no type test anywhere: no instanceof, no getClass(), no branch on strategy identity.

This is the shape to copy. Its one honest limitation, stated rather than hidden: only one concrete strategy currently exists, so interchangeability here is structural rather than exercised. The abstraction would survive a second implementation. It has simply never had to.

The broken sibling, in the same service. An interface CollisionAlgorithm exists. RayCastingCollisionAlgorithm implements CollisionAlgorithm. But its sibling HaversineDistanceCollisionAlgorithm is a @Component that carries no implements clause at all. The abstraction was written, one class was attached to it, and the second was never connected.

Everything downstream follows mechanically. The context class GeofencingEvaluationService cannot declare List<CollisionAlgorithm>, because the two classes share no supertype. It therefore injects both concrete types side by side and selects between them with a branch. That is a broken Strategy and a DIP violation in the same constructor, sitting directly beside the abstraction that would have fixed both.

The instructive part is not the defect but its neighbour. I got it exactly right in one file and left it unfinished two hundred lines away, in the same service. I was learning dependency inversion while writing the collision code, and I did not yet know the rule the first half of this page states. That is how these defects actually occur. Not through ignorance of the pattern, but through an abstraction that is started and not carried through, at the point where the second implementation arrives under time pressure and implements CollisionAlgorithm is one keystroke that nothing forces anyone to type. Nothing forced it here, so it was not typed, and the compiler had no opinion. The fix was one clause and one deleted branch.

…/index/RTree.java …/index/SplitStrategy.java …/index/LinearSplitStrategy.java …/algorithm/CollisionAlgorithm.java …/service/GeofencingEvaluationService.java
A missing implements clause is a compiler-silent architecture failure

Nothing fails. The service compiles, the tests pass, Spring wires both beans happily. The only symptom is a constructor signature that names two concrete classes, which is exactly why Test 1 is worth running by hand in review. No linter reports it, because at the level of the type system nothing is wrong; what is wrong is the direction of a dependency, and that is a question about intent.

Doing it properly

Three tabs: the defect as it was, the repair that shipped, and the reference implementation from the same service. Read the second and third tabs as the model to copy, and the first as the shape to recognise and avoid. The repair was not "add an interface", because the interface already existed. It was finishing it, then deleting the branch that only existed because it was unfinished.

javageofencing/GeofencingEvaluationService.java
package geofencing;

import geofencing.algorithm.HaversineDistanceCollisionAlgorithm;
import geofencing.algorithm.RayCastingCollisionAlgorithm;
import org.springframework.stereotype.Service;

/**
 * The abstraction already exists, one package away. This class never names it.
 * Constructor injection is used correctly, so this is textbook DI, and it is
 * not DIP, because every dependency it declares is a concrete type.
 */
@Service
public class GeofencingEvaluationService {

    private final RayCastingCollisionAlgorithm rayCasting;
    private final HaversineDistanceCollisionAlgorithm haversine;

    public GeofencingEvaluationService(RayCastingCollisionAlgorithm rayCasting,
                                       HaversineDistanceCollisionAlgorithm haversine) {
        this.rayCasting = rayCasting;
        this.haversine  = haversine;
    }

    public boolean isInside(Geometry area, Point position) {
        // Conditional dispatch, not Strategy. A third geometry means editing
        // this method, precisely what a Strategy Context never has to do.
        if (area instanceof Polygon polygon) {
            return rayCasting.contains(polygon, position);
        } else if (area instanceof Circle circle) {
            return haversine.contains(circle, position);
        }
        throw new IllegalArgumentException("Unsupported geometry: " + area);
    }
}
Three ways to select, in descending order of preference

Once the interface is finished, the Context still has to pick an implementation. These are the three ways to do that, in descending order of preference. The repair in the second tab uses the first one.

  1. Inject List<T> and index it yourself on a method the interface declares, such as supports(). The key is domain data, so it survives a rename of the bean.
  2. Inject Map<String, T>. Spring fills it with every implementation, keyed by bean name. This is fine when the key genuinely is a configuration string, but it couples selection to Spring’s naming.
  3. Use a factory when construction needs arguments the container does not have.

Not on the list: instanceof, getClass(), or a switch in the Context. Each of those puts the Context back in the business of knowing which implementations exist, which is the branch the repair deleted.

Strategy vs plain conditional dispatch

The honest counterweight to everything above: not every branch wants to be a class. The Strategy pattern's own documentation lists this as its first drawback, and it is the sentence to quote back at anyone converting a two-line if into three files.

If you only have a couple of algorithms and they rarely change, there's no real reason to overcomplicate the program with new classes and interfaces that come along with the pattern.

Refactoring.guru, Strategy: Cons refactoring.guru/design-patterns/strategy
Leave the conditional alone
  • The branch set is closed and stable: two arms that have not changed in three years and are not going to.
  • Each arm is a few lines with no dependencies of its own.
  • The condition is on a value, not a type: a boolean flag, a threshold, a null check.
  • The arms share almost all their context and separating them would mean passing six parameters into each strategy.

Converting this costs three files and buys an indirection. Don't.

Make it a Strategy
  • The branch grows: a new arm arrives every time the domain gains a case.
  • The condition tests a type: instanceof, getClass(), or a discriminator enum that shadows a class hierarchy.
  • Each arm needs its own collaborators, so the Context accumulates dependencies it uses one-tenth of the time.
  • The same branch is duplicated in two or more places and they have already drifted.
  • The set must be extensible without recompiling the Context: plugins, config-driven behaviour, per-tenant rules.
The deciding question

Not "how many branches are there" but "what does the next branch cost?" If adding a case means opening a file that has nothing to do with that case, editing a method other cases depend on, and re-testing all of them, the conditional has become a coordination point and Strategy pays for itself. If adding a case is a two-line edit nobody fears, the conditional is fine and the pattern is overhead.

Canonical sources