Beyond SOLID: Embracing CUPID for Modern Software Craftsmanship
CUPID shifts focus from rigid SOLID rules to practical, human-centric principles that make code composable, idiomatic, and enjoyable to maintain.
Join the DZone community and get the full member experience.
Join For FreeFor decades, the SOLID principles — Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — have been the undisputed gold standard of object-oriented design. They were forged in an era of monolithic desktop applications and strict C++ or Java hierarchies.
However, as our industry has shifted toward microservices, serverless functions, and dynamic languages, many developers find that strictly following SOLID can lead to "over-engineering." We end up with an explosion of interfaces for single-method classes and a cognitive load that makes the codebase feel like a dense, impenetrable thicket.
Enter CUPID. Proposed by Dan North, CUPID isn't a set of rules or "principles" to be enforced by a linter. Instead, it is a set of properties — qualities that we can observe in code. If SOLID is about how to build the engine, CUPID is about how the car drives.
In this deep dive, we will explore the five properties of CUPID and how they lead to software that is not just "clean," but genuinely joyful to maintain.
1. Composable (C)
Composability is the ability to take small, independent pieces and snap them together to create a larger system. In a composable system, the "glue" is thin, and the components are unaware of each other's internals.
The Problem with Tight Coupling
In many SOLID-compliant systems, we use Dependency Injection to pass interfaces. While this decouples the implementation, it often couples the lifecycle. If Component A requires Component B to function, they are still technically coupled.
The Composable Approach
Composable code favors Data-In, Data-Out. Instead of objects calling methods on other objects, we focus on functions that transform data. This makes testing trivial because you don't need complex mocking frameworks; you just pass in data and assert the result.
Example: A Composable Discount Logic
# Instead of a complex DiscountService with multiple dependencies...
def apply_seasonal_discount(price): return price * 0.9
def apply_loyalty_discount(price): return price - 5.0
# Composability allows us to chain these easily
final_price = apply_loyalty_discount(apply_seasonal_discount(original_price))
2. Unix-inspired (U)
The Unix philosophy, established in the 1970s, remains one of the most successful architectural patterns in history: "Write programs that do one thing and do it well. Write programs to work together. Write programs to handle text streams, because that is a universal interface."
Small Tools, Big Power
In software design, being Unix-inspired means your modules should have a narrow, well-defined purpose. They should be "pipes" in a larger pipeline.
- Single Purpose: Avoid "God Objects" or "Manager" classes that handle everything from validation to database persistence.
- Standard Interfaces: Just as Unix tools use
stdinandstdout, your modules should use standard data structures (JSON, Lists, Dictionaries) rather than proprietary, deeply nested custom objects.
3. Predictable (P)
Predictability is perhaps the most underrated quality in software. A predictable module does exactly what you expect it to do — no side effects, no hidden global state, and no "magic" configurations.
Characteristics of Predictable Code:
- Observability: When something goes wrong, the error message tells you exactly why and where.
- Deterministic: Given the same input, it produces the same output every time.
- No Side Effects: It doesn't secretly change a global variable or update a database unless that is its explicit, documented purpose.
The "Least Astonishment" Principle
If a function is named get_user_balance(), it should not also trigger a refresh of the user's session token. When code does more than its name implies, it becomes unpredictable and dangerous.
4. Idiomatic (I)
Every programming language has a "vibe" or a "way" of doing things. Pythonistas call it being "Pythonic." Gophers call it "The Go Way."
Why Idiomatic Code Matters
When you write code that doesn't follow the language's idioms, you create friction for every other developer who touches the code. If you try to write Java-style code in JavaScript (using heavy class hierarchies and decorators where simple functions would suffice), you make the code harder to read for JS developers.
Example: Non-Idiomatic vs. Idiomatic (Python)
Non-Idiomatic (The "Java" way in Python):
class ListProcessor: def __init__(self, data): self.data = data def get_even_numbers(self): result = [] for i in range(len(self.data)): if self.data[i] % 2 == 0: result.append(self.data[i]) return result
Idiomatic (The Pythonic way):
def get_even_numbers(data): return [x for x in data if x % 2 == 0]
The idiomatic version is shorter, faster, and more readable to anyone familiar with the language.
5. Domain-driven (D)
Code exists to solve a real-world problem. Too often, our code reflects our technology stack rather than our business domain.
Speaking the Language
In Domain-Driven Design (DDD), we talk about the "Ubiquitous Language." This means the words used by the business stakeholders (e.g., "Premium," "Escrow," "Claim") should be the exact words used in the code.
- Technocentric naming:
process_data_table_row() - Domain-driven naming:
submit_insurance_claim()
When your code is domain-driven, a non-technical stakeholder should almost be able to read your logic and understand the business rules. This reduces the "translation tax" between the product team and the engineering team.
Comparing SOLID and CUPID
| Feature | SOLID | CUPID |
| Nature | Principles (Rules to follow) | Properties (Qualities to achieve) |
| Focus | Class/Interface structure | Developer experience/System behavior |
| Goal | Minimize technical debt | Maximize joy and adaptability |
| Level | Micro (Code level) | Macro (System/Ecosystem level) |
Applying CUPID in Your Workflow
How do you actually start using this? It begins with your next code review. Instead of checking if a class is "Open for extension but closed for modification" (which is often abstract), ask these questions:
- Is it Composable? Can I use this logic in a CLI tool as easily as in a Web API?
- Is it Unix-inspired? Is this function trying to be too "smart," or does it do one simple job?
- Is it Predictable? If I pass a
nullvalue here, will the error message be helpful or cryptic? - Is it Idiomatic? Does this look like the code I'd find in the official documentation for this language?
- Is it Domain-driven? Am I using business terms or database terms in my variable names?
The Role of AI in Achieving CUPID
Artificial Intelligence, like LLMs, is exceptionally good at helping us achieve the Idiomatic and Predictable parts of CUPID. You can ask an AI: "Rewrite this function to be more idiomatic in Kotlin," or "Generate unit tests for all edge cases to ensure this function is predictable."
However, AI often struggles with Domain-driven design because it doesn't truly understand your business context. That is where the human architect remains essential — ensuring the code maps to the real world.
Conclusion
SOLID was a response to the "spaghetti code" of the 90s. It served us well. But as we enter an era of high-velocity development and AI-assisted coding, we need a framework that prioritizes clarity and simplicity over rigid architectural patterns.
By aiming for CUPID properties, you aren't just building a system that works; you're building a system that lives and breathes with the business. You're building code that you — and the developers who come after you — will actually enjoy reading.
Opinions expressed by DZone contributors are their own.
Comments