DEV Community

Cover image for The 4 Rules of Simple Design: A Practical Guide with TypeScript
Maxime Sahroui
Maxime Sahroui

Posted on

The 4 Rules of Simple Design: A Practical Guide with TypeScript

Kent Beck introduced the 4 Rules of Simple Design as part of Extreme Programming in the late 1990s. Decades later, they remain one of the most elegant and practical frameworks for writing maintainable software. These rules define what "simple" truly means in code — not naive or dumbed-down, but intentionally minimal.

The rules are ordered by priority. You never sacrifice a higher-priority rule to satisfy a lower one.

Rule 1: Passes the Tests

The code must work. It must do what it's supposed to do, and you must be able to prove it. A beautiful design that produces wrong results is worthless.

This rule also implies that tests exist. Untested code is not "simple" — it's unknown.

// ❌ No tests, no confidence
function calculateDiscount(price: number, tier: string): number {
  if (tier === "gold") return price * 0.8;
  if (tier === "silver") return price * 0.9;
  return price;
}

// ✅ Tested behavior — we know exactly what this does
describe("calculateDiscount", () => {
  it("applies 20% discount for gold tier", () => {
    expect(calculateDiscount(100, "gold")).toBe(80);
  });

  it("applies 10% discount for silver tier", () => {
    expect(calculateDiscount(100, "silver")).toBe(90);
  });

  it("applies no discount for unknown tiers", () => {
    expect(calculateDiscount(100, "bronze")).toBe(100);
  });
});
Enter fullscreen mode Exit fullscreen mode

The key insight: tests are not overhead. They are the first rule. Everything else builds on the confidence they provide.

Rule 2: Reveals Intention

Code should clearly communicate what it does and why. A reader — including your future self — should understand the purpose without digging through implementation details.

// ❌ What does this do? You have to execute it mentally.
function process(items: { a: number; s: string; d: Date }[]) {
  return items.filter((i) => {
    const n = Date.now();
    const t = i.d.getTime();
    return n - t < 86400000 && i.a > 0 && i.s === "A";
  });
}

// ✅ The code reads like a specification
type OrderItem = {
  amount: number;
  status: "active" | "cancelled" | "pending";
  createdAt: Date;
};

const ONE_DAY_MS = 24 * 60 * 60 * 1000;

function isRecentActiveOrder(order: OrderItem): boolean {
  const age = Date.now() - order.createdAt.getTime();
  return age < ONE_DAY_MS && order.amount > 0 && order.status === "active";
}

function getRecentActiveOrders(orders: OrderItem[]): OrderItem[] {
  return orders.filter(isRecentActiveOrder);
}
Enter fullscreen mode Exit fullscreen mode

Notice what changed: meaningful types, descriptive names, extracted constants, and a named predicate function. The behaviour is identical — but the intention is now unmistakable.

Naming: clarity is not verbosity

Revealing intention doesn't mean cramming every detail into a name. Over-descriptive names add cognitive load without adding clarity. If the name repeats context already obvious from the module, the type signature, or the surrounding code, it's noise — not intention.

// ❌ Too verbose — the names repeat context that's already clear
// file: userService.ts
function getUserByIdFromDatabaseAndReturnUserObject(userId: string): Promise<User> { ... }
function validateUserEmailAddressFormat(emailAddress: string): boolean { ... }
function checkIfUserAccountIsCurrentlyActive(user: User): boolean { ... }
function sendPasswordResetEmailNotificationToUser(user: User): Promise<void> { ... }

// ❌ Too vague — the names tell you nothing
function get(id: string): Promise<unknown> { ... }
function check(s: string): boolean { ... }
function do(u: unknown): Promise<void> { ... }

// ✅ Concise — context is already provided by the module and types
// file: users.ts
function findById(userId: string): Promise<User> { ... }
function isValidEmail(email: string): boolean { ... }
function isActive(user: User): boolean { ... }
function sendPasswordReset(user: User): Promise<void> { ... }

// The caller reads naturally:
import * as users from "./users";
const user = await users.findById(id);
if (users.isActive(user)) { ... }
Enter fullscreen mode Exit fullscreen mode

