Error handling in APIs can get pretty messy, right?
Whether it's throwing exceptions for "expected" situations like “User not found,” or dealing with ambiguous null values, traditional error handling approaches often leave us with more questions than answers. But don't worry—there's a better way!
Imagine you're a waiter taking orders: if a customer orders a dish that’s not available, you don’t throw a tantrum or drop everything in the kitchen (throw Exception). Instead, you let them know it’s not available and suggest something else. Same idea applies to your API!
In this post, I’ll walk you through how the Discriminated Union pattern (using a ServiceResult type) can simplify your error handling, making your code more predictable and easy to maintain.
In simple terms, a Discriminated Union is a way to define a type that can be one of several distinct types, each with its own properties.
Table of Contents
- Why Traditional Error Handling Doesn’t Cut It
- The ServiceResult Pattern
- Usage Example
- Benefits of This Approach
- Real-World Error Handling
- Separation of Concerns
- Flow of Code Example
- Caveats and Considerations
- Final Thoughts
Why Traditional Error Handling Doesn’t Cut It
When building APIs or services, one of the first design decisions you’ll face is how to represent the outcome of an operation.
Exceptions aren’t always exceptional (e.g. “User not found” is normal, not an error).
Nulls are ambiguous (is it missing, or did something go wrong?).
Wrapping results in a ServiceResult<T>
gives clarity: every call is either a success or a failure.
The ServiceResult Pattern
At its core, every service call has two possible outcomes.
It succeeds, giving you the entity / object you wanted.
It fails, giving you structured error details.
In functional programming, this is often called a Discriminated Union (a type with a fixed set of possibilities). You may be more familiar with union types if using for example Typescript.
In C# these don't exist, however, we can express this cleanly with records
:
Is is my interpretation is below:
public abstract record ServiceResult<TEntity>
{
public sealed record Success(TEntity Entity)
: ServiceResult<TEntity>;
public sealed record Failure(string[] Errors, ErrorType ErrorType)
: ServiceResult<TEntity>;
public static ServiceResult<TEntity> FromSuccess(TEntity entity)
=> new Success(entity);
public static ServiceResult<TEntity> FromFailure(string[] errors, ErrorType errorType)
=> new Failure(errors, errorType);
}
Below is the implementation of the ErrorType enum:
public enum ErrorType
{
Validation,
NotFound,
Conflict,
Database,
Unexpected
}
Here’s what’s happening:
Success → wraps the entity so you know the operation worked.
Failure → captures one or more error messages and an error type (NotFound, Conflict, etc.).
Instead of manually creating new instances of Success
or Failure
, the factory methods FromSuccess
and FromFailure
let you do it with one simple line of code. It's like a shortcut to avoid any mistakes or extra boilerplate.
This gives you a predictable, strongly-typed contract. Whenever you call a service, you must deal with either Success or Failure.
Here is an example of how ServiceResult and ErrorTypes could be used:
Case | What you get | Why it’s useful |
---|---|---|
Success | Entity |
Normal outcome, strongly typed result |
Failure |
Errors[] , ErrorType
|
Clear reason for failure, caller knows what broke |
Validation Failure |
Errors[] , Validation
|
Communicates bad input without exceptions |
Not Found |
Errors[] , NotFound
|
Expresses absence cleanly (instead of null/404) |
Conflict |
Errors[] , Conflict
|
Useful for concurrency/duplicate key scenarios |
Database/Server |
Errors[] , Database
|
Lets you bubble up infra issues in a controlled way |
Unexpected Exception | (thrown, not returned) | Reserved for truly exceptional conditions |
If the operation fails, you get one or more error messages and an ErrorType enum.
This gives the caller enough context to decide what to do next — maybe return a 404, maybe log an error, maybe retry.
Usage Example
Now that we’ve got our ServiceResult type, let’s see what it looks like in practice.
Imagine you’re building a movie service, and you need to update a movie. Instead of throwing an exception when the movie isn’t found, you return a Failure result:
// Service layer - MovieService.UpdateAsync()
public async Task<ServiceResult<Movie>> UpdateAsync(UpdateMovieRequest req)
{
var movie = await repository.GetByIdAsync(req.Id);
if (movie is null)
return ServiceResult<Movie>.FromFailure(
new[] { "Movie not found" },
ErrorType.NotFound
);
movie.Title = req.Title;
await repository.SaveChangesAsync();
return ServiceResult<Movie>.FromSuccess(movie);
}
A few things to notice here:
No exceptions for expected outcomes (Movie not found is normal, not exceptional).
Consistent shape: every exit path returns a ServiceResult.
Flexible: you can add richer error context over time (Validation, Conflict, etc.).
Then in your API handler (controller/endpoint), you can use pattern matching to decide what to return:
var result = await movieService.UpdateAsync(request);
switch (result)
{
case ServiceResult<Movie>.Success success:
return Ok(success.Entity);
case ServiceResult<Movie>.Failure failure:
return StatusCode(
MapErrorTypeToStatusCode(failure.ErrorType),
failure.Errors
);
default:
throw new InvalidOperationException("Unexpected result type");
}
Now the controller stays thin — no exception handling logic, no try/catch bloat — just clear translation of service outcomes into HTTP responses.
Benefits of This Approach
Predictable: Callers must handle both success and failure, making bugs less likely.
Expressive: You can return multiple errors, not just a single exception message.
Performant: You avoid throwing/catching exceptions for normal control flow.
Modern C#: Uses records and pattern matching — concise, readable, and type-safe.
Real-World Error Handling
So far we’ve only looked at simple success/failure outcomes, but real-world services often hit database or infrastructure errors. Traditionally, you’d wrap service logic in try/catch and bubble exceptions up to the controller — which makes your API code messy and inconsistent.
With ServiceResult<TEntity>
, we can catch exceptions inside the service and still return a clean, predictable result.
Here’s an example for deleting a movie:
public async Task<ServiceResult<bool>> DeleteAsync(int id)
{
var movieResult = await GetByIdAsync(id);
if (movieResult is ServiceResult<Movie>.Failure failure)
{
return new ServiceResult<bool>.Failure(failure.Errors, failure.ErrorType);
}
try
{
await repository.DeleteAsync(id);
await repository.SaveChangesAsync();
return new ServiceResult<bool>.Success(true);
}
catch (DbUpdateException ex)
{
logger.LogError(ex, "Database error while deleting movie {MovieId}", id);
return new ServiceResult<bool>.Failure(
new[] { $"Failed to delete movie: {ex.Message}" },
ErrorType.Database
);
}
}
Notice what’s happening here:
Expected errors (movie not found) are modeled as failures with ErrorType.NotFound.
Unexpected errors (like DbUpdateException) are caught and translated into ErrorType.Database
.
The calling code doesn’t need to care whether it was a validation error, missing data, or a DB issue — it just sees Failure and can respond appropriately.
Here’s what the API handler looks like:
public override async Task HandleAsync(DeleteMovieRequest req, CancellationToken ct)
{
var result = await movieService.DeleteAsync(req.Id);
switch (result)
{
case ServiceResult<bool>.Failure failedResult:
foreach (var error in failedResult.Errors)
{
AddError(error);
}
await Send.ErrorsAsync(MapErrorTypeToStatusCode(failedResult.ErrorType), ct);
return;
case ServiceResult<bool>.Success:
logger.LogInformation("Deleted movie with ID: {MovieId}", req.Id);
await Send.OkAsync(new DeleteMovieResponse(true), ct);
return;
}
}
No try/catch in the controller, no exception mapping logic scattered around.
All failure handling is centralized in the service, while the API handler simply translates ErrorType → HTTP status code.
Separation of Concerns:
Encourages separation of concerns as services don’t need to know about HTTP or status codes.
Endpoints/controllers can translate ServiceResult.Failure → 400/404/500 as appropriate.
Example:
if (result is ServiceResult<Movie>.Failure failure)
{
var status = failure.ErrorType switch
{
ErrorType.Validation => 400,
ErrorType.NotFound => 404,
ErrorType.Conflict => 409,
_ => 500
};
await Send.ErrorsAsync(status, ct, failure.Errors);
}
It’s a much cleaner alternative than handling multiple custom exception types in your API handler.
Flow of Code Example:
+----------------------+
| Service Call |
+----------------------+
|
v
+------------------------------+
| Was the operation a success?|
+------------------------------+
/ \
/ \
v v
+-------------------+ +--------------------------+
| Success | | Failure |
|-------------------| |--------------------------|
| Return Entity | | Error Type (e.g. |
| (TEntity) | | Validation, NotFound, |
+-------------------+ | Conflict, Database, |
| Unexpected) |
+--------------------------+
|
v
+--------------------------+
| Return Error Details |
| (Errors[], ErrorType) |
+--------------------------+
Caveats and Considerations
While the ServiceResult pattern is straightforward and provides clarity, it's important to be mindful of the context in which it's applied. In smaller applications with very simple error handling needs, this pattern might feel like overkill, but as your system grows and complexity increases, the benefits far outweigh the initial overhead.
One potential challenge is ensuring that your ErrorType
enum is comprehensive enough to cover all potential failure scenarios. If you miss a common error type, the pattern might lose some of its expressiveness. However, with proper design and planning, this pattern keeps error handling clean, maintainable, and easy to extend as your app evolves. Ultimately, it encourages good separation of concerns and helps avoid clutter in your API handlers.
The key takeaway is that it offers a solid, scalable foundation for error handling, and once in place, it keeps your codebase more predictable and robust in the long run.
Final Thoughts
For expected outcomes (like a user not found or bad input), use ServiceResult<T>
to return a structured Failure result. This is part of your normal control flow.
For truly exceptional, unexpected issues (like a database connection failure), let exceptions be thrown. This prevents messy try/catch blocks in your service and API layers.
Centralise logic; by catching infrastructure exceptions (like DbUpdateException
) inside your service and converting them to a ServiceResult.Failure
, you keep your API handlers clean and focused on translating outcomes into HTTP responses."
What do you think of this pattern? Do you use a similar approach, or do you prefer a different method for handling API errors? Share your thoughts in the comments!
Top comments (0)