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

📚 Interview question

When should you create a custom exception in .NET, and how do you design one properly? Cover naming, base class, constructors, extra properties, serialisation in modern .NET, and when built-in exceptions or result types are a better choice.

Designing custom exception types: when they add value, the standard shape, domain data, and alternatives.

Mid-Level Theory Intermediate api-design custom-exceptions domain-errors error-handling exceptions

🎯 Key points

  • Create a custom exception only when callers catch it specifically or need extra data
  • Prefer built-in exceptions such as `ArgumentException`, `InvalidOperationException`, `NotSupportedException`
  • Derive from `Exception` (not `ApplicationException`) and end the name in `Exception`
  • Provide the standard constructors, especially message plus inner exception
  • Add read-only domain properties and build the message from them; usually `sealed`
  • Do not add binary serialisation constructors; they are obsolete in modern .NET (SYSLIB0051)
  • Use result types or the Try pattern for expected business outcomes
  • Map domain exceptions centrally to `ProblemDetails`, never leaking sensitive data

⏱ 30-second answer

Create a custom exception only when callers need to catch it specifically or need extra data that built-in exceptions do not carry. Otherwise use the standard ones: ArgumentException family for bad input, InvalidOperationException for wrong state, NotSupportedException, KeyNotFoundException, TimeoutException. A good custom exception derives from Exception (or a more specific built-in type), not ApplicationException, ends in Exception, provides the three standard constructors (none, message, message plus inner), carries domain properties such as OrderId, and is usually sealed. Do not add the old binary-serialisation constructor: that API is obsolete in modern .NET (SYSLIB0051). For expected business outcomes, a result type is often better than any exception.

🗣 Full answer (senior)

How I would answer this in an interview

I create a custom exception only when callers need to handle that failure differently from existing exceptions, or when it carries domain-specific data that helps handling or diagnosis. Otherwise I use built-in exceptions for programming errors and result types for expected business outcomes. Then I'd walk through how to design one properly in modern .NET.

1. When a custom exception is justified

  • A caller will catch it specifically and do something different: retry, map to a specific HTTP status/problem type, compensate, show a specific message.
  • It represents a domain or infrastructure failure with useful data: PaymentDeclinedException with a decline code, ConcurrencyConflictException with entity ID and expected version, RateLimitExceededException with RetryAfter.
  • You're writing a library and want consumers to catch your failures without depending on internal details (e.g. wrap HttpRequestException from your SDK in ExternalServiceException).

2. When built-in exceptions are better

Situation Use
Null argument ArgumentNullException (ThrowIfNull)
Invalid argument value ArgumentException / ArgumentOutOfRangeException
Object in the wrong state for the call InvalidOperationException
Operation not supported by this implementation NotSupportedException
Used after disposal ObjectDisposedException
Cancellation OperationCanceledException / TaskCanceledException
Timeouts TimeoutException
Bad data format FormatException
"Should never happen" branches UnreachableException (.NET 7)

These signal bugs or contract violations; callers usually shouldn't catch them, so a custom type adds nothing. Never throw Exception, SystemException, ApplicationException or reserved types like NullReferenceException/IndexOutOfRangeException yourself (CA2201).

3. When result types are better

