Affordances in Software Engineering
Module 1 of 6 Beginner 40 min

Affordance Theory for Engineers

What You'll Learn


Why This Matters

Every day you form mental models of the systems you work in. You look at a function signature and decide whether it's safe to call. You read a class name and decide whether to extend it or treat it as a black box. You encounter a new library and form an immediate impression of whether its API makes sense. These are affordance judgments — and you make dozens of them per hour without realizing it.

When those judgments are accurate, you move fast and make good decisions. When they're wrong — when you think a function is safe to call in all cases but it silently drops data, or when you think a configuration parameter is optional but its absence causes a production failure — you ship bugs. The gap between what a system signals and what it actually does is where most integration problems live.

This module gives you the vocabulary and conceptual framework to think about that gap systematically. The goal is not to add ceremony to your work. It is to make precise what you already do informally, so you can do it better and explain it to others.


Core Concept

From Gibson to Norman: A One-Paragraph Handoff

James J. Gibson introduced affordances in ecological psychology to describe action possibilities the environment offers an organism — independent of whether the organism perceives them. A cliff affords falling whether or not you notice the edge. A door affords entry whether or not there's a handle. Gibson's affordances are objective properties of the world.

That framing is historically important, but it's the wrong frame for engineers. We cannot act on affordances we don't perceive. What shapes our behavior — what drives every API integration decision, every refactoring choice, every decision to extend or replace a component — is what we believe is possible. That is Don Norman's insight: for design purposes, what matters is the perceived affordance, the action possibility an actor believes is available based on the signals the system provides. This plan uses Norman's framing throughout. When you read "affordance" in these modules, it means: an action possibility that an engineer perceives as available in the system, based on its signifiers.

The Three-Way Taxonomy

Every capability in a system can be classified along two axes: does it actually exist, and does anything communicate it? This gives three distinct affordance types.

Real affordance: What the system actually allows, independent of perception. A function is callable. A field is writable. A service endpoint exists. These are facts about the system that hold regardless of what any engineer knows or believes.

Perceived affordance: What engineers believe the system allows, based on its signifiers — perceptible cues like naming conventions, type signatures, module structure, error messages, and API shape. Perceived affordances may match real affordances (the system works as it appears to) or diverge from them (the system misleads).

Hidden affordance: A capability that exists as a real affordance but is not communicated by any signifier. Nothing in the API surface tells you it's there. Hidden affordances are the most dangerous category: the system can do something, and it will do it if you discover the trick, but you have no way of knowing the trick exists.

Signifiers: The Bridge Between Real and Perceived

A signifier is a perceptible cue that communicates an affordance. In code, signifiers include:

This is the critical distinction to internalize: a signifier is a clue; an affordance is an action possibility. The function name charge() is a signifier. The ability to initiate a payment is the affordance that name communicates. Conflating them leads to sloppy thinking — you end up saying "the name affords understanding," when what you mean is "the name is a signifier that communicates the affordance of calling this function to initiate a charge."

Audience-Relativity

Affordances are not objective system properties. They are relational: the same signifier may communicate clearly to one engineer and remain opaque to another. A senior engineer sees Repository<T> and immediately perceives a data-access layer with CRUD operations. A newcomer sees an unfamiliar generic type. The pattern name is a signifier, but only if you recognize the pattern.

This has a practical implication: you cannot evaluate the affordance quality of a system in the abstract. You must specify the audience. An API that affords productive use for its creators may fail to communicate anything to engineers encountering it for the first time. Affordance design is always design for someone.

Constraints as Positive Affordances

A well-designed constraint doesn't restrict freedom — it guides behavior toward correct usage. When a constraint eliminates wrong options, it makes the right option clearer. Two constraint subtypes are most relevant here:

Physical constraints are enforced by the language or runtime. A type system that requires callers to handle a failure case before accessing a success value is a physical constraint. You cannot write the wrong code and have it compile. This constraint is itself a perceived affordance: "I can see from the types what paths are possible, and the compiler will catch me if I ignore one."

Logical constraints are enforced by protocol or contract. A required parameter that prevents incomplete configuration is a logical constraint. The PaymentConfig builder, which enforces that you specify both a merchant ID and an endpoint before the client can be constructed, is a logical constraint that affords correct initialization.