The module name (users) already provides the domain context. The type signature (User, string) already tells you what goes in and what comes out. The function name only needs to express the action — everything else is redundant. Good naming lives in the sweet spot between cryptic and encyclopedic.

Rule 3: No Duplication (DRY)

Every piece of knowledge should live in exactly one place. Duplication isn't just repeated code — it's repeated concepts. When the same business rule appears in two places, you'll eventually change one and forget the other.

// ❌ The pricing logic is duplicated across two functions
// invoice.ts

type LineItem = {
  price: number;
  quantity: number;
};

function calculateSubtotal(items: LineItem[]): number {
  let total = 0;
  for (const item of items) {
    total += item.price * item.quantity;
    if (item.quantity >= 10) {
      total -= item.price * item.quantity * 0.1; // bulk discount
    }
  }
  return total;
}

function generatePreview(items: LineItem[]): string {
  let total = 0;
  for (const item of items) {
    total += item.price * item.quantity;
    if (item.quantity >= 10) {
      total -= item.price * item.quantity * 0.1; // bulk discount
    }
  }
  return `Estimated total: $${total.toFixed(2)}`;
}

// ✅ Single source of truth — each concept lives in one place
// pricing.ts

const BULK_THRESHOLD = 10;
const BULK_DISCOUNT_RATE = 0.1;

type LineItem = {
  price: number;
  quantity: number;
};

function lineItemSubtotal(item: LineItem): number {
  const gross = item.price * item.quantity;
  const discount = item.quantity >= BULK_THRESHOLD ? gross * BULK_DISCOUNT_RATE : 0;
  return gross - discount;
}

function calculateSubtotal(items: LineItem[]): number {
  return items.reduce((sum, item) => sum + lineItemSubtotal(item), 0);
}

// invoice.ts
function generatePreview(items: LineItem[]): string {
  return `Estimated total: $${calculateSubtotal(items).toFixed(2)}`;
}
Enter fullscreen mode Exit fullscreen mode

The pricing logic now lives in lineItemSubtotal. Change the discount threshold once, and every consumer reflects it. No classes needed — just composable functions.

Rule 4: Fewest Elements

Once rules 1–3 are satisfied, use the minimum number of classes, functions, modules, and abstractions needed. Don't build for hypothetical futures. Don't add layers "just in case."

// ❌ Over-engineered for a simple need
type NotificationStrategy = {
  send(message: string, recipient: string): Promise<void>;
};

function createEmailStrategy(): NotificationStrategy {
  return {
    async send(message, recipient) {
      await sendEmail(recipient, message);
    },
  };
}

function createNotificationDispatcher(strategies: Record<string, NotificationStrategy>) {
  return async (type: string, message: string, recipient: string) => {
    const strategy = strategies[type];
    if (!strategy) throw new Error(`Unknown type: ${type}`);
    await strategy.send(message, recipient);
  };
}

const dispatch = createNotificationDispatcher({
  email: createEmailStrategy(),
});

// ✅ Just send the email. Add abstractions when you actually need them.
async function sendNotification(message: string, recipient: string): Promise<void> {
  await sendEmail(recipient, message);
}
Enter fullscreen mode Exit fullscreen mode

The dispatcher, the strategy interface, and the factory function — none of them are needed when you only have one notification channel. If you add SMS later, then introduce the abstraction. Not before.

The Priority Matters

The ordering is everything. Here's how conflicts are resolved:

  • Don't remove duplication (Rule 3) if it makes the code harder to understand (Rule 2). Sometimes a little repetition is clearer than a clever abstraction.
  • Don't simplify elements (Rule 4) if it reintroduces duplication (Rule 3). An extracted helper function is worth having even if it adds a function to the module.
  • Never sacrifice correctness (Rule 1) for anything. A clean, readable, DRY codebase that produces wrong results is just a well-organised bug.
// Sometimes duplication is clearer than a forced abstraction
// This is OK — Rule 2 (clarity) beats Rule 3 (DRY)

function formatUserGreeting(user: User): string {
  return `Hello, ${user.firstName} ${user.lastName}!`;
}

function formatAdminGreeting(admin: Admin): string {
  return `Welcome back, ${admin.firstName} ${admin.lastName}. You have ${admin.pendingReviews} pending reviews.`;
}