For expected business outcomes — validation failures, "not found", "insufficient funds", "order already shipped" — a result type (Result<T, Error>, OneOf, a discriminated record hierarchy, or Results<Ok<T>, NotFound, ValidationProblem> in minimal APIs) is usually better:

  • The outcome is visible in the signature; callers can't forget to handle it.
  • No exception cost on common paths (exceptions are 10–100×+ slower than returns, even after .NET 9's improvements).
  • Logs don't fill with "errors" that are normal behaviour.

A practical split: exceptions for exceptional/unrecoverable or cross-cutting failures, results for expected domain outcomes.

4. Designing a custom exception

namespace Payments;

public sealed class PaymentDeclinedException : Exception
{
    public PaymentDeclinedException() { }
    public PaymentDeclinedException(string message) : base(message) { }
    public PaymentDeclinedException(string message, Exception innerException) : base(message, innerException) { }

    public PaymentDeclinedException(string paymentId, string declineCode, Exception? innerException = null)
        : base($"Payment {paymentId} was declined ({declineCode}).", innerException)
    {
        PaymentId = paymentId;
        DeclineCode = declineCode;
    }

    public string? PaymentId { get; }
    public string? DeclineCode { get; }
}

Guidelines:

  • Name ends with Exception and describes the problem, not where it came from (PaymentDeclinedException, not PaymentServiceException).
  • Derive from Exception directly, or from a meaningful built-in base (InvalidOperationException, IOException) if it's genuinely a specialisation. Don't derive from ApplicationException (obsolete guidance).
  • Provide the three standard constructors (CA1032) so it works with tools and wrapping, plus domain constructors.
  • Add strongly typed, immutable properties for data callers need — don't make callers parse the message.
  • Message: clear, for developers, no secrets or personal data (messages end up in logs and sometimes responses); localise user-facing text elsewhere.
  • Pass inner exceptions when wrapping.
  • sealed unless you design a hierarchy on purpose (e.g. a base PaymentException for catch-all handling).
  • Optionally use Exception.Data sparingly for extra diagnostic context, or better, structured logging.
  • Public visibility if it crosses assembly boundaries (callers need to catch it).

5. Serialisation in modern .NET

In .NET Framework, exceptions needed [Serializable] and a protected (SerializationInfo, StreamingContext) constructor to cross AppDomains/remoting. In modern .NET:

  • BinaryFormatter is removed (.NET 9) and the serialisation constructor and GetObjectData on Exception are obsolete (SYSLIB0051, .NET 8).
  • Don't add those members to new exceptions.
  • Exceptions shouldn't cross process boundaries as serialised objects anyway; map them to ProblemDetails (RFC 9457) in HTTP APIs, to gRPC status codes, or to error messages in queues.

6. A production example

A checkout service threw Exception("Card declined") and the API layer used if (ex.Message.Contains("declined")) to return HTTP 402. When a developer changed the wording to "Payment was declined by issuer", every decline started returning HTTP 500 and paging the on-call engineer. We introduced PaymentDeclinedException with a DeclineCode property and mapped it in an IExceptionHandler to a ProblemDetails response (type: .../payment-declined, status 402, decline code as an extension). Later, because declines are an expected outcome, we moved the payment gateway client to return a PaymentResult instead, and kept exceptions for gateway outages and timeouts.

🔍 What the interviewer is checking

Checks API design judgement around errors rather than syntax.

  • Green flags: knows when not to create one, uses built-in exceptions correctly, includes inner-exception constructor, avoids ApplicationException and obsolete serialisation, mentions result types and central mapping.
  • Red flags: creates exceptions for every error, throws Exception directly, or uses exceptions for normal control flow.

📚 Learn in depth

1. What System.Exception contains

Member Purpose
Message Developer-facing description
InnerException The cause when wrapping
StackTrace Captured during propagation
HResult Integer code (used for COM interop and some I/O errors)
Data IDictionary for extra key/value diagnostics
Source, TargetSite Assembly / method that threw
HelpLink URL to documentation
GetBaseException() Innermost exception in the chain

Custom exceptions add typed properties rather than putting structured data in Data or the message. Override Message only if you need to compute it lazily from properties.

2. Exception hierarchies: depth and catchability

Good hierarchies are shallow and organised by how callers react, not by which component threw:

Exception
 └── ExternalServiceException          (catch to degrade gracefully / circuit-break)
      ├── ExternalServiceUnavailableException   (retryable)
      └── ExternalServiceRejectedException      (not retryable)

Callers can catch the base for general handling or a specific type for specific handling. Deep, component-based hierarchies (OrderModuleException → OrderRepositoryException → ...) don't help anyone decide what to do.

A useful property on a base type: bool IsTransient or TimeSpan? RetryAfter, which a resilience policy can read.

3. Mapping exceptions to HTTP responses

In ASP.NET Core 8+:

public sealed class PaymentExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not PaymentDeclinedException declined) return false;
        ctx.Response.StatusCode = StatusCodes.Status402PaymentRequired;
        return await problemDetails.TryWriteAsync(new()
        {
            HttpContext = ctx,
            ProblemDetails =
            {
                Type = "https://errors.example.com.au/payment-declined",
                Title = "Payment declined",
                Status = 402,
                Extensions = { ["declineCode"] = declined.DeclineCode }
            },
            Exception = ex
        });
    }
}

Register with builder.Services.AddExceptionHandler<PaymentExceptionHandler>(); builder.Services.AddProblemDetails(); and app.UseExceptionHandler();. Domain exception types become a stable contract between layers; the HTTP contract is the type URI and status.

4. Result types vs exceptions: trade-offs

Aspect Exceptions Result types
Visibility Hidden in signatures Explicit
Propagation through layers Automatic Manual (if (!r.IsSuccess) return r.Error;)
Performance on failure Expensive (stack capture, unwinding) Cheap
Composition with LINQ/async Natural Needs helpers (Bind, Map) or libraries
Risk Unhandled → 500/crash Ignored result → silent bug (mitigate with analysers or [MustUseReturnValue])
Best for Bugs, infrastructure failures, invariants Validation, business rules, expected "not found"

