CUPID - is SOLID slowly becoming obsolete?
If you have been in the industry for years, the SOLID principles are probably like the Ten Commandments to you. We memorised them before job interviews, applied them during code reviews, and defended them fiercely in architectural discussions. But the world of technology does not stand still. Have you recently wondered whether the rules carved in stone decades ago still fit perfectly into the era of the cloud, microservices and the ubiquitous rush? Recently, there has been a lot of buzz in the engineering community about the concept of CUPID – an approach that has the potential to become a modern alternative, or perhaps a necessary complement to our good old toolkit.
Why is SOLID sometimes problematic?
We must remember the historical context. The SOLID principles were developed at a time when the software development cycle looked completely different. The 1980s and 1990s were an era when version control systems were in their infancy and compiling a large project could take hours. Back then, the Open/Close Principle, which states that code should be closed for modification and open for extension, was a godsend. It protected us from costly tinkering with existing, working code.
Today, the situation is completely different. We have powerful IDE environments that can safely rename a method in thousands of files in seconds. We have automated tests and CI/CD processes that immediately catch regressions. In such an environment, rigid adherence to some SOLID principles can lead to excessive abstraction and complexity. Sometimes a simple modification of an existing method is better and more readable than creating additional layers of inheritance or design patterns just to satisfy dogma. In addition, SOLID focuses heavily on code structure and somewhat forgets what is most important – the comfort of the programmer's work.
CUPID: in search of "joyful software"
In response to these challenges, Dan North proposed an approach known as CUPID. It is not a set of strict rules that you either follow or fail to follow. Rather, it is a set of characteristics that define ‘joyful software’ – software that is a pleasure to work with, rather than a source of frustration. The key concept here is ‘habitability’, i.e. the suitability of the code for habitation. Code should be like a comfortable, tidy house that is easy to find your way around, rather than a maze full of traps. The acronym CUPID expands into five key characteristics: Composable, UNIX philosophy, Predictable, Idiomatic and Domain-based. Let's take a closer look at them by analysing the code.
Composable: the art of creating small building blocks
Composability is the first feature. It means that our modules should easily work with others and be ready for reuse. The key here is ‘small surface area’, i.e. an API that is small enough to be easy to use, but complete enough so that we don't have to improvise. It is also important to use names that reveal the author's intentions and to minimise external dependencies, which are often a source of conflict.
Let's look at an example of classes in Ruby:
class OrderProcessor
def initialize(order_repository:, payment_gateway:, notification_service:)
@order_repository = order_repository
@payment_gateway = payment_gateway
@notification_service = notification_service
end
def call(order_id)
order = @order_repository.find(order_id)
return false unless order # Check if order exists
# Assume we have an order object with details
if @payment_gateway.charge(order.amount, order.customer_info)
order.status = :paid
@order_repository.save(order)
@notification_service.send_confirmation(order)
true
else
order.status = :payment_failed
@order_repository.save(order)
@notification_service.send_failure_notification(order)
false
end
rescue StandardError => e
# Log the error, potentially notify admin
puts "Error processing order #{order_id}: #{e.message}"
false
end
endIn the example above, OrderProcessor clearly defines what it needs to function by accepting dependencies in its constructor. Its public API is simple and understandable, which makes it easy to plug into different parts of the system without dragging along unnecessary dependencies.
UNIX philosophy: do one thing, but do it well
Another pillar is the Unix philosophy. It boils down to creating components that perform one specific task and do it perfectly. This approach promotes outside-in thinking – we are interested in what a given piece of code does for the user or another module, not how complex its internal structure is. Small, specialised tools can then be combined into powerful processing pipelines, just as we do in the terminal by combining grep, sort and uniq.
class ReportGenerator
def call(data)
# Stage 1: Filter relevant data
filtered_data = filter_data_for_month(data, Date.today.month)
# Stage 2: Aggregate sales
aggregated_sales = aggregate_sales_by_product(filtered_data)
# Stage 3: Format report for output
format_report_for_email(aggregated_sales)
end
private
def filter_data_for_month(data, month)
data.select { |item| item[:date].month == month }
end
def aggregate_sales_by_product(data)
data.group_by { |item| item[:product_id] }
.transform_values { |items| items.sum { |item| item[:quantity] * item[:price] } }
end
def format_report_for_email(aggregated_sales)
# Logic to format string output
"Monthly Sales Report:\n" +
aggregated_sales.map { |product_id, total_sales| "Product #{product_id}: $#{total_sales}" }.join("\n")
end
endInstead of one giant method, ReportGenerator delegates tasks to smaller, specialised private functions. Each of them implements the Unix philosophy on a micro scale, which drastically simplifies reading and maintaining the code.
Predictable: trustworthy software
Predictability is a feature that is often lacking in modern systems. Predictable code is code that behaves deterministically and does not serve up surprises. If something is supposed to work, it works. If it is supposed to throw an error, it does so in a controlled manner. An extremely important piece of this puzzle is observability. Predictable systems are designed with monitoring and telemetry in mind from the very first line of code, rather than as an ‘add-on’ added after the first serious production failure.
class PaymentService
def process_payment(amount, currency, customer_id)
# Log key information for observability clearly
Rails.logger.info("Attempting to process payment for customer_id: #{customer_id}, amount: #{amount} #{currency}")
if amount > 0 && customer_id
# Simulate payment processing logic
successful = true
if successful
Rails.logger.info("Payment successful for customer_id: #{customer_id}")
{ status: :success, transaction_id: SecureRandom.uuid }
else
Rails.logger.warn("Payment failed for customer_id: #{customer_id}.")
{ status: :failure, error: "Payment gateway error" }
end
else
Rails.logger.error("Invalid input: amount must be positive.")
{ status: :failure, error: "Invalid input" }
end
rescue StandardError => e
# Catch unexpected errors to maintain predictability boundaries
Rails.logger.fatal("Critical error in PaymentService: #{e.message}")
{ status: :failure, error: "Unexpected system error" }
end
endThanks to built-in logging and exception handling, the behaviour of PaymentService is easy to track. Even in the event of a critical error, the system does not ‘explode’ in an uncontrolled manner, but returns a predictable error message.
Idiomatic and Domain-Based: feel at home
The last two letters of the acronym refer to naturalness and business language. Idiomatic code is code that respects the conventions of a given programming language. We do not try to write in Ruby as if we were writing in Java or C#. We use native constructs, blocks, and formatting standards, making the code understandable to every team member almost immediately. The domain-based approach, on the other hand, is a nod to Domain-Driven Design. The structure of the code and naming conventions should reflect business reality, eliminating the need to constantly translate technical terms into business terms in your head.
# Domain-based approach using Ubiquitous Language
class Book
attr_reader :isbn, :title, :author
def initialize(isbn:, title:, author:)
@isbn = isbn
@title = title
@author = author
end
end
class LibraryPatron # "Patron" is a specific domain term
attr_reader :library_card_number, :name
def initialize(library_card_number:, name:)
@library_card_number = library_card_number
@name = name
end
end
class LendingService
def lend_book(book:, patron:)
# Using specific domain language "lend" instead of generic "process"
puts "Lending '#{book.title}' to patron '#{patron.name}'"
end
endIn this example, the code speaks the language of a librarian. We do not have generic User or Item, but specific LibraryPatron and Book. This makes the barrier to entry into the project much lower for a newcomer – and even for a domain expert.
Is this the end of SOLID?
Absolutely not. Treating CUPID as the killer of SOLID is a mistake. These two approaches operate at different levels. SOLID works great as a set of tools for managing low-level class structure and dependencies. CUPID goes a level higher, offering a set of properties for the entire system and focusing on the developer experience. Instead of zero-one rules, we get sliders that we can use to adjust the quality of our software. It is worth incorporating CUPID thinking into your arsenal to create code that is not only technically correct, but also simply user-friendly in everyday use.
Happy CUPID-ing!