Programming Principles: DRY, KISS, SOLID, YAGNI and More

I’ve written before about the principle I use most when designing software, doing the simplest thing that could possibly work. This post collects the other programming principles and laws I keep returning to, from DRY and SOLID to Conway’s law and Brooks’s law. None of them can be applied mechanically, and several pull against each other. DRY competes with the Rule of Three, YAGNI with the Open/Closed Principle, and failing fast with degrading gracefully. Much of software design is deciding which principle should win in the case you’re looking at. For each principle, I’ve included where it came from and where it stops being useful.

Principles for writing code

Don’t Repeat Yourself (DRY) and the Rule of Three

Andy Hunt and Dave Thomas introduced DRY in The Pragmatic Programmer in 1999: “Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.” The key word is knowledge. Two methods that share five identical lines aren’t necessarily a DRY violation. If they exist for different reasons, say a shipping fee and a tax calculation that happen to round the same way today, merging them ties together two things that will change separately. The real violations are duplicated knowledge that doesn’t look duplicated: a validation rule written once in C# and again in JavaScript, or an order status hard-coded in the application, the database and the reports.

The Rule of Three, which Martin Fowler credits to Don Roberts in Refactoring, keeps DRY in check. Tolerate the second copy, and extract an abstraction when the third one appears and you can see what the copies really have in common. Abstracting too early has a cost. As Sandi Metz put it, “duplication is far cheaper than the wrong abstraction,” because removing a wrong abstraction later means first undoing everything that was built on top of it.

Keep It Simple (KISS)

KISS is usually traced to Kelly Johnson, who ran Lockheed’s Skunk Works and wanted jet aircraft that an average mechanic could repair in the field with basic tools. In software, it means choosing the design with the fewest moving parts that still does the job. The Extreme Programming version, Ward Cunningham and Kent Beck’s “do the simplest thing that could possibly work,” adds a time element. You build the simple version first, get feedback, and let real requirements push the design forward instead of guessing at them up front. That doesn’t mean taking the easiest hack or ignoring a problem you can already see. My earlier post covers this in detail. It works best together with YAGNI.

You Aren’t Gonna Need It (YAGNI)

Ron Jeffries, one of the creators of Extreme Programming, summed up YAGNI as “Always implement things when you actually need them, never when you just foresee that you need them.” A plugin system nobody plugs into, or a configuration option that never changes from its default, still has to be read, tested and maintained by everyone who comes later, whether or not the future it was built for ever arrives. When speculative code like that is already in the codebase, delete it the next time you refactor that area. YAGNI applies to features and flexibility, not to quality. Tests, logging and input validation aren’t speculative.

Readability: code for the maintainer

Code is read far more often than it’s written. In Clean Code, Robert C. Martin puts the ratio at “well over 10 to 1,” and anyone who has spent a morning decoding a single line of LINQ will believe him. When brevity and clarity conflict, choose clarity:

// Short, and you have to decode it
var r = o.Where(x => x.S == 2 && x.D > DateTime.UtcNow.AddDays(-30)).Sum(x => x.T * (1 - x.Dc));

// Longer, and you can read it
DateTime cutoff = DateTime.UtcNow.AddDays(-30);
decimal recentShippedRevenue = orders
    .Where(order => order.Status == OrderStatus.Shipped && order.ShippedAt > cutoff)
    .Sum(order => order.Total * (1 - order.Discount));

The person who maintains your code may be a colleague, a new hire, or you a year from now, long after you’ve forgotten why it works. John Woods put it more vividly in a 1991 newsgroup post: “Always code as if the guy who ends up maintaining your code will be a violent psychopath who knows where you live.” In practice, that means:

  • Use names that say what something is for. recentShippedRevenue tells you what r doesn’t.
  • Keep functions short enough to understand without scrolling.
  • Write comments that explain why. The code already shows what it does.
  • Follow the principle of least astonishment. A method called GetCustomer shouldn’t create a customer when it can’t find one.

Consistency

