Affordances in Software Engineering
Module 2 of 6 Beginner 45 min

Code as Communication Medium

Prerequisites: affordance-theory-for-engineers

What You'll Learn

Why This Matters

The computer does not care what you name your variables. It will execute identical bytecode whether you call a function proc or processPaymentWithIdempotencyKey. The engineer who reads that code next week — or next year, or when the on-call incident fires at 2 AM — cares enormously.

Code is a medium of communication between engineers. Every naming decision, every module boundary, every design pattern you choose or ignore is a signifier: a perceptible cue that either communicates what the code affords or forces the reader to reverse-engineer it from scratch. When a codebase has strong affordances at the code level, engineers can move confidently, modify safely, and onboard quickly. When the affordances are weak, the codebase becomes a friction machine — full of unproductive friction that imposes cognitive overhead without any protective benefit. That friction is not accidental. It is the accumulated result of choices, and it can be understood, measured, and corrected.

Core Concept

Code's Primary Audience

The computer is not your audience. Your audience is every engineer who will read, debug, extend, or maintain this code — including yourself in six months. Code that communicates clearly to that audience has strong affordances. Code that only runs correctly has done the minimum and no more.

In Module 01, you learned that a signifier is a perceptible cue that communicates an affordance. At the code level, signifiers are everywhere: function names, parameter names, return types, module boundaries, access modifiers, design pattern names, and file organization. Each one is a small promise about what the code affords and how it should be used.

Signifier Types at the Code Level

Function names are the most visible signifiers in a codebase. A name like charge() on the PaymentClient signals payment initiation. It does not signal whether that operation is idempotent, whether it retries, or whether it affects state permanently. A name like chargeWithIdempotencyKey() communicates more — but as Module 01 showed, even that fuller name may not expose a hidden affordance buried in an optional parameter. Names carry the most weight for perceived affordances because they are the first thing an engineer reads.

Parameter names are a close second. A parameter named options affords almost nothing; a parameter named idempotencyKey affords safe retry behavior. A required parameter named authorization affords understanding that this operation requires elevation. These differences are not cosmetic — they determine whether engineers use an API correctly on first contact.

Return types are signifiers that type systems make structural. A function returning PaymentResult — a discriminated union of success, failure, and pending — affords explicit handling of all three outcomes. A function returning any affords nothing; the engineer must read the implementation to discover what is actually returned.

Module boundaries and access modifiers signal what is public contract versus internal implementation. An internal/ directory, an index.ts that exports only selected symbols, or TypeScript's private keyword are all signifiers of the same perceived affordance: "this surface is stable and safe to use; that surface is not yours to touch."

Design pattern names are affordance shortcuts. When a class is named PaymentClientFactory, engineers who recognize the Factory pattern know immediately what it does and how to use it — without reading the implementation. The pattern name is itself a signifier that encodes a well-understood set of affordances. This is what the research community calls a shared vocabulary — a cultural constraint that communicates expected behavior without documentation.

Three Properties That Afford Safe Modification

Beyond comprehension, code needs to afford confident modification. Three structural properties deliver this:

Explicit public/private boundaries: When you cannot easily tell where the public contract ends and implementation details begin, every modification carries the risk of breaking things you did not mean to touch. Explicit module exports, access modifiers, and directory conventions (a PaymentClient exposed through index.ts with LegacyPaymentClient kept internal) signal exactly what the safe modification surface is.

Visible dependencies: Code that takes its dependencies explicitly — through constructor injection, explicit imports, or typed parameter lists — affords tracing. You can read a function signature and know what it needs. Code that reaches for global state, hidden singletons, or ambient configuration hides its dependencies and makes modification dangerous without a full mental model of the system.

Explicit side-effect contracts: A pure function — one that takes inputs and returns outputs without modifying external state — affords safe reasoning. Immutability markers, pure function conventions, and readonly types signal to the reader that calling this function has no hidden consequences. The inverse — a function that silently mutates shared state — degrades the affordance for safe modification.

Self-Documenting Code

