We continue the series on Practical Object-Oriented Design by Sandi Metz. In the previous article we saw why design matters and how to make a class have a single responsibility. Now it's time to talk about what is probably the number one source of pain in software maintenance: dependencies.
Because here's the thing: you can have classes with a perfectly defined single responsibility, and still end up with code that breaks in cascade every time you touch something. The problem isn't what each class does, but what it knows about the others.
When Objects Know Too Much
Objects don't live in isolation. To solve complex problems, they need to collaborate with each other. And that collaboration has a price: every time one object knows details about another, a dependency is created.
Metz is very specific about when a dependency exists. An object depends on another when it knows:
-
The name of another class —
GearknowsWheelexists. -
The name of a message —
Gearexpects to callDiameter. -
The arguments it needs —
GearknowsWheelrequiresrimandtire. -
The order of those arguments — rim first, then tire.
Each of these points is an invisible thread tying one class to another. The more threads, the more coupling. And the more coupling, the more fragile the system: small changes in one place cause breakages in unexpected places.
The Problem with new
Let's look at a classic example. Imagine a Gear class that needs to calculate "gear inches" (a measure that combines gear ratio with wheel diameter):
// BAD DESIGN: Strong coupling
public class Gear
{
private readonly double rim;
private readonly double tire;
public Gear(double rim, double tire)
{
this.rim = rim;
this.tire = tire;
}
public double GearInches()
{
// Gear creates the instance explicitly.
// It knows the class name, its arguments and their order.
return Ratio() * new Wheel(rim, tire).Diameter;
}
private double Ratio() => 52.0 / 11.0;
}It looks innocent, but that line with new Wheel(rim, tire) is a declaration of intent: Gear is saying "I only work with Wheel, and don't tell me about anything else". If tomorrow you need to calculate gear inches with a training roller, a drum, or anything else that has a diameter, you can't. Gear refuses to cooperate.
And not only that: Gear knows all four types of dependencies we mentioned. It knows the class is called Wheel, that it has a Diameter property, that it needs two arguments, and that they come in a specific order. If any of these things changes, Gear breaks.
Dependency Injection: Don't Care About the "What", Only the "How"
The solution Metz proposes is elegant in its simplicity: Gear doesn't care what the object is, it only cares that it has a diameter. So instead of creating the dependency internally, it receives it from outside:
public class Gear
{
private readonly Wheel wheel;
// The dependency is injected from outside
public Gear(Wheel wheel)
{
this.wheel = wheel;
}
public double GearInches()
{
// Gear collaborates with anything that has Diameter
return Ratio() * wheel.Diameter;
}
private double Ratio() => 52.0 / 11.0;
}With this change, Gear goes from knowing four dependencies (class name, property, arguments, order) to knowing only one (that the object has Diameter). The creation of Wheel is no longer Gear's responsibility, but the responsibility of whoever uses it:
var wheel = new Wheel(26, 1.5);
var gear = new Gear(wheel);
var inches = gear.GearInches();This will remind you of SOLID's dependency inversion principle. And it's exactly that. The difference is that Metz doesn't present it as an abstract principle to memorize, but as a natural solution to a concrete problem. That's one of the book's strengths: the "why" always comes before the "how".
When You Can't Inject: Isolate
In an ideal world, you'd always inject dependencies. But reality has constraints: legacy code, frameworks that control instance creation, or simply code you inherited and can't refactor all at once. For these cases, Metz proposes isolating the dependency.
The idea is simple: if you can't remove the new, at least make it obvious and easy to change.
Technique 1: Isolate the creation. Move the new to its own method:
public class Gear
{
private Wheel? wheel;
private readonly double rim;
private readonly double tire;
public Gear(double rim, double tire)
{
this.rim = rim;
this.tire = tire;
}
public double GearInches()
{
// The calculation stays clean
return Ratio() * GetWheel().Diameter;
}
// All creation knowledge is isolated here.
// If Wheel's constructor changes, we only touch this method.
private Wheel GetWheel() => wheel ??= new Wheel(rim, tire);
private double Ratio() => 52.0 / 11.0;
}Technique 2: Isolate external messages. If the call to wheel.Diameter is prone to changes (for example, if it's a deep call chain like wheel.Tire.OuterDiameter), wrap it:
public class Gear
{
private readonly Wheel wheel;
public Gear(Wheel wheel) => this.wheel = wheel;
public double GearInches()
{
// Gear talks to itself, not to a stranger
return Ratio() * Diameter();
}
// If Wheel's API changes (from .Diameter to .TotalWidth),
// we only break this wrapper method.
private double Diameter() => wheel.Diameter;
private double Ratio() => 52.0 / 11.0;
}Both techniques share the same philosophy: you don't eliminate the dependency, but you convert it into a single, visible change point. When Wheel changes (and it will), you'll know exactly where to go.
Argument Order is a Hidden Dependency
There's a type of dependency we often overlook: the order of arguments. When you see something like new Gear(52, 11, 26, 1.5), each position is a dependency. Is 52 the cog or the chainring? Is 26 the rim or the tire? If the order changes, all the code that instantiates Gear breaks.
Metz's solution is to use what in Ruby would be an options hash. In C# we have several ways to achieve the same:
public record GearOptions
{
public int Chainring { get; init; } = 40;
public int Cog { get; init; } = 18;
public required Wheel Wheel { get; init; }
}
public class Gear
{
public int Chainring { get; }
public int Cog { get; }
public Wheel Wheel { get; }
public Gear(GearOptions options)
{
Chainring = options.Chainring;
Cog = options.Cog;
Wheel = options.Wheel;
}
}
// Order no longer matters, and names document each argument
var gear = new Gear(new GearOptions
{
Cog = 11,
Wheel = new Wheel(26, 1.5)
});The dependency shifts from "order" to "property names". And names are much more stable than positions.
Wrappers: Protect Yourself from What You Don't Control
When the dependency is an external library whose API you can't modify, Metz proposes creating a wrapper or factory that isolates that knowledge:
// Class from an external library that we CANNOT touch
public class ExternalFrameworkGear
{
public ExternalFrameworkGear(int chainring, int cog, Wheel wheel)
{
// ...
}
}
// Our wrapper offers a clean interface
public static class GearFactory
{
public static ExternalFrameworkGear Create(GearOptions options) =>
new(options.Chainring, options.Cog, options.Wheel);
}The rest of your application uses GearFactory.Create(options) and knows nothing about the external library's positional constructor. If the library changes its API in an update, you only touch the wrapper.
This pattern is especially relevant when working with third-party SDKs (payment providers, cloud services, HTTP clients...) that can change between versions.
The Most Important Decision: Direction
So far we've seen how to reduce coupling. But there's an even more fundamental design decision: the direction of the dependency. Having A depend on B or having B depend on A is not the same, and choosing wrong can be very costly.
Metz proposes a simple rule: depend on things that change less frequently than you.
Classes vary in three dimensions: their probability of change, their level of abstraction, and the number of classes that depend on them. With these three dimensions, you can classify any piece of code into four zones:
Zone A — The abstract zone. Changes rarely, many classes depend on them. These are interfaces, abstract classes, language types like string or IEnumerable<T>. It's the ideal zone to depend on: stable and widely used.
Zone B — Stable and solitary. Changes rarely and few classes depend on them. Utility code, internal helpers. Harmless.
Zone C — Volatile but isolated. Changes often but nobody depends on them. Controllers, migration scripts, UI classes. Although the code is unstable, changes don't generate side effects because nobody else references them.
Zone D — The danger zone. Changes often and many classes depend on it. It's a concrete class that everyone uses but needs to be modified constantly. This is the source of pain in maintenance. The goal of design is to prevent any class from ending up here.
What's interesting about this analysis is that it reveals a subtle consequence: a class with many dependents will be under enormous pressure to never change. If that class is also volatile (because business requirements push it to change), you have a serious conflict. You need to change it, but can't without breaking half the system.
What I Take Away from This Chapter
If the single responsibility chapter taught me to think about what each class does, the dependencies chapter taught me to think about what each class knows about the others. And that distinction completely changes how you design.
Dependency injection is not a framework pattern — it's a design decision. Before reaching any DI container, the fundamental question is: does this class need to know how its collaborator is created, or does it only need to use it?
Isolation is your plan B. When you can't inject (and in the real world, often you can't), isolating the dependency at a single visible point is the next best option.
Direction matters more than quantity. You can have many dependencies and it not be a problem, if they all point toward things more stable than you. The real problem appears when something unstable has many dependents.
See you in the next post!