Many teams use both: result types inside the domain/application layer for expected outcomes, exceptions for everything unexpected, and a global handler as the safety net.

5. Serialisation history

  • .NET Framework: [Serializable] + ISerializable constructor needed for remoting, AppDomains and BinaryFormatter.
  • .NET Core 2.0–7: still supported for compatibility; analysers suggested adding them.
  • .NET 8: Exception(SerializationInfo, StreamingContext) and GetObjectData marked obsolete (SYSLIB0051).
  • .NET 9: BinaryFormatter removed from the runtime (throws PlatformNotSupportedException).

If a library still targets .NET Framework/netstandard, you may keep the serialisation constructor under #if NETFRAMEWORK. For new .NET 10 code, omit it.

6. Exceptions and logging

  • Structured logging with logger.LogError(ex, "Payment {PaymentId} declined", id) records type, message, stack and inner exceptions.
  • OpenTelemetry records exceptions as span events (exception.type, exception.message, exception.stacktrace), so custom exception type names become searchable dimensions — another reason for meaningful names.
  • Avoid personal data (card numbers, emails, TFNs) in messages and Data; the Privacy Act and PCI DSS both apply to logs.

Try it yourself

  1. Write a custom exception with typed properties, throw it with an inner exception, and inspect ToString().
  2. Add an IExceptionHandler that maps it to ProblemDetails and call the endpoint.
  3. Refactor one expected failure (e.g. "customer not found") from an exception to a result type and compare the calling code.
  4. Enable CA1032 and CA2201 and see what they report.

💻 Code

// Base for all domain errors, mapped centrally to 4xx responses
public abstract class DomainException : Exception
{
    protected DomainException(string code, string message, Exception? inner = null)
        : base(message, inner) => Code = code;

    public string Code { get; }
}

public sealed class InsufficientStockException : DomainException
{
    public InsufficientStockException(string sku, int requested, int available, Exception? inner = null)
        : base("stock.insufficient",
               $"Requested {requested} of {sku} but only {available} available.", inner)
    {
        Sku = sku;
        Requested = requested;
        Available = available;
    }

    public string Sku { get; }
    public int Requested { get; }
    public int Available { get; }
}

// Standard-shape exception for a library (three constructors)
public sealed class ConfigurationLoadException : Exception
{
    public ConfigurationLoadException() { }
    public ConfigurationLoadException(string message) : base(message) { }
    public ConfigurationLoadException(string message, Exception inner) : base(message, inner) { }
}

// Expected outcome: a result instead of an exception
public readonly record struct RegisterResult(bool Succeeded, string? Error)
{
    public static RegisterResult Ok() => new(true, null);
    public static RegisterResult Fail(string error) => new(false, error);
}

⚠ Common mistakes

1. Creating custom exceptions nobody catches

What happens: every module defines its own XyzException hierarchy. Why it is wrong: adds code without enabling different handling; catch blocks still catch Exception. Do instead: create custom types only when a caller handles them specifically or needs their data.

2. Using exceptions for expected business outcomes

What happens: ValidationException, NotFoundException thrown for routine cases and caught in controllers. Why it is wrong: costly, hidden control flow, noisy error logs and alerts. Do instead: return result types for expected outcomes; keep exceptions for exceptional cases.

3. Encoding data in the message

What happens: throw new Exception($"DECLINED|{code}"), and callers parse the message. Why it is wrong: brittle — any wording change breaks handling. Do instead: add typed properties to a custom exception.

4. Throwing base or reserved exception types

What happens: throw new Exception(...), throw new ApplicationException(...), throw new NullReferenceException(). Why it is wrong: callers can't catch specifically; reserved types imply runtime bugs (CA2201). Do instead: use specific built-in types or a meaningful custom type.

5. Missing standard constructors

What happens: a custom exception only has a constructor taking custom arguments. Why it is wrong: can't be wrapped consistently, and some tools/frameworks expect the standard constructors (CA1032). Do instead: provide parameterless, message, and message + inner constructors.

6. Losing the inner exception

What happens: wrapping with throw new DataAccessException("Failed"). Why it is wrong: the root cause and its stack trace disappear. Do instead: always pass innerException when wrapping.

7. Adding obsolete serialisation members in .NET 10

What happens: copying old templates with [Serializable] and SerializationInfo constructors. Why it is wrong: obsolete APIs (SYSLIB0051) and dead code; BinaryFormatter is gone. Do instead: omit them; map exceptions to ProblemDetails or messages at boundaries.

8. Leaking sensitive data in messages

What happens: card numbers, emails or SQL text in exception messages that reach logs or API responses. Why it is wrong: privacy and security exposure (Privacy Act, PCI DSS). Do instead: include identifiers, not sensitive values; never return raw exception messages to clients in production.

