ASP.NET CORE MVC - DB CRUD: Create and Read

ASP.NET CORE MVC TUTORIAL SERIES · PART 5

Building Create and Read Operations with Entity Framework Core

Connect ProductsController to the SQLite database, retrieve product records asynchronously, display database-backed Index and Details pages, build a Create form, use model binding, and save new products with Entity Framework Core.

Objective

By the end of this tutorial, users will be able to create products and retrieve product information from the SQLite database.

Starting point

Part 4 created ApplicationDbContext, configured SQLite, created the InitialCreate migration, and generated ProductManagement.db. The database exists, but ProductsController is still using temporary product objects.

In this tutorial
  1. Verify the Part 4 database
  2. Inject ApplicationDbContext into the controller
  3. Replace the hard-coded Index action
  4. Read products asynchronously with ToListAsync()
  5. Build a database-backed Details action
  6. Create a Create GET action and form
  7. Understand model binding
  8. Create the POST action
  9. Save a product with SaveChangesAsync()
  10. Use the Post/Redirect/Get pattern
  11. Verify records in SQLite
  12. Troubleshoot common problems

1. Open the Existing Project

cd ~/aspnet-mvc-tutorial/ProductManagement
pwd

The expected path is:

/home/xubuntu/aspnet-mvc-tutorial/ProductManagement

Confirm the Part 4 files:

ls Data/ApplicationDbContext.cs
ls Models/Product.cs
ls ProductManagement.db

Check the migration:

dotnet ef migrations list

You should see InitialCreate. Open the project:

code .

2. Confirm the Database Structure

Before changing the controller, inspect the SQLite database:

sqlite3 ProductManagement.db

At the SQLite prompt:

.tables
.schema Products
.quit

The Products table should contain columns corresponding to Id, Name, Description, Price, and Quantity.

3. The Change We Are Making

At the end of Part 4, the controller and database were separate:

ProductsController → hard-coded Product objects ApplicationDbContext → SQLite database

We will now connect them:

Browser ↓ ProductsController ↓ ApplicationDbContext ↓ Entity Framework Core ↓ SQLite

4. Inject ApplicationDbContext

Open:

Controllers/ProductsController.cs

Replace the existing controller with this starting version:

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;

namespace ProductManagement.Controllers;

public class ProductsController : Controller
{
    private readonly ApplicationDbContext _context;

    public ProductsController(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<IActionResult> Index()
    {
        var products = await _context.Products.ToListAsync();

        return View(products);
    }
}

5. How Constructor Injection Works

The controller declares:

private readonly ApplicationDbContext _context;

Its constructor requests a configured context:

public ProductsController(ApplicationDbContext context)
{
    _context = context;
}

Because Part 4 registered ApplicationDbContext in Program.cs, ASP.NET Core can supply it automatically.

Program.cs ↓ AddDbContext<ApplicationDbContext>() ↓ Dependency Injection ↓ ProductsController constructor ↓ _context

6. Read Products with ToListAsync()

The new Index action contains:

var products = await _context.Products.ToListAsync();

_context.Products represents the Products entity set. ToListAsync() executes the query asynchronously and returns the records as a list of Product objects.

The action is therefore declared with:

public async Task<IActionResult> Index()
Why async?

Database operations involve I/O. ASP.NET Core applications commonly use asynchronous EF Core methods such as ToListAsync(), FindAsync(), and SaveChangesAsync() so a request does not unnecessarily block a thread while waiting for database work.

7. Test the Database-Backed Index Page

Build first:

dotnet build

Then run:

dotnet run

Open the localhost address displayed by the application and browse to:

/Products

The table may currently be empty. That is expected because Part 4 created the database structure but did not insert product records.

Important milestone

Even if no rows appear, /Products is now querying SQLite instead of using the hard-coded product list from Part 3.

8. Create a Database-Backed Details Action

Add this action below Index():

public async Task<IActionResult> Details(int? id)
{
    if (id == null)
    {
        return NotFound();
    }

    var product = await _context.Products
        .FirstOrDefaultAsync(p => p.Id == id);

    if (product == null)
    {
        return NotFound();
    }

    return View(product);
}

9. Understand the Details Query

The parameter is:

int? id

The question mark makes the integer nullable, allowing the action to detect a missing ID.

This query:

await _context.Products
    .FirstOrDefaultAsync(p => p.Id == id);

asks EF Core for the first Product whose Id matches the route value. If no record exists, the result is null.

The two NotFound() checks protect against both a missing ID and a nonexistent product.

10. Keep the Existing Details View

The strongly typed view from Part 3 can continue to be used:

Views/Products/Details.cshtml

It should begin with:

@model Product

No database code belongs in the Razor view. The controller obtains the Product and passes it to the view.

/Products/Details/1 ↓ Details(1) ↓ EF Core query ↓ Product object ↓ Details.cshtml

11. Add the Create GET Action

Add this action to ProductsController:

[HttpGet]
public IActionResult Create()
{
    return View();
}

The GET action displays an empty form.

GET /Products/Create ↓ Create() ↓ return View() ↓ Views/Products/Create.cshtml

12. Create the Create View Before Testing It

Create the Razor view first

Do not browse to /Products/Create until the matching view exists. Otherwise MVC will report that the Create view was not found.

Create the file:

touch Views/Products/Create.cshtml
code Views/Products/Create.cshtml

Verify it:

ls -l Views/Products/Create.cshtml

13. Build the Create Form

Add this to Create.cshtml:

@model Product

@{
    ViewData["Title"] = "Create Product";
}

<h1>Create Product</h1>

<form asp-action="Create" method="post">
    <div class="mb-3">
        <label asp-for="Name" class="form-label"></label>
        <input asp-for="Name" class="form-control" />
    </div>

