Code and APIs should behave as users expect: naming, side effects, .NET conventions (Try pattern, Async suffix, CancellationToken last), equality, exceptions and defaults.
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.
"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.
TryParsereturnsbooland doesn't throw for bad input. An...Asyncmethod returns aTask/ValueTaskand takes aCancellationTokenas its last parameter. Properties are cheap and side-effect free.Equals,GetHashCodeand==agree. Collections are never returned asnull. Defaults are the safe option. Just as important is consistency inside a codebase: similar methods should behave similarly. Typical surprises I fix are aGetCustomerthat 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."
| 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 |
| 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 |
Use a method when the operation:
Guid.NewGuid(), DateTime.Now-like),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.
Checks API design sense and knowledge of .NET conventions.
Green flags
Red flags
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.
| 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.
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.Parse for callers who expect valid input.[NotNullWhen(true)] lets nullable analysis know result is non-null on success.| 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 |
// 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);
| 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.
[Obsolete("Use X instead")] before removal.Microsoft.DotNet.ApiCompat or public API analysers detect accidental breaking changes.<AnalysisMode>Recommended</AnalysisMode> in a library and fix CA1068/CA2016/CA1065 warnings.TryParse/Parse for a value object with [NotNullWhen(true)].Get* that write data and rename or split them.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);
}
}
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.
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.
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.
CancellationToken, or with it firstWhat 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.
null collectionsWhat happens: "no results" returns null.
Why it is wrong: foreach and LINQ throw.
Do instead: return empty collections.
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.
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.
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.
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.
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.
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 |
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.
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.
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.
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.
Last updated 31 Oct 2026.