Mert Özen Further With Every Line
Software Design SOLID and Design Principles — #12

Critiquing the Principles: CUPID, Over-Engineering and Where SOLID Really Pays Off

Mert Özen Nov 7, 2026 11 min 4 views
Critiquing the Principles: CUPID, Over-Engineering and Where SOLID Really Pays Off

SOLID's value depends on context: in multi-team, long-lived, frequently changing code it lowers the cost of change, while in scripts, prototypes and small CRUD apps it often just adds layers. The measured cost of adding a push channel to minimal, balanced and excessive designs of the same notification feature, Dan North's CUPID proposal and Ousterhout's deep module warning.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

In the first post of the series we took the Bookstore's order module and its notifications as the starting point and said the push channel would arrive one day. That day has come. Suppose three teams wrote the same feature, notifying the customer through their preferred channel when an order is placed, in three different ways:

  • Minimal: as in the first post, a switch inside OrderService. Two files.
  • Balanced: an INotificationChannel interface, one class per channel, wiring in the composition root. Three files.
  • Excessive: everything has an interface; a channel factory, message formatters, recipient resolvers, a notification service, a DTO (Data Transfer Object) and a mapper. Twenty files.

All three produce the same output today. How many files and how many lines does adding the push channel take in each? Which one follows SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) most closely, and which one is the cheapest?

Three Designs, One Change

I wrote all three designs, added the push channel to each, and measured them with a small script that compares the two versions with git diff --no-index:

#!/bin/zsh
# Size of each design (v1) and the cost of adding the push channel (v1 → v2)
cd "$(dirname "$0")"
printf "%-10s %6s %6s %6s %8s %8s\n" design files lines types changed added
for d in minimal balanced excessive; do
  files=$(find $d/v1 -name '*.cs' | wc -l | tr -d ' ')
  lines=$(cat $d/v1/**/*.cs | wc -l | tr -d ' ')
  types=$(cat $d/v1/**/*.cs | grep -cE '^public (sealed |static )?(class|interface|record|enum) ')
  stat=$(git diff --no-index --shortstat $d/v1 $d/v2)
  changed=$(echo $stat | grep -oE '[0-9]+ files? changed' | grep -oE '[0-9]+')
  added=$(echo $stat | grep -oE '[0-9]+ insertions?' | grep -oE '[0-9]+')
  printf "%-10s %6s %6s %6s %8s %8s\n" $d $files $lines $types $changed $added
done
design      files  lines  types changed    added
minimal         2     50      5        2        8
balanced        3     62      8        3       12
excessive      20    191     21        7       33

In all three, 2 lines are the demo itself (a new customer and a new order); the rest is the price of the design. The numbers alone don't deliver a verdict, so let's look at the changes one by one. In the minimal design the diff is (I removed the git diff header lines):

-public enum NotificationChannel { Email, Sms }
+public enum NotificationChannel { Email, Sms, Push }

-public record Customer(int Id, string Name, string Email, string Phone, NotificationChannel PreferredChannel);
+public record Customer(int Id, string Name, string Email, string Phone, NotificationChannel PreferredChannel,
+    string? DeviceToken = null);
@@ -32,6 +33,9 @@ public sealed class OrderService
             case NotificationChannel.Sms:
                 Console.WriteLine($"[sms] to {customer.Phone}: {message}");
                 break;
+            case NotificationChannel.Push:
+                Console.WriteLine($"[push] to {customer.DeviceToken}: {message}");
+                break;

This is the fewest lines. But the new lines went into the middle of the method that saves the order: to add push, you opened and changed the code that knows how orders are stored, and you need to run its tests again. In the balanced design OrderService was not touched at all:

// The one point expected to change: the channel
public interface INotificationChannel
{
    void Send(Customer customer, string message);
}

public sealed class OrderService(IReadOnlyDictionary<NotificationChannel, INotificationChannel> channels)
{
    private readonly List<Order> _orders = [];