    <div class="mb-3">
        <label asp-for="Description" class="form-label"></label>
        <textarea asp-for="Description" class="form-control"></textarea>
    </div>

    <div class="mb-3">
        <label asp-for="Price" class="form-label"></label>
        <input asp-for="Price" class="form-control" />
    </div>

    <div class="mb-3">
        <label asp-for="Quantity" class="form-label"></label>
        <input asp-for="Quantity" class="form-control" />
    </div>

    <button type="submit" class="btn btn-primary">Create</button>
    <a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>

14. Understand the Form Tag Helpers

Tag HelperPurpose
asp-action="Create"Generates the form action for the Create action.
asp-for="Name"Associates the element with the Product Name property.
asp-for="Price"Associates the element with the Product Price property.
asp-for="Quantity"Associates the element with the Product Quantity property.

Tag Helpers allow the form markup to remain HTML-like while connecting the elements to MVC model properties.

15. Why There Is No Id Input

The Create form deliberately does not ask the user to enter Id.

For this model, EF Core and SQLite treat the integer primary key as a generated value. The database assigns the identifier when the record is inserted.

General principle

Fields controlled by the application or database should not be unnecessarily exposed as editable user inputs.

16. Add the Create POST Action

Add another Create action:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Product product)
{
    if (ModelState.IsValid)
    {
        _context.Add(product);
        await _context.SaveChangesAsync();

        return RedirectToAction(nameof(Index));
    }

    return View(product);
}

17. GET Create versus POST Create

ActionPurpose
[HttpGet] Create()Displays the empty form.
[HttpPost] Create(Product product)Receives submitted form values and attempts to save the new Product.
GET /Products/Create ↓ Display form ↓ User enters data ↓ POST /Products/Create ↓ Receive Product ↓ Save to SQLite

18. What Is Model Binding?

When the form is submitted, ASP.NET Core model binding reads request values and attempts to populate the action parameter:

Product product

Conceptually:

Form field: Name = "Laptop" Form field: Price = "3299.90" Form field: Quantity = "8" ↓ ASP.NET Core model binding ↓ Product object

This is why the input elements use property-aware Tag Helpers such as asp-for="Name".

19. What Does ModelState.IsValid Mean?

The POST action checks:

if (ModelState.IsValid)

ModelState contains information about model binding and validation. At this stage, the Product model has very few explicit validation rules. Part 7 will add data annotations such as [Required], [StringLength], and [Range].

Validation is not complete yet

Do not interpret ModelState.IsValid in Part 5 as meaning that all business rules have been implemented. Proper product validation is introduced in Part 7.

