Errors are inevitable, but inconsistent error handling is a choice. The MVC Project Manager's Todo feature handles errors in a predictable, structured way: every handler returns an ApiResponse<T>, validation failures return field-level errors as structured JSON, unexpected exceptions are caught by global middleware, and partial failures (like file uploads that die mid-save) are rolled back. This part codifies those patterns so you can apply them to every VSA slice.
The philosophy is simple: use return values for expected errors, use exceptions for unexpected errors. Validation failures, not-found records, and business rule violations are expected — they return ApiResponse.Fail() with structured messages. Database failures, IO errors, and bugs are unexpected — they throw, and global middleware converts them to a consistent 500 response. This split keeps handlers readable and error contracts explicit.
The ApiResponse<T> Wrapper
Every handler returns ApiResponse<T> — a standardized envelope with Success, Data, and Message fields. Factory methods make construction clean:
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string? Message { get; set; }
public IDictionary<string, string[]>? Errors { get; set; }
public static ApiResponse<T> Success(T data, string? message = null) =>
new() { Success = true, Data = data, Message = message };
public static ApiResponse<T> Fail(string message, IDictionary<string, string[]>? errors = null) =>
new() { Success = false, Message = message, Errors = errors };
}
The wrapper is the contract between handlers and endpoints. An endpoint calls handler.HandleAsync(request), inspects Success, and returns either Results.Ok(apiResponse) or an appropriate error status. Clients (Blazor services, Vue.js fetch calls) deserialize the same envelope and display Message and field Errors predictably.
Validation Error Responses
When FluentValidation fails, the handler converts the result into the structured error format and returns a failed ApiResponse:
var validator = new CreateTodoValidator();
var validationResult = await validator.ValidateAsync(request, ct);
if (!validationResult.IsValid)
{
return ApiResponse<CreateTodoResponse>.Fail(
"Validation failed", validationResult.ToDictionary());
}
validationResult.ToDictionary() produces Dictionary<string, string[]> keyed by property name — "Name": ["Name is required"], "Priority": ["Priority must be a valid value"]. The Vue.js frontend displays these next to the corresponding form fields. The Blazor CRM takes a different route (client-side MudForm validation), but the server-side contract is the same: structured, field-level errors the UI can render without parsing prose.
Try-Catch in Handlers: Rollback and Re-throw
Handlers that touch multiple resources (database + filesystem) need rollback. The pattern is: do the work in try, clean up in catch, re-throw so global middleware returns a consistent 500:
try
{
// Save files to disk and create entities
await _context.SaveChangesAsync(cancellationToken);
return ApiResponse<CreateTodoResponse>.Success(response, "Todo created");
}
catch (Exception)
{
// Rollback: delete any files that were written to disk
foreach (var image in entity.TodoImageAttachments)
_fileStorage.DeleteFile(image.FilePath);
foreach (var file in entity.TodoFileAttachments)
_fileStorage.DeleteFile(file.FilePath);
throw; // Let global middleware format the 500
}
Re-throwing is important — the handler doesn't try to translate unexpected errors. It cleans up partial state (no orphaned files, no half-written records) and lets the exception bubble to global middleware, which logs it and returns a consistent error envelope. The client sees one predictable shape for all unexpected failures.
Global Exception Middleware
Unexpected exceptions from any handler converge at global exception middleware — registered once, applied to every slice:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
if (exception == null) return;
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new
{
success = false,
message = "An unexpected error occurred. Please try again.",
errorId = Activity.Current?.Id ?? context.TraceIdentifier
});
});
});
Because handlers return ApiResponse for expected errors and throw for unexpected ones, middleware only sees true bugs. It logs the exception with its correlation ID and returns a safe generic message — no stack traces leaked to clients. The errorId lets users quote the trace identifier to support.
Logging Strategies Within Slices
Handlers use structured logging with ILogger<THandler>, injecting the logger through the constructor. Key events are logged at Info (creation, updates), warnings for validation failures, and errors only in catch blocks. Structured properties make logs greppable:
_logger.LogInformation("Todo {TodoId} created by {User}", entity.Id, request.Name);
_logger.LogWarning("Todo validation failed: {Errors}", validationResult.ToDictionary());
The ILogger<CreateTodoHandler> type parameter includes the handler's name, so log entries automatically carry the slice context. In a multi-feature app, grepping Category=CreateTodoHandler or filtering by the TodoId property instantly isolates the relevant entries. This is observability built into the VSA structure itself.