📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the principle of least astonishment, and how do you apply it to C# APIs and library design?

Code and APIs should behave as users expect: naming, side effects, .NET conventions (Try pattern, Async suffix, CancellationToken last), equality, exceptions and defaults.

Mid-Level Theory Intermediate api-design design-principles least-astonishment naming

🎯 Key points

  • Behaviour should match what a reasonable reader expects from the name and signature
  • Follow .NET conventions: TryX returns bool with out, XAsync returns a Task, CancellationToken is last
  • Getters and properties should be cheap and free of side effects
  • Equality, ToString and GetHashCode must be consistent
  • Use safe defaults and avoid hidden global state changes
  • Framework Design Guidelines and analysers encode the conventions

⏱ 30-second answer

The principle of least astonishment says code should behave the way a reasonable user expects from its name, signature and context. In C# that means following .NET conventions: a TryParse method returns bool and never throws for bad input, an ...Async method returns a task and accepts a CancellationToken as its last parameter, properties are cheap and have no side effects, and Equals, GetHashCode and == agree. Surprises such as a GetUser that creates a user, or a property that makes a network call, cause bugs because callers rely on the convention rather than reading the implementation.

🗣 Full answer (senior)

How I would answer this in an interview

"The principle of least astonishment says code should behave the way a reasonable user expects from its name, signature and context. For APIs and libraries the 'user' is another developer, and they rely on conventions rather than reading every implementation, so each surprise turns into a bug at someone else's call site. In C# the expectations come from the .NET Framework Design Guidelines and the BCL. TryParse returns bool and doesn't throw for bad input. An ...Async method returns a Task/ValueTask and takes a CancellationToken as its last parameter. Properties are cheap and side-effect free. Equals, GetHashCode and == agree. Collections are never returned as null. Defaults are the safe option. Just as important is consistency inside a codebase: similar methods should behave similarly. Typical surprises I fix are a GetCustomer that silently creates customers, a property that makes a network call, and a method that mutates the list you passed in. I enforce the conventions with analysers (CA1068, CA2016, CA1065, CA1024) and review API surface changes separately from implementation."


1. Common astonishments and fixes

Surprise Why it bites Least-surprising alternative
GetCustomer(id) creates a customer if missing Reads cause writes; duplicate records GetOrCreateCustomerAsync
Property Orders calls a remote API Called in loops as if cheap (N+1 HTTP calls) GetOrdersAsync(ct) method
TryParse throws on bad input Callers don't wrap it in try/catch Return false
SaveAsync() with no CancellationToken Callers can't cancel; timeouts leak work SaveAsync(..., CancellationToken ct = default)
Equals overridden, GetHashCode not Dictionary/HashSet lookups fail Override both, or use a record
Sort(list) mutates the caller's list Caller's data changes unexpectedly Return a new list, or name it SortInPlace
Delete(path, recursive = true) default Accepting defaults destroys data Safe default: recursive = false
Returns null for "no results" foreach throws NullReferenceException Empty collection
ToString() throws or is expensive Breaks logging and debugging Cheap, never throws
Method name says Validate but it also saves Hidden side effects Separate methods or honest name
Same concept, different behaviour across modules FindX returns null here, throws there One convention per codebase

2. .NET conventions that set expectations

Convention Expectation
TryX(..., out T result) Returns bool; no exception for expected failure; [NotNullWhen(true)] on result
XAsync Returns Task/ValueTask; truly asynchronous; accepts CancellationToken last
Properties Cheap, no side effects, same value on repeated reads (barring state changes)
IDisposable / IAsyncDisposable Caller owns disposal via using / await using
GetX vs FindX Often: Get throws if missing, Find returns null (pick one convention, document it)
Collections in APIs Never null; prefer IReadOnlyList<T>/IEnumerable<T>
Exceptions ArgumentException family for bad arguments; InvalidOperationException for wrong state
Equality record for value semantics; reference equality for entities unless designed otherwise

3. Properties vs methods (Framework Design Guidelines)

Use a method when the operation:

  • is expensive or does I/O,
  • has observable side effects,
  • returns a different result on each call (Guid.NewGuid(), DateTime.Now-like),
  • returns a copy of internal state (e.g. an array),
  • can throw for reasons unrelated to object state.

Production example

A shared [customer] library exposed Customer.Orders as a property that lazily called a remote orders API. A customer list page rendered [500] customers and touched Orders for each, so [500] sequential HTTP calls took the page to [about 40 seconds]. APM showed the calls, but nothing at the call site hinted at the cost, because developers reasonably assumed a property was in memory. I replaced it with GetOrdersAsync(CancellationToken ct) so the cost and the await were visible, and added GetOrdersForCustomersAsync(ids, ct) for list pages. The page dropped to [under a second]. We added an API review checklist (no I/O in properties, Async + CancellationToken conventions) and turned on the relevant analysers for the library.

🔍 What the interviewer is checking

Checks API design sense and knowledge of .NET conventions.