The principle of self-documenting code is often misunderstood as a style preference or even an extreme position ("never write comments"). It is neither. It is a claim about what strong signifiers accomplish structurally.

When a function name, its parameter names, and its return type together communicate what the function does, who calls it, and what it returns — a comment explaining what it does is noise. Worse, that comment will drift. Code is executed and therefore tested; comments are not. A comment that contradicts the code it describes is an affordance failure — it actively misleads.

The precise rule is this: if your code needs a comment to explain what it does (not why a non-obvious choice was made), the code has a naming problem. Comments that explain why — an architectural tradeoff, a regulatory constraint, a non-obvious dependency on external behavior — are genuinely useful because they carry information that structure cannot encode. Comments that explain what are evidence that the structure itself has failed as a signifier. Documentation plays the same supporting role at module level. A README that explains the rationale for a module boundary supplements the structural affordance. A README that explains how to call a confusingly named function does not fix the affordance; it masks the deficit. Structure beats description.

Concrete Example

Here is the PaymentService library across two points in its evolution: v1, when the naming was coherent and the module structure was clear, and v3, where convention drift has accumulated.

Note that this v1 design shows idempotencyKey as an explicit required parameter — the corrected, intended surface. Module 01 showed the actual initial implementation, where idempotencyKey was an undocumented optional parameter: a hidden affordance. The v1 code here represents what the API should have looked like from the start.

v1: Strong Affordances

// Strong affordance
// PaymentClient — public contract
export class PaymentClient {
  constructor(private readonly config: PaymentConfig) {}

  async charge(
    amount: Money,
    customerId: CustomerId,
    idempotencyKey: IdempotencyKey
  ): Promise<PaymentResult> { ... }

  async refund(
    transactionId: TransactionId,
    authorization: RefundAuthorization
  ): Promise<PaymentResult> { ... }

  async getTransaction(
    transactionId: TransactionId
  ): Promise<Transaction> { ... }
}

Read this signature cold. You know: charge() takes an amount, a customer, and an idempotency key — the last parameter signals safe retry behavior without any documentation. refund() requires explicit authorization — a physical constraint that prevents unauthorized calls. getTransaction() retrieves a transaction by ID. The return types tell you to handle PaymentResult variants. Module 01 showed that idempotencyKey was a hidden affordance in an earlier version of this API; in v1, it is explicit and visible.

The module structure reinforces this:

payment-service/
  index.ts              # exports PaymentClient, PaymentConfig, PaymentResult only
  PaymentClient.ts      # public surface — the contract consumers interact with
  PaymentConfig.ts      # builder for initialization — physical constraint on setup sequence
  PaymentResult.ts      # discriminated union of success | failure | pending
  internal/
    LegacyPaymentClient.ts  # historical implementation, not exported
    ledger.ts               # internal state management, not a public affordance

The index.ts pattern signals which surface is stable. The internal/ directory signals that everything inside is implementation detail — not part of the perceived affordance of the library.

v3: Affordance Debt Accumulates

// Poor affordance
export class PaymentClient {
  async charge(...) { ... }
  async refund(...) { ... }
  async getTransaction(...) { ... }
  async fetchTransaction(...) { ... }  // added in v3 — what is the difference?
}

export class LegacyPaymentClient {      // now also exported — why?
  async chargeV1(...) { ... }
  async getTransaction(...) { ... }     // same name, different behavior
}

getTransaction and fetchTransaction coexist in v3. Which one should you use? Both are exported from LegacyPaymentClient and PaymentClient. Do they return the same type? Does fetchTransaction have a retry policy that getTransaction lacks? The names do not tell you. The engineer must read the implementation — or the documentation, if it exists and is current — to discover what these functions actually afford.

This is unproductive friction: resistance that imposes cognitive overhead without protective benefit. It is also the beginning of affordance debt — the accumulated loss of communicative clarity in the system. The code still works. The technical debt may be low. But the affordance debt is growing.

Analogy