Concrete Example

Consider the PaymentService library — a library used across a mid-sized engineering organization, maintained by a platform team, consumed by five product teams.

Its core entry point is PaymentClient, with three primary methods:

// Strong affordance
class PaymentClient {
  charge(request: ChargeRequest): Promise<PaymentResult>;
  refund(request: RefundRequest, authorization: RefundAuthorization): Promise<PaymentResult>;
  getTransaction(id: TransactionId): Promise<Transaction | NotFound>;
}

type PaymentResult =
  | { status: "success"; transactionId: TransactionId }
  | { status: "failure"; reason: string; retryable: boolean }
  | { status: "pending"; estimatedCompletionMs: number };

Perceived affordance: The type signature for charge() signals that it returns a Promise<PaymentResult>. The PaymentResult union type signals three possible outcomes. An engineer reading this signature perceives: "I must handle success, failure, and pending. The failure variant tells me whether to retry. The pending variant tells me how long to wait." The type structure communicates the affordance.

Real affordance: The function does exactly what the type says. Here, perceived and real affordances align. This is the goal.

Hidden affordance: Now look at the actual implementation of charge():

// Poor affordance (hidden parameter)
async charge(request: ChargeRequest, idempotencyKey?: string): Promise<PaymentResult>

The third argument — idempotencyKey — enables a fast-path deduplication behavior. If you pass the same key twice, the second call returns the cached result instead of charging the customer again. This is a real affordance: it exists, it works, and it is sometimes the only safe way to call charge() in a retry loop. But nothing in the public type signature tells you it exists. The parameter is present in the implementation, undocumented in the public API, and invisible to engineers who haven't read the source or happened across an internal Slack message.

An engineer building a retry mechanism without knowing about idempotencyKey will either double-charge customers or build their own deduplication layer at significant cost. The capability they need is real. They simply have no way to perceive it.

This is what hidden affordances cost.


Analogy

Consider a well-designed physical tool: a quality chef's knife. The weight distribution tells you how to hold it. The shape of the blade tells you what cuts it's designed for. The bolster between blade and handle signals where your grip should stop. You don't read a manual — the object's physical form is the signifier system that communicates its affordances.

Now imagine the same knife with a handle that looks identical from both ends. Nothing signals which end is the blade. The blade is still there (real affordance), but nothing guides you toward it safely (signifier absent). The capability exists. The perception of how to use it safely does not.

An API with correct functionality but misleading or absent naming is the software equivalent of a symmetric knife handle. Engineers will eventually figure it out — or they'll get cut.


Going Deeper

The Perceived/Real Mismatch — Both Directions

Mismatches between perceived and real affordances cut both ways, and the less-discussed direction is often more dangerous.

The obvious case: an API appears safe when it isn't. charge() looks idempotent because the name says nothing about state mutation, but it bills a customer every time it's called. The perceived affordance (safe to retry) doesn't match the real affordance (each call is a billable event).

The less-obvious case: an API appears unsafe when it's actually safe. Overly alarming naming, error-prone-looking signatures, or intimidating type complexity can cause engineers to avoid using something that would be entirely appropriate. The real affordance exists; the perceived affordance is absent or negative. Engineers route around the capability, write duplicate code, or build workarounds — all because the signifier system communicated the wrong thing.

Both directions create cost. The first produces bugs; the second produces unnecessary complexity.

Affordance Quality Requires Maintenance

Affordances are not set once at design time. As the PaymentService library evolves through v1, v2, and v3, its signifier system changes. Names drift. Types accumulate exceptions. Parameters are added without documentation. The gap between what the system signals and what it actually does grows. This is affordance degradation — and it is examined in depth in Module 05. The key point to hold here: affordance quality is not a property you design in and then preserve passively. It requires active maintenance, the same way performance and correctness do.


Common Misconceptions

Misconception 1: "Affordances are obvious — good APIs are self-explanatory."

