Part 1 built the MVC VSA foundation: the Areas-based feature folder, plain CQRS handlers, the role-protected controller, and the DataTables.js list view. Part 2 completes the slice with the command side — creating and updating Todo records through Vue.js forms, server-side validation with FluentValidation, the ApiResponse<T> error contract, REST routes for create/update/delete, and the diff-based update pattern for child items. All patterns come from the real MVC Project Manager.
By the end, the Todo feature is a complete vertical slice: a DataTables list, a Vue.js create form, a Vue.js edit form with child items and file attachments, and a full REST API — all inside Areas/Main/Todo.
Step 1: The Create Slice
The create handler validates the request first, then builds the entity. Validation is FluentValidation, run inside the handler so every caller — the form, the API, a future import — gets the same rules:
public class CreateTodoValidator : AbstractValidator<CreateTodoRequest>
{
public CreateTodoValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
.MaximumLength(200).WithMessage("Name must not exceed 200 characters");
RuleFor(x => x.Priority)
.IsInEnum().WithMessage("Priority must be a valid value (Low, Medium, or High)");
RuleFor(x => x.Category)
.IsInEnum().WithMessage("Category must be a valid value (Personal, Work, or Learning)");
RuleFor(x => x.Progress)
.InclusiveBetween(0, 100).WithMessage("Progress must be between 0 and 100");
}
}
If validation fails, the handler returns an ApiResponse with Success = false, a message, and a dictionary of field errors. If it passes, it maps the request onto a new Todo entity and saves:
public class CreateTodoHandler
{
private readonly AppDbContext _context;
private readonly FileStorageService _fileStorageService;
public CreateTodoHandler(AppDbContext context, FileStorageService fileStorageService)
{
_context = context;
_fileStorageService = fileStorageService;
}
public async Task<ApiResponse<CreateTodoResponse>> HandleAsync(
CreateTodoRequest request, CancellationToken cancellationToken = default)
{
var validator = new CreateTodoValidator();
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
return ApiResponse<CreateTodoResponse>.Fail("Validation failed",
validationResult.ToDictionary());
var entity = new Todo
{
Name = request.Name,
Description = request.Description,
DueDate = ParseDateTimeOffset(request.DueDate),
Priority = request.Priority,
Progress = request.Progress,
Category = request.Category,
Items = request.Items?.Select(i => new TodoItem
{
Name = i.Name,
IsCompleted = i.IsCompleted,
StartDate = ParseDateTimeOffset(i.StartDate)
}).ToList()
};
_context.Todo.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return ApiResponse<CreateTodoResponse>.Ok(
new CreateTodoResponse { Id = entity.Id, Name = entity.Name },
"Todo has been created successfully");
}
}
Notice the response helpers: ApiResponse<T>.Fail(...) and ApiResponse<T>.Ok(...). They keep envelope construction consistent across every handler in the app.
Step 2: The Diff-Based Update Slice
Update is the most interesting handler. Instead of blindly deleting and re-inserting child items (which burns identity values and breaks audit history), it uses a diff-based pattern: soft-delete removed children, keep the ones still present, and insert brand-new ones:
if (entity.Items != null)
{
foreach (var oldItem in entity.Items)
{
_context.SoftDelete(oldItem);
}
}
entity.Items = request.Items?.Select(i => new TodoItem
{
TodoId = entity.Id,
Name = i.Name,
IsCompleted = i.IsCompleted,
AssignedToUserId = i.AssignedToUserId,
StartDate = ParseDateTimeOffset(i.StartDate),
StartTime = ParseTimeSpan(i.StartTime)
}).ToList();
This is the "replace" pattern for the items collection: old items are soft-deleted with _context.SoftDelete(...), and the whole collection is rebuilt from the request. Because deletion is a soft delete, audit trails and referential integrity stay intact. For image and file attachments, MVC Project Manager goes one step further: it diffs by Id, keeps existing attachments whose Id is still in the request, deletes removed ones from disk, and saves new ones — so nothing gets orphaned on disk.
Step 3: The Vue.js Create Form
On the front end, the Create view hosts a Vue 3 app mounted at #app-create. The companion script Create.cshtml.js defines a reactive form object that mirrors the CreateTodoRequest:
const { createApp, ref, reactive, computed, onMounted } = Vue;
createApp({
setup() {
const contentReady = ref(false);
const submitting = ref(false);
const errorMessage = ref(null);
const errors = reactive({});
const form = reactive({
name: '',
description: '',
isCompleted: false,
tags: '',
dueDate: '',
dueTime: '',
priority: '',
progress: 0,
category: '',
ownerUserId: '',
items: []
});
function validate() {
Object.keys(errors).forEach(function(key) { delete errors[key]; });
errorMessage.value = null;
if (!form.name || !form.name.trim()) {
errors.name = 'Todo Name is required';
}
if (!form.priority) {
errors.priority = 'Priority is required';
}
return Object.keys(errors).length === 0;
}
return { contentReady, submitting, errorMessage, errors, form, validate };
}
}).mount('#app-create');
The v-model bindings in Create.cshtml connect form inputs to the reactive object, and validate() mirrors the server-side rules for instant feedback. Note that validation here is a UX convenience — the real enforcement happens in the handler.
Step 4: Submitting with fetch
Submitting is a plain fetch POST to /api/todo with the whole form as JSON. The Vue app builds the payload, posts it, and handles the ApiResponse result:
async function submitForm() {
if (!validate()) return;
submitting.value = true;
errorMessage.value = null;
var payload = {
name: form.name,
description: form.description || null,
dueDate: form.dueDate || null,
dueTime: form.dueTime || null,
priority: form.priority || null,
progress: form.progress,
category: form.category || null,
ownerUserId: form.ownerUserId || null,
items: form.items.map(function(item) {
return {
name: item.name,
isCompleted: item.isCompleted,
assignedToUserId: item.assignedToUserId,
startDate: item.startDate,
startTime: item.startTime
};
})
};
var response = await fetch('/api/todo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
var result = await response.json();
if (result.success) {
submitting.value = false;
window.showToast('success', 'Todo Created',
'Todo "' + result.data.name + '" has been created successfully.');
} else {
Object.keys(errors).forEach(function(key) { delete errors[key]; });
if (result.errors) {
for (var key in result.errors) {
errors[key] = result.errors[key][0];
}
}
errorMessage.value = result.message || 'Failed to create todo';
submitting.value = false;
window.showToast('error', 'Failed', result.message || 'Failed to create todo');
}
}
The same shape works for update: PUT to /api/todo with id included in the payload.
Step 5: ApiResponse Error Handling
The ApiResponse envelope is what makes error handling uniform. Validation failures come back as { success: false, message: "Validation failed", errors: { name: ["Name is required"] } }. The Vue app maps result.errors directly onto the reactive errors object, so each field shows its server-side message inline — the same way FluentValidation surfaced it in the handler.
if (result.errors) {
for (var key in result.errors) {
errors[key] = result.errors[key][0];
}
}
errorMessage.value = result.message;
Server-driven field errors mean the client never needs to duplicate the full rule set. It validates for snappy UX, then lets the server's rules win on submit.
Step 6: Tom Select and flatpickr
MVC Project Manager uses Tom Select for searchable dropdowns and flatpickr for date/time pickers. Tom Select wraps a native <select> so the value still flows through the Vue form:
function initTomSelect() {
var priorityEl = document.getElementById('ts-priority');
if (priorityEl) {
tsPriority = new TomSelect(priorityEl, {
allowEmptyOption: true,
dropdownParent: 'body',
create: false,
onChange: function(value) {
form.priority = value || '';
}
});
}
}
function initFlatpickr() {
flatpickrCustom.initDate('.flatpickr-date', {
onChange: function(selectedDates, dateStr) {
form.dueDate = dateStr || '';
}
});
flatpickrCustom.initTime('.flatpickr-time', {
onChange: function(selectedDates, dateStr) {
form.dueTime = dateStr || '';
}
});
}
Both libraries push their selected values back into the reactive form, so the JSON payload always reflects the final UI state.
Step 7: Delete with Confirmation
Delete follows the REST API. The index script confirms, then DELETEs:
async function confirmDelete(id) {
if (!window.confirm('Delete this todo?')) return;
var response = await fetch('/api/todo/' + id, { method: 'DELETE' });
var result = await response.json();
if (result.success) {
window.showToast('success', 'Deleted', 'Todo has been deleted successfully.');
$('#todoTable').DataTable().ajax.reload();
} else {
window.showToast('error', 'Failed', result.message || 'Delete failed');
}
}
The DataTable reloads from the server after a successful delete, so the list always matches the database.
Step 8: The REST Routes
Here's the complete CRUD surface from the endpoint group. Create and update carry a 100 MB request-size limit for attachments:
group.MapPost("/", async (CreateTodoRequest request, AppDbContext db,
FileStorageService fileStorageService, CancellationToken ct) =>
{
var handler = new CreateTodoHandler(db, fileStorageService);
var result = await handler.HandleAsync(request, ct);
return result.Success
? Results.Created($"/api/todo/{result.Data?.Id}", result)
: Results.BadRequest(result);
}).WithName("CreateTodo")
.WithMetadata(new RequestSizeLimitAttribute(104857600));
group.MapPut("/", async (UpdateTodoRequest request, AppDbContext db,
FileStorageService fileStorageService, CancellationToken ct) =>
{
var handler = new UpdateTodoHandler(db, fileStorageService);
var result = await handler.HandleAsync(request, ct);
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
}).WithName("UpdateTodo")
.WithMetadata(new RequestSizeLimitAttribute(104857600));
group.MapDelete("/{id}", async (string id, AppDbContext db,
FileStorageService fileStorageService, CancellationToken ct) =>
{
var handler = new DeleteTodoHandler(db, fileStorageService);
var result = await handler.HandleAsync(id, ct);
return result.Success ? Results.Ok(result) : Results.NotFound(result);
}).WithName("DeleteTodo");
Every route calls a handler, and every handler returns an ApiResponse. The endpoint maps Success to the right HTTP status code — 201 for create, 200 for update, 200 or 404 for delete.
The Complete MVC Slice
The Todo feature is now complete: a DataTables list, a Vue.js create form, a Vue.js edit form with diff-based child items, and a full REST API with server-side validation. Copy the folder, swap the entity, and you have your next feature. That's the whole promise of VSA in MVC — the slice is repeatable.