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.