20. Add the Product to the Context

This line tells EF Core that the Product should be inserted:

_context.Add(product);

At this point the change is tracked by the context, but it has not necessarily been written to SQLite yet.

21. Save the Product

This line writes pending changes to the database:

await _context.SaveChangesAsync();
Product object ↓ _context.Add(product) ↓ EF Core tracks Added entity ↓ SaveChangesAsync() ↓ INSERT ↓ SQLite Products table

22. Redirect After a Successful Create

After saving, the action returns:

return RedirectToAction(nameof(Index));

This redirects the browser to the Index action, which queries the database again and displays the updated product list.

This is an example of the Post/Redirect/Get pattern:

POST Create ↓ Save record ↓ Redirect ↓ GET Index ↓ Display updated list

Redirecting after a successful POST also helps avoid accidental resubmission when the user refreshes the resulting page.

23. Anti-Forgery Protection

The POST action includes:

[ValidateAntiForgeryToken]

The MVC Form Tag Helper can generate the anti-forgery token required by the protected POST action. This helps defend form submissions against cross-site request forgery.

Keep the protection enabled

Do not remove anti-forgery protection simply to make a form error disappear. If a protected form fails, investigate the form markup and request flow.

24. Completed ProductsController

At this point, ProductsController.cs should contain:

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;

namespace ProductManagement.Controllers;

public class ProductsController : Controller
{
    private readonly ApplicationDbContext _context;

