# CUPID Beyond SOLID: Five Properties for More Habitable Code

A codebase can have small classes, dependency injection, carefully separated interfaces, and several familiar design patterns, yet still be exhausting to maintain.

The design may look correct through the lens of SOLID, yet understanding one use case requires opening fifteen files. Names describe technical roles instead of business concepts. Important effects happen behind events. Interfaces have one implementation, factories build other factories, and nobody can predict what a method will do from its name alone.

This does not make SOLID useless. It suggests that SOLID does not describe every quality that makes software pleasant and safe to change. Richard Gabriel called one of those missing qualities *habitability*: the degree to which someone can move into a codebase, understand it, and change it without rebuilding it first.

In [“What Does SOLID Mean to You?”](https://rodrigojuarez.hashnode.dev/what-does-solid-mean-to-you), I described SOLID as a set of principles for reducing the cost and risk of software change. The value is not in reciting five definitions or maximizing the number of abstractions. It is in making responsibilities, contracts, and dependencies easier to reason about.

CUPID, proposed by Dan North, approaches the same broad goal from a different direction. Instead of prescribing structures, it describes five properties of code that is enjoyable, or at least comfortable, to work with.

## A question raised by React

The same question ran through [a Spanish-language Reddit thread about what SOLID means in practice](https://www.reddit.com/r/devsarg/comments/1vmisa0/que_significa_solid_para_vos/). In summary, some developers argued that applying SOLID literally in React feels unnatural because modern React favors functions, hooks, and composition rather than object-oriented class hierarchies. Others believed that the underlying concerns can still be translated into focused components, small prop contracts, composition, and explicit dependencies.

The disagreement is useful even without a winner. SOLID originated in object-oriented design, so some formulations become awkward when carried literally into another paradigm. At the same time, cohesion, stable contracts, and dependency direction remain relevant beyond classes.

React is a good example of why we should separate a design goal from its historical implementation. React still supports class components, but its [documentation recommends function components for new code](https://react.dev/reference/react/Component). Forcing every SOLID letter into classes and interfaces would be unidiomatic. Abandoning all thought about responsibilities and dependencies would be equally unhelpful.

CUPID avoids much of this translation problem because its properties apply to functions, components, hooks, modules, services, and entire systems.

## Where CUPID came from

Dan North first challenged SOLID in a deliberately provocative five-minute talk titled *Why Every Element of SOLID is Wrong*, given at PubConf London in 2016. When asked what he would propose instead, he began developing five ideas that became CUPID. He described the context in [“CUPID: the back story”](https://dannorth.net/blog/cupid-the-back-story/) and presented the full proposal in [“CUPID: for joyful coding”](https://dannorth.net/blog/cupid-for-joyful-coding/).

The important shift is from **principles** to **properties**.

A principle offers general guidance and requires interpretation. A property is a quality that code can exhibit to a greater or lesser degree. A heuristic is a contextual practice that may help us move toward that property. A rule is an explicit constraint with a relatively binary outcome.

SOLID consists of principles. CUPID describes properties. Recommendations such as minimizing dependencies are heuristics, not guarantees. A security requirement such as “never store credentials in source control” is a rule.

This distinction changes the conversation. Code does not pass or fail CUPID. It can become more predictable, more composable, or closer to its domain through a small change.

## The five CUPID properties

CUPID is a backronym built from five properties: **C**omposable, **U**nix philosophy, **P**redictable, **I**diomatic, and **D**omain-based.

### Composable: plays well with others

Composable code can be used, tested, and combined without pulling in the application around it. It has an intention-revealing API, a manageable surface area, and no unnecessary dependencies.

```ts
const pricedOrder = addTaxes(
  applyDiscounts(
    priceLineItems(order)
  )
);
```

Each step takes a priced order and returns a more complete one, so the following step still has everything it needs. None of them requires a global container or the complete checkout process.

More fragmentation is not always better. An API can become so finely divided that consumers must know a secret sequence of ten functions. The goal is a cohesive unit that is small enough to combine and complete enough to be useful.

> **Review question:** Could I use or test this piece without starting half of the application?

### Unix philosophy: does one thing well

The Unix property asks whether a component has one clear and complete purpose. It overlaps with the Single Responsibility Principle, but the perspective is different. SRP often looks inward at reasons to change; the Unix property looks outward at what the component offers its consumer.

A use case that coordinates three steps still does one thing, provided those steps stay visible:

```ts
const payment = await authorizePayment(order, card);
await paymentStore.save(payment);
await receiptSender.send(payment.receipt);
```

Authorization, persistence, and notification are visible operations. The orchestration is a legitimate responsibility of its own, because their order belongs to the use case.

“One thing” does not mean one line, one method, or one microservice. Splitting a workflow into dozens of trivial classes may hide its purpose rather than clarify it.

> **Review question:** Can I describe this component without repeatedly saying “and it also…”?

### Predictable: does what you expect

Predictable code behaves as its name and structure suggest. Its results, side effects, failure modes, and operational boundaries are understandable.

```ts
const LATE_FEE_RATE = Percentage.of(2);

function calculateLateFee(
  invoice: Invoice,
  asOf: Date
): Money {
  return asOf > invoice.dueDate
    ? LATE_FEE_RATE.of(invoice.balance)
    : Money.zero(invoice.currency);
}
```

The evaluation time is a parameter, so the same inputs produce the same result and there is no hidden dependency on the system clock. The rate has a name and a unit instead of being a bare number whose meaning the reader must guess.

Not every operation can be pure or deterministic. Networks fail, clocks move, and concurrent systems produce emergent behavior. Predictability therefore also includes observability: explicit timeouts, consistent errors, idempotency, useful logs, metrics, and traces.

> **Review question:** What could surprise someone who only knows this operation’s name and signature?

### Idiomatic: feels natural

Idiomatic code follows recognizable conventions of its language, framework, ecosystem, and team. A developer familiar with that context should not need to learn a private mini-framework before doing an ordinary task.

In React, this is familiar:

```tsx
function OrderTotal({ order }: { order: Order }) {
  const total = calculateOrderTotal(order);
  return <span>{formatMoney(total)}</span>;
}
```

Resolving an `IOrderTotalPresenter` from a dependency container might be justified by a real variation. Adding it only to demonstrate Dependency Inversion would make the code less natural in React.

Idiomatic does not mean fashionable or clever. Local consistency can be more valuable than several individually elegant but incompatible styles.

> **Review question:** Will someone experienced in this ecosystem recognize the intent, or must they first learn our custom machinery?

### Domain-based: models the problem in language and structure

Domain-based code minimizes the distance between the problem and its implementation. The domain appears in names, types, module structure, and boundaries.

```ts
type Money = {
  amount: Decimal; // from a decimal library, because floats do not belong in money
  currency: Currency;
};

function reserveStock(
  order: Order,
  inventory: Inventory
): StockReservation;
```

`Money`, `Order`, and `StockReservation` express more than primitive values and generic names such as `Manager` or `Processor`.

Structure matters too. Organizing the entire repository into global `controllers`, `services`, and `repositories` folders scatters one business change across the codebase. A top-level `checkout` or `billing` capability makes the system easier to navigate.

This property is compatible with Domain-Driven Design but does not require adopting all of DDD. Even a small script or UI component can speak the language of its problem.

> **Review question:** Would someone who understands the business recognize the concepts and flow represented here?

## SOLID and CUPID compared

| Dimension | SOLID | CUPID |
|---|---|---|
| **Primary goal** | Reason about responsibilities, extension, substitution, interfaces, and dependency direction. | Assess qualities that make code understandable, combinable, and trustworthy. |
| **Form** | Principles requiring contextual interpretation. | Properties that exist in degrees. |
| **Paradigm** | Originated in object-oriented class design. | Applies across functions, modules, components, services, and systems. |
| **Main lens** | Structure and relationships between units. | Behavior and the human experience of changing code. |
| **Dogmatic failure mode** | Premature interfaces, excessive layers, and artificial separation. | Turning properties into another compliance checklist or maximizing one at the expense of the others. |

SOLID can expose a broken substitution contract or a business policy tied directly to volatile infrastructure. CUPID can expose surprising behavior, unfamiliar conventions, framework-driven structure, or components that cannot be used outside their original application.

They are not direct competitors. They inspect different aspects of the same design.

## A small refactoring example

Consider an over-engineered checkout:

```ts
const service = checkoutServiceFactory.create(
  strategyResolver.resolve(config),
  repositoryFactory.create(),
  eventBusAdapter
);

await service.execute(checkoutRequestDto);
```

There is one `ICheckoutService` implementation, a strategy resolver for a variation that never occurs, and event handlers that hide stock reservation, payment, and notification. The system looks decoupled, but the use case is difficult to follow.

After protecting current behavior with characterization tests, we can remove speculative indirection, restore domain names, and make the flow explicit:

```ts
type CheckoutDeps = {
  inventory: Inventory;
  payments: PaymentGateway;
  orders: OrderStore;
};

async function checkout(
  command: CheckoutCommand,
  { inventory, payments, orders }: CheckoutDeps
): Promise<CheckoutResult> {
  const pricedOrder = priceOrder(command.cart);
  const reservation = await inventory.reserve(pricedOrder.items);
  const payment = await payments.authorize(pricedOrder.total);

  return orders.confirm({ pricedOrder, reservation, payment });
}
```

`priceOrder` is the composition from the first example: line items, discounts, then taxes. The remote boundaries remain abstract because they represent real external variation, and they arrive as arguments so the use case can be exercised without starting a container. The orchestration knows the order of the steps because that order is part of the business behavior.

This version is easier to compose and predict, uses domain language, and follows ordinary TypeScript. The trade-off is less configuration-driven flexibility. If several payment flows later emerge, a strategy may become justified. CUPID does not forbid that abstraction; it asks us to pay for it only when it provides observable value.

## CUPID is not another set of rules

The properties can conflict. Removing dependencies may lead to reinventing a mature library. Splitting a workflow may improve composition but damage readability. Following a local idiom may preserve a poor convention. Modeling every detail may add ceremony without reducing risk.

That is why “this code violates CUPID” is not a useful review comment. A better discussion identifies a property and its trade-off:

> Email delivery happens in a hidden listener. Making it explicit would make the checkout more predictable. Would that clarity justify the additional step in the orchestration?

CUPID works best as a direction of travel rather than a destination.

In practice:

1. Choose a workflow with real maintenance pain.
2. Describe and protect its current behavior.
3. Evaluate it through the five properties.
4. Select one concrete source of friction.
5. Make the smallest useful change.
6. Reassess clarity, coupling, testability, and operational cost.

Avoid rewriting a system “to adopt CUPID.” Avoid scoring teams against the acronym. Most importantly, avoid importing an implementation style from another ecosystem without asking whether it solves a problem in this one.

## More lenses, fewer dogmas

CUPID is particularly useful when a system appears structurally correct but remains difficult to navigate, when effects are surprising, when abstractions obscure the flow, or when the framework has replaced the language of the domain.

SOLID remains useful for examining responsibilities, contracts, substitution, and dependencies. CUPID adds a perspective centered on behavior, context, familiarity, and the experience of the next person who must change the code.

React makes the distinction concrete. SOLID’s object-oriented origins make literal translation questionable, but the underlying concerns do not disappear. CUPID lets us discuss those concerns without requiring classes, inheritance, or a particular architectural style.

Good design is not the design with the most acronyms. It is the design that makes the problem understandable, allows the solution to change with confidence, and makes its trade-offs explainable in context.

## Sources and related reading

- [Dan North: “CUPID: the back story”](https://dannorth.net/blog/cupid-the-back-story/)
- [Dan North: “CUPID: for joyful coding”](https://dannorth.net/blog/cupid-for-joyful-coding/)
- [CUPID properties reference](https://cupid.dev/properties/)
- [Spanish-language Reddit discussion: “¿Qué significa SOLID para vos?”](https://www.reddit.com/r/devsarg/comments/1vmisa0/que_significa_solid_para_vos/)
- [Previous article: “What Does SOLID Mean to You?”](https://rodrigojuarez.hashnode.dev/what-does-solid-mean-to-you)
- [React documentation: class and function components](https://react.dev/reference/react/Component)
