VSABlazorTutorialSeptember 2026 · 9 min read

Tutorial Vertical Slice Architecture VSA in Blazor — Part 2: Complete CRUD with MudBlazor

TL;DR

Part 2 completes the Todo vertical slice with full CRUD. We add the CreateTodo, UpdateTodo, and DeleteTodoById handlers with their FluentValidation validators, extend the endpoint group with POST routes, build the state-machine page pattern (Table/Create/Update/View) from Blazor CRM, wire MudForm with validation, confirm deletes through MudDialog, and surface results through MudSnackbar. When you finish, the Todo feature is a complete, copyable vertical slice.

Part 2 of 2 in the VSA in Blazor Tutorial Series|← Previous: Part 1

Help Us Grow

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

In Part 1 you built the read-only half of a Blazor VSA slice: the GetTodoList query, handler, endpoint, service, and a MudTable page. Now we complete the slice with the command side — create, update, and delete. Each operation is its own handler, its own validator, and its own endpoint route, all inside the same Features/Utilities/Todo folder. When you're done, the Todo feature is a full vertical slice you can copy as the template for any future feature.

This part uses the exact patterns from Blazor CRM: command records with IRequest, handlers that take only the DbContext, FluentValidation rules, the TodoPage.razor state machine (Table/Create/Update/View), MudForm with validation, MudDialog for delete confirmation, and MudSnackbar for user feedback.

Step 1: The Create Slice

The create slice has three files: a request + command, a validator, and a handler. The request is the plain data object the form posts; the command wraps it for MediatR; the handler does the work:

public class CreateTodoRequest
{
    public string? Name { get; set; }
    public string? Description { get; set; }
    public DateTime? StartTime { get; set; }
    public DateTime? EndTime { get; set; }
    public bool IsCompleted { get; set; }
}

public record CreateTodoCommand(CreateTodoRequest Data) : IRequest<CreateTodoResponse>;

The handler generates an auto-number, builds the entity, saves it, and returns the new Id and Name. Auto-numbering uses _context.GenerateAutoNumberAsync(entityName, prefixTemplate) — Blazor CRM's helper that produces values like BKG/2026/00001:

public class CreateTodoHandler : IRequestHandler<CreateTodoCommand, CreateTodoResponse>
{
    private readonly AppDbContext _context;

    public CreateTodoHandler(AppDbContext context) => _context = context;

    public async Task<CreateTodoResponse> Handle(CreateTodoCommand request, CancellationToken ct)
    {
        var entityName = nameof(Todo);

        var autoNo = await _context.GenerateAutoNumberAsync(
            entityName: entityName,
            prefixTemplate: $"BKG/{Year}/",
            ct: ct
        );

        var entity = new Todo
        {
            AutoNumber = autoNo,
            Name = request.Data.Name,
            Description = request.Data.Description,
            StartTime = request.Data.StartTime,
            EndTime = request.Data.EndTime,
            IsCompleted = request.Data.IsCompleted
        };

        _context.Todo.Add(entity);
        await _context.SaveChangesAsync(ct);

        return new CreateTodoResponse { Id = entity.Id, Name = entity.Name };
    }
}

The validator stays in the same folder, right next to the handler. Blazor CRM's CreateTodoValidator is a plain AbstractValidator<CreateTodoRequest>:

public class CreateTodoValidator : AbstractValidator<CreateTodoRequest>
{
    public CreateTodoValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required")
            .MaximumLength(GlobalConsts.StringLengthShort);
    }
}

Step 2: The Update Slice

Update looks up the existing entity first. The important pattern here is the Success flag: when the Id isn't found, the handler returns a response with Success = false instead of throwing, so the caller — and the endpoint — can respond gracefully:

public class UpdateTodoHandler : IRequestHandler<UpdateTodoCommand, UpdateTodoResponse>
{
    private readonly AppDbContext _context;

    public UpdateTodoHandler(AppDbContext context) => _context = context;

    public async Task<UpdateTodoResponse> Handle(UpdateTodoCommand request, CancellationToken ct)
    {
        var entity = await _context.Todo
            .FirstOrDefaultAsync(x => x.Id == request.Data.Id, ct);

        if (entity == null)
        {
            return new UpdateTodoResponse { Id = request.Data.Id, Success = false };
        }

        entity.Name = request.Data.Name;
        entity.Description = request.Data.Description;
        entity.StartTime = request.Data.StartTime;
        entity.EndTime = request.Data.EndTime;
        entity.IsCompleted = request.Data.IsCompleted;

        await _context.SaveChangesAsync(ct);

        return new UpdateTodoResponse { Id = entity.Id, Success = true };
    }
}

