ASP.NET CORE MVC - DB CRUD: Edit and Delete
Completing CRUD with Edit and Delete
Complete the Product Management System's basic CRUD workflow by editing existing records, saving changes to SQLite, confirming deletions, removing records, and handling missing products safely.
By the end of this tutorial, the application will support Create, Read, Update and Delete operations for Products stored in SQLite.
Part 5 connected ProductsController to ApplicationDbContext, added database-backed Index and Details pages, and created new Products with a POST form.
- Verify the Part 5 application
- Add Edit and Delete links to the Index view
- Create the Edit GET action
- Create the Edit Razor view
- Create the Edit POST action
- Understand entity updates
- Handle basic concurrency
- Create a Delete confirmation page
- Create the Delete POST action
- Verify changes in SQLite
- Review the complete CRUD flow
- Troubleshoot common problems
1. Open and Verify the Existing Project
cd ~/aspnet-mvc-tutorial/ProductManagement
pwd
The expected path is:
/home/xubuntu/aspnet-mvc-tutorial/ProductManagement
Confirm the Part 5 files:
ls Controllers/ProductsController.cs
ls Views/Products/Index.cshtml
ls Views/Products/Details.cshtml
ls Views/Products/Create.cshtml
ls ProductManagement.db
Open the project:
code .
Build before continuing:
dotnet build
2. Review the CRUD Workflow
CRUD represents four fundamental data operations:
Parts 4 and 5 implemented the database, Create and Read. Part 6 adds Update and Delete.
3. Add Edit and Delete Links to Index
Open:
Views/Products/Index.cshtml
Replace the existing action cell with:
<td>
<a asp-action="Details" asp-route-id="@product.Id">Details</a>
|
<a asp-action="Edit" asp-route-id="@product.Id">Edit</a>
|
<a asp-action="Delete" asp-route-id="@product.Id">Delete</a>
</td>
4. Create the Edit GET Action
Add this action to ProductsController:
[HttpGet]
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products.FindAsync(id);
if (product == null)
{
return NotFound();
}
return View(product);
}
5. Understand FindAsync()
FindAsync(id) looks for an entity with the matching primary key. If no Product exists, the action returns NotFound().
6. Create the Edit View Before Testing
Do not browse to /Products/Edit/1 before Views/Products/Edit.cshtml exists.
touch Views/Products/Edit.cshtml
code Views/Products/Edit.cshtml
7. Build the Edit Form
@model Product
@{
ViewData["Title"] = "Edit Product";
}
<h1>Edit Product</h1>
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<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">Save Changes</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>
8. Why the Hidden Id Is Needed
<input type="hidden" asp-for="Id" />
The ID is not edited by the user, but the POST action needs it to identify the Product being updated.
9. Create the Edit POST Action
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, Product product)
{
if (id != product.Id)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
_context.Update(product);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!ProductExists(product.Id))
{
return NotFound();
}
throw;
}
return RedirectToAction(nameof(Index));
}
return View(product);
}
10. Add ProductExists()
private bool ProductExists(int id)
{
return _context.Products.Any(p => p.Id == id);
}
11. Update the Entity
_context.Update(product) marks the entity as modified. SaveChangesAsync() persists the changes to SQLite.
12. Basic Concurrency Handling
The action catches DbUpdateConcurrencyException. In this introductory implementation, if the Product no longer exists we return 404; otherwise the exception is rethrown.
Full optimistic concurrency with concurrency tokens and conflict-resolution UI is beyond this basic CRUD part.
13. Test and Verify Edit
dotnet run
Open /Products, click Edit, modify a Product, and save it. Then verify directly in SQLite:
sqlite3 ProductManagement.db
SELECT Id, Name, Price, Quantity FROM Products;
.quit
14. Create the Delete GET Action
[HttpGet]
public async Task<IActionResult> Delete(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);
}
This GET action only loads the Product for confirmation. It does not delete anything.
15. Create Delete.cshtml
touch Views/Products/Delete.cshtml
code Views/Products/Delete.cshtml
@model Product
@{
ViewData["Title"] = "Delete Product";
}
<h1>Delete Product</h1>
<p>Are you sure you want to delete this product?</p>
<dl class="row">
<dt class="col-sm-2">ID</dt>
<dd class="col-sm-10">@Model.Id</dd>
<dt class="col-sm-2">Name</dt>
<dd class="col-sm-10">@Model.Name</dd>
<dt class="col-sm-2">Price</dt>
<dd class="col-sm-10">RM @Model.Price.ToString("N2")</dd>
<dt class="col-sm-2">Quantity</dt>
<dd class="col-sm-10">@Model.Quantity</dd>
</dl>
<form asp-action="Delete" method="post">
<input type="hidden" asp-for="Id" />
<button type="submit" class="btn btn-danger">Delete</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>
16. Create the Delete POST Action
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var product = await _context.Products.FindAsync(id);
if (product != null)
{
_context.Products.Remove(product);
await _context.SaveChangesAsync();
}
return RedirectToAction(nameof(Index));
}
17. Why ActionName("Delete") Is Used
The C# method is called DeleteConfirmed, while [ActionName("Delete")] lets the form post to the logical MVC action name Delete.
18. Why GET Must Not Delete
The GET action displays a confirmation page. The destructive operation is performed by POST, not by merely visiting a URL.
19. Test and Verify Delete
Run the application and click Delete beside a Product. Confirm that Cancel leaves the record untouched and Delete removes it.
Verify in SQLite:
sqlite3 ProductManagement.db
SELECT Id, Name, Price, Quantity FROM Products;
.quit
20. Completed ProductsController
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);
}
[HttpGet]
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products.FindAsync(id);
if (product == null)
{
return NotFound();
}
return View(product);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, Product product)
{
if (id != product.Id)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
_context.Update(product);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!ProductExists(product.Id))
{
return NotFound();
}
throw;
}
return RedirectToAction(nameof(Index));
}
return View(product);
}
[HttpGet]
public async Task<IActionResult> Delete(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);
}
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var product = await _context.Products.FindAsync(id);
if (product != null)
{
_context.Products.Remove(product);
await _context.SaveChangesAsync();
}
return RedirectToAction(nameof(Index));
}
private bool ProductExists(int id)
{
return _context.Products.Any(p => p.Id == id);
}
}
21. Complete CRUD Request Flow
22. Current Product Views
Views/Products/
├── Index.cshtml
├── Details.cshtml
├── Create.cshtml
├── Edit.cshtml
└── Delete.cshtml23. Troubleshooting
The Edit view was not found
ls -l Views/Products/Edit.cshtmlCheck the exact case-sensitive filename.
The Delete view was not found
ls -l Views/Products/Delete.cshtmlDbUpdateConcurrencyException is not recognised
Confirm the controller contains:
using Microsoft.EntityFrameworkCore;Edit returns 404
Confirm that the route ID matches the Product ID and that the record still exists.
Edit does not persist
Confirm the POST action calls _context.Update(product) and SaveChangesAsync().
Delete confirmation works but the row remains
Confirm the POST action calls Remove() and then SaveChangesAsync().
24. Hands-On Exercise
- Create a Product named External SSD.
- Set its price to 399.90 and quantity to 10.
- Open its Details page.
- Edit the quantity to 15.
- Verify the update directly in SQLite.
- Delete the Product.
- Verify in SQLite that the row no longer exists.
25. Knowledge Check
- What does CRUD stand for?
- What is the purpose of the Edit GET action?
- Why does the Edit form contain a hidden Id?
- Why compare the route ID with
product.Id? - What does
_context.Update(product)do? - What does
SaveChangesAsync()do after an update? - What is the purpose of the Delete GET action?
- Why should a GET request not directly delete a Product?
- What does
Remove(product)do? - Why use anti-forgery protection on POST actions?
Show suggested answers
- Create, Read, Update and Delete.
- It loads an existing Product and displays it in the Edit form.
- So the POST request identifies the Product being updated.
- To ensure the route and submitted model refer to the same record.
- It marks the entity as modified.
- It writes the update to SQLite.
- It displays the Product and asks the user to confirm deletion.
- GET should retrieve/display information rather than perform destructive state changes.
- It marks the entity for deletion.
- To protect form submissions against cross-site request forgery.
26. Part 6 Summary
- added Edit and Delete links;
- created Edit GET and POST actions;
- loaded Products with
FindAsync(); - updated entities with
Update(); - saved updates with
SaveChangesAsync(); - introduced basic concurrency handling;
- created a Delete confirmation page;
- created a POST delete action;
- removed entities with
Remove(); and - completed the full CRUD workflow.
In Part 7, we will add validation rules to the Product model using data annotations such as [Required], [StringLength] and [Range], then display server-side and client-side validation messages in the Create and Edit forms.