Green flags

  • Cites concrete .NET conventions (Try pattern, Async, CancellationToken placement).
  • Knows properties should be cheap and side-effect free.
  • Mentions consistent equality and safe defaults.
  • Uses analysers to enforce conventions.

Red flags

  • Names that hide behaviour.
  • "Callers should read the code."

📚 Learn in depth

1. Origin

The principle (also "principle of least surprise") comes from UI and programming-language design in the 1970s–80s and is often quoted in API design: "a component should behave in a way that most users expect". Krzysztof Cwalina and Brad Abrams' Framework Design Guidelines turned it into concrete .NET conventions, and the BCL follows them closely.

2. Where expectations come from

Source Example
Language/platform conventions TryParse, Async suffix, IDisposable
BCL precedent Dictionary.TryGetValue, Stream.ReadAsync(buffer, ct)
Domain vocabulary "Cancel" in your domain implies refunds? Document it
Your own codebase If every repository's FindAsync returns null, the next one must too

Consistency inside a codebase often matters more than the "best" individual choice.

3. The Try pattern done right

public static bool TryParse(string? input, [NotNullWhen(true)] out Postcode? result)
public static Postcode Parse(string input)   // throws FormatException for invalid input
  • Try never throws for expected failures (bad input); it may still throw for programming errors.
  • Pair it with a throwing Parse for callers who expect valid input.
  • [NotNullWhen(true)] lets nullable analysis know result is non-null on success.

4. Async conventions

Expectation Detail
Returns Task/ValueTask Never async void in libraries
Truly asynchronous No hidden Task.Run around blocking I/O
CancellationToken last, defaulted DoAsync(x, CancellationToken cancellationToken = default) (CA1068)
Token forwarded Pass it to every async call inside (CA2016)
No sync-over-async No .Result inside

5. Equality and value semantics

// Surprise: Equals overridden, GetHashCode not → HashSet/Dictionary misbehave
public sealed class Money { public decimal Amount; public override bool Equals(object? o) => o is Money m && m.Amount == Amount; }

// Least surprise: records give consistent Equals, GetHashCode, ==, ToString
public readonly record struct Money(decimal Amount, string Currency);

6. Analysers that encode the conventions

Rule Catches
CA1068 CancellationToken not last
CA2016 Token not forwarded
CA1065 Exceptions thrown from unexpected places (ToString, Equals, GetHashCode, property getters, finalizers)
CA1024 / CA1721 Property vs method design, confusing name pairs
CA2007 ConfigureAwait usage in library code
CA1815 / CA2231 Equality operators/overrides on value types

Enable with <AnalysisMode>Recommended</AnalysisMode> (or All) and treat warnings as errors for public libraries.

7. Versioning without surprises

  • Don't change behaviour silently under the same signature; add a new method or option.
  • Mark old APIs [Obsolete("Use X instead")] before removal.
  • Use semantic versioning for packages; breaking changes = major version.
  • Tools like Microsoft.DotNet.ApiCompat or public API analysers detect accidental breaking changes.

8. Try it yourself

  1. Enable <AnalysisMode>Recommended</AnalysisMode> in a library and fix CA1068/CA2016/CA1065 warnings.
  2. Review every property getter in a shared library for I/O or side effects.
  3. Implement TryParse/Parse for a value object with [NotNullWhen(true)].
  4. Search for methods named Get* that write data and rename or split them.
  5. Add the public API analyser to a package and make a breaking change to see it flagged.

💻 Code

using System.Diagnostics.CodeAnalysis;

Console.WriteLine(Postcode.TryParse("6000", out var pc) ? pc.ToString() : "invalid");
Console.WriteLine(Postcode.TryParse("60A0", out _));                 // False, no exception

var repo = new CustomerRepository();
var c = await repo.GetOrCreateAsync("[email protected]");             // name says it may create
Console.WriteLine(c);

// Value semantics with consistent Equals, GetHashCode and ToString.
public readonly record struct Postcode
{
    public string Value { get; }
    private Postcode(string value) => Value = value;

    public static bool TryParse(string? input, [NotNullWhen(true)] out Postcode? result)
    {
        result = input is { Length: 4 } && input.All(char.IsAsciiDigit) ? new Postcode(input) : null;
        return result is not null;
    }

    public override string ToString() => Value;
}

public sealed record Customer(Guid Id, string Email);

public sealed class CustomerRepository
{
    private readonly Dictionary<string, Customer> _byEmail = new(StringComparer.OrdinalIgnoreCase);

    // Async suffix, CancellationToken last with a default, honest name.
    public Task<Customer> GetOrCreateAsync(string email, CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();
        if (!_byEmail.TryGetValue(email, out var customer))
            _byEmail[email] = customer = new Customer(Guid.NewGuid(), email);
        return Task.FromResult(customer);
    }
}

⚠ Common mistakes

1. I/O or expensive work in property getters

What happens: customer.Orders calls a remote API. Why it is wrong: properties are assumed cheap and get called in loops. Do instead: an async method so the cost is visible.

2. Names that hide side effects