I’d argue consistency underlies most other clean code practices. If you handle errors, name things or structure a feature one way, do it the same way throughout the project, so a reader who has learned one part of the codebase can find their way around the rest. When you do have to break from the pattern, write down why. A comment is enough for a single exception. A short architecture decision record (ADR) is better for anything bigger, because the next developer will otherwise assume the inconsistency was an accident. A shared coding convention enforced by tooling takes care of most of this.

The Boy Scout Rule

Robert C. Martin popularized this in Clean Code, adapting Scouting founder Robert Baden-Powell’s “Try and leave this world a little better than you found it.” Every time you touch some code, leave it slightly cleaner. Rename a confusing variable, extract a method from a long function, or delete the dead code next to the line you came to change. Over months, those small improvements add up to a codebase that gets better instead of worse.

The rule is about code you’re already working on. It’s not an excuse to refactor half the system during a bug fix. Put cleanup in its own commit, or its own pull request, separate from the behavior change, so a reviewer can tell which lines changed what the code does. Cleaning up also has to be balanced against shipping working software.

Avoid premature optimization

Donald Knuth’s line, “premature optimization is the root of all evil,” comes from his 1974 paper Structured Programming with go to Statements, and it’s usually quoted without its context. The full passage reads: “We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.” Kent Beck’s version gives the order of work: “Make it work, make it right, make it fast.”

The reason is that guesses about where a program spends its time are usually wrong, and optimized code is harder to read, debug and change. So measure first. BenchmarkDotNet handles micro-benchmarks, and dotnet-counters, dotnet-trace and the Visual Studio profiler show where a running app actually spends its time. That doesn’t excuse bad decisions at the design level, though. Choosing an O(n²) algorithm over a large list, or writing a query that hits the database once per row, is cheap to avoid early and expensive to fix later. In performance-critical domains like games or high-frequency trading, performance is part of the requirements from the first day.

Principles for designing code

SOLID

Robert C. Martin collected these five object-oriented design principles around 2000, and Michael Feathers later arranged them into the acronym. Each one is easy to quote and easy to misread:

  • Single Responsibility Principle: In Martin’s later wording, “a module should be responsible to one, and only one, actor.” An InvoiceService that calculates totals, renders the PDF and sends the email changes whenever the finance team changes a pricing rule or the design team changes the layout. Those are two reasons to change, so the class should be split into two.
  • Open/Closed Principle: Bertrand Meyer’s principle (1988) says software should be open for extension and closed for modification. Today that usually means you add a new payment provider by writing a new IPaymentProvider implementation, not by adding another case to a switch statement in tested code. This is where OCP runs into YAGNI. Add the extension point when the second variation actually arrives, not when you first imagine it.
  • Liskov Substitution Principle: Barbara Liskov introduced this in 1987. A subtype must work anywhere its base type is expected, without surprising the caller. .NET has a well-known violation of its own: ReadOnlyCollection<T> implements ICollection<T>, but its Add method throws NotSupportedException. Code that accepts an ICollection<T> and calls Add compiles fine and fails at runtime. That’s why .NET 4.5 added IReadOnlyCollection<T>.
  • Interface Segregation Principle: Clients shouldn’t be forced to depend on methods they don’t use. A class that only reads orders shouldn’t need a fifteen-method IOrderRepository that includes delete and bulk import. Give it a small IOrderReader instead.
  • Dependency Inversion Principle: High-level policy shouldn’t depend on low-level details. Both should depend on abstractions, and the abstractions shouldn’t depend on details either. Registering a class in a DI container doesn’t satisfy DIP by itself. The point is that the business layer owns the interface, for example IOrderNotifier, and the email or SMS adapter implements it.

Program to an interface, and prefer composition

Both principles come from the Gang of Four’s Design Patterns (1994): “Program to an interface, not an implementation” and “Favor object composition over class inheritance.” The first means code should depend on the behavior it needs, not on a specific class. .NET’s sorting works this way. List<T>.Sort doesn’t care what it’s sorting, only that the items can be compared:

public sealed class ByShippingDate : IComparer<Order>
{
    public int Compare(Order? x, Order? y) => Nullable.Compare(x?.ShippedAt, y?.ShippedAt);
}

orders.Sort(new ByShippingDate());

The same sort works for any type and any ordering you can describe, which also makes the code easy to test. Composition follows the same idea. Instead of building a deep inheritance tree, combine small objects that each do one job. Then you can change behavior by swapping one of those objects, without touching a base class that a dozen subclasses depend on. One caveat: “interface” here means a contract, not necessarily a C# interface. Creating an IOrderService for every OrderService that will only ever have one implementation adds files without adding flexibility. The exception is when you need the interface as a seam for tests.

Separation of concerns and decomposition

Edsger Dijkstra coined “separation of concerns” in 1974, and it’s the principle I rely on most after simplicity. It means splitting a program into parts that each handle one concern. When I build a web application with user registration and login, those live in separate modules, so a change to the registration flow can’t break login. Separate modules also let two developers work on them at the same time without stepping on each other.

Decomposition, or “divide and conquer,” is how you get there. Break a large problem into pieces small enough to build, test and explain on their own, then put them back together. The skill is in where you draw the lines. Split along the lines where things change independently, the same reasoning as the Single Responsibility Principle. Splitting along technical layers alone can mean every new feature touches every piece.

The Law of Demeter

The Law of Demeter, also called the principle of least knowledge, came out of the Demeter Project at Northeastern University in 1987. It’s often summarized as “only talk to your immediate friends.” Formally, a method M of an object O should only call methods on:

  1. O itself
  2. M’s parameters
  3. objects M creates
  4. O’s direct component objects
  5. global objects accessible to O in the scope of M
// Reaches through three objects, so this code depends on the shape of all of them
string city = order.Customer.Address.City;

// Asks the order for what it needs
string city = order.ShippingCity;

If the order later stores its shipping address separately from the customer’s address, only Order has to change, not every caller. Like most principles, this one has limits. Fluent APIs and LINQ chains don’t violate it, because each call returns the same object or a new one, not something internal. Plain data objects such as DTOs and records exist to expose their data, so reading through them is fine. Applied too strictly, the law gives you classes full of one-line wrapper methods.

Fail fast, then degrade gracefully at the edges

Jim Shore made the case for failing fast in a 2004 IEEE Software column. When something is wrong, stop as close to the cause as possible. If you don’t, bad data travels through the system and causes a confusing failure somewhere else, often after it has already corrupted something. In .NET, that means guard clauses like ArgumentNullException.ThrowIfNull, nullable reference types, and validating configuration when the app starts instead of when it’s first used:

builder.Services.AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection("Smtp"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

A missing SMTP host now stops the app at deployment, not hours later when the first email fails to send.

Graceful degradation sounds like the opposite, but it applies to a different kind of problem. If the recommendations service is down, the product page should still render, just without recommendations. A useful way to tell them apart is the cause. Bugs and invalid state should fail fast. An unavailable dependency should degrade gracefully. Degrading should never mean quietly saving bad data, which is exactly what failing fast is there to prevent.

Occam’s razor

Occam’s razor is attributed to the 14th-century friar William of Ockham. It says that among explanations that fit the facts, prefer the one with the fewest assumptions. For design, that’s KISS again. Where it really helps is debugging. When an API that worked yesterday starts failing, look at what changed in yesterday’s deployment, and check for an expired certificate, before you suspect a compiler bug or a race condition in the framework. The boring explanation is usually the right one, and it’s quicker to rule out.

Don’t reinvent the wheel, but look at what you’re installing

Before you build something, check whether a library, an API or a pattern already solves the problem. Look at how others have handled it, and sketch at least two designs before committing to one, which helps you avoid tunnel vision on your first idea. Reuse has costs too. Every dependency needs updating and security patches, and it may disappear. In 2016, an 11-line npm package called left-pad was unpublished and broke thousands of builds. Check how well a library is maintained, what license it uses and how many dependencies it pulls in, the same way you’d review code you wrote yourself.

Principles about people and projects

Conway’s law

In 1968, Melvin Conway wrote in Datamation: “Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization’s communication structure.” Eric Raymond put it more bluntly: if four groups work on a compiler, you’ll get a four-pass compiler. Teams that rarely talk to each other build components with awkward, carefully negotiated interfaces between them. A single team owning everything tends to produce a single tightly coupled system.

The useful part is that this works in both directions. If you want a particular architecture, organize the teams to match it first, which is known as the “inverse Conway maneuver.” A move from a monolith to microservices rarely succeeds if every service is still owned by the same shared team. Team Topologies by Matthew Skelton and Manuel Pais is a book-length guide to applying this deliberately.

Brooks’s law

“Adding manpower to a late software project makes it later.” Fred Brooks wrote this in The Mythical Man-Month in 1975. There are two reasons. New people need weeks to become productive, and that time comes out of the existing team, who have to answer their questions. Communication also grows quadratically, not linearly. A team of n people has n(n−1)/2 possible communication paths, so 5 people have 10, 10 people have 45 and 20 people have 190. And some work can’t be split at all. In Brooks’s words, “The bearing of a child takes nine months, no matter how many women are assigned.”

Brooks himself later called the law an “outrageous oversimplification,” and it does have exceptions. Adding people early in a project, or to work that splits cleanly, like testing, documentation or a separate module, can speed things up. For a late project, though, cutting scope is almost always more effective than adding people.

The project management triangle

Scope, time and cost are linked. Change one and at least one of the others has to move, and quality, which sits in the middle of the triangle, suffers when all three are squeezed at once. The informal version is “fast, good, cheap: pick two.” The triangle mainly helps when you’re setting expectations. When a stakeholder adds a feature mid-project, it gives you a neutral way to ask which of the other two should give. Agile methods change which corner is flexible. A sprint fixes time and the team (and therefore cost), so scope is what adjusts. That’s why a well-run Scrum team cuts scope instead of missing the date.

Keep learning

.NET ships a major version every November, and React, TypeScript and the cloud platforms change at least as quickly. Some of the advice I learned early in my career is now outdated. BinaryFormatter has been removed, and the guidance on password policies has reversed. Read the release notes for your main stack once a year, try new features on a side project before you need them at work, and read the books below.

References and further reading

  1. The Pragmatic Programmer: Your Journey to Mastery (20th anniversary edition) by David Thomas and Andrew Hunt
  2. Clean Code: A Handbook of Agile Software Craftsmanship by Robert C. Martin
  3. Extreme Programming Explained: Embrace Change by Kent Beck and Cynthia Andres
  4. Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides
  5. Code Complete: A Practical Handbook of Software Construction by Steve McConnell
  6. Refactoring: Improving the Design of Existing Code by Martin Fowler
  7. The Mythical Man-Month: Essays on Software Engineering by Frederick P. Brooks Jr.
  8. Building Microservices: Designing Fine-Grained Systems by Sam Newman
  9. Object-Oriented Design Heuristics by Arthur J. Riel
  10. Code Simplicity: The Fundamentals of Software by Max Kanat-Alexander
  11. Effective Java (3rd edition) by Joshua Bloch
  12. You Don’t Know JS (book series) by Kyle Simpson
  13. The Wrong Abstraction by Sandi Metz
  14. Fail Fast by Jim Shore (IEEE Software, PDF)
  15. How Do Committees Invent? by Melvin Conway (1968)
Share...
 

Hamid Mosalla

Hi, I'm Hamid ("Arman"). I'm a software developer with 8+ years of experience in C#, .NET Core, Software Architecture and Web Development. I enjoy creating dev tools, contributing to open-source projects, and sharing insights on my blog. Outside of tech, I’m into indie cinema, classical music and abstract art.

 

Leave a Reply

Your email address will not be published. Required fields are marked *