MVCMVC GuideSeptember 2026 · 8 min read

ASP.NET Core MVC Tutorial: Controller, View, Model, EF Core and SQL

By go2ismail · Published · .NET 10

TL;DR

This ASP.NET Core MVC tutorial builds one complete loop: a URL routes to a controller action, the action selects a Razor view, the view becomes HTML, a form submits JSON to a Minimal API endpoint, a handler validates and maps it, EF Core writes it to SQL, and the browser shows the result. Every step uses small, real C# code, not pseudo-code.

Reading about MVC is not the same as being able to build with it. This tutorial closes that gap by walking one feature — a Todo manager — from URL to database and back. The pieces are deliberately small so you can reproduce them in a fresh ASP.NET Core project on .NET 10 and see each boundary with your own eyes.

If the pattern itself is still fuzzy, read Part 5 first: What Is MVC? Model-View-Controller Explained. Otherwise, continue.

Step 1 — The controller and its actions

Everything starts with a controller class. In this feature it lives under Areas/Main/Todo/Controllers and is decorated with the area name and an authorization rule. Each action returns a view by an explicit path:

[Area("Main")]
[Authorize(Roles = $"{ApplicationRoles.AdminConst},{ApplicationRoles.MemberConst}")]
public class TodoController : Controller
{
    public IActionResult Index()
        => View("~/Areas/Main/Todo/Views/Index.cshtml");

    public IActionResult Create()
        => View("~/Areas/Main/Todo/Views/Create.cshtml");

    public IActionResult Edit(string id)
    {
        ViewBag.TodoId = id;
        return View("~/Areas/Main/Todo/Views/Edit.cshtml");
    }

    public IActionResult Detail(string id)
    {
        ViewBag.TodoId = id;
        return View("~/Areas/Main/Todo/Views/Detail.cshtml");
    }
}

Notice what these actions do not do: no database access, no validation, no business rules. Each one answers a single question — which screen should this URL render — and passes an identifier through ViewBag when the screen needs one. That discipline is what keeps the controller readable as the feature grows.

Step 2 — Routing: how a URL becomes an action

ASP.NET Core matches incoming URLs against route patterns. For areas, the conventional template is {area:exists}/{controller=Home}/{action=Index}/{id?}, so a request to /Main/Todo/Edit/todo-42 resolves to the Edit action of TodoController in the Main area, with id bound to todo-42. The action parameter name matches the route token, which is why it is declared as Edit(string id).

Two conventions keep routing predictable in business applications:

  • Pages use MVC routes that mirror the navigation structure people see in the browser.
  • Data operations use explicit API routes such as /api/todo, mapped separately from the page routes.

That split is the architectural backbone of the rest of this tutorial, and Part 9 explains how to keep both surfaces organized per feature.

Step 3 — The Razor view

A view is a .cshtml file that mixes HTML with server-side Razor expressions. The list screen starts with a title and layout assignment, then renders a table shell that its JavaScript will populate:

@{
    ViewData["Title"] = "Todo Management";
    Layout = "/Areas/_LayoutArea.cshtml";
}

<table id="todoTable" class="table align-middle mb-0" style="width:100%;">
    <thead>
        <tr>
            <th>Auto Number</th>
            <th>Name</th>
            <th>Priority</th>
            <th>Status</th>
        </tr>
    </thead>
</table>

@section Scripts {
    <script src="~/areas/Main/Todo/Views/Index.cshtml.js" asp-append-version="true"></script>
}

Razor handles server-side concerns — layout selection, asset versioning, rendering sections — and then hands over to the browser. The collocated Index.cshtml.js file is loaded alongside the view, which keeps a screen's markup and its behavior in one place instead of scattering scripts across the project. Part 7 examines that file closely.

Step 4 — Models and request types

There are two kinds of "model" in a healthy MVC feature, and they should not be the same class:

  • Entity models describe how data is stored: table columns, relationships, keys.
  • Request models (DTOs) describe what a client may send: a flat shape with only the properties an operation accepts.

The create operation accepts a request type that matches its form, including child items and attachments as simple strings and lists:

public class CreateTodoRequest
{
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public bool IsCompleted { get; set; }
    public string? Tags { get; set; }
    public string? DueDate { get; set; }
    public TodoPriority? Priority { get; set; }
    public decimal Progress { get; set; }
    public TodoCategory? Category { get; set; }
    public string? OwnerUserId { get; set; }
    public List<CreateTodoItemRequest>? Items { get; set; }
}

The entity can change its storage details without breaking the API contract, and a client cannot set fields the request type does not expose — a simple but effective safety boundary.

Step 5 — EF Core, DbContext, and SQL

Entity Framework Core maps C# objects to SQL tables. A entity class describes the row; a DbContext exposes the sets and tracks changes:

public class Todo : BaseEntity
{
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public bool IsCompleted { get; set; }
    public TodoPriority? Priority { get; set; }
    public decimal Progress { get; set; }
    public string? OwnerUserId { get; set; }
    public List<TodoItem>? Items { get; set; }
}

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

    public DbSet<Todo> Todo => Set<Todo>();
}

Reading data is LINQ. The list query below filters, sorts, projects only the columns the grid needs, and returns a page of results — the pattern used by real business tables:

var query = _context.Todo.Include(x => x.OwnerUser);

if (!string.IsNullOrWhiteSpace(request.Search))
    query = query.Where(x => x.Name != null
        && x.Name.ToLower().Contains(request.Search.ToLower()));

var items = await query
    .OrderByDescending(x => x.CreatedAt)
    .Skip((request.Page - 1) * request.PageSize)
    .Take(request.PageSize)
    .Select(x => new TodoListItem
    {
        Id = x.Id,
        Name = x.Name,
        Priority = x.Priority,
        Progress = x.Progress,
        OwnerEmail = x.OwnerUser != null ? x.OwnerUser.Email : null
    })
    .ToListAsync(cancellationToken);

Write operations load the entity, mutate it, and call SaveChangesAsync. EF Core turns the change tracking into the appropriate UPDATE statement, inside a transaction, against whichever database provider the application is configured with.

Step 6 — Handling the form submission

The Create screen gathers fields in the browser and posts them as JSON. The collocated script owns that contract:

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) {
    window.showToast('success', 'Todo Created',
        'Todo "' + result.data.name + '" has been created.');
}

On the server, a Minimal API endpoint receives the same request type from Step 4, delegates to a handler, and translates the handler result into an HTTP status — 201 Created on success, 400 Bad Request when validation fails. Part 7 shows the endpoint and handler line by line.

Step 7 — Validation

Validation rules live with the use case, not with the storage entity. This FluentValidation validator is invoked inside the create handler before anything is saved:

public class CreateTodoValidator : AbstractValidator<CreateTodoRequest>
{
    public CreateTodoValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Todo Name is required")
            .MaximumLength(200).WithMessage("Todo Name must not exceed 200 characters");

        RuleFor(x => x.Progress)
            .InclusiveBetween(0, 100).WithMessage("Todo Progress must be between 0 and 100");

        RuleFor(x => x.Priority)
            .IsInEnum().WithMessage("Todo Priority must be a valid value");
    }
}

The handler returns a structured failure — field messages plus a summary — that the browser renders next to the offending inputs. Client-side checks are a convenience; this server-side validation is the contract.

Step 8 — Verify the whole loop

A practical exercise closes the tutorial. In a local copy:

  • Put a breakpoint in the Index action and open the list page — the page request alone reaches it.
  • Put a breakpoint in the create handler and submit the form — this time the request arrives through /api/todo, not through an MVC action.
  • Remove the name field from the JSON in an API client and resubmit — the server should reject it with validation errors even though the browser would normally prevent the submission.
  • Check the browser Network panel: a successful create is 201, a validation failure is 400, and both show the same response envelope.

Microsoft's own tutorial covers the same concepts in more depth across several pages: start with Get started with ASP.NET Core MVC, then continue with adding a controller, adding a view, adding a model, and working with SQL.

Key Takeaways

  • A controller action selects a screen; data operations belong to endpoints and handlers.
  • Route templates connect URLs to actions; page routes and API routes are separate surfaces.
  • Keep storage entities and request DTOs apart so contracts stay stable.
  • EF Core turns LINQ queries and change-tracked entities into SQL through DbContext.
  • Validation belongs to the use case and must run on the server, regardless of client-side checks.

FAQ

Do I need Entity Framework Core to use ASP.NET Core MVC?

No — MVC works with any data access technology. EF Core is the standard choice in modern .NET because it maps cleanly to C# models and supports multiple database providers.

Should controllers talk to the DbContext directly?

For a first prototype it works, but as soon as an action does validation, mapping, and persistence at once, the controller becomes untestable. Handlers keep those concerns out of the controller — see Part 8.

Where should validation live: the entity or the request?

Storage constraints belong to the entity and database configuration; input rules belong to request validators that run inside the use case. The two overlap but are not identical.

Can I use an MVC page form without JavaScript?

Yes. Classic MVC posts the form to another action. The API-based flow in this tutorial keeps the page and the write operation independent, which scales better as screens get richer.

Continue this guide

Next in the ASP.NET Core MVC Guide: take this tutorial one level deeper with a complete real feature — controller, Razor view, collocated JavaScript, Minimal API endpoint, CQRS handler, and database.

Continue to Part 7

The ASP.NET Core MVC AI Ready Starter contains this exact loop repeated across real business modules.

Get the MVC EDevKit ASP.NET Core MVC AI-Ready Starter