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

📚 Interview question

Code review: a service method returns null when an order is not found, reports errors through two out parameters, returns its internal List<Order> directly, and is called from async code. How would you redesign its return types, and what return options does C# give you?

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.

Senior CodeReview Advanced code-review csharp language-basics methods return-types tuples

🎯 Key points

  • Return types are part of the API contract: they say whether absence, failure and asynchrony are possible
  • Make absence explicit with a nullable return type (Order?) under nullable reference types, or a TryGet pattern
  • Out parameters cannot be used in async methods; return a tuple, record or result type instead
  • Expected failures belong in a result object; exceptions are for unexpected failures
  • Do not leak mutable internal collections; return IReadOnlyList<T>, a copy, or an immutable collection
  • Return Task<T> for async work; ValueTask<T> only when results are often synchronous and you have measured; await a ValueTask once
  • Tuples suit private or internal helpers; named records suit public APIs
  • ref returns give access to storage without copying (Span indexer, CollectionsMarshal) and are a specialist tool

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The original code

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

2. Problems, in order of impact

  1. 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.
  2. Three channels for one result (return, success, error) can contradict each other — nothing stops success == true with a null order.
  3. null means "not found" but is not visible in the type without nullable reference types, so callers forget to check and get NREs.
  4. Stringly-typed errors: callers cannot branch on "Not found" reliably, and the text cannot be mapped to an HTTP status safely.
  5. Returning the internal 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.
  6. Sync-over-async risk: callers in async code may wrap this with Task.Run or the service may block on async I/O internally.

3. The return options C# gives you

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

4. My redesign

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);
  • For a simple lookup, 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()).
  • When there are several expected failure reasons (not found, already shipped, not owned by this customer), I return a result type such as Task<Result<Order, OrderError>> or Results<Ok<OrderDto>, NotFound, Conflict> in minimal APIs, so every outcome is explicit and mappable to ProblemDetails.
  • Collections come back as IReadOnlyList<T> of DTOs/projections, never the internal list or tracked EF entities.
  • Exceptions stay for genuinely exceptional cases: database down, invariants broken, cancellation.

5. A production example

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.

🔍 What the interviewer is checking

The interviewer is assessing API design judgement through a realistic review.

Green flags

  • Spots all four problems: hidden null, out parameters versus async, leaked mutable list, sync I/O.
  • Proposes nullable annotations or a result type with reasons.
  • Knows ValueTask trade-offs and that tuples are compile-time named.
  • Mentions cancellation tokens and mapping to HTTP status codes.

Red flags

  • Fixes only style issues.
  • Suggests throwing exceptions for every not-found case in hot paths without discussing cost.
  • Uses async void or ValueTask everywhere.

📚 Learn in depth

1. Why out and ref cannot cross await

An 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.

2. Nullable reference types as the contract for "not found"

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.

3. Result types — what they are and how to implement one

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.

4. Tuples vs records

  • 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.
  • A 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.

5. Returning collections safely

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.

6. 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.

7. ref returns

ref 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.

Try it yourself

  1. Try to declare async Task<Order> GetAsync(int id, out string error) and read CS1988.
  2. Return _orders as IReadOnlyList<Order>, cast it back to List<Order> in the caller and Clear() it — then switch to AsReadOnly() or ToArray().
  3. Implement a small OrderError hierarchy and a switch expression that maps it to TypedResults.

💻 Code

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());

⚠ Common mistakes

1. Using null for several different meanings

What 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.

2. Reporting errors through out parameters

What 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.

3. Returning internal mutable collections

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.

4. Returning IEnumerable<T> from a query that is not materialised

What 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.

5. Throwing exceptions for expected business outcomes

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.

6. Wrapping a synchronous method in 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.

7. Using tuples in public contracts

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.

8. Returning 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.

9. Leaking EF Core entities through return types

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.

10. No CancellationToken on the redesigned async method

What 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.

🏛 Senior insight: trade-offs

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)
  • Agree one error-handling convention per codebase; mixing nulls, exceptions and result types in the same layer is worse than any single choice.
  • Accept the most general input (IEnumerable<T>) and return the most specific useful read-only type (IReadOnlyList<T>), so callers get Count and indexing without mutation rights.
  • Map result types to HTTP responses (404, 409, ProblemDetails) in one place at the API edge.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate removes out parameters from the method. Why: Checks the input side of the API: ref, out, in and params trade-offs.
    How to answer

    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.

  2. When: The candidate proposes returning Order?. Why: Making absence explicit depends on nullable reference types and their limits.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Not-found orders returned HTTP 500 instead of 404
Situation
A fulfilment API in Azure App Service handled about 2 million requests a day. The repository returned null for unknown order ids and controllers dereferenced the result.
Signals
Application Insights showed a steady stream of NullReferenceException and 500 responses on GET /orders/{id}; partner integrations retried them, adding load.
Investigation
Grouped exceptions by operation in Application Insights, traced the stack to the controller, then enabled nullable reference types on the project to find every unsafe call site.
Resolution
Changed repository methods to return Task<Order?>, fixed 14 warnings, mapped null to Results.NotFound() and expected failures to ProblemDetails.
Prevention
Nullable warnings as errors in the solution, an integration test for unknown ids per endpoint, and an alert on NullReferenceException counts.

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.

↩ Usually asked after

More in Language Basics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me