    public ProductsController(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<IActionResult> Index()
    {
        var products = await _context.Products.ToListAsync();

        return View(products);
    }

    public async Task<IActionResult> Details(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var product = await _context.Products
            .FirstOrDefaultAsync(p => p.Id == id);

        if (product == null)
        {
            return NotFound();
        }

        return View(product);
    }

    [HttpGet]
    public IActionResult Create()
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(Product product)
    {
        if (ModelState.IsValid)
        {
            _context.Add(product);
            await _context.SaveChangesAsync();

            return RedirectToAction(nameof(Index));
        }

        return View(product);
    }
}

25. Update the Index View

Open:

Views/Products/Index.cshtml

Use this database-backed list view:

@model IEnumerable<Product>

@{
    ViewData["Title"] = "Products";
}

<h1>Products</h1>

<p>
    <a asp-action="Create" class="btn btn-primary">Create New Product</a>
</p>

@if (!Model.Any())
{
    <p>No products have been created yet.</p>
}
else
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th>ID</th>
                <th>Name</th>
                <th>Price</th>
                <th>Quantity</th>
                <th>Action</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var product in Model)
            {
                <tr>
                    <td>@product.Id</td>
                    <td>@product.Name</td>
                    <td>RM @product.Price.ToString("N2")</td>
                    <td>@product.Quantity</td>
                    <td>
                        <a asp-action="Details"
                           asp-route-id="@product.Id">Details</a>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

26. Why Handle an Empty List?

Before the first Product is created, the query returns an empty collection. This condition:

@if (!Model.Any())

lets the page display a useful message instead of an empty table.

27. Build and Run

Save all files, then:

dotnet build
dotnet run

Use the localhost address displayed in the terminal and open:

/Products

Click Create New Product.

28. Create the First Database Record

Enter sample values:

NameLaptop
Description14-inch business laptop
Price3299.90
Quantity8

Click Create.

The application should redirect to /Products, where Laptop now appears in the table.

29. Add More Products

Create two more records:

NameDescriptionPriceQuantity
Wireless MouseBluetooth wireless mouse89.9025
USB-C HubMulti-port USB-C hub149.0015

The Index page should now display three records retrieved from SQLite.

30. Test the Details Page

Click the Details link beside a product. The request follows this flow:

Details link ↓ /Products/Details/{id} ↓ ProductsController.Details(id) ↓ EF Core SELECT query ↓ Product ↓ Details.cshtml

Try a nonexistent ID such as:

/Products/Details/99999

The action should return a 404 response through NotFound().

31. Verify the Records Directly in SQLite

Stop the application if necessary with Ctrl+C, then:

sqlite3 ProductManagement.db

At the SQLite prompt:

SELECT Id, Name, Price, Quantity FROM Products;

You should see the products created through the MVC form. Exit:

.quit
Persistence confirmed

The products now exist in SQLite. They remain stored even after the ASP.NET Core application stops and restarts.

32. Current Project Structure

ProductManagement/
├── Controllers/
│   ├── HomeController.cs
│   └── ProductsController.cs
├── Data/
│   └── ApplicationDbContext.cs
├── Migrations/
├── Models/
│   └── Product.cs
├── Views/
│   ├── Products/
│   │   ├── Index.cshtml
│   │   ├── Details.cshtml
│   │   └── Create.cshtml
│   └── Shared/
├── appsettings.json
├── ProductManagement.db
├── ProductManagement.csproj
└── Program.cs

33. Troubleshooting

The view 'Create' was not found

Check the exact case-sensitive path:

ls -l Views/Products/Create.cshtml

The file must be Views/Products/Create.cshtml.

SQLite Error: no such table: Products

Check the migration state:

dotnet ef migrations list
dotnet ef database update

Also confirm that the connection string points to the database you expect.

ApplicationDbContext cannot be resolved

Confirm that Part 4 registered it in Program.cs with AddDbContext<ApplicationDbContext>(...).

ToListAsync or FirstOrDefaultAsync is not recognised

Confirm that the controller contains:

using Microsoft.EntityFrameworkCore;
The Create form reloads instead of saving

The model may have a binding or validation error. Keep return View(product); when ModelState.IsValid is false. In Part 7 we will add visible validation messages and stronger validation rules.

Changes disappear after restarting

Confirm that the controller is using _context.Products and SaveChangesAsync(), not the old hard-coded List<Product> from Part 3.

34. Hands-On Exercise

Create a fourth Product:

NameMechanical Keyboard
DescriptionUSB mechanical keyboard
Price249.90
Quantity12

Then:

  1. confirm that it appears on the Index page;
  2. open its Details page;
  3. stop and restart the application;
  4. confirm that the Product still appears; and
  5. query the record directly with SQLite.

35. Knowledge Check

  1. Why is ApplicationDbContext injected into ProductsController?
  2. What does ToListAsync() do?
  3. Why is the Index action marked async?
  4. Why does Details return NotFound()?
  5. What is the purpose of the Create GET action?
  6. What is the purpose of the Create POST action?
  7. What is model binding?
  8. What does ModelState.IsValid check?
  9. What does _context.Add(product) do?
  10. What does SaveChangesAsync() do?
  11. Why redirect to Index after a successful POST?
  12. Why is Id not entered by the user?
Show suggested answers
  1. So the controller can access the configured EF Core database context.
  2. It asynchronously executes a query and returns the results as a list.
  3. Because it awaits an asynchronous database operation.
  4. When an ID is missing or no matching Product exists.
  5. To display the Create form.
  6. To receive submitted values and save a Product.
  7. The process that maps request/form values to .NET objects and action parameters.
  8. Whether model binding and configured validation rules produced a valid model state.
  9. It marks/adds the entity for insertion through the context.
  10. It persists tracked changes to the database.
  11. To follow Post/Redirect/Get and show the updated list without directly rendering the POST result.
  12. The primary key is generated by the database/EF Core configuration rather than supplied by the form.

36. Part 5 Summary

In this tutorial, you:

  • injected ApplicationDbContext into ProductsController;
  • replaced the hard-coded Product list with an EF Core query;
  • used ToListAsync() and FirstOrDefaultAsync();
  • returned 404 responses for missing Products;
  • created GET and POST Create actions;
  • built a Razor form with Tag Helpers;
  • introduced model binding and ModelState;
  • used SaveChangesAsync() to persist Products;
  • used Post/Redirect/Get after successful creation; and
  • verified the stored records directly in SQLite.
Browser ↓ ProductsController ↓ ApplicationDbContext ↓ Entity Framework Core ↓ SQLite ↓ Persistent Products
Next: Part 6 — CRUD: Edit and Delete

In Part 6, we will complete CRUD by loading existing records into an Edit form, updating entities, deleting products, handling missing records, and using SaveChangesAsync() for update and delete operations.