Choosing method return types in C#: void, values, tuples, out parameters, result objects, ref returns, IEnumerable and IAsyncEnumerable, Task and ValueTask, reviewed through a realistic service method.
The return type is the contract, so I would make it honest. Not-found becomes Task<Order?> with nullable reference types on, so callers are forced to handle null, or a Result<Order> if there are several expected failure reasons. The two out parameters must go because async methods cannot have out parameters; their information moves into the result object. The internal List<Order> is replaced with IReadOnlyList<Order> (or a copy) so callers cannot mutate our state. C# gives us void, values, tuples, records, out/Try pattern, ref returns, IEnumerable<T>/IAsyncEnumerable<T>, and Task/ValueTask.
I would treat this as a contract design problem, not a syntax problem. The current method has four smells: an ambiguous null, out parameters for errors (which cannot work in async code), a leaked mutable internal list, and no distinction between expected failures and exceptional ones. Then I would walk through the return options C# gives you and pick the right one for each case.
public Order GetOrder(int id, out bool success, out string error)
{
var order = _orders.FirstOrDefault(o => o.Id == id);
if (order == null) { success = false; error = "Not found"; return null; }
success = true; error = null;
return order;
}
public List<Order> GetOrders() => _orders; // returns the internal list
out parameters cannot be used in async methods. The moment the data comes from a database or API, this signature has to change for every caller.return, success, error) can contradict each other — nothing stops success == true with a null order.null means "not found" but is not visible in the type without nullable reference types, so callers forget to check and get NREs."Not found" reliably, and the text cannot be mapped to an HTTP status safely.List<Order> lets callers Clear() or Add() to the service's state and breaks encapsulation; if the list is shared across requests, it is also a thread-safety bug.Task.Run or the service may block on async I/O internally.| Option | Use for |
|---|---|
T |
Always succeeds or throws |
T? (with nullable reference types on) |
"Might not exist" lookups where absence is normal and needs no reason |
bool TryX(..., out T value) |
Synchronous, hot-path, expected failure (parsing, cache lookups) |
Tuple (bool Found, Order? Order) |
Internal/private helpers returning a couple of values |
record result type |
Public APIs returning several related values |
Result<T> / OneOf<...> / discriminated hierarchy |
Expected business failures with reasons (not found, conflict, validation) |
| Exceptions | Unexpected or unrecoverable failures, contract violations |
Task<T> / ValueTask<T> |
Any of the above when I/O is involved |
IReadOnlyList<T> / IReadOnlyCollection<T> |
Returning a materialised collection without exposing mutation |
IEnumerable<T> |
Lazy streaming in-memory; IAsyncEnumerable<T> for async streaming |
ref / ref readonly returns |
Low-level, performance-critical access to storage (rare in services) |
void / Task |
Commands with no meaningful result |
public sealed record OrderNotFound(int OrderId);
public async Task<Order?> FindOrderAsync(int id, CancellationToken ct)
=> await _db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task<IReadOnlyList<OrderSummary>> GetOrdersAsync(CustomerId customer, CancellationToken ct)
=> await _db.Orders.Where(o => o.CustomerId == customer)
.Select(o => new OrderSummary(o.Id, o.Total, o.Status))
.ToListAsync(ct);
Task<Order?> with nullable reference types enabled is honest and idiomatic: absence is in the type, and the controller maps null to 404 (TypedResults.NotFound()).Task<Result<Order, OrderError>> or Results<Ok<OrderDto>, NotFound, Conflict> in minimal APIs, so every outcome is explicit and mappable to ProblemDetails.IReadOnlyList<T> of DTOs/projections, never the internal list or tracked EF entities.In an order API, a method like this returned null for both "not found" and "belongs to another customer". The controller returned 404 in both cases, which was fine for security, but the support team could not tell from logs which happened, and one caller treated null as "create new" and duplicated orders. Introducing an explicit result type (NotFound, Forbidden, Ok) made the logs and metrics distinguishable, kept the external response a 404 for both, and the compiler forced the "create new" caller to handle each case deliberately.
The interviewer is assessing API design judgement through a realistic review.
Green flags
ValueTask trade-offs and that tuples are compile-time named.Red flags
async void or ValueTask everywhere.out and ref cannot cross awaitAn async method is compiled into a state machine whose locals are hoisted into fields so they survive across await. An out or ref parameter is a managed pointer to the caller's storage — possibly a stack slot. Managed pointers cannot be stored in heap objects (the state machine is a heap object once the method suspends), because the stack frame they point to may be gone. So the language forbids ref, in and out parameters on async methods and iterators (CS1988). This is the fundamental reason "Try" methods have no async equivalent and async APIs return tuples, records or nullable results instead.
With <Nullable>enable</Nullable>, Task<Order?> tells callers and the compiler that null is possible. Callers who dereference without checking get warning CS8602, which teams usually promote to an error. The return type also flows into tooling: OpenAPI generators can mark the response as nullable, and static analysis follows null-state through the code.
The limitation: NRT is compile-time only. Callers compiled without nullable context, reflection, or deserialisers can still pass or receive null. Public boundaries still need guards.
A result type makes expected failures part of the return type instead of side channels or exceptions:
public abstract record OrderError
{
public sealed record NotFound(int OrderId) : OrderError;
public sealed record AlreadyShipped(int OrderId, DateOnly ShippedOn) : OrderError;
public sealed record NotOwned(int OrderId) : OrderError;
}
public readonly record struct Result<T, TError>(T? Value, TError? Error)
{
public bool IsSuccess => Error is null;
public static Result<T, TError> Ok(T value) => new(value, default);
public static Result<T, TError> Fail(TError error) => new(default, error);
}
The caller then pattern-matches:
return result.Error switch
{
null => TypedResults.Ok(result.Value!.ToDto()),
OrderError.NotFound => TypedResults.NotFound(),
OrderError.AlreadyShipped e => TypedResults.Conflict(e),
OrderError.NotOwned => TypedResults.NotFound(), // don't leak existence
_ => throw new UnreachableException()
};
Trade-offs: result types add ceremony and must be propagated manually through layers (C# has no ? operator like Rust). Libraries such as OneOf, ErrorOr and FluentResults reduce boilerplate. Use them for business outcomes; don't wrap every database call.
ValueTuple ((bool Found, Order? Order)) is a mutable struct with fields Item1, Item2; the names are compile-time only (stored in TupleElementNamesAttribute), so they disappear in reflection and serialisation. Great for private helpers and deconstruction.record (or record struct) has real named properties, value equality, a good ToString, and can evolve by adding properties. Prefer records for anything public or serialised.Returning _orders directly hands out the same object. Options:
| Return | Caller can mutate service state? | Cost |
|---|---|---|
List<T> (internal field) |
Yes — bad | None |
IReadOnlyList<T> wrapping the internal list |
Only by casting back to List<T> |
None, but still a live view |
_orders.AsReadOnly() (ReadOnlyCollection<T>) |
No, but it is a live view | One small wrapper |
_orders.ToArray() / ToList() |
No; snapshot | Copy |
ImmutableArray<T> / FrozenSet<T> field |
No; safe to share across threads | Copy on change |
For EF Core, return projections (Select into DTOs) rather than tracked entities, so callers cannot accidentally change tracked state or trigger lazy loading.
Task<T> vs ValueTask<T>Task<T> is the default: it can be awaited multiple times, stored, and combined with WhenAll.ValueTask<T> avoids an allocation when the result is usually synchronous (e.g. a cache hit). It must be awaited exactly once and not used concurrently. Use it only on measured hot paths, and keep public service APIs on Task<T> unless profiling justifies the change.ref returnsref T / ref readonly T return a reference to existing storage (an array element, a struct field). CollectionsMarshal.GetValueRefOrAddDefault and Span<T> indexers use them. They avoid copying large structs and allow in-place updates, but they are a low-level tool for libraries and hot paths, not service APIs.
async Task<Order> GetAsync(int id, out string error) and read CS1988._orders as IReadOnlyList<Order>, cast it back to List<Order> in the caller and Clear() it — then switch to AsReadOnly() or ToArray().OrderError hierarchy and a switch expression that maps it to TypedResults.namespace Orders;
public sealed record Order(int Id, string Customer, decimal Total);
public sealed record PlaceOrder(string Customer, decimal Total);
public sealed record Result<T>
{
public T? Value { get; private init; }
public string? Error { get; private init; }
public bool IsSuccess => Error is null;
public static Result<T> Ok(T value) => new() { Value = value };
public static Result<T> Fail(string error) => new() { Error = error };
}
public sealed class OrderService
{
private readonly List<Order> _orders = [new(1, "Ada", 120m)];
private readonly Lock _gate = new(); // C# 13 / .NET 9 Lock type
// Absence is explicit and async-friendly
public Task<Order?> FindOrderAsync(int id, CancellationToken ct = default)
{
lock (_gate)
{
return Task.FromResult(_orders.FirstOrDefault(o => o.Id == id));
}
}
// Expected failures in a result, not out parameters
public Task<Result<Order>> PlaceOrderAsync(PlaceOrder cmd, CancellationToken ct = default)
{
if (cmd.Total <= 0)
return Task.FromResult(Result<Order>.Fail("Total must be positive"));
lock (_gate)
{
var order = new Order(_orders.Count + 1, cmd.Customer, cmd.Total);
_orders.Add(order);
return Task.FromResult(Result<Order>.Ok(order));
}
}
// No leaking of internal state: a snapshot copy
public IReadOnlyList<Order> GetRecent()
{
lock (_gate) { return _orders.TakeLast(10).ToArray(); }
}
// Tuple for an internal helper
internal (int Count, decimal Sum) Totals()
{
lock (_gate) { return (_orders.Count, _orders.Sum(o => o.Total)); }
}
}
// Usage at the HTTP edge (Minimal API):
// app.MapGet("/orders/{id:int}", async (int id, OrderService s, CancellationToken ct) =>
// await s.FindOrderAsync(id, ct) is { } order ? Results.Ok(order) : Results.NotFound());
null for several different meaningsWhat happens: null is returned for not found, not authorised, invalid input and "database timed out but we swallowed it".
Why it is wrong: callers cannot distinguish the cases; logs, metrics and HTTP status codes become meaningless, and some callers treat null as "create a new one".
Do instead: null only for plain absence (with NRT on); explicit result types for multiple expected outcomes; exceptions for unexpected failures.
out parametersWhat happens: bool success, string error as out parameters next to a return value.
Why it is wrong: cannot be used in async methods, the three channels can contradict each other, and string errors cannot be handled programmatically.
Do instead: return a record or result type with typed errors.
What happens: public List<Order> Orders => _orders; or return _orders;.
Why it is wrong: callers can modify service state, invalidate invariants, and cause race conditions if the list is shared.
Do instead: return IReadOnlyList<T> snapshots or immutable collections; return DTOs instead of entities.
IEnumerable<T> from a query that is not materialisedWhat happens: a repository returns IEnumerable<Order> backed by an EF Core query or a yield iterator.
Why it is wrong: the query executes later — possibly after the DbContext is disposed (ObjectDisposedException) — and may execute multiple times.
Do instead: materialise with ToListAsync and return IReadOnlyList<T>, or deliberately expose IAsyncEnumerable<T> for streaming.
What happens: throw new OrderNotFoundException() for routine lookups, caught by a controller to return 404.
Why it is wrong: exceptions are expensive, make control flow invisible, and turn routine conditions into noisy error logs.
Do instead: model expected outcomes in the return type; reserve exceptions for genuinely exceptional cases.
Task.Run to make it "async"What happens: public Task<Order> GetOrderAsync(int id) => Task.Run(() => GetOrder(id)); in ASP.NET Core.
Why it is wrong: it uses another thread-pool thread to block, adding overhead without freeing anything (async-over-sync).
Do instead: make the I/O genuinely async end to end (FirstOrDefaultAsync, HttpClient), or keep the method synchronous.
What happens: Task<(bool, Order?, string?)> GetAsync(...) on a public service or API.
Why it is wrong: tuple element names are compile-time only, serialise as Item1/Item2, and adding an element breaks every caller.
Do instead: use a named record for anything public or serialised.
ValueTask<T> everywhere "for performance"What happens: every async method returns ValueTask<T>.
Why it is wrong: ValueTask has strict rules (await once, never concurrently); misuse causes subtle bugs, and the benefit only exists when results are often synchronous.
Do instead: default to Task<T>; use ValueTask<T> on measured hot paths with frequent synchronous completion.
What happens: services return tracked Order entities that controllers serialise.
Why it is wrong: over-posting and over-exposure of fields (including personal information covered by the Privacy Act), lazy-loading surprises and cycles during serialisation.
Do instead: project to DTOs in the query and return those.
CancellationToken on the redesigned async methodWhat happens: the method becomes async but ignores request cancellation.
Why it is wrong: when clients disconnect or time out, the database work continues and wastes capacity.
Do instead: accept CancellationToken and pass it to every async call.
A senior reviews return types as contracts that encode absence, failure, ownership and asynchrony.
| Concern | Signal in the return type |
|---|---|
| Absence | T? with NRT, or TryX |
| Expected failure | Result<T> / union-style record |
| Unexpected failure | exception (not in the type) |
| Ownership | IReadOnlyList<T> / immutable vs mutable List<T> |
| Asynchrony | Task<T>, IAsyncEnumerable<T> |
| Hot-path allocation | ValueTask<T>, ref returns, spans (measured) |
IEnumerable<T>) and return the most specific useful read-only type (IReadOnlyList<T>), so callers get Count and indexing without mutation rights.ProblemDetails) in one place at the API edge.C# passes everything by value by default. For a value type that copies the data; for a reference type it copies the reference, so the method can change the object but reassigning the parameter does not affect the caller. ref passes the caller's variable itself; out is a ref the callee must assign, used by TryParse; in and ref readonly pass a read-only reference, useful for large structs. params takes a variable number of arguments, and since C# 13 it can be a ReadOnlySpan<T> to avoid allocating. Optional parameters' defaults are compiled into the caller, which matters when versioning public APIs.
Nullable reference types make nullability part of the declared type for static analysis: string means "should never be null" and string? means "may be null". The compiler tracks a null-state for every expression and warns when a maybe-null value is dereferenced or assigned to a non-nullable. It is compile-time only: there are no run-time checks, and ! merely silences a warning. Attributes like [NotNullWhen(true)] on TryGet methods or [MemberNotNull] on initialisers tell the analyser about contracts it cannot infer. Nulls can still arrive from deserialisation, arrays, default and unannotated code, so I still validate at the boundaries.
How to tell it (STAR)
**S:** Our order API [at company] returned 500s for unknown orders, and partners retried them. **T:** I owned fixing it properly rather than adding null checks one by one. **A:** I traced it with Application Insights, made absence explicit by returning Order? with nullable reference types on, fixed every warning the compiler raised, and mapped null to 404 at the edge. **R:** 500s on that endpoint dropped to [near zero], retry load fell, and nullable warnings are now build errors.
Last updated 30 Oct 2026.