ASP.NET CORE MVC - DB CRUD: Create and Read
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.
By the end of this tutorial, users will be able to create products and retrieve product information from the SQLite database.
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.
- Verify the Part 4 database
- Inject
ApplicationDbContextinto the controller - Replace the hard-coded Index action
- Read products asynchronously with
ToListAsync() - Build a database-backed Details action
- Create a Create GET action and form
- Understand model binding
- Create the POST action
- Save a product with
SaveChangesAsync() - Use the Post/Redirect/Get pattern
- Verify records in SQLite
- 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:
We will now connect them:
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.
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()
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.
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.
11. Add the Create GET Action
Add this action to ProductsController:
[HttpGet]
public IActionResult Create()
{
return View();
}
The GET action displays an empty form.
12. Create the Create View Before Testing It
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 Helper | Purpose |
|---|---|
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.
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
| Action | Purpose |
|---|---|
[HttpGet] Create() | Displays the empty form. |
[HttpPost] Create(Product product) | Receives submitted form values and attempts to save the new Product. |
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:
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].
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();
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:
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.
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:
| Name | Laptop |
|---|---|
| Description | 14-inch business laptop |
| Price | 3299.90 |
| Quantity | 8 |
Click Create.
The application should redirect to /Products, where Laptop now appears in the table.
29. Add More Products
Create two more records:
| Name | Description | Price | Quantity |
|---|---|---|---|
| Wireless Mouse | Bluetooth wireless mouse | 89.90 | 25 |
| USB-C Hub | Multi-port USB-C hub | 149.00 | 15 |
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:
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
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.cs33. Troubleshooting
The view 'Create' was not found
Check the exact case-sensitive path:
ls -l Views/Products/Create.cshtmlThe file must be Views/Products/Create.cshtml.
SQLite Error: no such table: Products
Check the migration state:
dotnet ef migrations list
dotnet ef database updateAlso 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:
| Name | Mechanical Keyboard |
|---|---|
| Description | USB mechanical keyboard |
| Price | 249.90 |
| Quantity | 12 |
Then:
- confirm that it appears on the Index page;
- open its Details page;
- stop and restart the application;
- confirm that the Product still appears; and
- query the record directly with SQLite.
35. Knowledge Check
- Why is
ApplicationDbContextinjected intoProductsController? - What does
ToListAsync()do? - Why is the Index action marked
async? - Why does Details return
NotFound()? - What is the purpose of the Create GET action?
- What is the purpose of the Create POST action?
- What is model binding?
- What does
ModelState.IsValidcheck? - What does
_context.Add(product)do? - What does
SaveChangesAsync()do? - Why redirect to Index after a successful POST?
- Why is
Idnot entered by the user?
Show suggested answers
- So the controller can access the configured EF Core database context.
- It asynchronously executes a query and returns the results as a list.
- Because it awaits an asynchronous database operation.
- When an ID is missing or no matching Product exists.
- To display the Create form.
- To receive submitted values and save a Product.
- The process that maps request/form values to .NET objects and action parameters.
- Whether model binding and configured validation rules produced a valid model state.
- It marks/adds the entity for insertion through the context.
- It persists tracked changes to the database.
- To follow Post/Redirect/Get and show the updated list without directly rendering the POST result.
- 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
ApplicationDbContextintoProductsController; - replaced the hard-coded Product list with an EF Core query;
- used
ToListAsync()andFirstOrDefaultAsync(); - 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.
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.