CUPID - is SOLID slowly becoming obsolete?

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
end

In 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 grepsort 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
end

Instead 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
end

Thanks 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
end

In 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!