The Principle of Least Astonishment (POLA)
Naming and Astonishment
Read aloud is unavailable in this browser.
Names Are the First Contract
Before a developer reads documentation, before they look at type signatures, before they examine the implementation—they see the name. The name of a function, a class, a variable, or a parameter is the first thing that sets expectations.
await userService.deleteUser(userId);
What happens when this runs? A competent developer would expect the user to be removed from the system. But what if deleteUser only marks the user as 'deleted' in a flag? What if it queues the deletion for later? What if it deletes the user from one database but not another?
In each case, the name deleteUser made a promise that the behavior didn't keep. The developer is astonished when they discover the truth—usually after a bug, a production incident, or hours of debugging.
Names are not documentation. Names are contracts.
By the end of this page, you'll understand how naming choices create or violate expectations, common naming patterns that lead to astonishment, specific techniques for choosing names that accurately communicate intent, and how to develop a 'naming sense' that makes your code self-documenting.
Why Naming Matters More Than You Think
The claim 'naming is hard' has become a cliché, but few developers understand why it's hard or why it matters. Naming isn't hard because words are hard. Naming is hard because naming is specification.
When you name something, you're making claims about:
- What it represents (classes, variables)
- What it does (methods, functions)
- How it relates to other things (parameters, return values)
- What guarantees it provides (contracts)
The Reading Asymmetry:
Code is read far more often than it's written. A function you write in 10 minutes might be read by hundreds of developers over years. Each reader forms expectations from the name in milliseconds. If those expectations are wrong, every reader pays a cost: confusion, debugging, bugs. The cost of a bad name compounds across every reader, forever.
| Activity | Time | Frequency | Total Impact |
|---|---|---|---|
| Writing the name | 2 seconds | Once | 2 seconds |
| Reading the name | 0.5 seconds | 1000+ times | 500+ seconds |
| Misunderstanding the name | 5-30 minutes | 1-100 times | Hours to days |
| Debugging due to misunderstanding | 30 min - 2 hours | 1-10 times | Hours to weeks |
| Production incident due to misunderstanding | Hours + incident cost | 0-few times | Potentially catastrophic |
Developers don't read code—they skim it. They look at names and form mental models without reading implementations. A good name lets them skip to the next line with correct understanding. A bad name either requires reading the implementation (slow) or creates incorrect understanding (dangerous).
Common Naming Violations of POLA
Some naming patterns consistently create astonishment. Recognizing these anti-patterns is the first step to avoiding them:
Naming Anti-Patterns
- •Action Verbs Without the Action
queue.remove(item)suggests the item is immediately removed, but it might only mark it for later removal. If removal is deferred, say so:queue.scheduleRemoval(item)orqueue.markForRemoval(item). - •Accessor Names With Side Effects
cache.getValue(key)that creates the key if missing. The 'get' prefix universally implies read-only access. If it modifies state, it's not a 'get': usecache.getOrCreate(key)orcache.computeIfAbsent(key, factory). - •Partial Truth Names
validateAndSave(entity)that validates and queues but doesn't actually save synchronously. Every word in the name should be accurate. Rename tovalidateAndQueue(entity). - •Overloaded Terms
user.delete()where 'delete' means soft-delete in this codebase but hard-delete in the language's standard library. Either align with external conventions or be explicit:user.softDelete()vsuser.permanentlyDelete(). - •Abbreviations That Obscure
proc,mgr,svc,implsave a few characters but hide meaning.processManagervsprocMgr—the former is instantly clear; the latter requires context. Save abbreviations for truly universal terms (id,url,html). - •Generic Names
handle(),process(),doWork(),run(),execute()tell you nothing about what is being handled. Prefer specific names:handlePaymentFailure(),processRefundRequest(),executeScheduledTasks(). - •Boolean Confusion
isValidthat returns true for valid items... or is itisInvalid? Negative booleans (notEmpty,isDisabled,hasNoErrors) force readers to perform mental negation. Prefer positive phrasing.
- •
user.remove()— soft-deletes - •
cache.get(k)— creates if missing - •
order.submit()— saves draft only - •
validate()— also logs + notifies - •
config.set(k, v)— requires restart - •
file.close()— flushes async
- •
user.softDelete()oruser.archive() - •
cache.getOrCreate(k, factory) - •
order.saveDraft() - •
validateAndNotify() - •
config.setRequiringRestart(k, v) - •
file.closeAsync()→ returns Promise
Naming Conventions and Their Implicit Promises
Programming languages and communities develop conventions that carry implicit promises. Violating these conventions violates POLA even if your behavior is documented.
Java/C# Conventions:
| Prefix/Pattern | Implied Promise |
|---|---|
get*() | Read-only accessor; no side effects; returns value |
set*() | Mutator; modifies object state; void or fluent return |
is*(), has*() | Boolean accessor; cheap; no side effects |
create*(), new*() | Factory; returns new instance |
find*() | Query; may return null/empty |
to*() | Conversion; returns new instance of different type |
Python Conventions:
| Pattern | Implied Promise |
|---|---|
_single_underscore | Internal use; not part of public API |
__double_underscore | Name mangling; avoid external access |
| No return statement | Function returns None |
@property | Attribute-like access; computed but cheap |
1234567891011121314151617181920212223242526
// ❌ Convention violations that astonish class UserRepository { // 'get' implies no side effects, but this modifies lastAccessTime getUser(id: string): User { const user = this.db.find(id); user.lastAccessTime = Date.now(); // Side effect! return user; } // 'is' implies cheap boolean, but this makes network call async isUserActive(id: string): Promise<boolean> { const response = await fetch(`/api/users/${id}/status`); return response.json().active; // Network call! } // 'create' implies new instance, but this returns cached instance createLogger(name: string): Logger { if (this.loggers.has(name)) { return this.loggers.get(name); // Not creating! } const logger = new Logger(name); this.loggers.set(name, logger); return logger; }}12345678910111213141516171819202122232425262728293031
// ✅ Respecting conventions class UserRepository { // Read-only: matches 'get' convention getUser(id: string): User { return this.db.find(id); } // Side effect: name reflects it getUserAndRecordAccess(id: string): User { const user = this.db.find(id); user.lastAccessTime = Date.now(); return user; } // Async and potentially slow: name reflects it async checkIsUserActive(id: string): Promise<boolean> { const response = await fetch(`/api/users/${id}/status`); return response.json().active; } // May return cached: name reflects it getOrCreateLogger(name: string): Logger { if (this.loggers.has(name)) { return this.loggers.get(name); } const logger = new Logger(name); this.loggers.set(name, logger); return logger; }}Be careful with conventions when working across languages. A 'property' in Python has different expectations than a 'property' in JavaScript or C#. A 'module' means something different in Python vs JavaScript vs Ruby. When bridging languages, consider whether your audience's mental model comes from the source language or target language.
Building a Vocabulary of Intent
Expert developers maintain a mental vocabulary of terms with precise meanings. This vocabulary enables precise communication through names:
Precision in CRUD Operations:
| Term | Precise Meaning | Contrast With |
|---|---|---|
| create | Insert new; fail if exists | save (upsert) |
| insert | Add to collection; duplicates allowed | add (no duplicates) |
| add | Add if not present; idempotent | insert (allows dupes) |
| update | Modify existing; fail if not exists | save (upsert) |
| save | Insert or update; upsert semantics | create/update |
| delete | Remove permanently; hard delete | remove/archive |
| remove | Remove from collection; may be soft | delete (permanent) |
| archive | Soft delete; can be restored | delete (permanent) |
| purge | Delete and clean up all traces | delete (just record) |
| restore | Bring back archived/deleted item | create (from scratch) |
Precision in Query Operations:
| Term | Precise Meaning | Return on Not Found |
|---|---|---|
| find | Query; may not exist | null/undefined |
| get | Access; assumed to exist | throw if not found |
| lookup | Search by key | null/undefined |
| fetch | Retrieve from remote source | throw on failure |
| load | Retrieve and populate object | throw if not found |
| search | Query with criteria; multiple results | empty collection |
| query | Execute query; multiple results | empty collection |
| exists | Check existence | boolean |
| contains | Check membership | boolean |
Precision in State Changes:
| Term | Precise Meaning |
|---|---|
| initialize | Set initial state; call once |
| configure | Set options; call before start |
| start | Begin operation; transition to running |
| stop | End operation; can restart |
| pause | Temporarily halt; resume possible |
| resume | Continue after pause |
| reset | Return to initial state |
| clear | Remove content; keep structure |
| destroy | Clean up; cannot reuse |
| dispose | Release resources; cannot reuse |
Create and maintain a glossary of terms for your team or project. Document exactly what 'delete' vs 'archive' vs 'purge' means in your system. When everyone shares the same vocabulary, names become reliable communication.
Naming for Temporal Behavior
One of the most common sources of astonishment is timing. When does something happen? Immediately? Later? Asynchronously? Names must communicate temporal behavior clearly.
- •Synchronous vs Asynchronous
send()vssendAsync(): If your language doesn't enforce async in the type system, suffix async operations. Or use the type:send(): Promise<Result>makes async nature explicit. - •Immediate vs Deferred
delete()vsscheduleDelete()vsqueueForDeletion(): If the action doesn't happen immediately, say so. Never use an immediate-sounding verb for a deferred action. - •Eager vs Lazy
loadUsers()suggests immediate loading. If loading is deferred until access, uselazyLoadUsers()or return aLazyCollection<User>that makes laziness type-visible. - •Blocking vs Non-blocking
read()vsreadBlocking()vstryRead(): In environments where blocking vs non-blocking matters, encode it in the name.tryRead()conventionally doesn't block. - •Fire-and-Forget vs Awaitable
notify()that returns void suggests fire-and-forget. If you need to await completion,notifyAsync(): Promise<void>ornotifyAndWait()makes this clear.
123456789101112131415161718192021222324252627282930313233343536373839404142
// ❌ Temporal confusion class EmailService { // Does this send immediately or queue? send(email: Email): void { this.queue.add(email); // Surprise! Doesn't send immediately } // Does this block until message is received? receiveMessage(): Message { return this.blockingQueue.take(); // Surprise! Blocks forever }} // ✅ Temporal clarity class EmailService { // Name clarifies: queued, not sent queueForDelivery(email: Email): void { this.queue.add(email); } // Name clarifies: sends immediately, async async sendImmediately(email: Email): Promise<DeliveryResult> { return await this.smtpClient.send(email); } // Name clarifies: blocking operation receiveMessageBlocking(): Message { return this.blockingQueue.take(); } // Name clarifies: non-blocking, may not have message tryReceiveMessage(): Message | null { return this.queue.poll(); } // Name clarifies: async with timeout async receiveMessageAsync(timeout: Duration): Promise<Message | null> { return await this.queue.pollAsync(timeout); }}In languages with rich type systems, let types communicate timing. Promise<T> says 'async'. Observable<T> says 'stream of values over time'. Lazy<T> says 'computed on demand'. Types are harder to ignore than name suffixes.
Naming Parameters and Return Values
Method names get the most attention, but parameter and return value names also set expectations. Poorly named parameters cause misuse; unclear return semantics cause bugs.
Parameter Naming Principles:
1. Name for Role, Not Type
// ❌ Named for type
function transfer(string1: string, string2: string, number1: number)
// ✅ Named for role
function transfer(sourceAccount: string, targetAccount: string, amount: number)
2. Clarify Units
// ❌ What unit is timeout?
function connect(timeout: number)
// ✅ Unit is explicit
function connect(timeoutMs: number)
function connect(timeout: Duration)
3. Clarify Semantics of Common Types
// ❌ Is this inclusive or exclusive? 0-indexed?
function getRange(start: number, end: number)
// ✅ Names clarify semantics
function getRange(startIndexInclusive: number, endIndexExclusive: number)
12345678910111213141516171819202122232425262728293031323334353637
// ❌ Parameter naming that causes bugs function schedule( callback: () => void, time: number // Seconds? Milliseconds? Absolute time?): void; function copy( src: string, // Source or destination first? dst: string): void; function between( a: number, // Inclusive? Exclusive? b: number, value: number // Is value the thing being tested or a bound?): boolean; // ✅ Parameter naming that prevents bugs function scheduleAfterMs( callback: () => void, delayMilliseconds: number): CancellationToken; function copyFromTo( sourcePath: string, destinationPath: string, options?: CopyOptions): Promise<void>; function isValueBetween( value: number, lowerBoundInclusive: number, upperBoundExclusive: number): boolean;Return Value Semantics:
| Return Pattern | What It Signals | Expectation |
|---|---|---|
T | Operation succeeded; value returned | Never null/undefined |
| `T | null` | Value may not exist |
| `T | undefined` | Value may not be set |
Optional<T> | Explicit optionality | Use unwrap patterns |
Result<T, E> | Operation may fail | Caller handles error case |
Promise<T> | Async operation | Await or chain |
void | No return value | Side effect only |
| Collection | Zero or more results | Empty if none; never null |
Never use null to mean two different things. If null means 'not found', don't also use it to mean 'explicitly set to nothing'. Use Optional<T> or separate methods: find() returns null if not found; getRequired() throws if not found.
Class and Module Naming
Class and module names set expectations about responsibility and behavior. A well-named class tells you what it does; a poorly-named class tells you nothing—or worse, lies.
Class Naming Anti-Patterns
- •Manager/Handler/Processor — So vague as to be meaningless.
UserManagercould do anything related to users. Prefer specific names:UserAuthenticator,UserPermissions,UserProfileEditor. - •Impl/Implementation —
UserServiceImplsays nothing about how it differs from other implementations. Name for distinction:CachedUserService,RemoteUserService. - •Helper/Util/Common — Becomes a dumping ground. Split into focused modules:
StringFormatting,DateParsing,ValidationRules. - •Base/Abstract prefix —
AbstractUserRepositorytells you it's abstract, not what it does. The abstract nature is in the language (abstract class). Name for purpose:UserRepositoryis fine. - •Overly Long Names —
UserAccountProfilePictureUploadValidationServiceis too specific and rigid. If the class does one thing, a shorter name suffices.
Class Naming Patterns That Work:
| Pattern | Example | What It Signals |
|---|---|---|
*Service | PaymentService | Orchestrates business operations |
*Repository | UserRepository | Data access; CRUD operations |
*Factory | ConnectionFactory | Creates instances |
*Builder | QueryBuilder | Fluent construction |
*Validator | EmailValidator | Validates; returns boolean or errors |
*Adapter | LegacyApiAdapter | Translates between interfaces |
*Decorator | LoggingDecorator | Wraps and enhances |
*Strategy | PricingStrategy | Encapsulates algorithm |
*Specification | ActiveUserSpec | Encapsulates business rule |
The best class names come from the domain, not from technical patterns. Instead of OrderProcessor, use domain terms: OrderFulfillment, ShipmentCoordinator, InvoiceGenerator. Domain names communicate intent to business stakeholders and connect code to requirements.
12345678910111213141516171819202122232425262728293031323334
// ❌ Vague class names class UserHelper { validate(user: User): boolean; format(user: User): string; sendEmail(user: User, template: string): void; checkPermissions(user: User, resource: Resource): boolean; // What is this class's responsibility? Everything?} class DataProcessor { process(data: unknown): unknown; // What does this even do?} // ✅ Focused, intention-revealing names class UserValidator { validate(user: User): ValidationResult;} class UserProfileFormatter { formatForDisplay(user: User): FormattedProfile; formatForExport(user: User): ExportableProfile;} class WelcomeEmailSender { sendWelcomeEmail(user: User): Promise<DeliveryStatus>;} class ResourceAccessChecker { canAccess(user: User, resource: Resource): boolean; getAccessibleResources(user: User): Resource[];}KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.