Overview
A product catalog is where ASP.NET Core MVC's three pieces finally work together end to end: a `Product` model carrying validation rules as data annotations, a `ProductsController` handling requests and talking to the database, and Razor `.cshtml` views rendering HTML that a browser can actually submit a form to. The same `[Required]`/`[Range]` attributes you put on `Product` in Step 1 get used twice — once by EF Core, to decide column constraints in the database, and once by ASP.NET Core's model binder, to validate a submitted form before your controller code ever runs.
By the end of this tutorial you will have a `ProductsController` with `Index`, `Create`, and `Edit` actions, matching Razor views for each, and an EF Core `CatalogDbContext` backed by **SQLite** — a real, file-based SQL database (`catalog.db`), not an in-memory stand-in. That choice matters here specifically because this project is about persistence surviving past a single run: a visitor creates a product through an HTML form, and it needs to still be there the next time the app starts. A production deployment would swap the single `UseSqlite(...)` call in Step 2 for `UseSqlServer(...)` or `UseNpgsql(...)` against SQL Server or PostgreSQL — every controller and view below stays exactly the same.
- A `Product` model with `[Required]`, `[Range]`, and `[StringLength]` data annotations.
- A `CatalogDbContext` backed by SQLite, with migrations applied automatically on startup.
- A `ProductsController` with `Index` (list), `Create` (GET/POST), and `Edit` (GET/POST) actions.
- Razor views using tag helpers (`asp-for`, `asp-action`) for model-bound forms and validation messages.
- Server-side model binding and validation on every submitted form, backed by `[ApiController]`-free conventional MVC.
- A redirect-after-POST pattern on every successful save, avoiding duplicate submissions on refresh.
Prerequisites
- C# fundamentals — classes, properties, and `async`/`await`.
- MVC basics — the idea of a controller action returning a `View()`.
- HTML forms — `<form method="post">`, input names, and how a browser packages submitted fields.
- EF Core basics — `DbContext`, `DbSet<T>`, and `SaveChangesAsync()` (covered in the Task Manager Web API project).
- Data annotations — attributes like `[Required]` and `[Range]` applied to a model's properties.
Project Structure
Running `dotnet new mvc -o ProductCatalog` scaffolds the conventional ASP.NET Core MVC layout: `Program.cs` at the root; `Controllers/` holding `ProductsController.cs`; `Models/` holding `Product.cs`; and — the piece a pure Web API project does not have — a `Views/` folder, with one subfolder per controller. Every action method in `ProductsController` that calls the parameterless `View()` looks for a `.cshtml` file at `Views/Products/{ActionName}.cshtml` by convention, so `Index()` renders `Views/Products/Index.cshtml`, `Create()` renders `Views/Products/Create.cshtml`, and so on — no explicit file path is ever written in the controller code.
This tutorial also adds a `Data/` folder for `CatalogDbContext.cs`, the same convention used in the Task Manager Web API project. Because this project uses a real SQLite file rather than an in-memory database, `Program.cs` also applies any pending EF Core migrations automatically on startup in Step 2, so `catalog.db` always matches the current shape of the `Product` model without a separate manual migration step during local development.
Step 1: Define the Product Model
Every attribute here does double duty. `[Required]` and `[Range]` are read by EF Core when it builds the database schema (a `[Required]` string becomes a `NOT NULL` column), and the exact same attributes are read again by ASP.NET Core's model binder on every `POST` to decide whether the submitted data is valid — one set of rules, enforced consistently in two different places. `Price` is declared as `decimal`, not `double` or `float`, which matters specifically for money: `decimal` avoids the small binary floating-point rounding errors that make `double` unsuitable for currency values.
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Models;
// Data annotations here are read twice: EF Core uses them to build column// constraints in the database (Step 2), and ASP.NET Core's model binder uses// the exact same attributes to validate a submitted form (Steps 4 and 5).public class Product{ public int Id { get; set; } // Primary key; EF Core recognizes "Id" by convention
[Required(ErrorMessage = "Product name is required.")] [StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2-100 characters.")] public string Name { get; set; } = string.Empty;
[Required] [Range(0.01, 100000, ErrorMessage = "Price must be between 0.01 and 100,000.")] public decimal Price { get; set; } // decimal, not double/float — the correct C# type for money, avoiding binary rounding error
[Range(0, 10000, ErrorMessage = "Stock cannot be negative.")] public int StockQuantity { get; set; }
[StringLength(500)] public string? Description { get; set; } // Nullable: the one genuinely optional field on this model}Step 2: Configure the DbContext and Program.cs
`AddControllersWithViews()` is the MVC equivalent of the Web API project's `AddControllers()` — it registers everything `AddControllers()` does, plus Razor view rendering, so a controller action can call `View()` and have it actually resolve a `.cshtml` file. `app.Database.Migrate()` runs inside a DI scope created manually with `CreateScope()`, since `AppDbContext`/`CatalogDbContext` is registered as scoped (one instance per request) but startup code runs before any request exists — `CreateScope()` is what makes a scoped service resolvable from top-level code like this.
using Microsoft.EntityFrameworkCore;using ProductCatalog.Models;
namespace ProductCatalog.Data;
public class CatalogDbContext : DbContext{ public CatalogDbContext(DbContextOptions<CatalogDbContext> options) : base(options) { }
public DbSet<Product> Products => Set<Product>();}using Microsoft.EntityFrameworkCore;using ProductCatalog.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews(); // Adds Razor view rendering on top of what AddControllers() alone provides
// SQLite is a real, file-backed SQL database, not an in-memory stand-in — this// catalog's data survives across restarts. A production deployment would point// this same call at SQL Server or PostgreSQL instead, changing only the provider// package and connection string, not any controller or view code below.builder.Services.AddDbContext<CatalogDbContext>(options => options.UseSqlite("Data Source=catalog.db"));
var app = builder.Build();
// Applies any pending EF Core migrations automatically on startup, so catalog.db// always matches the current Product model. CreateScope() is required because// CatalogDbContext is registered as scoped, but this code runs before any request exists.using (var scope = app.Services.CreateScope()){ var db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>(); db.Database.Migrate();}
app.UseStaticFiles(); // Serves wwwroot/ (CSS and client-side validation scripts referenced by the views in Step 6)app.UseRouting();
app.MapControllerRoute( name: "default", pattern: "{controller=Products}/{action=Index}/{id?}"); // Products/Index becomes this app's default landing page
app.Run();Before running the app for the first time, create the initial migration from the command line: `dotnet ef migrations add InitialCreate` (this requires the `Microsoft.EntityFrameworkCore.Design` and `Microsoft.EntityFrameworkCore.Sqlite` NuGet packages, plus the `dotnet-ef` global tool). `db.Database.Migrate()` in `Program.cs` then applies that migration to `catalog.db` automatically every time the app starts.
Step 3: Build the Index Action
The parameterless `View(products)` call is what makes the controller-to-view connection: it passes `products` as the view's strongly typed `@model`, and — because no view name is given explicitly — ASP.NET Core looks for `Views/Products/Index.cshtml` by convention, matching both the controller name (`Products`, from `ProductsController`) and the action name (`Index`).
using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using ProductCatalog.Data;using ProductCatalog.Models;
namespace ProductCatalog.Controllers;
public class ProductsController : Controller // "Controller", not "ControllerBase" — Controller adds View()/PartialView() support{ private readonly CatalogDbContext _db; // Injected per-request by the DI container configured in Program.cs
public ProductsController(CatalogDbContext db) { _db = db; }
public async Task<IActionResult> Index() { var products = await _db.Products.OrderBy(p => p.Name).ToListAsync(); return View(products); // Renders Views/Products/Index.cshtml with products as its @model }}Step 4: Build the Create Action
This is the same GET-shows-a-form, POST-processes-it pattern used by the Contact Form Handler and Blog CMS PHP tutorials, expressed through ASP.NET Core's conventions instead: two methods share the name `Create`, distinguished by the `[HttpPost]` attribute on the second one. By the time `Create(Product product)` runs, model binding has already matched submitted form fields to `Product`'s properties by name and validated every data annotation from Step 1 against the bound values — `ModelState.IsValid` is simply the yes/no answer to "did all of that validation pass?" `[ValidateAntiForgeryToken]` requires a hidden token the form tag helper in Step 6 embeds automatically, which is what stops a malicious site from submitting this form on a visitor's behalf (cross-site request forgery).
public IActionResult Create(){ return View(); // GET: renders Views/Products/Create.cshtml with a blank/default Product}
[HttpPost][ValidateAntiForgeryToken] // Requires the hidden token the form tag helper in Step 6 embeds, blocking cross-site request forgerypublic async Task<IActionResult> Create(Product product){ // Model binding already ran before this method started: ASP.NET Core matched submitted // form fields to Product's properties by name, then validated every [Required]/[Range] // attribute from Step 1 against the bound values. if (!ModelState.IsValid) { return View(product); // Redisplay the form with the same values and validation messages attached }
_db.Products.Add(product); await _db.SaveChangesAsync(); return RedirectToAction(nameof(Index)); // Redirect-after-POST avoids a duplicate submission if the visitor refreshes the page}Step 5: Build the Edit Action
`Edit(int id)` follows the same overload pattern as `Create`, but its GET version first loads the existing product with `FindAsync(id)` so the form in Step 6 can pre-fill every field with the product's current values. The POST version checks `id != product.Id` before doing anything else — the route's `id` and the form's hidden `Id` field should always agree, and a mismatch here signals something is wrong with the request rather than a normal validation failure.
public async Task<IActionResult> Edit(int id){ var product = await _db.Products.FindAsync(id); if (product is null) { return NotFound(); } return View(product); // Renders Views/Products/Edit.cshtml, pre-filled with the existing product's values}
[HttpPost][ValidateAntiForgeryToken]public async Task<IActionResult> Edit(int id, Product product){ if (id != product.Id) // The route id and the submitted hidden Id field must agree { return BadRequest(); } if (!ModelState.IsValid) { return View(product); }
_db.Update(product); // Marks every property as modified, not just the ones that actually changed await _db.SaveChangesAsync(); return RedirectToAction(nameof(Index));}Step 6: Build the Razor Views
Every view starts with `@model` declaring exactly what type the controller is expected to pass in — `IEnumerable<Product>` for the list, `Product` for the forms — which gives full IntelliSense and compile-time checking inside the `.cshtml` file itself. Tag helpers like `asp-for="Name"` and `asp-validation-for="Name"` read the same data annotations from Step 1 to generate the right `<input>` attributes and wire up client-side validation automatically, without a single line of validation logic being duplicated by hand in the view.
@model IEnumerable<ProductCatalog.Models.Product>
<h1>Product Catalog</h1><a asp-action="Create">+ New Product</a>
<table> <thead> <tr><th>Name</th><th>Price</th><th>Stock</th><th></th></tr> </thead> <tbody> @foreach (var product in Model) { <tr> <td>@product.Name</td> <td>@product.Price.ToString("C")</td> <!-- "C" is a standard format string: renders as currency, e.g. $19.99 --> <td>@product.StockQuantity</td> <td><a asp-action="Edit" asp-route-id="@product.Id">Edit</a></td> </tr> } </tbody></table>@model ProductCatalog.Models.Product
<h1>New Product</h1>
<form asp-action="Create" method="post"> <div asp-validation-summary="ModelOnly"></div> <!-- Lists any validation errors not tied to one specific field -->
<label asp-for="Name"></label> <input asp-for="Name" /> <span asp-validation-for="Name"></span> <!-- Renders the [Required]/[StringLength] error message for Name, if any -->
<label asp-for="Price"></label> <input asp-for="Price" /> <span asp-validation-for="Price"></span>
<label asp-for="StockQuantity"></label> <input asp-for="StockQuantity" /> <span asp-validation-for="StockQuantity"></span>
<label asp-for="Description"></label> <textarea asp-for="Description"></textarea>
<button type="submit">Save</button></form>@model ProductCatalog.Models.Product
<h1>Edit Product</h1>
<form asp-action="Edit" method="post"> <input type="hidden" asp-for="Id" /> <!-- Round-trips the product's id so the POST action can confirm it matches the route --> <div asp-validation-summary="ModelOnly"></div>
<label asp-for="Name"></label> <input asp-for="Name" /> <span asp-validation-for="Name"></span>
<label asp-for="Price"></label> <input asp-for="Price" /> <span asp-validation-for="Price"></span>
<label asp-for="StockQuantity"></label> <input asp-for="StockQuantity" /> <span asp-validation-for="StockQuantity"></span>
<label asp-for="Description"></label> <textarea asp-for="Description"></textarea>
<button type="submit">Save Changes</button></form>Complete Code
Here is the full project across its files, ready to run with `dotnet ef migrations add InitialCreate` followed by `dotnet run` after installing the `Microsoft.EntityFrameworkCore.Sqlite`, `Microsoft.EntityFrameworkCore.Design`, and `Microsoft.EntityFrameworkCore.Tools` NuGet packages.
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Models;
public class Product{ public int Id { get; set; }
[Required(ErrorMessage = "Product name is required.")] [StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2-100 characters.")] public string Name { get; set; } = string.Empty;
[Required] [Range(0.01, 100000, ErrorMessage = "Price must be between 0.01 and 100,000.")] public decimal Price { get; set; }
[Range(0, 10000, ErrorMessage = "Stock cannot be negative.")] public int StockQuantity { get; set; }
[StringLength(500)] public string? Description { get; set; }}using Microsoft.EntityFrameworkCore;using ProductCatalog.Models;
namespace ProductCatalog.Data;
public class CatalogDbContext : DbContext{ public CatalogDbContext(DbContextOptions<CatalogDbContext> options) : base(options) { }
public DbSet<Product> Products => Set<Product>();}using Microsoft.EntityFrameworkCore;using ProductCatalog.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<CatalogDbContext>(options => options.UseSqlite("Data Source=catalog.db"));
var app = builder.Build();
using (var scope = app.Services.CreateScope()){ var db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>(); db.Database.Migrate();}
app.UseStaticFiles();app.UseRouting();
app.MapControllerRoute( name: "default", pattern: "{controller=Products}/{action=Index}/{id?}");
app.Run();using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using ProductCatalog.Data;using ProductCatalog.Models;
namespace ProductCatalog.Controllers;
public class ProductsController : Controller{ private readonly CatalogDbContext _db;
public ProductsController(CatalogDbContext db) { _db = db; }
public async Task<IActionResult> Index() { var products = await _db.Products.OrderBy(p => p.Name).ToListAsync(); return View(products); }
public IActionResult Create() { return View(); }
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Create(Product product) { if (!ModelState.IsValid) { return View(product); }
_db.Products.Add(product); await _db.SaveChangesAsync(); return RedirectToAction(nameof(Index)); }
public async Task<IActionResult> Edit(int id) { var product = await _db.Products.FindAsync(id); if (product is null) { return NotFound(); } return View(product); }
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Edit(int id, Product product) { if (id != product.Id) { return BadRequest(); } if (!ModelState.IsValid) { return View(product); }
_db.Update(product); await _db.SaveChangesAsync(); return RedirectToAction(nameof(Index)); }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `Delete` action (GET confirmation view + `[HttpPost]` `DeleteConfirmed`) following the same pattern as `Edit`, guarded by `[ValidateAntiForgeryToken]`.
- Add a `Category` model with a one-to-many relationship to `Product`, and a dropdown in the Create/Edit forms populated from `_db.Categories`.
- Add server-side search and pagination to the `Index` action using `.Where()`/`.Skip()`/`.Take()` before `ToListAsync()`.
- Add image upload support for each product using `IFormFile`, saving files under `wwwroot/images/` and storing the relative path on `Product`.
- Add a custom `[Remote]` validation attribute that checks product name uniqueness against the database asynchronously as the visitor types.
Summary
You built a complete MVC application where a `Product` model's data annotations drive both the database schema and form validation, a `ProductsController` handles the full list/create/edit lifecycle with model binding turning submitted form fields straight into typed objects, and Razor views with tag helpers render forms and validation messages without hand-written HTML-generation logic. The GET-shows-a-form, POST-validates-and-saves pattern you wrote in `Create` and `Edit` is the backbone of nearly every data-entry screen you will build in ASP.NET Core MVC from here on.