    public Order PlaceOrder(Customer customer, IReadOnlyList<OrderItem> items)
    {
        Order order = new(1001 + _orders.Count, customer, items);
        _orders.Add(order);

        channels[customer.PreferredChannel].Send(customer, $"Order {order.Id} confirmed: {order.Total:0.00} EUR");
        return order;
    }
}
namespace Bookstore.Orders;

public sealed class PushChannel : INotificationChannel
{
    public void Send(Customer customer, string message) => Console.WriteLine($"[push] to {customer.DeviceToken}: {message}");
}
// Composition root
OrderService orders = new(new Dictionary<NotificationChannel, INotificationChannel>
{
    [NotificationChannel.Email] = new EmailChannel(),
    [NotificationChannel.Sms] = new SmsChannel(),
    [NotificationChannel.Push] = new PushChannel(),
});

Four more lines were written, but the change lives in a new file and in the composition root; the enum value and the field added to Customer are common to every design. The file list of the excessive design:

Abstractions/IChannelFactory.cs Abstractions/IMessageFormatter.cs Abstractions/INotificationChannel.cs Abstractions/INotificationService.cs Abstractions/IRecipientResolver.cs Channels/EmailChannel.cs Channels/SmsChannel.cs Domain/Customer.cs Domain/NotificationChannel.cs Domain/Order.cs Dtos/NotificationRequest.cs Formatters/EmailMessageFormatter.cs Formatters/SmsMessageFormatter.cs Mapping/NotificationRequestMapper.cs Program.cs Resolvers/EmailRecipientResolver.cs Resolvers/SmsRecipientResolver.cs Services/ChannelFactory.cs Services/NotificationService.cs Services/OrderService.cs
using Bookstore.Orders.Abstractions;
using Bookstore.Orders.Channels;
using Bookstore.Orders.Domain;
using Bookstore.Orders.Formatters;
using Bookstore.Orders.Resolvers;

namespace Bookstore.Orders.Services;

public sealed class ChannelFactory : IChannelFactory
{
    // Despite all the layers, the switch is back
    public (INotificationChannel Channel, IMessageFormatter Formatter, IRecipientResolver Resolver) Create(NotificationChannel channel) =>
        channel switch
        {
            NotificationChannel.Email => (new EmailChannel(), new EmailMessageFormatter(), new EmailRecipientResolver()),
            NotificationChannel.Sms => (new SmsChannel(), new SmsMessageFormatter(), new SmsRecipientResolver()),
            NotificationChannel.Push => (new PushChannel(), new PushMessageFormatter(), new PushRecipientResolver()),
            _ => throw new NotSupportedException($"Channel {channel} is not supported.")
        };
}