This is wrong. Affordances are learned. What feels obvious to the engineer who designed the API is the product of months of context. A naming convention that "clearly" communicates intent to your team is opaque to engineers outside that context. What appears self-explanatory is often deeply cultural — the shared vocabulary of a team or ecosystem that newcomers must acquire. This is not a reason to abandon clarity; it is a reason to be deliberate about which signifiers you rely on and which audiences you're designing for.

Misconception 2: "Signifiers and affordances are the same thing."

They are not. A signifier is a perceptible cue — the function name, the type annotation, the directory name. An affordance is the action possibility the signifier communicates. The name charge() is a signifier. The ability to initiate a payment transaction is the affordance. You can have an affordance without a signifier (hidden affordance: the capability exists but nothing points to it). You can have a signifier without a corresponding real affordance (misleading signifier: the name implies a capability the function doesn't have). Keep them separate.

Misconception 3: "More affordances are always better — flexible APIs are good APIs."

Flexibility is not the same as quality. An API that accepts any combination of parameters, returns any of several types depending on internal state, and allows engineers to use it in twelve different ways is not affording more — it is communicating less. Every additional affordance competes for attention with the others. Well-designed systems constrain affordances to those that matter, making the intended use obvious and the unintended use difficult. The goal is not maximum affordance count; it is alignment between perceived and real affordances for the target audience.


Check Your Understanding

  1. A library exports a function processPayment(data: any): any. What does this signature communicate about its affordances, and what does it fail to communicate?
Reveal answer The function name `processPayment` is a signifier that communicates a broad, unspecified affordance: do something payment-related with data. But the `any` types eliminate all structural signifiers. The caller cannot perceive: what shape of data is expected, what the function returns, what failure modes are possible, or whether all cases are handled. The perceived affordance is "this accepts something and returns something payment-related." The real affordance may be far more specific. This is a wide perceived/real mismatch created by absent type signifiers.
  1. You discover that a function you've been using for months has an optional parameter that, when set, changes its behavior in a way that would have saved you significant implementation work. What affordance category does this represent, and what signifier was absent?
Reveal answer This is a hidden affordance: the capability exists (real affordance) but nothing in the public API surface communicated it (no signifier). The absent signifier could have been: documentation in the function signature, a discoverable overload, a clearly named variant of the function, or a prominent example in the library's guide. Hidden affordances are the most costly category because engineers who don't discover them implement workarounds — work that the library could have made unnecessary.
  1. Explain why affordances are audience-relative, using the PaymentResult union type as your example.
Reveal answer A `PaymentResult` union type with `success`, `failure`, and `pending` variants communicates immediately to an engineer familiar with discriminated union patterns: "there are three possible outcomes, and my type-checker will ensure I handle them all." This signifier is highly effective for its target audience. To an engineer unfamiliar with union types or from a language ecosystem that doesn't use them, the same type signature is opaque — they may not perceive the three-case requirement, or may not know how to consume the type. The affordance the type provides is real for both engineers, but only one of them perceives it.
  1. The PaymentConfig builder requires that you set merchantId and endpoint before calling .build(). If you call .build() without them, it throws at construction time. Is this a constraint or a restriction? What does it afford?
Reveal answer This is a constraint — specifically a logical constraint, enforced by the protocol of the builder pattern. It is not a restriction in the sense of arbitrary limitation; it is a mechanism that guides behavior toward correct usage. It affords correct initialization: by making incomplete configuration fail fast (at construction, before any network call), it prevents a harder-to-diagnose failure (a production payment call that fails because the client was never properly configured). The constraint is itself a signifier: "these fields are required, not optional."
  1. An API was designed by a platform team for internal use. After two years it is opened to external developers. Why might it have high-quality affordances for the platform team and poor affordances for external developers, even though the code hasn't changed?
Reveal answer Affordances are relational and audience-specific. The internal API was designed with an assumed audience: engineers who attended design meetings, read internal ADRs, work in the same Slack workspace, and share a naming vocabulary. Many of its signifiers rely on that shared context. External developers lack that context. A parameter named `lp_charge_v2_mode` may be immediately legible to the platform team as "legacy processing charge v2 mode" and completely opaque to an external engineer. The real affordances haven't changed. The perceived affordances for a new audience are far weaker because the signifier system was designed for a different audience.

Key Takeaways


References