Think of a codebase as a city map. A well-designed map uses consistent symbols, clear street names, and a logical hierarchy of roads — highways, arterials, local streets — that immediately signals where high-traffic flows, where you navigate carefully, and what is a shortcut versus a through-route. You can orient yourself quickly. You know where the landmarks are.

A codebase with strong affordances works the same way. Consistent naming conventions are the consistent street-name format. Module boundaries are the district lines. Design pattern names are the well-known landmarks. An engineer new to the codebase can orient themselves using the map the code provides.

A codebase with affordance degradation is a map with streets that have two names (one official, one historical), districts whose boundaries were redrawn but not updated on the map, and landmarks that have moved. You can still navigate — but you have to ask for directions every few blocks. That cognitive overhead compounds across every engineer who works in the system, every day.

Going Deeper

Shared Vocabularies as Cultural Constraints

Design patterns — Factory, Observer, Repository, Builder — are more than solutions to recurring problems. They are a cultural constraint: a naming and structural convention so widely shared that using the pattern communicates expected behavior without a word of documentation. When you name a class PaymentClientFactory, you commit to that class creating PaymentClient instances, not doing something else. The pattern name is a binding promise.

This is what the shared contract in this curriculum calls a cultural constraint: enforced by team convention and tooling, not by the compiler. The constraint works because it is widely understood. When it is violated — a class named Factory that does something else — the signifier lies, and the perceived affordance is wrong. That is worse than no signifier at all.

Audience-Relativity of Code Signifiers

The same code communicates differently to different engineers. PaymentResult as a discriminated union is an immediately legible signifier to a TypeScript engineer familiar with the pattern. To a new engineer who has not encountered discriminated unions before, it may be opaque. The same function names that clearly communicate to the platform team that designed the PaymentService may communicate nothing to a new product engineer integrating with it for the first time.

This is audience-relativity operating at the code level. The affordance exists relative to the reader, not as an objective property of the code. A codebase optimized for its original authors may have severe affordance deficits for newcomers. This is worth considering in code review: "Will someone unfamiliar with this module understand what this function affords from its signature alone?"

The Cost of Inconsistency

Research on naming conventions consistently shows that inconsistency — not complexity, not abbreviation, not any particular style choice — is the primary driver of comprehension overhead. An engineer who encounters getTransaction in one place and fetchTransaction in another must stop and evaluate: are these the same? Is there a distinction being drawn? Which is preferred? This is not a one-time cost. It is paid every time any engineer reads that code. Multiplied across a team over months, naming inconsistency is not a cosmetic concern — it is a measurable productivity drain.

Common Misconceptions

Misconception 1: "Comments make code clearer, so more comments means clearer code."

This is wrong. Comments that explain what code does signal that the code's structure has failed as a signifier. A function named proc with a comment explaining it filters and doubles high values has a naming problem, not a comment deficit. The comment is a patch on a structural deficiency. Worse, comments are not executed — they drift out of sync with the code they describe. A comment that no longer matches the code is an affordance failure that actively misleads. The right fix is a better name, not more documentation. Comments that explain why — non-obvious tradeoffs, external dependencies, regulatory context — are genuinely valuable and should be written.

Misconception 2: "Naming conventions are style preferences — they do not affect correctness."

This is wrong. Naming is not cosmetic. Research on code comprehension demonstrates that naming inconsistency measurably increases comprehension time and error rates. When getTransaction and fetchTransaction both exist in the same API without clear distinction, engineers make real mistakes: calling the wrong function, assuming interchangeability where there is none, or spending time on investigation that could be spent on the work itself. The affordance deficit has a concrete cost. Calling it a "style preference" is a way of avoiding the work of fixing it.

Misconception 3: "Self-documenting code means never writing comments."

This conflates two distinct things. Self-documenting code means that the code's structure — its names, its types, its organization — communicates what it does without needing a prose explanation alongside every line. That is about structural affordance quality. Comments that explain why a decision was made — why this boundary was drawn here, why this retry strategy was chosen, what alternative was rejected and why — are not redundant with self-documenting code. They carry information that structure cannot encode. The argument against excessive comments is not an argument against all comments. It is an argument for strong structural signifiers as the primary communication mechanism, with comments reserved for context and rationale that structure genuinely cannot express.

