VSAMVCTutorialSeptember 2026 · 9 min read

Tutorial Vertical Slice Architecture VSA in ASP.NET Core MVC — Part 2: Complete CRUD with Vue.js

TL;DR

Part 2 completes the MVC Todo vertical slice. We add server-side FluentValidation inside handlers, build Vue.js create and edit forms, post JSON to the TodoEndpoint REST routes (POST/PUT/DELETE), map ApiResponse<T> field errors back onto the form, use Tom Select and flatpickr for inputs, and implement the diff-based update pattern for child items — all from real MVC Project Manager code.

Part 2 of 2 in the VSA in ASP.NET Core MVC 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.

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.

Key Takeaways

  • FluentValidation runs inside the handler so every caller (form, API, import) gets the same rules
  • ApiResponse<T>.Ok/Fail keeps error handling uniform, with field errors mapped straight back onto the Vue form
  • Update uses a diff-based pattern: soft-delete old child items, then rebuild the collection from the request
  • Vue 3 + Tom Select + flatpickr produce the payload; fetch posts JSON to /api/todo (POST/PUT/DELETE)
  • The endpoint group maps Success to HTTP status codes — 201 create, 200 update, 200/404 delete

Frequently Asked Questions

Q: Why use Vue.js inside an MVC app?

For interactivity that Razor views alone don't provide well: dynamic item rows, file attachment uploads, and instant form feedback. The Razor view is the page shell; a Vue 3 app mounted on a <div id="app-...">/ handles the interactive parts and talks to the API. This keeps the controller thin and the VSA slice intact.

Q: How do Vue.js forms submit data?

Plain fetch. The Vue app builds a JSON payload that mirrors the request DTO and POSTs (create) or PUTs (update) it to /api/todo. The server returns an ApiResponse, and the client renders the result. No form posts, no page reloads.

Q: Where do validation errors display?

Inline, per field. The handler's FluentValidation rules produce an errors dictionary in the failed ApiResponse. The Vue app maps result.errors onto the reactive errors object, and each input renders its message underneath. A general error message shows above the form.

Q: How are child items handled on update?

With a diff-based replace pattern. The handler loads the existing entity, soft-deletes the old child items (_context.SoftDelete), then rebuilds the collection from the request. Attachments are diffed by Id: kept if still present, deleted from disk if removed, saved if new. This preserves audit history and avoids orphaned files.

Part 2 of 2 in the VSA in ASP.NET Core MVC 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