ASP.NET CORE MVC - DB CRUD: Edit and Delete

ASP.NET CORE MVC TUTORIAL SERIES · PART 6

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.

Objective

By the end of this tutorial, the application will support Create, Read, Update and Delete operations for Products stored in SQLite.

Starting point

Part 5 connected ProductsController to ApplicationDbContext, added database-backed Index and Details pages, and created new Products with a POST form.

In this tutorial
  1. Verify the Part 5 application
  2. Add Edit and Delete links to the Index view
  3. Create the Edit GET action
  4. Create the Edit Razor view
  5. Create the Edit POST action
  6. Understand entity updates
  7. Handle basic concurrency
  8. Create a Delete confirmation page
  9. Create the Delete POST action
  10. Verify changes in SQLite
  11. Review the complete CRUD flow
  12. 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:

Create Read Update Delete

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().

GET /Products/Edit/2 ↓ Edit(2) ↓ FindAsync(2) ↓ Product found? ┌────┴────┐ Yes No ↓ ↓ View NotFound()

6. Create the Edit View Before Testing

Create the view first

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.

Edit form ↓ POST Edit ↓ _context.Update(product) ↓ SaveChangesAsync() ↓ SQL UPDATE ↓ 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.

Scope

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

Important HTTP design rule

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

CREATE GET Create → Form POST Create → INSERT READ GET Index → SELECT many GET Details → SELECT one UPDATE GET Edit → Load record POST Edit → UPDATE DELETE GET Delete → Confirmation POST Delete → DELETE

22. Current Product Views

Views/Products/
├── Index.cshtml
├── Details.cshtml
├── Create.cshtml
├── Edit.cshtml
└── Delete.cshtml

23. Troubleshooting

The Edit view was not found
ls -l Views/Products/Edit.cshtml

Check the exact case-sensitive filename.

The Delete view was not found
ls -l Views/Products/Delete.cshtml
DbUpdateConcurrencyException 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

  1. Create a Product named External SSD.
  2. Set its price to 399.90 and quantity to 10.
  3. Open its Details page.
  4. Edit the quantity to 15.
  5. Verify the update directly in SQLite.
  6. Delete the Product.
  7. Verify in SQLite that the row no longer exists.

25. Knowledge Check

  1. What does CRUD stand for?
  2. What is the purpose of the Edit GET action?
  3. Why does the Edit form contain a hidden Id?
  4. Why compare the route ID with product.Id?
  5. What does _context.Update(product) do?
  6. What does SaveChangesAsync() do after an update?
  7. What is the purpose of the Delete GET action?
  8. Why should a GET request not directly delete a Product?
  9. What does Remove(product) do?
  10. Why use anti-forgery protection on POST actions?
Show suggested answers
  1. Create, Read, Update and Delete.
  2. It loads an existing Product and displays it in the Edit form.
  3. So the POST request identifies the Product being updated.
  4. To ensure the route and submitted model refer to the same record.
  5. It marks the entity as modified.
  6. It writes the update to SQLite.
  7. It displays the Product and asks the user to confirm deletion.
  8. GET should retrieve/display information rather than perform destructive state changes.
  9. It marks the entity for deletion.
  10. 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.
Create ↓ Read ↓ Update ↓ Delete ↓ Complete Product CRUD
Next: Part 7 — Model Validation

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.