Why Design Principles Exist: Change, Dependencies and a Short History of SOLID
Design principles exist to name the unplanned dependencies that make change expensive and to draw boundaries around them: rigidity, fragility, immobility and viscosity share dependency as their root. The history from Parnas's information hiding to Feathers's SOLID acronym, and the series' Bookstore order and notification example.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
In Bookstore, two places use the order summary: accounting's invoice line and the SMS (Short Message Service) sent to the customer. Last week SMS messages hit the carrier's character limit; the developer who picked up the ticket added truncation to the shared OrderText.Summary method, ran the SMS test, saw it pass, and the change was merged. Nobody touched the invoice. When this code runs, what does the invoice line say?
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Order order = new(1042, "Ada",
[
new OrderLine("Dune", 2, 12.99m),
new OrderLine("Emma", 1, 7.50m),
new OrderLine("The Hobbit", 1, 10.99m)
]);
Console.WriteLine(InvoiceWriter.Write(order));
Console.WriteLine(SmsNotifier.Text(order));
public record OrderLine(string Title, int Quantity, decimal UnitPrice);
public record Order(int Id, string CustomerName, IReadOnlyList<OrderLine> Lines)
{
public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice);
}
public static class OrderText
{
// added last week because of the 160-character SMS limit
public static string Summary(Order order)
{
string text = string.Join(", ", order.Lines.Select(l => $"{l.Quantity} x {l.Title}"));
return text.Length <= 30 ? text : text[..27] + "...";
}
}
public static class InvoiceWriter
{
public static string Write(Order order) =>
$"INVOICE #{order.Id} | {order.CustomerName} | {OrderText.Summary(order)} | {order.Total:0.00} EUR";
}
public static class SmsNotifier
{
public static string Text(Order order) =>
$"Order {order.Id}: {OrderText.Summary(order)}";
}
The Part Nobody Touched Broke
Output:
INVOICE #1042 | Ada | 2 x Dune, 1 x Emma, 1 x The... | 44.47 EUR
Order 1042: 2 x Dune, 1 x Emma, 1 x The...
The third book on the invoice was cut down to "The...". The SMS is correct because the change was made for it. The invoice is broken because it depended on the same method and nobody kept that link in mind. When accounting asks at month end why book titles on invoices are cut in half, it takes hours to trace the line back to an SMS change; git blame shows no commit related to invoices at all.
This post is about exactly that feeling: the code works, the tests are green, yet every change breaks something somewhere unexpected and over time the team grows afraid to touch it. What we call design principles exist to name the causes of that feeling and to talk about what we can do against them. In the first post of the series we will talk less about the principles themselves and more about why they appeared: the symptoms of bad design, dependency as their common root, and a short history running from David Parnas (the software engineering pioneer who laid the foundations of module design in the 1970s) to the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) acronym.
Let's look at the fix first. The summary rules for the invoice and the SMS come from different people; accounting decides one, the SMS carrier's limit decides the other. So the two should live in different places:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Order order = new(1042, "Ada",
[
new OrderLine("Dune", 2, 12.99m),
new OrderLine("Emma", 1, 7.50m),
new OrderLine("The Hobbit", 1, 10.99m)
]);
Console.WriteLine(InvoiceWriter.Write(order));
Console.WriteLine(SmsNotifier.Text(order));
public record OrderLine(string Title, int Quantity, decimal UnitPrice);
public record Order(int Id, string CustomerName, IReadOnlyList<OrderLine> Lines)
{
public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice);
}
public static class InvoiceWriter
{
// The invoice is accounting's rule: every line is written in full
public static string Write(Order order) =>
$"INVOICE #{order.Id} | {order.CustomerName} | " +
string.Join(", ", order.Lines.Select(l => $"{l.Quantity} x {l.Title}")) +
$" | {order.Total:0.00} EUR";
}
public static class SmsNotifier
{
private const int MaxSummaryLength = 30;
// The SMS shortening rule lives only here
public static string Text(Order order)
{
string summary = string.Join(", ", order.Lines.Select(l => $"{l.Quantity} x {l.Title}"));
if (summary.Length > MaxSummaryLength)
summary = summary[..(MaxSummaryLength - 3)] + "...";
return $"Order {order.Id}: {summary}";
}
}
INVOICE #1042 | Ada | 2 x Dune, 1 x Emma, 1 x The Hobbit | 44.47 EUR
Order 1042: 2 x Dune, 1 x Emma, 1 x The...
Yes, the string.Join line now appears twice. It looks like a DRY (Don't Repeat Yourself) violation, but it isn't: the two lines look the same today and will change in different directions for different reasons tomorrow. We will discuss the cost of merging code that looks the same but represents different knowledge in detail in the upcoming DRY, KISS (Keep It Simple, Stupid) and YAGNI (You Aren't Gonna Need It) post; for now, note this: the real mistake in the first version was not avoiding repetition, it was tying two unrelated reasons for change to a single method.
Four Symptoms of Bad Design
Robert C. Martin's (also known as "Uncle Bob", author of Clean Code and Clean Architecture) 2000 paper "Design Principles and Design Patterns" lists four primary symptoms of a rotting design. The paper is 25 years old, but the symptoms still come up in every code review. Let's go through them with Bookstore.
Rigidity: even a simple change requires a cascade of further changes. "Let's add push notifications to orders" looks like two days of work; then it turns out you need to touch OrderService, the customer table, the settings screen and the reports, and the job stretches to three weeks. Martin's observation: this unpredictability makes managers afraid to allow non-critical fixes, and over time a design problem turns into a management policy. Every team with a "don't touch that module" rule has lived through this.
Fragility: a change breaks places that have no conceptual relationship with it. The invoice example above is exactly that. As fragility grows, every fix produces new bugs and trust between the team and its users erodes.
Immobility: reusing a part somewhere else costs more than extracting it. Say the returns module also wants to send customers an SMS. The SMS code lives inside OrderService and depends on the Order type, the order number format and the SMTP (Simple Mail Transfer Protocol) settings. In the end someone copies the SMS code and pastes it into the returns module. In Martin's words, the software is rewritten rather than reused.
Viscosity: doing the right change is harder than doing the hack. It comes in two kinds. Viscosity of the design: if adding a case to the switch for push notifications takes five minutes while building a new abstraction takes much longer, the team picks the five minutes every time. Viscosity of the environment: if a build takes ten minutes, developers prefer the change that touches fewer files, not the one that is right for the design. In the .NET world the second kind usually arrives with a large solution whose project references tie everything to everything.
The four are not independent. In a rigid system people reach for hacks (viscosity), hacks create hidden links (fragility), and hidden links make it impossible to extract and move parts (immobility).
The Root Cause: Dependencies, Not Change
The first suspect for these symptoms is changing requirements. Martin rejects that in the same paper: we all know requirements change, and the requirements document is the most volatile document in a project. If our design collapses under constantly changing requirements, the fault lies in the design, not in the requirements.
The real cause is unplanned dependencies. In the invoice example, InvoiceWriter's dependency on OrderText is perfectly legitimate as far as the compiler is concerned; but that dependency also tied the invoice to the SMS's reason for change. The remedy Martin proposes is a "dependency firewall": boundaries across which change does not propagate. Every principle in this series is a different answer to where and how to draw those boundaries:
- Coupling and cohesion: how strong is the link between two parts, and how well does the inside of a part hold together?
- SRP (Single Responsibility Principle): how many different reasons for change does a class have?
- OCP (Open/Closed Principle) and DIP (Dependency Inversion Principle): can we add new behavior without opening existing code, and which way does the dependency arrow point?
- LSP (Liskov Substitution Principle) and ISP (Interface Segregation Principle): does an abstraction keep its promise, and do clients depend on things they don't need?
A Short History: From Parnas to SOLID
SOLID is often presented as five rules that appeared suddenly in the 2000s. In fact each letter has decades of debate behind it, and knowing the order makes it easier to understand why the principles were formulated the way they were:
| Year | Who | Idea |
|---|---|---|
| 1972 | David Parnas | Information hiding: split a system into modules by design decisions likely to change, not by the steps of a flowchart |
| 1974 | Stevens, Myers, Constantine | Structured Design: the concepts of coupling and cohesion |
| 1974 | Edsger Dijkstra | Separation of concerns |
| 1987–1994 | Barbara Liskov (and Jeannette Wing) | OOPSLA (the Object-Oriented Programming, Systems, Languages and Applications conference) 1987 keynote "Data Abstraction and Hierarchy", a formal definition of behavioral subtyping in 1994 |
| 1988 | Bertrand Meyer | Object-Oriented Software Construction: the Open/Closed Principle and Design by Contract |
| 1992 | Meilir Page-Jones | Connascence: grading coupling by kind and strength |
| 1996–2000 | Robert C. Martin | Articles on OCP, LSP, DIP, ISP; "Design Principles and Design Patterns" in 2000 |
| 2002 | Robert C. Martin | The Agile Software Development book: SRP joins the other principles |
| ~2004 | Michael Feathers (author of Working Effectively with Legacy Code) | Reorders the principles and suggests the SOLID acronym |
Two interesting conclusions come out of the table. First, the 2000 paper has no SRP; it covers OCP, LSP, DIP and ISP as class principles, followed by six package principles. SRP's roots go back to Parnas and to Constantine's cohesion; in his 2014 SRP article Martin says he gathered these ideas under one name in the late 1990s and that he may have taken the name from Bertrand Meyer but cannot confirm it. Second, the acronym came long after the principles. Putting five letters side by side is partly about memorability; it does not mean the principles carry equal weight or that there are no others. Martin himself presents OCP as "the most important" in the 2000 paper, and DIP as the primary mechanism behind OCP.
Parnas's 1972 paper is still worth reading today. He splits the same small program into modules in two ways: one by the steps of a flowchart (read, shift, sort, print), the other by the design decisions to hide (such as how the data is stored in memory). Both versions work; the difference shows up in how many modules are affected when a design decision changes. The idea of "hide what is likely to change" is the ancestor of almost every principle in this series.
An Example from .NET's Own Library
To keep the principles from staying abstract, throughout the series we will look at where each of them sits in .NET's own code. The first example is one of .NET's oldest abstractions, Stream:
using System.IO.Compression;
using System.Text;
byte[] catalog = Encoding.UTF8.GetBytes(
string.Concat(Enumerable.Repeat("978-0441013593;Dune;Frank Herbert;12.99\n", 500)));
using MemoryStream memory = new();
Console.WriteLine($"MemoryStream: {catalog.Length} -> {Compress(catalog, memory)} bytes");
string path = Path.Combine(Path.GetTempPath(), "catalog.csv.gz");
using (FileStream file = File.Create(path))
{
Console.WriteLine($"FileStream: {catalog.Length} -> {Compress(catalog, file)} bytes");
}
File.Delete(path);
// GZipStream does not know whether the target is memory or a file
static long Compress(byte[] data, Stream destination)
{
using (GZipStream gzip = new(destination, CompressionLevel.SmallestSize, leaveOpen: true))
{
gzip.Write(data);
}
return destination.Length;
}
MemoryStream: 20000 -> 140 bytes
FileStream: 20000 -> 140 bytes
Neither the Compress method nor GZipStream knows what the target is; all they have is a Stream. The same code works against a MemoryStream, a FileStream, a network stream or a stream you write yourself. Adding a new kind of storage does not require touching GZipStream's source code (OCP), and the compression code depends on an abstraction rather than a concrete file API (Application Programming Interface), which is DIP. You will meet the same design again in ASP.NET Core's request body, in HttpClient's content and in the stream overloads of System.Text.Json.
The "Architectural principles" page on Microsoft Learn gives a similar list: separation of concerns, encapsulation, dependency inversion, explicit dependencies, single responsibility, DRY, persistence ignorance and bounded contexts. This series covers most of them; bounded contexts belong to the upcoming Domain-Driven Design series.
A Principle Is Not a Rule: Each One Names a Trade-off
The most expensive misunderstanding about principles is reading them like compiler rules. "This class violates SRP" should be the start of a discussion in code review, not a verdict. Every principle names a tension, and there is a real cost on the other side too.
Push SRP to the limit and you put every method in its own class and open ten files for one change. Push OCP to the limit and you put extension points in places that will never change, and the code becomes unreadable. Push DIP to the limit and every class gets an IXxx twin. Push DRY to the limit and you tie unrelated code together, as in the invoice example. That is why every post in this series has sections on "overdoing it" and "when not to use it".
The question that works in practice is: "Does this design make the change we actually expect in this system cheaper?" Applying a principle where no change is expected is a cost; not applying it where change keeps arriving is debt. In the upcoming Clean Code series we ask the same question at the method and naming level; in this series we ask it at the class, interface and project level.
The Example We Will Use Throughout the Series
The posts will build on a shared example: Bookstore's order module and the notifications attached to it (email, SMS, later push). The starting point is the kind of "works but tangled" code you will see in many real projects. Orders.cs:
namespace Bookstore.Orders;
public enum NotificationChannel { Email, Sms }
public record Customer(int Id, string Name, string Email, string Phone, NotificationChannel PreferredChannel);
public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);
public sealed class Order(int id, Customer customer, IReadOnlyList<OrderItem> items)
{
public int Id { get; } = id;
public Customer Customer { get; } = customer;
public IReadOnlyList<OrderItem> Items { get; } = items;
public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}
public sealed class OrderService
{
private readonly List<Order> _orders = [];
public Order PlaceOrder(Customer customer, IReadOnlyList<OrderItem> items)
{
if (items.Count == 0)
throw new ArgumentException("An order needs at least one item.", nameof(items));
Order order = new(1001 + _orders.Count, customer, items);
_orders.Add(order);
// The notification decision is made in the middle of the order code
string message = $"Order {order.Id} confirmed: {order.Total:0.00} EUR";
switch (customer.PreferredChannel)
{
case NotificationChannel.Email:
Console.WriteLine($"[smtp] to {customer.Email}: {message}");
break;
case NotificationChannel.Sms:
Console.WriteLine($"[sms] to {customer.Phone}: {message}");
break;
}
return order;
}
}
Program.cs:
using System.Globalization;
using Bookstore.Orders;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
OrderService orders = new();
Customer ada = new(1, "Ada", "ada@example.com", "+905550000001", NotificationChannel.Email);
Customer alan = new(2, "Alan", "alan@example.com", "+905550000002", NotificationChannel.Sms);
orders.PlaceOrder(ada, [new OrderItem("978-0441013593", "Dune", 2, 12.99m)]);
orders.PlaceOrder(alan, [new OrderItem("978-0141439518", "Emma", 1, 7.50m)]);
[smtp] to ada@example.com: Order 1001 confirmed: 25.98 EUR
[sms] to +905550000002: Order 1002 confirmed: 7.50 EUR
There is no problem with this code today; two customers, two channels, everything works. But it is not hard to guess where change will come from. Marketing will ask for push notifications and the switch will grow. Accounting will want to change how the order total is calculated; that code sits in the same method as the notifications. In tests we will want to plug in a fake sender instead of Console.WriteLine so that no real SMS goes out, but the sender is buried inside the method. In the following posts we will take this code apart principle by principle: first we measure it with coupling and cohesion, then separate responsibilities with SRP, close the switch with OCP, test the promises of abstractions with LSP and ISP, and turn the dependency arrow around with DIP.
Common Pitfalls
Arguing about a principle by its acronym. Saying "there's an OCP violation here" convinces nobody if it doesn't say which change has become expensive. Saying "when the push channel arrives, this file and those two files will change" is both concrete and verifiable.
Applying a principle before the pain arrives. Hiding everything behind interfaces from day one means building walls without knowing which direction change will come from. If the wall stands in the wrong place, the real change has to punch through it and you end up with both a wall and a hole.
Confusing principles with patterns. Design patterns such as Strategy, Decorator and Factory are ready-made solutions to specific problems; principles are the criteria you use to judge whether a solution is good. The patterns themselves are the subject of the upcoming Design Patterns series. Adding a pattern "to make it SOLID" confuses the criterion with the goal.
Thinking SOLID belongs only to OOP (Object-Oriented Programming). In a functional code base too, it makes sense for a module to have a single reason to change, for a function to be able to trust the promise of the function passed to it, and for high-level policy not to depend on low-level detail. In C# we can do this with Func<> parameters as well; we will see it in the OCP post.
Blaming the symptoms on people. Sentences that start with "whoever wrote this module..." are usually unfair. Rigidity and fragility rarely come from one bad decision; they come from hundreds of small, reasonable-looking dependencies piling up. The developer in the invoice example had run their own test, too.
A Small Experiment
★☆☆ Imagine the email subject line also depends on OrderText.Summary from the first example. How does the SMS truncation affect the email? Write the smallest change that makes all three clients work correctly and compare the output.
★★☆ Find the five files that changed most in your own project over the last six months (git log --since="6 months ago" --format= --name-only | sort | uniq -c | sort -rn | head). For each one, answer "who changes this file, and for what reason?" Are there files with more than one reason?
★★★ Add the push channel to OrderService in the series example. How many places did you have to touch? Note each line you touched as an example of rigidity, fragility, immobility or viscosity; we will come back to this list at the end of the series.
In the next post we make these intuitions measurable: the kinds of coupling and the levels of cohesion, connascence for grading how strongly two parts are linked, and the Law of Demeter, which explains why chains like order.Customer.Wallet.Balance are dangerous.