LearnAI ToolsCareerPractice BuildsPlayContact
C# & .NETIntermediate~3 hours

Product Catalog with Razor Pages

Build a browsable product catalog with forms, validation, and a SQL-backed database using MVC.

MVCRazorModel Binding

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.

What You'll Build
  • 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.

Models/Product.cs
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.

Data/CatalogDbContext.cs
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>();
}
Program.cs
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`).

Controllers/ProductsController.cs
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 forgery
public 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.

Views/Products/Index.cshtml
@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>
Views/Products/Create.cshtml
@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>
Views/Products/Edit.cshtml
@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.

Models/Product.cs
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; }
}
Data/CatalogDbContext.cs
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>();
}
Program.cs
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();
Controllers/ProductsController.cs
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

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.