A Philosophy of Software DesignThe ProblemLesson 2 of 13

Complexity

Complexity is the central enemy of software. Not bugs, not performance, not missing features. Complexity. Everything else is a symptom.

John Ousterhout defines complexity precisely: anything that makes a system hard to understand or modify. This is not about code being "complicated" in the colloquial sense. A compiler is complicated but can be well-structured. A CRUD app can be simple but poorly structured. Complexity is about structure, not domain difficulty.

The Three Symptoms

Ousterhout identifies three symptoms of complexity. Learn to recognize them. They are how complexity announces itself before it becomes unmanageable.

1. Change Amplification

A single logical change requires modifications in many different places.

// Change amplification: adding a new user role
// You must update ALL of these:

// 1. The role type (ok, this is expected)
type Role = "admin" | "editor" | "viewer";

// 2. The permissions map
const permissions: Record<Role, string[]> = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

// 3. The role display names
const roleLabels: Record<Role, string> = {
  admin: "Administrator",
  editor: "Editor",
  viewer: "Viewer",
};

// 4. The role colors in the UI
const roleColors: Record<Role, string> = {
  admin: "#ff0000",
  editor: "#00ff00",
  viewer: "#0000ff",
};

// 5. The role validation in the API
// 6. The role dropdown in settings
// 7. The role migration script
// 8. The role in the seed data
// Adding "moderator" means touching 8+ files.
// Miss one and you have a runtime crash.

The fix is colocating related data. When a role's permissions, label, and color live together, adding a role means adding one object.

// Fix: colocate role data
const roles = {
  admin: { label: "Administrator", color: "#ff0000", permissions: ["read", "write", "delete"] },
  editor: { label: "Editor", color: "#00ff00", permissions: ["read", "write"] },
  viewer: { label: "Viewer", color: "#0000ff", permissions: ["read"] },
} as const satisfies Record<string, RoleConfig>;

type Role = keyof typeof roles;
// Adding "moderator" means adding one object. Done.

2. Cognitive Load

The amount of knowledge a developer must hold in their head to complete a task.

Cognitive load is not about the number of lines. A 200-line function with linear flow has lower cognitive load than twenty 10-line functions that call each other in a web. What matters is how many things you must understand simultaneously.

// High cognitive load: you must understand the tax system, discount rules,
// shipping logic, AND the inventory check to modify any one of them
function processOrder(order: Order, user: User, inventory: Inventory): ProcessedOrder {
  const subtotal = order.items.reduce((sum, item) => {
    const stock = inventory.check(item.id);
    if (stock < item.quantity) throw new Error(`Insufficient stock for ${item.id}`);
    const discount = user.tier === "premium" ? 0.1 : user.tier === "gold" ? 0.05 : 0;
    const taxRate = item.category === "food" ? 0 : item.category === "electronics" ? 0.08 : 0.05;
    return sum + item.price * item.quantity * (1 - discount) * (1 + taxRate);
  }, 0);
  // ... shipping calculation mixed in ...
  // ... loyalty points mixed in ...
  return { subtotal, /* ... */ } as ProcessedOrder;
}

Every concern is entangled. To change the tax calculation, you must understand the discount calculation, the inventory check, and the shipping logic, because they are all woven together.

3. Unknown Unknowns

The worst symptom. You do not know what you do not know. You make a change, it seems correct, and something breaks in a place you did not know was connected.

// Unknown unknown: changing this function breaks the billing system
function formatUserName(user: User): string {
  return `${user.firstName} ${user.lastName}`;
}

// Somewhere, deep in the billing module:
function generateInvoice(user: User) {
  const name = formatUserName(user);
  // Billing parses this string to extract first/last name
  // for tax documents. If you add a middle name, billing breaks.
  const [first, last] = name.split(" ");
  // ...
}

Unknown unknowns are the most dangerous because they are invisible. You cannot write a test for a dependency you do not know exists. The only defense is making dependencies explicit and local.

Complexity is Incremental

This is the insight most developers miss. Complexity rarely arrives in a single dramatic commit. It arrives in a hundred small commits, each of which seems reasonable in isolation.

"I will just add a flag here." "Let me add a special case for this edge." "We need one more parameter." Each change adds a tiny bit of complexity. None of them seems worth refactoring. But complexity compounds.