9. Deep, component-based hierarchies

What happens: OrdersException → OrdersRepositoryException → OrdersSqlRepositoryException. Why it is wrong: callers care about how to react, not which class threw. Do instead: organise by reaction (transient vs permanent, not found vs conflict).

10. Inheriting from the wrong base to "get behaviour"

What happens: deriving a domain exception from ArgumentException or IOException just to be caught by existing handlers. Why it is wrong: semantically misleading; handlers may treat it incorrectly (e.g. retry logic for I/O). Do instead: derive from Exception or a base that truly matches the meaning.

🏛 Senior insight: trade-offs

Seniors design exceptions as part of the API contract and connect them to how errors are surfaced to clients.

Error kind Mechanism Surface
Programmer error (bad argument) built-in Argument*Exception 500, fix the bug
Invalid state InvalidOperationException 500 or 409
Domain rule violated custom DomainException subtype 4xx ProblemDetails
Expected outcome result type / Try pattern normal response
Infrastructure failure let it bubble, wrap at boundary 500/503, retry policy

The senior point is consistency: one base domain exception with a code, one central mapper (IExceptionHandler) to ProblemDetails, and a rule about when to use results instead of exceptions, so every team handles errors the same way.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate maps domain exceptions to HTTP responses Why: Checks IExceptionHandler and ProblemDetails
    How to answer

    In ASP.NET Core, register AddProblemDetails(), one or more IExceptionHandler implementations (.NET 8+) with AddExceptionHandler<T>(), and app.UseExceptionHandler() early in the pipeline. Handlers log once and map known exceptions to the right status code and an RFC 9457 ProblemDetails body, and never leak stack traces in production. For the process as a whole, AppDomain.CurrentDomain.UnhandledException is a last-chance notification: you can log and flush, but the process still terminates. TaskScheduler.UnobservedTaskException fires when a faulted task that nobody awaited is garbage-collected; it does not crash the process by default. In a BackgroundService, an unhandled exception in ExecuteAsync stops the host by default (.NET 6+). async void exceptions go to the synchronisation context and usually crash the process, so avoid async void except for event handlers.

  2. When: Candidate suggests result types instead of exceptions Why: Checks exception cost reasoning
    How to answer

    A try block costs almost nothing when no exception is thrown; .NET uses table-based handling, so there is no work on the happy path (though a method containing try/catch may get fewer JIT optimisations such as inlining). Throwing is expensive: allocating the exception, capturing the stack trace, and the two-pass search and unwind cost microseconds, versus nanoseconds for a normal return, and more with deep stacks. .NET 9 replaced the C++-based exception handling with a managed implementation (from NativeAOT), making throw and catch roughly 2 to 4 times faster, but it is still far slower than returning a value. So keep exceptions for exceptional cases, and on hot paths with expected failures use Try methods (int.TryParse, TryGetValue), result types, and throw helpers that keep the throwing code out of line. Measure exception rates with dotnet-counters before optimising.

🛠 Real-world problems: how you handle them

Out-of-stock errors returned HTTP 500 and a generic UI message
Situation
An e-commerce checkout API threw `new Exception("Not enough stock")` when stock ran out. The global handler treated all exceptions as 500s.
Signals
Spikes of 500 errors during sales; the frontend showed 'Something went wrong'; support tickets asked why checkout failed; alerting woke on-call engineers for a normal business case.
Investigation
Traced the 500s to the stock check. The generic `Exception` gave the handler no way to tell a business rule from a real failure, and carried no data for the UI.
Resolution
Added a `DomainException` base with a `Code`, and `InsufficientStockException` with `Sku`, `Requested` and `Available`. An `IExceptionHandler` maps domain exceptions to 409 `ProblemDetails` with those values as extensions. The UI now shows how many are left.
Prevention
Error-handling guideline with the decision table, an analyzer rule against throwing `System.Exception` (CA2201), and alerting only on true 5xx errors.

How to tell it (STAR)

**Situation:** During [a sale event] at [company], our checkout showed a generic error and our on-call phone kept ringing, yet most failures were simply items going out of stock. **Task:** I wanted customers to see a useful message and on-call to stop being paged for normal business outcomes. **Action:** The stock check threw a plain `Exception`, so the global handler returned 500. I introduced a small domain exception hierarchy with error codes, created `InsufficientStockException` with the SKU and quantities, and mapped domain exceptions to 409 responses using `ProblemDetails`. I also enabled CA2201 to stop new code throwing `System.Exception`. **Result:** 500s during the next sale dropped by [about X%], customers saw 'only N left', support tickets for checkout fell, and on-call was paged only for real failures.

↩ Usually asked after

More in Exception Handling

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me