Push needed three new classes (channel, formatter, resolver) and one line in the factory's switch. Despite all those layers, the switch came back; it just moved to another file. The email and SMS (Short Message Service) formatters produce exactly the same text; the only reason they are separate classes is the thought that "they might diverge one day". This is the carry cost from the DRY (Don't Repeat Yourself) and YAGNI (You Aren't Gonna Need It) post made concrete: 191 lines against the minimal design's 50, yet none of the extra lines makes any change easier today; on top of that, every new channel demands three more files, and someone reading the code opens six files to understand how one notification is sent.

The conclusion of this measurement is not "minimal is best". With two channels and a single team, the minimal design is perfectly reasonable; moving to the balanced design when a third channel arrives is a small refactoring. The real question is which direction change comes from and how often. Principles don't answer that question; they make you ask it.

Criticisms of the Principles

Vagueness. SRP (Single Responsibility Principle) says "a class should have one reason to change", but it doesn't say what a "reason" is. Robert C. Martin (the author who collected and popularized SOLID) had to write a separate post in 2014 to explain the principle and redefined the reason as "the person or group requesting the change", that is, an actor; this series' SRP post started from that definition. When a principle's author has to re-explain it twenty years later, it shows how differently the words can be read.

Interface inflation. The header interfaces we saw in the DIP (Dependency Inversion Principle) post, an I-prefixed twin next to every class. In the excessive design above, five of the twenty-one types are interfaces and none of them has a second implementation. ISP (Interface Segregation Principle), when misread as "single-method interfaces", feeds the same inflation.

Mapping per layer. A request from the API (Application Programming Interface) is turned into a DTO, the DTO into a command, the command into an entity, the entity into a persistence model, each so that its layer stays "independent". Four types, three mappers, and four places to change for every new field. If the independence is worth something, you pay the price; if not, only the price remains.

Context. SOLID was born in the C++ world of the 1990s; one justification for ISP was that changing a header file recompiled everything that depended on it. In his 2020 post "Solid Relevance", Martin argues that this still holds in compiled languages; the reference assembly measurement in the previous post also showed that dependents really do recompile when the public surface changes. But, as the same post showed, when only a method body changes, .NET does not recompile the dependents; the weight of some justifications has shifted.

Dan North and CUPID

The best-known criticism came from Dan North (who introduced behaviour-driven development). In his five-minute talk "Why Every Element of SOLID is Wrong" at PubConf London in 2016, he repeated the same advice for every letter: write simple code. In 2020 Martin answered letter by letter in "Solid Relevance"; he agreed with "write simple code" but argued that reaching simplicity takes discipline and principles, and that just telling people "be simple" is the shortest road to a complicated mess.

In 2022 North turned the criticism into a proposal: CUPID (Composable, Unix philosophy, Predictable, Idiomatic, Domain-based). The core idea of his post "CUPID—for joyful coding" is properties instead of principles: a principle is a rule, code either complies or it doesn't; a property is a quality you can move toward, and code is always a little closer to it or a little further away. The five properties in the acronym:

  • Composable: plays well with others; a small surface, few dependencies, names that reveal intent. LINQ (Language Integrated Query) operators in C# are a good example.
  • Unix philosophy: does one thing well; similar to SRP, but framed around purpose rather than reasons to change.
  • Predictable: does what you expect; deterministic, observable, no surprises. A relative of the Principle of Least Astonishment and fail fast.
  • Idiomatic: feels natural; follows the conventions of the language and the team. In .NET this means following the naming and API conventions of the Framework Design Guidelines (Microsoft's .NET library design rules).
  • Domain-based: the language and structure of the solution reflect the language and structure of the problem; the central idea of the upcoming Domain-Driven Design series.

CUPID is not a rule set that replaces SOLID but a different point of view. SOLID looks at the structure of code in the face of change; CUPID looks at the experience of the people who read and use the code. You can use both: next to "does this class follow SRP?" add "could someone seeing this code for the first time guess what it does?".

Deep Modules: Ousterhout's Warning

John Ousterhout (computer science professor at Stanford and creator of the Tcl language) reaches the same place from a different angle in his 2018 book A Philosophy of Software Design. A good module should be "deep": powerful functionality behind a simple interface. A bad module is "shallow": its interface is as complex as the functionality it provides. He calls the approach that treats small classes as a virtue in themselves "classitis", and gives as an example the need in Java to stack three streams by hand to read objects from a file; something almost everyone wants, like buffering, should have been the default.

This is an interesting counterweight to the Stream decorators we praised in the composition post. .NET offers both paths here: FileStream buffers internally by default, and File.ReadAllText hides opening the file, detecting the encoding and closing it behind a single call. Both are deep modules. The EmailRecipientResolver in the excessive design, on the other hand, is a textbook shallow module: it hides a one-line property access behind an interface and a class.

When to Relax SOLID

Scripts and prototypes. Building abstractions for a data migration script that runs once and is thrown away, or for a prototype written to see whether an idea works, is investing in a question whose answer you don't know yet. When the prototype is chosen to live on, rewriting or refactoring it is cheaper.

Small CRUD (Create, Read, Update, Delete) applications. In an application with no business rules that moves data from tables to screens, repository, service and mapper layers often just pass things through. The "Layered Structure in ASP.NET Core Web API: The Service Layer and the Repository Debate" post in the ASP.NET Core Web API series discussed how Entity Framework Core's (EF Core) DbContext already is a repository and a unit of work.

Hot paths. In a loop called millions of times per second, every interface call is an indirection. Dynamic PGO (profile-guided optimization), on by default since .NET 8, erases most of that cost by letting the JIT (Just-in-Time compiler) guess the most frequently seen implementation at run time and call it directly; sealed classes give the JIT the same information up front. Still, don't decide without measuring: BenchmarkDotNet first, design change second.

Where SOLID Really Pays Off

Multi-team code bases. According to the observation Melvin Conway made in 1968, systems mirror the communication structure of the organization that designs them. If three teams change one order module together, SRP's actor definition and the component principles are not theory but the remedy for daily friction.

Long-lived, frequently changing domains. In a system that will live for five years and get a new channel, a new campaign or a new payment provider every month, separating the points of change in advance means not a twenty-file design like the one above, but a three-file design in the right place.

Public libraries. Taking back an API that others use is very expensive. If you publish a NuGet package, LSP (Liskov Substitution Principle), ISP and the component principles are not a preference but part of the promise you make to your users. That is why the .NET team is so cautious about virtual members and extensibility points.

Wrapping Up the Series

Over twelve posts we treated each principle as a question. In a single table:

PrincipleThe question it asks
Coupling, cohesion, Law of DemeterHow tightly are these two parts bound, and why?
SRPWho wants to change this code, and for what reason?
OCP (Open/Closed Principle)Can the expected change be added without opening existing code?
LSPDoes the subtype keep the promise the base type makes?
ISPDoes the client depend on things it doesn't actually use?
DIPDoes the compile-time arrow point from policy to detail, or from detail to policy?
DRY, KISS (Keep It Simple, Stupid), YAGNIHow many places hold this knowledge, and what does this complexity serve today?
Composition over inheritanceIs the behavior buried in a hierarchy, or can it be plugged in and out?
Fail fast, CQS (Command-Query Separation), GRASP (General Responsibility Assignment Software Patterns)When does the error become visible, does the question change the answer, who owns the responsibility?
Component principlesDoes this project hold what changes together, and do the arrows point toward stability?

The common criterion under all of them is the same: how much will the next likely change cost? The four symptoms we listed in the first post (rigidity, fragility, immobility, viscosity) were four views of that cost.

Common Pitfalls

Making the principle the goal. "Let this code be SOLID" is not a goal; "adding the push channel should take one file" is. Principles help you make decisions on the way to the goal.

Using criticism as a reason to throw the principle away. North's criticism and Ousterhout's warning are both aimed at overuse. A code base that was never separated, where everything depends on everything, doesn't satisfy CUPID either.

Designing once and walking away. The minimal design should evolve into the balanced one when a third channel arrives, and the balanced one into further separation at ten channels and five teams. The right design is not a moment but a direction.

Arguing in acronyms. Instead of "this is an OCP violation", saying "when the push channel arrives we'll have to open OrderService and rerun its tests" is both more convincing and verifiable.

A Small Experiment

★☆☆ Apply a more ordinary change than adding a channel to all three designs: add the order lines to the confirmation text. How many files changed in which design? Did the ranking stay the same as for the push channel?

★★☆ Refactor the excessive design toward the balanced one: which interfaces can you delete, which classes can you merge? Before deleting each, ask "does it have a second implementation today, or will it soon?".

★★★ At the end of the first post we asked you to note every line you touched while adding the push channel under rigidity, fragility, immobility and viscosity. Open that list, make the same change in the balanced design, and write down which principle made each line disappear.

The SOLID and Design Principles series ends here. The principles were the criteria you use to judge whether a design is good; the next step is the named solutions to problems those criteria keep running into. In the upcoming Design Patterns series we will read Strategy, Decorator, Adapter and the others together with the questions of this series; readability at the level of methods and names is the subject of the Clean Code series.