// ❌ Don't force this into a shared function to eliminate duplication
// The "shared" part (name formatting) is trivial, and the contexts are different
Enter fullscreen mode Exit fullscreen mode

Summary

Priority Rule Question to Ask
1 Passes the Tests Does it work? Can I prove it?
2 Reveals Intention Can someone else understand this without asking me?
3 No Duplication Is every concept expressed in exactly one place?
4 Fewest Elements Can I remove anything without breaking rules 1–3?

These four rules are not a checklist you apply once. They form a continuous loop—the heartbeat of refactoring. After every change: green tests → clarify intent → remove duplication → simplify.


Appendix: AI Coding Rules

The following rule set can be used as instructions for AI coding assistants (LLMs, copilots, agents) to enforce Kent Beck's 4 Rules of Simple Design. Copy and adapt them to your system prompt, project rules, or .cursorrules / .claude configuration.

# AI Coding Rules — 4 Rules of Simple Design

You follow Kent Beck's 4 Rules of Simple Design in the order of their priority.
When rules conflict, higher-priority rules always win.

## Rule 1: Passes the Tests (Highest Priority)

- Every function you write or modify MUST have corresponding tests.
- If modifying existing code, run existing tests first. Never break them.
- Write the test BEFORE or alongside the implementation, never as an afterthought.
- Tests must cover: expected behaviour, edge cases, and error paths.
- If you are unsure whether behaviour is correct, ask — do not guess.
- Never mark a task as complete if tests are failing.

## Rule 2: Reveals Intention

- Use descriptive, specific names for variables, functions, types, and modules.
  Bad: `data`, `process`, `handle`, `item`, `tmp`, `val`
  Good: `unpaidInvoices`, `calculateShippingCost`, `OrderStatus`
- But avoid over-descriptive names that repeat context already clear from
  the module, the type signature, or the surrounding code.
  Bad: `getUserByIdFromDatabaseAndReturnUserObject`, `checkIfUserIsActive`
  Good: `findById` (in a users module), `isActive` (takes a User)
- Extract magic numbers and strings into named constants.
- Use TypeScript types to document the shape of data. Prefer `type` over
  `interface` — use `interface` only when declaration merging is needed.
- Prefer named functions over inline lambdas for non-trivial logic.
- Each function should do one thing. If you need "and" to describe it, split it.
- Comments explain WHY, never WHAT. The code itself explains what.

## Rule 3: No Duplication

- Never copy-paste logic. Extract shared behaviour into a function, type, or module.
- Duplication includes: repeated business rules, repeated conditionals,
  repeated data transformations, and repeated structural patterns.
- When you spot duplication, refactor it — even if you didn't introduce it.
- BUT: do not force an abstraction when two pieces of code only look similar
  on the surface but represent different concepts (Rule 2 takes priority).

## Rule 4: Fewest Elements (Lowest Priority)

- Do not create abstractions for hypothetical future requirements.
- Do not introduce unnecessary abstractions, factories, strategies, or wrappers
  unless there is a concrete, current need.
- Prefer functions over classes when no state management is required.
- Prefer module-level functions with namespace imports (`import * as users`)
  over classes with methods. Use types for data shapes, functions for behaviour.
- Remove dead code, unused imports, and unnecessary parameters.
- If an abstraction makes the code harder to follow without reducing real
  duplication, remove it.
- One file with 3 clear functions is better than 3 files with 1 function each,
  unless the functions serve genuinely different domains.

## Conflict Resolution

When rules conflict, apply this priority:
1. Working, tested code (Rule 1) > everything.
2. Clarity (Rule 2) > DRY (Rule 3). A little repetition is OK if the
   alternative is an unclear abstraction.
3. DRY (Rule 3) > Minimalism (Rule 4). An extra function to eliminate
   duplication is justified.
4. Never add complexity to satisfy Rule 4. Minimalism means removing
   unnecessary things, not avoiding necessary ones.

## Refactoring Loop

After every change, mentally run this loop:
1. Are all tests green? → If not, fix.
2. Does the code clearly express intent? → If not, rename/restructure.
3. Is there duplication? → If yes, extract.
4. Can anything be removed without breaking rules 1-3? → If yes, remove.
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
a-k-0047 profile image
ak0047

Thank you for sharing this article!
I'll keep these in mind.