The Cost of Change in Software
When a program works and solves the problem it was built for, that doesn’t mean the work is done.
Software is constantly evolving (users request changes, the business grows, and requirements appear that nobody anticipated). This is where you find out whether your code was written or designed. The difference determines whether every future change will be a ten-minute operation or a three-day surgical procedure.
In this article we won’t talk about specific principles or design patterns, but about the mental framework behind them: why to design, when to do it, and how to evaluate whether we’re doing it well.
Designing Is Preserving Changeability
Sandi Metz wrote this definition of design in her book Practical Object-Oriented Design:
The purpose of design is to allow you to design later, and its primary goal is to reduce the cost of change.
Design is not about anticipating the future. It’s not about guessing which requirements will arrive next month and building infrastructure for them. That’s speculation, and speculation in software is expensive. Designing is something more modest: organizing code so that when change inevitably comes, you can accommodate it without breaking what already works.
Think of it like the difference between a well-organized restaurant kitchen and a chaotic one. Both can cook. But when a busy Saturday night hits with a full house and three special orders, the organized kitchen absorbs the pressure while the chaotic one falls apart.
To put this idea into practice, we’ll build an ordering system for a restaurant. We’ll start with something that works correctly, and then let reality do what it always does: change the rules.
The First Version: Code That Works
We’ll start with a program that calculates the total for a bill:
class Bill def initialize @items = [] end
def add(name, price) @items << { name: name, price: price } end
def total @items.sum { |item| item[:price] } end
def print @items.each do |item| puts "#{item[:name]}: $#{'%.2f' % item[:price]}" end
puts "-" * 30
puts "Total: $#{'%.2f' % total}" endend
bill = Bill.newbill.add("Caesar Salad", 9.50)bill.add("Mushroom Risotto", 14.00)bill.add("Sparkling Water", 2.50)bill.printOutput:
Caesar Salad: $9.50Mushroom Risotto: $14.00Sparkling Water: $2.50------------------------------Total: $26.00This code is simple and straightforward. For a small restaurant that only needs this, it would be more than adequate. Adding complexity would be over-engineering.
But restaurants’ needs, like those of any other business, tend to evolve.
The Friction: When Change Starts to Hurt
One day, the restaurant owner tells you: “We need to apply sales tax. 10% for food and 21% for drinks”. Let’s update the class:
class Bill def initialize @items = [] end
def add(name, price, type) @items << { name: name, price: price, type: type } end
def total @items.sum { |item| price_with_tax(item) } end
def print @items.each do |item| puts "#{item[:name]}: $#{'%.2f' % item[:price]}" end
puts "-" * 30
puts "Total with tax: $#{'%.2f' % total}" end
private
def price_with_tax(item) case item[:type] when :food item[:price] * 1.10 when :drink item[:price] * 1.21 end endendThis new version is also simple, and it solves the problem. The change was fast. However, notice something: add now requires a third argument. All the code that called add with two arguments is now broken. This is the domino effect of unmanaged dependencies: a change in one place forces changes everywhere that depends on it.
The following week brings another request: “We need to calculate tips”. Let’s add a method:
class Bill def initialize @items = [] end
def add(name, price, type) @items << { name: name, price: price, type: type } end
def total @items.sum { |item| price_with_tax(item) } end
def total_with_tip(tip_percentage) total * (1 + tip_percentage / 100.0) end
def print @items.each do |item| puts "#{item[:name]}: $#{'%.2f' % item[:price]}" end
puts "-" * 30
puts "Total with tax and tip: $#{'%.2f' % total_with_tip(5)}" end
private
def price_with_tax(item) case item[:type] when :food item[:price] * 1.10 when :drink item[:price] * 1.21 end endendThe code is still manageable. But the requests keep coming: discounts, splitting the bill among diners, printing a receipt with a tax breakdown… Each requirement gets solved in the most direct way: add another method to the existing class. Bill keeps accumulating responsibilities, but the code doesn’t hurt too much yet.
Until the requirement that breaks it arrives: “We need to differentiate between takeout and dine-in orders. Takeout orders don’t get a tip, but they do have a $1.50 packaging surcharge”. Here are the relevant changes:
def total_with_tip(tip_percentage, takeout: false) if takeout total + 1.50 else total * (1 + tip_percentage / 100.0) endend
def print(takeout: false) @items.each do |item| tax = item[:type] == :food ? "10%" : "21%" puts "#{item[:name]}: $#{'%.2f' % item[:price]} (tax #{tax})" end
puts "-" * 30
if takeout puts "Packaging fee: $1.50" end
puts "Total with tax and tip: $#{'%.2f' % total_with_tip(0, takeout: takeout)}"endThe takeout boolean spreads like an infection. Every method needs to know whether the order is for takeout or dine-in. And worse: methods that used to be simple now have conditional branches. Imagine also having to apply a discount and split the bill: the boolean would have to travel through all those methods. The cost of each new change grows because every change interacts with all the previous ones.
This is code friction. It’s not a syntax error or a bug. It’s the resistance that code puts up when you try to modify it. And like physical friction, it accumulates: the more design decisions stacked up without structure, the more energy you need to move any piece.
TRUE: A Criterion for Evaluating Design
How do you know if your code is well designed? Sandi Metz, in her book Practical Object-Oriented Design, proposes an acronym that works as a compass: TRUE. Well-designed code is:
- Transparent: The consequences of a change are obvious in the code that changes and in the code that depends on it.
- Reasonable: The cost of a change is proportional to its benefit.
- Usable: Existing code can be reused in new and unexpected contexts.
- Exemplary: The code encourages those who modify it to perpetuate these qualities.
These are not binary criteria. They’re a spectrum. But they help evaluate concrete decisions.
Let’s review our Bill class against these criteria:
Is it transparent? No. If you change the tax logic, you can’t easily know which other calculations are affected. The tip depends on the total, which depends on the tax, which depends on the item type… The dependencies between calculations are implicit in the order of the code, not in its structure.
Is it reasonable? Less and less so. Adding “takeout orders” was a conceptually simple change, but it required modifying multiple methods. The cost of change is no longer proportional to its complexity.
Is it usable? No. If you needed the tax calculation in another context (for example, to generate invoices), you’d have to extract it from Bill. It’s trapped inside a class that does too many things.
Is it exemplary? No. The most natural way to add functionality to this class is to follow the established pattern: add another method, another conditional, another boolean parameter. The code invites you to perpetuate the same decisions that are degrading it.
Technical Debt vs. Over-Engineering
Here’s the paradox of design: designing too little and designing too much produce the same result: code that’s expensive to maintain.
Technical debt is easy to recognize. It’s what we did with Bill: solving each problem in the fastest way, without considering how it interacts with what came before. Like financial debt, this works in the short term. The problem is compound interest: every shortcut makes the next change more expensive.
Over-engineering is another risk. Imagine if from the very beginning we had anticipated all future requirements:
class Bill def initialize(tax_calculator:, discount_calculator:, tip_calculator:, surcharge_calculator:, bill_splitter:, formatter:, print_strategy:) @tax_calculator = tax_calculator @discount_calculator = discount_calculator @tip_calculator = tip_calculator @surcharge_calculator = surcharge_calculator @bill_splitter = bill_splitter @formatter = formatter @print_strategy = print_strategy end
# ...endSeven injected dependencies. An elaborate architecture with interchangeable strategies for every aspect of the calculation. Everything extensible. Everything configurable. Everything unnecessarily complex for a restaurant that, at the start, only needed to add up prices.
This illustrates the two ways design fails. Both failures are costly. Both come from misreading the context.
Practical design doesn’t anticipate the future. It accepts that change will come, but doesn’t try to guess its shape. What it does is preserve options for adapting to whatever comes next.
The Right Time to Design
If designing too early is dangerous and designing too late is costly, when is the right time?
Let’s go back to the restaurant. The first version of Bill was correct for its context. When taxes arrived, the change was tolerable. When discounts came, the discomfort started. When the dine-in vs. takeout distinction arrived, the pain was clear.
That pain is the signal. The code tells us when it needs a redesign through concrete symptoms:
- Simple changes require modifying multiple methods.
- Parameters that propagate throughout the entire class.
caseorif/elseblocks that grow with every new requirement.- Testing one thing requires setting up the context of other, unrelated things.
- Adding new functionality risks breaking existing functionality.
When you feel these symptoms, it’s time to stop and redesign. Let’s see what a design that respects changeability would look like:
class Item REDUCED_TAX_RATE = 0.10 STANDARD_TAX_RATE = 0.21
attr_reader :name, :price, :tax_type
def initialize(name, price, tax_type: :standard) @name = name @price = price @tax_type = tax_type end
def price_with_tax price * (1 + tax_rate) end
private
def tax_rate case tax_type when :reduced then REDUCED_TAX_RATE when :standard then STANDARD_TAX_RATE else 0 end endend
class Bill def initialize(items: [], discount: 0) @items = items @discount = discount end
def subtotal @items.sum(&:price_with_tax) end
def total subtotal * (1 - @discount / 100.0) endend
class DineInBill < Bill def total_with_tip(percentage) total * (1 + percentage / 100.0) end
def split(diners, tip: 0) total_with_tip(tip) / diners endend
class TakeoutBill < Bill PACKAGING_FEE = 1.50
def total super + PACKAGING_FEE endend
items = [ Item.new("Caesar Salad", 9.50, tax_type: :reduced), Item.new("Mushroom Risotto", 14.00, tax_type: :reduced), Item.new("Sparkling Water", 2.50, tax_type: :standard)]
# Dine-indine_in = DineInBill.new(items: items)dine_in.total_with_tip(10) # => 31.76dine_in.split(2, tip: 10) # => 15.88
# Takeouttakeout = TakeoutBill.new(items: items, discount: 5)takeout.total # => 28.93The difference is significant. Each Item knows how to calculate its own price with tax. That knowledge belongs to the object that holds the data. Bill is solely responsible for aggregating items and applying discounts. DineInBill and TakeoutBill encapsulate the differences between order types without polluting the base class.
Let’s evaluate with TRUE:
Transparent: if you change the reduced tax rate, the change happens in Item#tax_rate. The consequences are visible and localized.
Reasonable: adding a new tax type (for example, :super_reduced at 4%) requires one line in Item. The cost is proportional to the change.
Usable: Item can be used in other contexts (an inventory system, an invoice generator) because it isn’t coupled to tip logic or bill-splitting logic.
Exemplary: the pattern is clear. If a new order type appears (for example, delivery orders with shipping fees), the natural path is to create a DeliveryBill that inherits from Bill and adds its own specific logic.
Principles and Patterns: Guides, Not Rules
If you’re interested in digging into specific principles, I’ve written about the five SOLID principles applied to Ruby: single responsibility, open-closed, Liskov substitution, interface segregation, and dependency inversion. There are also design patterns (strategy, composition, dependency injection…) that offer proven solutions to recurring problems. But before getting to any of them, there’s something fundamental to establish: principles and patterns are tools, not laws.
A design principle is an empirical observation. Someone noticed that a certain way of organizing code produced better results than another. Over time, that observation was formalized, given a name, and shared. The early object-oriented programmers discovered, through trial and error, that certain code arrangements made their work easier and others made it harder.
Later, these observations were quantified, measuring things like class size, coupling between objects, and the depth of inheritance hierarchies. And these metrics were correlated with the quality of the resulting software. Studies, including some on large-scale NASA applications, confirmed that design principles have a measurable correlation with code quality.
Design patterns, popularized by the Gang of Four in 1995, go a step further: they’re not just observations, but reusable solutions to recurring problems. They’re like proven recipes. For example, knowing that the Strategy pattern is used to swap out behaviors saves you from reinventing the solution every time you need it.
But here’s the critical nuance: a poorly applied tool is worse than no tool at all. If you applied patterns everywhere, even where they’re not needed, the result would be unnecessarily complex code that’s hard to understand and hard to change.
Principles point toward where good design tends. Patterns offer proven paths to get there. But the judgment of when and how much to apply them is yours.
Metrics: Useful, but Not Definitive
In Ruby there are gems that analyze your code and produce scores based on object-oriented design principles. These metrics are useful, but there’s an important asymmetry:
Bad metrics are a reliable signal of bad design. Code that scores poorly will be hard to change. However, good metrics don’t guarantee good design. In other words, it’s possible to build an elegant architecture that anticipates the wrong future. That structure will produce flawless metrics, but it will be costly to adapt when the real requirements arrive.
The ultimate metric would be cost per feature over time. But cost, functionality, and the relevant time interval are hard to define and track separately.
In practice, this means that metrics are a good problem detector, but a poor quality certificate. Use them to find what’s wrong, not to confirm that everything is right.
Conclusion: The Economics of Code
Object-oriented design is not an aesthetic exercise. Code that isn’t designed accumulates a cost that grows with every change, and that cost eventually surpasses the original cost of writing the application.
But the answer isn’t to design to the maximum from the start. The answer is to develop the sensitivity to recognize when the code is asking for a redesign, when technical debt is accumulating, and when a bit more structure will significantly reduce the cost of future changes.
Start simple. Write the most straightforward code that solves your current problem. When you feel the friction (the pain of changing, the fear of breaking things, the growing complexity of each new requirement) stop and design. Not before.
And when you design, use TRUE as your compass: does this change make my code more transparent, reasonable, usable, and exemplary? If the answer is yes, you’re heading in the right direction. If not, you might be adding complexity without real benefit.
If you want to explore the concrete tools of design (how to create classes with clear responsibilities, how to manage dependencies, how to design flexible interfaces) check out the series on SOLID principles in Ruby. Remember that the best design is the one that minimizes the total cost of change over the lifetime of your application.
Test your knowledge
-
What is the primary goal of object-oriented design?
-
A boolean parameter starts spreading across multiple methods in your class. This is a symptom of:
-
What does the “R” in TRUE stand for?
-
You’re building a small internal tool that only needs CSV export. A teammate suggests adding a Strategy pattern to support future formats. What’s the best approach?
-
Good design metrics on your codebase mean: