VSATutorialSeptember 2026 · 8 min read

VSA Todo App — Part 19: Error Handling & Resiliency Patterns in VSA

TL;DR

Part 19 standardizes error handling across VSA slices. We cover the ApiResponse wrapper (Success/Data/Message), validation error responses via validationResult.ToDictionary(), try-catch in handlers, global exception middleware for unexpected errors, rollback on partial failure (file attachments), and logging strategies that keep slice logs greppable.

Part 19 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 18|Next: Part 20 →

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

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.

Key Takeaways

  • Use return values (ApiResponse) for expected errors, exceptions for unexpected errors — never mix the two
  • Validation failures return structured Dictionary<string, string[]> errors the UI renders field-by-field
  • Handlers with side effects (files + DB) clean up partial state in catch, then re-throw for global middleware
  • Global exception middleware logs with a correlation ID and returns a safe generic 500 envelope
  • ILogger<CreateTodoHandler> puts the slice name in every log category — greppable observability

Frequently Asked Questions

Q: How to standardize error responses in VSA?

Use the ApiResponse<T> wrapper everywhere. Success returns Success=true with data; validation and business failures return Success=false with a message and field errors. Global middleware handles unexpected exceptions. Every client deserializes the same envelope, so error handling is uniform across all slices.

Q: Should handlers catch exceptions?

Only for cleanup. If a handler writes files and a database operation fails, catch to delete orphaned files, then re-throw so global middleware formats the response. Don't translate unexpected exceptions into error messages in handlers — that scatters error handling and makes logs inconsistent.

Q: How to rollback on partial failure in VSA?

Wrap the multi-resource operation in try-catch. In the catch block, reverse any side effects that already happened — delete written files, remove added entities — then re-throw. For database work, rely on SaveChangesAsync being transactional, and for files, explicitly delete what you wrote.

Q: Where to put logging in VSA?

Inject ILogger<THandler> into each handler. Log Info for state changes (with entity IDs), Warning for validation failures, and Error only in cleanup catch blocks. The handler type name becomes the log category, making per-slice log filtering trivial.

Part 19 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 18|Next: Part 20 →

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use.

Star on GitHub