Ousterhout calls this the "boiling frog" problem. Each increment is too small to notice. By the time you notice, the codebase is too complex to refactor safely.

The defense is not letting complexity in at all. Every change should leave the code a little cleaner than you found it. Not because you are a perfectionist, but because the alternative is slow death by a thousand paper cuts.

The Tactical Tornado

Every team has one. The developer who ships features fast, leaves cleanup to others, and produces code that works but cannot be maintained.

The tactical tornado optimizes for today. Their code works. Their pull requests are quick. Their velocity looks great in sprint reviews. But they leave a wake of complexity that slows everyone else down for months.

// Tactical tornado code: ships fast, creates tech debt
function handleRequest(req: any) {
  // TODO: fix types later
  const data = JSON.parse(req.body); // no error handling
  if (data.type == "create") { // == instead of ===, no exhaustive check
    db.query(`INSERT INTO users VALUES ('${data.name}')`); // SQL injection
    return { ok: true };
  }
  if (data.type == "delete") {
    db.query(`DELETE FROM users WHERE name = '${data.name}'`);
    return { ok: true };
  }
  return { ok: false }; // silent failure for unknown types
}

This code "works." It ships. It will be in production before lunch. And it will cause an incident before dinner.

The Strategic Programmer

The strategic programmer invests in design. Ousterhout suggests spending about 15% of your development time on design improvement, not writing new features.

This is not perfectionism. It is investment. A small amount of extra thought now prevents a large amount of debugging later.

The strategic programmer:

  • Writes code that is obvious to the next reader
  • Defines errors out of existence rather than handling them
  • Makes interfaces simple even when implementations are complex
  • Treats each commit as an opportunity to improve the system
// Strategic programmer code: same feature, properly designed
type RequestType = "create" | "delete";

interface UserRequest {
  readonly type: RequestType;
  readonly name: string;
}

function parseRequest(raw: unknown): UserRequest {
  if (!raw || typeof raw !== "object") throw new RequestError("Invalid request body");
  const body = raw as Record<string, unknown>;
  if (body.type !== "create" && body.type !== "delete") {
    throw new RequestError(`Unknown request type: ${String(body.type)}`);
  }
  if (typeof body.name !== "string" || body.name.length === 0) {
    throw new RequestError("Name is required");
  }
  return { type: body.type, name: body.name };
}

function handleRequest(raw: unknown): Result<void> {
  const request = parseRequest(raw);
  switch (request.type) {
    case "create": return createUser(request.name);
    case "delete": return deleteUser(request.name);
  }
  // TypeScript exhaustiveness: if a new type is added, this is a compile error
}

More code. More thought. But: no SQL injection, no type confusion, exhaustive handling, and the next developer can read it without guessing.

Measuring Complexity

You cannot measure complexity with a number. Lines of code, cyclomatic complexity, and class counts are proxies at best. The real measure is qualitative:

Can a new team member understand this code in a reasonable time?

If the answer is "yes, once they learn our conventions," the code is well-structured. If the answer is "no, you need to talk to Sarah because she wrote that part and nobody else understands it," the code has a complexity problem.

Check Your Understanding

What is Ousterhout's definition of complexity?

Not quite. The correct answer is highlighted.

Which symptom of complexity is the most dangerous?

Not quite. The correct answer is highlighted.
The developer who ships fast but leaves complexity for others is called a tornado.
Not quite.Expected: tactical

How does complexity usually enter a codebase?

Not quite. The correct answer is highlighted.

Why is the tactical tornado's high velocity misleading?

Expected Answer

The tactical tornado ships features quickly but creates technical debt and complexity that slows down the entire team. Their personal velocity is high, but the team's velocity drops because everyone else spends time working around the mess. Short-term speed creates long-term cost.

How did you do?
Nice work!

Practice

Refactor a god-object into isolated concerns:

Summary

  • Complexity defined: anything that makes code hard to understand or modify.
  • Three symptoms: change amplification (one change, many files), cognitive load (too much to hold in your head), unknown unknowns (invisible dependencies).
  • Complexity is incremental. It arrives in small, seemingly reasonable commits. The defense is constant vigilance.
  • Tactical tornado vs strategic programmer. Short-term speed vs long-term sustainability. Invest ~15% of development time in design.
  • You cannot measure complexity with a number. The real test: can a new person understand this code?

Next, we learn the antidote: deep modules that hide complexity behind simple interfaces.