The examples in this post are available in a demo repository here: https://github.com/liavzi/DesigningErrorFlows.
Introduction
Recently, I needed to integrate with an external API to synchronize data. From past experience, I have learned that failures are not a question of if but when. Network issues, timeouts, validation errors, authentication failures, unexpected payloads, and third-party outages can all cause requests to fail, often at the worst possible moment.
To make the system more resilient and easier to support, I decided to persist every failed request in the database and capture as much context as possible. This included the request payload, the response when available, and as many details as possible about the nature of the error.
Let’s explore three approaches to designing an API call with errors in mind. For simplicity, assume the call can produce only four outcomes: success with data, an authentication error, a validation error, or a general error.
Exception-Oriented Flow
public class ApiServiceExceptionsOriented(HttpClient httpClient)
{
public async Task<TResponse> Post<TResponse>(string url, object payload)
{
string accessToken;
try
{
accessToken = await GetAccessToken();
}
catch (Exception)
{
throw new AuthenticationException("Failed to get access token");
}
var httpRequest = new HttpRequestMessage();
httpRequest.Content = JsonContent.Create(payload);
httpRequest.Headers.Add("Authorization", $"Bearer {accessToken}");
var httpResponse = await httpClient.SendAsync(httpRequest);
if (httpResponse.StatusCode == HttpStatusCode.BadRequest) {
var validationErrorResponse = await httpResponse.Content.ReadFromJsonAsync<ExternalApiValidationErrorResponse>();
throw new ValidationException(validationErrorResponse);
}
if (!httpResponse.IsSuccessStatusCode) {
var rawResponse = await httpResponse.Content.ReadAsStringAsync();
throw new GeneralApiException(rawResponse);
}
var response = await httpResponse.Content.ReadFromJsonAsync<TResponse>();
return response;
}
public async Task<string> GetAccessToken()
{
return "SOME_ACCESS_TOKEN";
}
}
Here is what an example call might look like:
public async Task<SyncEmployeeResponse> CallApiExample()
{
var syncEmployeeRequest = new SyncEmployeeRequest();
try
{
var response = await Post<SyncEmployeeResponse>("https://some.api/sync-employee", syncEmployeeRequest);
// other logic
return response;
}
catch (AuthenticationException ex)
{
// Handle authentication failure
throw;
} catch (ValidationException ex)
{
// Handle validation failure, access details via ex.ValidationErrorResponse and maybe show them to the user
throw;
} catch (GeneralApiException ex)
{
// Handle other API failures, maybe log the error details (ex.Message contains the raw API response)
throw;
}
}
The main advantage of this approach is its simplicity on the happy path. You call the API, receive a response, and continue with your logic. However, this simplicity is also its primary drawback.
When an error occurs, you must handle each one in its own catch block. This not only clutters the code but also introduces additional challenges, such as variable scope. If you need access to variables declared inside the try block, you are often forced to move their declarations to an outer scope, which reduces readability and weakens encapsulation.
Moreover, the caller has no clear understanding of which errors might be thrown by the API call. Instead, they must dig into the implementation to discover the possible exceptions. This increases the risk of unhandled errors and can easily lead to future bugs, especially when new failure modes are introduced, such as a rate limit error.
Success Flags
public async Task<ApiCallResult<TResponse>> Post<TResponse>(string url, object payload)
{
string accessToken;
try
{
accessToken = await GetAccessToken();
}
catch (Exception)
{
return new ApiCallResult<TResponse> { IsAuthenticationFailure = true };
}
var httpRequest = new HttpRequestMessage();
httpRequest.Content = JsonContent.Create(payload);
httpRequest.Headers.Add("Authorization", $"Bearer {accessToken}");
var httpResponse = await httpClient.SendAsync(httpRequest);
if (httpResponse.StatusCode == HttpStatusCode.BadRequest) {
var validationErrorResponse = await httpResponse.Content.ReadFromJsonAsync<ExternalApiValidationErrorResponse>();
return new ApiCallResult<TResponse>
{
ValidationErrorResponse = validationErrorResponse,
};
}
if (!httpResponse.IsSuccessStatusCode) {
var rawResponse = await httpResponse.Content.ReadAsStringAsync();
return new ApiCallResult<TResponse>
{
HasGeneralFailure = true,
ErrorRawResponse = rawResponse,
};
}
var response = await httpResponse.Content.ReadFromJsonAsync<TResponse>();
return new ApiCallResult<TResponse>
{
Data = response
};
}
Now, instead of relying on exceptions, we return an ApiCallResult<T> object that explicitly represents the outcome of the operation:
public class ApiCallResult<T>
{
public T Data { get; set; }
public ExternalApiValidationErrorResponse ValidationErrorResponse { get; set; }
public string ErrorRawResponse { get; set; }
public bool HasGeneralFailure { get; set; }
public bool IsSuccess => Data != null;
public bool IsAuthenticationFailure { get; set; }
}
Now the caller can examine the object and decide how to proceed based on the different flags it exposes:
public async Task CallApiExample()
{
var syncEmployeeRequest = new SyncEmployeeRequest();
var callResult = await Post<SyncEmployeeResponse>("https://some.api/sync-employee", syncEmployeeRequest);
switch (callResult)
{
case { IsSuccess: true }:
// Handle success. can access the response data via callResult.Data
break;
case { IsAuthenticationFailure: true }:
// Handle authentication failure
break;
case { ValidationErrorResponse: { } }:
// Handle validation errors
break;
case { HasGeneralFailure: true }:
// Handle general API failure
break;
}
}
We improved the readability of the code and encouraged the caller to think about failure scenarios rather than focusing only on the happy path.
However, the caller still needs to understand and examine the ApiCallResult class to determine which fields are populated for each possible outcome. This implicit contract can become harder to maintain as new error types are introduced.
Moreover, if a new error type is added, we must remember to handle it across all call sites. This raises an important design question: can we force the caller to handle new error types instead of relying on memory and code reviews?
Discriminated Unions
Let’s take this one step further and model the result as a discriminated union.
Instead of returning a single object with multiple nullable fields or boolean flags, we define a closed set of possible outcomes, where each case represents exactly one valid state:
Success with data
Authentication error
Validation error
General error
public abstract record ApiResult<TData>
{
private ApiResult() { }
public sealed record SuccessResult(TData Data) : ApiResult<TData>;
public sealed record ValidationFailedResult(ExternalApiValidationErrorResponse ValidationErrorResponse) : ApiResult<TData>;
public sealed record GeneralFailureResult(string ErrorRawResponse) : ApiResult<TData>;
public sealed record AuthenticationFailure : ApiResult<TData>;
}
Now every outcome, whether success or a specific error type, has its own class and its own data. There are no boolean flags and no need to guess which property might be null.
Each case represents a single, valid state. A success contains data. A validation error contains validation details, and so on. The structure itself communicates the intent clearly.
This eliminates invalid combinations, improves readability, and makes the contract between the API call and its caller explicit and self-documenting.
Let’s look at the implementation of the Post method:
public async Task<ApiResult<TResponse>> Post<TResponse>(string url, object payload)
{
string accessToken;
try
{
accessToken = await GetAccessToken();
}
catch (Exception)
{
return new ApiResult<TResponse>.AuthenticationFailure();
}
var httpRequest = new HttpRequestMessage();
httpRequest.Content = JsonContent.Create(payload);
httpRequest.Headers.Add("Authorization", $"Bearer {accessToken}");
var httpResponse = await httpClient.SendAsync(httpRequest);
if (httpResponse.StatusCode == HttpStatusCode.BadRequest) {
var validationErrorResponse = await httpResponse.Content.ReadFromJsonAsync<ExternalApiValidationErrorResponse>();
return new ApiResult<TResponse>.ValidationFailedResult(validationErrorResponse);
}
if (!httpResponse.IsSuccessStatusCode) {
var rawResponse = await httpResponse.Content.ReadAsStringAsync();
return new ApiResult<TResponse>.GeneralFailureResult(rawResponse);
}
var response = await httpResponse.Content.ReadFromJsonAsync<TResponse>();
return new ApiResult<TResponse>.SuccessResult(response);
}
We generate a concrete class for every possible outcome. Notice how much more readable this is compared to the “flags” approach.
In addition, the caller is now required to consider the different result types and handle them appropriately:
public async Task CallApiExample()
{
var syncEmployeeRequest = new SyncEmployeeRequest();
var callResult = await Post<SyncEmployeeResponse>("https://some.api/sync-employee", syncEmployeeRequest);
switch (callResult)
{
case ApiResult<SyncEmployeeResponse>.SuccessResult successResult:
// Handle success. can access the response data via successResult.Data
break;
case ApiResult<SyncEmployeeResponse>.AuthenticationFailure:
// Handle authentication failure
break;
case ApiResult<SyncEmployeeResponse>.ValidationFailedResult validationFailedResult:
// Handle validation errors
break;
case ApiResult<SyncEmployeeResponse>.GeneralFailureResult generalFailureResult:
// Handle general API failure
break;
default:
throw new NotSupportedException("Unknown result type");
}
}
Notice the default branch. It acts as a safety net, ensuring that if a new error type is introduced and not explicitly handled, it will fail at runtime rather than silently being ignored.
The reason we model ApiResult as a closed hierarchy is intentional. By limiting inheritance to a fixed set of known types, we define a finite set of possible outcomes. This makes the design explicit and controlled.
Even more importantly, this opens the door to stronger compile-time guarantees. If, in the future, the language or our implementation enforces exhaustive pattern matching over a closed hierarchy, the compiler will be able to require callers to handle all possible result types. At that point, adding a new error case would immediately surface compile errors in every unhandled call site, forcing correct handling at compile time rather than relying on runtime behavior.
Final Thoughts
As always, there is no silver bullet.
The discriminated union approach is very explicit, but if we apply it to every method, we may end up with switch expressions and conditional handling scattered throughout the codebase. This is one of the strongest advantages of exceptions: you can continue with the natural flow of the program, and if something goes wrong, you throw and let a higher level in the call chain decide how to handle it.
So in the end, it comes down to intent.
If you want to force the caller to at least think about the possible error cases, use a discriminated union or a success-result object. Both approaches make failure explicit and part of the method’s contract.
If you want to preserve a clean and linear flow while centralizing error handling, exceptions are often the more natural and practical choice.
The key is not to choose one approach blindly, but to understand the trade-offs and use each tool where it fits best (as always 🙂).
Top comments (0)