Check Your Understanding

  1. The PaymentService team renames getTransaction() to fetchTransaction() in v3 and deprecates the old name, but does not remove it — both now exist on the same class. Using the vocabulary from this module, what is the specific affordance problem this creates, and what property of code does it violate?
Reveal answer Coexisting `getTransaction` and `fetchTransaction` on the same class destroys the signifier clarity for both functions. Engineers perceive two affordances (retrieve a transaction two different ways) where there may be only one intended behavior, or two subtly different behaviors that are not distinguishable by name alone. This violates the **signifier consistency** principle: the naming system has fragmented into two "dialects" that each require separate learning. It creates **unproductive friction** — cognitive overhead without protective benefit — and contributes to **affordance debt** by degrading the communicative clarity of the API surface.
  1. A senior engineer argues that a complex 80-line function is "readable enough" because it has a detailed comment block at the top explaining what it does step by step. What affordance lens critique would you offer?
Reveal answer The comment block is evidence of a structural affordance deficit, not a solution to it. The function's internal structure — its length, the complexity distributed across 80 lines — is not communicating clearly enough on its own, which is why the comment is needed. The correct response is to refactor the function into smaller, named pieces whose function names communicate each step's purpose. Each named function becomes a signifier. Comments that explain *what* a function does are patches; structure is the fix. A comment block describing 80 lines of logic can usually be replaced by five to ten well-named function calls whose composition is self-explanatory.
  1. You are reviewing a pull request. The new code introduces a UserServiceHelper class that constructs User objects, validates them, and persists them to the database. What affordance problem does the name UserServiceHelper create, and what would you suggest instead?
Reveal answer `UserServiceHelper` is a nearly contentless signifier. "Helper" communicates nothing specific about the class's responsibilities — it could do almost anything. The class description reveals three distinct affordances: object construction, validation, and persistence. Each of those maps to a well-understood pattern: `UserFactory` (or a builder) for construction, a `UserValidator` for validation, and a `UserRepository` for persistence. Splitting the class along these boundaries and using **design pattern names** as signifiers gives engineers immediate, accurate **perceived affordances** — they know what each class does and what it does not do, without reading the implementation.
  1. The PaymentClient module in v1 exports only PaymentClient, PaymentConfig, and PaymentResult through its index.ts. LegacyPaymentClient lives in internal/ and is not exported. What specific affordance does this module boundary enforce, and what type of constraint is it?
Reveal answer The module boundary enforces the affordance of a stable, intentional public contract. Engineers can only access what is exported through `index.ts`, so the perceived affordance of the library is exactly the surface the platform team intends — not implementation internals. `LegacyPaymentClient` in `internal/` is not a perceived affordance for consumers; it does not exist from their perspective. This is a **physical constraint** — enforced by the module system, not by convention. Engineers cannot accidentally import `LegacyPaymentClient` in a strongly-encapsulated module system; they are structurally prevented from doing so.
  1. Your team is debating whether to add a comment explaining the rationale for a specific API boundary in the PaymentService. One engineer says, "We should document this in code comments, not a separate ADR." Using the shared contract's documentation principle, how do you evaluate this position?
Reveal answer The principle from the shared contract is that documentation cannot be an affordance — it supplements structural affordances but cannot replace them, and "structure beats description." A comment in the code that explains *why* an API boundary was drawn the way it was is appropriate; that is context and rationale that structure cannot encode. But comments in code are harder to find, harder to update consistently across files, and do not support the kind of structured reasoning an ADR enables (problem statement, decision, consequences, alternatives considered). The ADR is the right vehicle for architectural rationale — it supplements the structural affordance without trying to replace it. Code comments that describe the implementation are fine for local context; architectural rationale belongs in a dedicated, findable, updatable record.

Key Takeaways

References