The UpdateTodoRequest also carries audit fields (CreatedAt, CreatedBy, and so on) so the form can display them read-only while editing. The handler ignores them for writes — it only mutates the editable properties.

Step 3: The Delete Slice

Delete is the smallest slice: find the entity, remove it, return a bool. A false return tells the endpoint the row was already gone:

public class DeleteTodoByIdHandler : IRequestHandler<DeleteTodoByIdCommand, bool>
{
    private readonly AppDbContext _context;

    public DeleteTodoByIdHandler(AppDbContext context) => _context = context;

    public async Task<bool> Handle(DeleteTodoByIdCommand request, CancellationToken ct)
    {
        var entity = await _context.Todo
            .FirstOrDefaultAsync(x => x.Id == request.Data.Id, ct);

        if (entity == null) return false;

        _context.Todo.Remove(entity);
        await _context.SaveChangesAsync(ct);

        return true;
    }
}

Step 4: Wire the Routes

Extend the endpoint group from Part 1 with POST routes. Blazor CRM maps create to POST /, update to POST /update, and delete to POST /delete/{id}, wrapping each result in the ApiResponse envelope:

group.MapPost("/", async (CreateTodoRequest request, IMediator mediator) =>
{
    var result = await mediator.Send(new CreateTodoCommand(request));
    return result.ToApiResponse("Todo has been created successfully",
        StatusCodes.Status201Created);
}).WithName("CreateTodo");

group.MapPost("/update", async (UpdateTodoRequest request, IMediator mediator) =>
{
    var result = await mediator.Send(new UpdateTodoCommand(request));
    if (!result.Success)
        return ((object?)null).ToApiResponse("Update failed. The todo data could not be found.");
    return result.ToApiResponse("Todo has been updated successfully");
}).WithName("UpdateTodo");

group.MapPost("/delete/{id}", async (string id, IMediator mediator) =>
{
    var result = await mediator.Send(new DeleteTodoByIdCommand(new DeleteTodoByIdRequest(id)));
    if (!result)
        return ((object?)null).ToApiResponse("Delete failed. The todo data could not be found.");
    return true.ToApiResponse("Todo has been deleted successfully");
}).WithName("DeleteTodoById");

MediatR routes each command to the right handler by type, so there's no manual switch statement or per-route DI boilerplate.

Step 5: The State-Machine Page

Now the front end. Blazor CRM's TodoPage.razor uses a private enum as a state machine. The page renders one of three views depending on the current state — Table, Create, or Update/View:

@if (_currentView == ViewMode.Create)
{
    <_TodoCreateForm OnCancel="BackToTable"
                     OnSuccess="HandleSuccess" />
}
else if (_currentView == ViewMode.Update || _currentView == ViewMode.View)
{
    <_TodoUpdateForm Data="_selectedData!"
                     ReadOnly="@(_currentView == ViewMode.View)"
                     OnCancel="BackToTable"
                     OnSuccess="HandleSuccess" />
}
else
{
    <_TodoDataTable OnAdd="() => ShowCreate()"
                    OnEdit="(item) => ShowUpdate(item, false)"
                    OnView="(item) => ShowUpdate(item, true)" />
}

@code {
    private enum ViewMode { Table, Create, Update, View }
    private ViewMode _currentView = ViewMode.Table;
    private UpdateTodoRequest? _selectedData;

    private void ShowCreate() => _currentView = ViewMode.Create;

    private void ShowUpdate(UpdateTodoRequest data, bool isReadOnly)
    {
        _selectedData = data;
        _currentView = isReadOnly ? ViewMode.View : ViewMode.Update;
    }

    private void BackToTable()
    {
        _currentView = ViewMode.Table;
        _selectedData = null;
    }

    private void HandleSuccess()
    {
        _currentView = ViewMode.Table;
        _selectedData = null;
    }
}

This is much simpler than nested routing: the page never navigates anywhere, it just swaps which component renders. Navigation happens only once, to /todo. The ReadOnly flag reuses the update form for the read-only detail view — one form, two modes.

Step 6: MudForm with Validation

The create form binds a CreateTodoRequest to MudBlazor inputs and runs FluentValidation through MudBlazor's validation integration. A typical _TodoCreateForm.razor looks like this:

<MudForm @ref="_form"
         Model="_model"
         Validation="@(new MudBlazorValidator<CreateTodoRequest>(new CreateTodoValidator()))"
         @bind-Valid="isValid">
    <MudTextField @bind-Value="_model.Name"
                  Label="Name"
                  For="@(() => _model.Name)" />
    <MudTextField @bind-Value="_model.Description"
                  Label="Description"
                  Lines="3"
                  For="@(() => _model.Description)" />
    <MudDatePicker @bind-Date="_startTime"
                    Label="Start Time" />
    <MudDatePicker @bind-Date="_endTime"
                    Label="End Time" />
    <MudCheckBox @bind-Checked="_model.IsCompleted"
                  Label="Completed" />
</MudForm>

The For expressions wire MudBlazor's field display to the FluentValidation rules, so "Name is required" appears inline under the field exactly when the validator rejects the value. Validation runs on the client, and the same validator runs again inside the handler on the server — validation is never trusted from the browser alone.

Step 7: MudDialog Delete Confirmation

Destructive actions get a confirmation dialog. The data table opens a MudDialog and only calls the delete service if the user confirms:

private async Task OpenDeleteDialog(TodoListItem item)
{
    var options = new DialogOptions
    {
        CloseButton = true,
        MaxWidth = MaxWidth.Small,
        FullWidth = true
    };

    var dialog = DialogService.Show<ConfirmDeleteDialog>(
        "Delete Todo",
        new DialogParameters { ["ContentText"] = $"Delete \"{item.Name}\"?" },
        options);

    var result = await dialog.Result;
    if (result is not null && !result.Cancelled)
    {
        var success = await TodoService.DeleteTodoByIdAsync(item.Id!);
        if (success)
        {
            Snackbar.Add("Todo deleted", Severity.Success);
            await ReloadAsync();
        }
    }
}

The delete service call posts to api/todo/delete/{id} and returns response.IsSuccess. A MudSnackbar message confirms the result, then the table reloads from the list endpoint.

Step 8: Snackbar Feedback on Create and Update

Every mutation ends with a Snackbar notification. Because the ApiResponse envelope carries a message, the service can surface it directly:

var response = await TodoService.CreateTodoAsync(_model);
if (response?.IsSuccess == true)
{
    Snackbar.Add(response.Message, Severity.Success);
    await OnSuccess.InvokeAsync();
}
else
{
    Snackbar.Add(response?.Message ?? "Create failed", Severity.Error);
}

The Complete Slice

That's the full CRUD vertical slice: four handlers, two validators, one endpoint group, one state-machine page, and three form/table components — all inside Features/Utilities/Todo. The next feature you build (products, customers, anything) copies this folder and swaps the entity. The patterns are the same; only the domain changes.

Key Takeaways

  • Each command is its own slice-file pair: request record plus IRequestHandler<,> in Features/Utilities/Todo/Cqrs
  • CreateTodoHandler generates auto-numbers and returns Id + Name; UpdateTodoHandler returns Success = false when the Id isn't found
  • FluentValidation runs on the client via MudForm and again inside the handler — never trust the browser alone
  • TodoPage.razor uses a ViewMode state machine (Table/Create/Update/View) instead of nested routing
  • MudDialog confirms deletes and MudSnackbar reports every mutation result

Frequently Asked Questions

Q: Where does validation run in a Blazor VSA slice?

Twice. On the client, MudForm invokes the FluentValidation rules against the bound request object and shows inline errors. On the server, the same validator class is instantiated inside the handler (or run by a MediatR pipeline behavior) before any database work. Client validation is for UX; server validation is the security boundary.

Q: How do I handle delete confirmation in Blazor?

Use MudDialog. Show a small dialog with the item name and Cancel/Delete buttons, await dialog.Result, and only call the delete endpoint when the result is not cancelled. After a successful delete, show a MudSnackbar message and reload the list.

Q: Why use a state machine in TodoPage.razor?

Because the page never needs to navigate. A ViewMode enum (Table/Create/Update/View) plus a private field decides which component renders. This keeps the URL clean, preserves component state, and makes the data flow explicit — _selectedData is only set when moving to Update or View, and cleared on every return to Table.

Q: How do VSA handlers get registered for DI?

You don't register them individually. AddMediatR scans the assembly and registers every IRequestHandler<,> automatically, which is why new handlers need zero changes in Program.cs. For the manual-handler pattern used in the MVC tutorials, you either register each handler with the DI container or construct it directly in the endpoint/controller.

Part 2 of 2 in the VSA in Blazor Tutorial Series|← Previous: Part 1

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