If you've been in this development game for a while, you know that feeling: you open a project, they ask you for a "simple" change, and suddenly you realize that to touch one line you have to understand the entire system.
That's what happens when design fails.
Recently I started studying "Practical Object-Oriented Design in Ruby" (POODR) by Sandi Metz in depth. Although the book uses Ruby, its lessons about architecture transcend the language. They are universal truths about how to manage dependencies.
In this post I want to distill the learnings from the first two chapters and translate those concepts (and their bicycle examples) into C#.
1. Design is the Art of Managing Change
Sandi Metz starts with an uncomfortable truth: If your application never had to change, design wouldn't matter.
You could write everything in a single Program.cs file with 5000 lines and, if it compiles and works, it would be "fine". The problem is that change is inevitable.
Design is, therefore, a journey through a branching path. It's not about following fixed rules, but about making decisions today that don't close doors tomorrow.
-
Bad design: Small changes cause cascading side effects.
-
Good design: Preserves variability. You can change your mind without rewriting the app.
2. Is Your Code TRUE?
Defining "clean code" is subjective. Metz gives us a concrete acronym to evaluate whether a class is truly easy to change: TRUE.
-
T (Transparent): The consequences of a change must be obvious in both the local and remote code.
-
R (Reasonable): The cost of the change is proportional to the benefit.
-
U (Usable): The code must be reusable in new and unexpected contexts.
-
E (Exemplary): The code encourages those who touch it afterward to perpetuate these qualities.
3. The Single Responsibility Principle (SRP)
The foundation of a maintainable system is that each class does the least useful task possible.
Look at this Gear class. Its responsibility is to calculate the ratio between the chainring and the cog.
public class Gear
{
public int Chainring { get; }
public int Cog { get; }
public Gear(int chainring, int cog)
{
Chainring = chainring;
Cog = cog;
}
public double Ratio()
{
return (double)Chainring / Cog;
}
}How do we know if it fulfills SRP? Sandi suggests interrogating the class:
"Hey Gear, what's your ratio?" → ✅ Makes sense.
"Hey Gear, what's your tire size?" → ❌ Sounds weird.
If the class had methods about tires or wheels, we'd be violating cohesion. A class with multiple responsibilities is hard to reuse because, if you only want one part (the ratio), you're forced to carry the rest (the wheels), increasing the risk of failures.
4. The Danger of Complex Data Structures
This is where we fail most often in day-to-day work. We often pass "raw" data (arrays, lists of lists, parsed JSON) throughout the application. This creates a brutal dependency on the structure of that data.
Imagine we receive a list of wheel data, where position 0 is the rim and position 1 is the tire.
The Coupled Approach (Obscuring References)
public class ObscuringReferences
{
private readonly List<int[]> _data;
public ObscuringReferences(List<int[]> data)
{
_data = data;
}
public List<int> Diameters()
{
// ⚠️ DANGER: 0 is the rim, 1 is the tire.
// If the array structure changes, this breaks.
// The code "knows" too much about the internal structure.
return _data.Select(cell => cell[0] + (cell[1] * 2)).ToList();
}
}This code is fragile. If the data provider changes the order of the array, we have to find every place where we wrote cell[0] and fix it. Also, reading cell[1] tells us nothing about what that data represents.
The Revealing Approach (Revealing References)
The solution is to separate structure from meaning. We convert that raw list into named objects (using a C# record, for example) as soon as they enter our class.
public class RevealingReferences
{
public List<Wheel> Wheels { get; }
public RevealingReferences(List<int[]> data)
{
// We convert raw data into meaningful objects at the start
Wheels = Wheelify(data);
}
public List<int> Diameters()
{
// Now the code speaks the domain language
return Wheels.Select(wheel => wheel.Rim + (wheel.Tire * 2)).ToList();
}
private List<Wheel> Wheelify(List<int[]> data)
{
return data.Select(cell => new Wheel(cell[0], cell[1])).ToList();
}
// A simple record gives us semantics and protection
private record Wheel(int Rim, int Tire);
}Now, if the array structure changes, we only have one point of failure: the Wheelify method. The rest of the application is protected and much more readable.
Conclusion: Design for the Future, Code for the Present
The most important lesson from these first chapters is about when to make decisions.
We often feel pressure to create the perfect architecture from day one. But premature design is as harmful as no design at all.
If the cost of fixing it in the future is the same as doing it now, postpone the decision.
Use principles like SRP and TRUE to write code that is tolerant to change, not code that tries to predict the future.
The path to maintainable software starts by recognizing that applications are not static. They are living conversations between objects.