Dependency Inversion: Why Two Concrete Classes Shouldn't Depend on Each Other

The naive fix for duplicated flying code creates a new, subtler bug. The real fix is depending on an interface instead of a concrete class, and here's why that's called inversion.

September 14, 20268 min read16 / 20

The previous post left Crow and Owl with identical flying code, duplicated across two files. The fix looks obvious at first: pull that shared code into its own class.

The Naive Fix, and the Bug Hiding Inside It

Create a class purely to hold the shared logic:

class CrowOwlFlyingBehavior { public: void makeFly() { /* the flying logic crow and owl share */ } }; class ParrotSparrowFlyingBehavior { public: void getToFly() { /* the flying logic parrot and sparrow share */ } };

Then have each bird call into it instead of duplicating the code:

class Crow : public Bird, public Flyable { public: void fly() override { behavior.makeFly(); } private: CrowOwlFlyingBehavior behavior; }; class Owl : public Bird, public Flyable { public: void fly() override { behavior.makeFly(); } private: CrowOwlFlyingBehavior behavior; };

The duplication is genuinely gone. The shared logic lives in exactly one place now, and both birds lean on it instead of carrying their own copy. So far, this looks like a clean win.

What Happens When the Behavior Needs to Change

Now suppose Crow decides it wants to fly the way a parrot does instead. That should be a small change. Look at what it actually takes.

class Crow : public Bird, public Flyable { public: void fly() override { behavior.getToFly(); } // the method name changed too, not just the class private: ParrotSparrowFlyingBehavior behavior; };

Two things had to change, not one. The class being created changed, but the method being called changed too, from makeFly() to getToFly(), because the two behavior classes were never built against a shared shape. Crow was directly wired to the exact class and method name of CrowOwlFlyingBehavior, and swapping that out means touching both.

That's a small edit in one file, today. But picture this same pattern spread across a codebase: dozens of classes each hardwired to a specific concrete class and method name. Any future swap risks becoming a hunt through every call site, renaming methods and classes by hand. That's exactly the kind of change that quietly breaks things when one call site gets missed.

The Real Fix: An Interface Between Them

The actual fix isn't extracting the shared code differently. It's changing what Crow is allowed to depend on in the first place.

class FlyingBehavior { public: virtual ~FlyingBehavior() = default; virtual void makeFly() = 0; }; class CrowOwlFlyingBehavior : public FlyingBehavior { public: void makeFly() override { /* shared crow/owl logic */ } }; class ParrotSparrowFlyingBehavior : public FlyingBehavior { public: void makeFly() override { /* shared parrot/sparrow logic */ } }; class Crow : public Bird, public Flyable { public: void fly() override { behavior->makeFly(); } private: std::unique_ptr<FlyingBehavior> behavior = std::make_unique<CrowOwlFlyingBehavior>(); };

Crow now declares its field as FlyingBehavior, the interface, not CrowOwlFlyingBehavior, the concrete class. Both behavior classes implement the same interface, so they're both guaranteed to expose the exact same method name.

Switch Crow to fly like a parrot now, and exactly one line changes:

std::unique_ptr<FlyingBehavior> behavior = std::make_unique<ParrotSparrowFlyingBehavior>();

The method call, this.behavior.makeFly(), never needs to change, because the interface guarantees that name exists on whatever concrete class sits behind it. That's the entire difference between the naive fix and the real one: the naive fix removed duplicated code, but left Crow still married to one specific class's exact shape. This fix breaks that marriage.

Why It's Called "Inversion"

The name makes more sense once you track the dependency arrows before and after.

Before: Crow depended on CrowOwlFlyingBehavior directly. CrowOwlFlyingBehavior depended on nothing. It just sat there, a plain class with a method.

After: Crow depends on FlyingBehavior, the interface. CrowOwlFlyingBehavior also depends on FlyingBehavior, because implementing an interface counts as depending on it.

The concrete class went from depending on nothing, to depending on the same interface its user depends on. Both sides now point at the interface in the middle, instead of one concrete class pointing straight at another. That flip is why it's called the Dependency Inversion Principle: no two concrete classes should depend on each other directly. Both should depend on an interface standing between them.

The Same Idea, Said Differently: Code to an Interface

There's another common way to state this exact rule: code to an interface, not an implementation.

Take a much simpler example, completely outside birds. Declaring a collection two different ways:

// Option 1 std::vector<int> items; // Option 2 std::vector<int> items = {};

The real version of this choice, in languages with a proper collection interface, is declaring a variable as the interface type (List) instead of the concrete type (ArrayList), whenever the code only needs the interface's methods. Do that, and switching to a different concrete class later, a linked list instead of an array-backed one, is a one-line change. That's the exact same win Crow just got from depending on FlyingBehavior instead of CrowOwlFlyingBehavior directly.

This only pays off if the interface actually has every method the code needs. Coding to an interface isn't free. It means giving up direct access to anything specific to one class, anything outside what the interface promises. That's usually a good trade because it keeps future swaps cheap. But the interface has to be designed around what callers actually need, not just around whatever was convenient for the first version.

One Change Still Remains

Even after all of this, notice something that hasn't gone away. If Crow decides to change how it flies, there's still one line inside the Crow class itself that has to change, the line that creates new CrowOwlFlyingBehavior().

It's a much smaller change than before, one line instead of a class name and a method name both. But it's still a change to Crow, for a decision that has nothing to do with what a crow actually is. Whether that last change can be removed entirely, and what removing it would even mean, is exactly the next question worth asking.