What happens: GetUser creates users; Validate also saves. Why it is wrong: callers trigger writes they didn't intend. Do instead: name methods after everything they do, or split them.

3. Breaking the Try pattern

What happens: TryParse throws on invalid input. Why it is wrong: callers rely on the convention and don't catch. Do instead: return false for expected failures.

4. Async methods without CancellationToken, or with it first

What happens: SaveAsync(CancellationToken ct, Order order) or no token. Why it is wrong: breaks conventions and analysers; callers can't cancel. Do instead: token last, defaulted, and forwarded.

5. Returning null collections

What happens: "no results" returns null. Why it is wrong: foreach and LINQ throw. Do instead: return empty collections.

6. Dangerous defaults

What happens: overwrite: true or recursive: true by default. Why it is wrong: accepting defaults causes destructive behaviour. Do instead: safe defaults; risky behaviour must be explicit.

7. Inconsistent equality

What happens: Equals overridden without GetHashCode, or == disagreeing with Equals. Why it is wrong: collections and comparisons behave unpredictably. Do instead: use records or implement all members consistently.

8. Mutating input arguments

What happens: a method sorts or clears the list passed in. Why it is wrong: the caller's data changes unexpectedly. Do instead: return new data, or make mutation explicit in the name and signature.

9. Inconsistent conventions across a codebase

What happens: FindAsync returns null in one repository and throws in another. Why it is wrong: developers can't rely on patterns. Do instead: agree conventions and document them.

10. Silent behaviour changes in new versions

What happens: a library method's behaviour changes under the same signature. Why it is wrong: consumers break without a compile error. Do instead: new APIs, [Obsolete], semantic versioning, API compatibility checks.

🏛 Senior insight: trade-offs

For shared libraries and public APIs, least astonishment is the main usability goal, because consumers cannot read the source every time. I lean on conventions and analysers rather than personal taste, and I review API surface changes separately from implementation changes.

API decision Least-surprising choice
Parse that may fail TryParse + Parse
Async operation Task-returning XAsync(..., CancellationToken ct = default)
Value type semantics record / record struct
Expensive or I/O read Method, not property
Optional destructive behaviour Off by default
Empty results Empty collection, not null

🔗 Follow-ups the interviewer may ask

  1. When: Candidate talks about exceptions from APIs Why: Probes fail fast versus Try pattern
    How to answer

    Fail fast means detecting a problem at the earliest point and stopping clearly, instead of carrying bad state forward until it causes a confusing failure later. In .NET that means guard clauses (ArgumentNullException.ThrowIfNull, ArgumentOutOfRangeException.ThrowIfNegativeOrZero), constructors that refuse invalid objects, nullable reference types, options validation with ValidateOnStart(), and DI ValidateOnBuild. It does not mean crashing on every transient error: inside a component I fail fast on programming and configuration errors, and at boundaries (network, other services) I use retries, timeouts and graceful degradation.

  2. When: Candidate mentions methods mutating inputs Why: Checks mutable versus immutable design
    How to answer

    Prefer immutable types for values, messages, configuration, cache entries and anything shared across threads: an object that cannot change is automatically thread-safe, safe to cache and share, safe as a dictionary key, and easier to reason about. C# gives you readonly fields, init and get-only properties, records with with, readonly struct, ImmutableArray<T>/ImmutableList<T> and FrozenDictionary/FrozenSet for read-mostly data. Remember that read-only is not immutable: IReadOnlyList<T> over a List<T> can still change underneath. Keep mutability where it is natural and contained: entities whose state changes through methods that guard invariants, builders, and hot loops where allocating new objects would cost too much.

  3. When: Candidate relies on platform conventions Why: Moves to convention over configuration
    How to answer

    Convention over configuration means a framework assumes sensible defaults, so you only write configuration where your case differs. EF Core is the clearest .NET example: a property called Id or OrderId becomes the primary key, navigation properties create relationships and foreign keys, and table names come from DbSet names. ASP.NET Core loads appsettings.{Environment}.json automatically, maps __ in environment variables to : in configuration keys, and finds controllers by naming. It removes boilerplate and makes projects consistent, but conventions are invisible, so I document team conventions, use ConfigureConventions for project-wide rules, and override explicitly where the default is wrong.

🛠 Real-world problems: how you handle them

Lazy property making 500 HTTP calls
Situation
A shared [customer] library exposed `Customer.Orders` as a property backed by a remote API.
Signals
A customer list page took [40 seconds]; APM showed [500] sequential HTTP calls.
Investigation
Developers assumed the property was in-memory; nothing at the call site showed the cost.
Resolution
Replaced it with `GetOrdersAsync(ct)` and added a batch method for lists.
Prevention
API review checklist: no I/O in properties; analyser rules for async conventions.

How to tell it (STAR)

A page took [40 seconds] because a library property quietly called a remote API, and a loop did that [500] times. I changed the property to an async method so the cost was obvious at the call site, and added a batch call. The page dropped to under a second and the API now follows normal .NET expectations.

↩ Usually asked after

More in Additional Principles

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me