ASP.NET Core MVC · Production

Complete E-Commerce Project

Everything from this track comes together in one runnable app: EF Core with SQL Server, a service layer, a session cart, validated checkout with antiforgery, an order confirmation that only the buyer can see, and an admin area behind authorization. A scripted customer walks through a real purchase.

Advanced 13 min Lesson 42 of 42Examples target .NET 9.
0/42 in ASP.NET Core MVC
On this page · 13 min · Advanced

In simple words#

The final project is opening a small online shop: catalogue, cart, checkout, admin and login — every lesson becomes one working part.

Layers
Controllers → services → EF Core → SQL Server.
Identity
Customers and admins.
Deploy
Docker or IIS with HTTPS.

Learning objectives#

  • Combine EF Core, services, session, validation and authorization in one app
  • Implement a checkout that checks stock and saves the order in one transaction
  • Protect order confirmations and the admin area
  • Verify the whole flow end to end

Explanation#

Data: ShopContext with Categories, Products, Orders and OrderLines. Order lines copy the product name and price at the time of purchase, so later price changes do not rewrite history.

Services: CartService keeps the cart (product id → quantity) in session; OrderService turns the cart into an order, checks and reduces stock, and saves everything with one SaveChangesAsync — one transaction.

Controllers stay thin: Home (catalogue projection), Cart (add, view), Checkout (validated form → service → redirect), Orders (confirmation), Admin ([Authorize(Roles = "Admin")]).

Security: a global antiforgery filter, input models, and an order confirmation that checks the order id against the buyer’s session — so nobody can read other people’s orders by changing the id in the URL.

To finish it for production: Identity for customers and admins (Identity lesson), migrations instead of EnsureCreated, output caching for the catalogue, logging, and Docker or IIS deployment.

Code example: ShopSmart: a full purchase, end to end#

C#Program.cs Illustrative
// appsettings.json
{
  "ConnectionStrings": {
    "Shop": "Server=(localdb)\\mssqllocaldb;Database=ShopSmart_Complete;Trusted_Connection=True;TrustServerCertificate=True"
  }
}


// Program.cs
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                          // keep the demo output clean
builder.Services.AddControllersWithViews(o => o.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));
builder.Services.AddDbContext<ShopContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<CartService>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o => o.LoginPath = "/Account/Login");


var app = builder.Build();
await SeedData.ResetAsync(app.Services);                                   // demo: a fresh database with sample products
app.UseRouting();
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Urls.Add("http://127.0.0.1:5124");
await app.StartAsync();


// ── A customer's visit ──
using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { BaseAddress = new Uri("http://127.0.0.1:5124") };
string Token(string html) => Regex.Match(html, "__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]+)").Groups[1].Value;
string Texts(string html, string css) => string.Join(" | ", Regex.Matches(html, 
quot;class=\"
{css}\"[^>]*>([^<]+)<").Select(m => m.Groups[1].Value));
var home = await http.GetStringAsync("/"); Console.WriteLine(
quot;Catalogue:
{Texts(home, "product")}");
foreach (var (id, quantity) in new[] { (1, 2), (2, 1) }) await http.PostAsync("/Cart/Add", new FormUrlEncodedContent( [new("productId",
quot;
{id}"), new("quantity",
quot;
{quantity}"), new("__RequestVerificationToken", Token(home))]));
Console.WriteLine(
quot;Cart:
{Texts(await http.GetStringAsync("/Cart"), "line")}");
var checkout = await http.GetStringAsync("/Checkout"); var invalid = await http.PostAsync("/Checkout", new FormUrlEncodedContent( [new("CustomerName", "Asha"), new("Email", "not-an-email"), new("__RequestVerificationToken", Token(checkout))])); Console.WriteLine(
quot;Checkout (bad email):
{(int)invalid.StatusCode} {Texts(await invalid.Content.ReadAsStringAsync(), "field-validation-error")}");
var placed = await http.PostAsync("/Checkout", new FormUrlEncodedContent( [new("CustomerName", "Asha"), new("Email", "asha@example.com"), new("__RequestVerificationToken", Token(checkout))])); Console.WriteLine(
quot;Checkout:
{(int)placed.StatusCode} -> {placed.Headers.Location}");
Console.WriteLine(
quot;Confirmation:
{Texts(await http.GetStringAsync(placed.Headers.Location!.ToString()), "confirmation")}");
Console.WriteLine(
quot;Cart after order:
{Texts(await http.GetStringAsync("/Cart"), "empty")}");
Console.WriteLine(
quot;Catalogue after order:
{Texts(await http.GetStringAsync("/"), "product")}");
using var stranger = new HttpClient { BaseAddress = http.BaseAddress }; Console.WriteLine(
quot;Another visitor opens /Orders/Confirmation/1:
{(int)(await stranger.GetAsync("/Orders/Confirmation/1")).StatusCode}");
var admin = await http.GetAsync("/Admin/Orders"); Console.WriteLine(
quot;Admin area (not signed in):
{(int)admin.StatusCode} -> {admin.Headers.Location?.AbsolutePath}");
await app.StopAsync(); await SeedData.DropAsync(app.Services); // Models/Shop.cs using System.ComponentModel.DataAnnotations; using Microsoft.EntityFrameworkCore; public class Category { public int Id { get; set; } [MaxLength(50)] public string Name { get; set; } = ""; public List<Product> Products { get; set; } = []; } public class Product { public int Id { get; set; } [MaxLength(100)] public string Name { get; set; } = ""; [Precision(10, 2)] public decimal Price { get; set; } public int Stock { get; set; } public int CategoryId { get; set; } public Category? Category { get; set; } } public class Order { public int Id { get; set; } [MaxLength(80)] public string CustomerName { get; set; } = ""; [MaxLength(120)] public string Email { get; set; } = ""; [Precision(12, 2)] public decimal Total { get; set; } public DateTime PlacedAt { get; set; } public List<OrderLine> Lines { get; set; } = []; } public class OrderLine { public int Id { get; set; } public int OrderId { get; set; } public int ProductId { get; set; } [MaxLength(100)] public string ProductName { get; set; } = ""; // copied at purchase time [Precision(10, 2)] public decimal UnitPrice { get; set; } // later price changes do not rewrite history public int Quantity { get; set; } } // Data/ShopContext.cs using Microsoft.EntityFrameworkCore; public class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options) { public DbSet<Category> Categories => Set<Category>(); public DbSet<Product> Products => Set<Product>(); public DbSet<Order> Orders => Set<Order>(); } // Data/SeedData.cs public static class SeedData { public static async Task ResetAsync(IServiceProvider services) { using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<ShopContext>(); await db.Database.EnsureDeletedAsync(); // real apps: migrations await db.Database.EnsureCreatedAsync(); var accessories = new Category { Name = "Accessories" }; db.Products.AddRange( new Product { Name = "Wireless Mouse", Price = 799m, Stock = 10, Category = accessories }, new Product { Name = "USB-C Hub", Price = 1499m, Stock = 5, Category = accessories }, new Product { Name = "Bluetooth Speaker", Price = 3299m, Stock = 3, Category = new Category { Name = "Audio" } }); await db.SaveChangesAsync(); } public static async Task DropAsync(IServiceProvider services) { using var scope = services.CreateScope(); await scope.ServiceProvider.GetRequiredService<ShopContext>().Database.EnsureDeletedAsync(); } } // Services/CartService.cs using System.Text.Json; public class CartService(IHttpContextAccessor accessor) { private ISession Session => accessor.HttpContext!.Session; public Dictionary<int, int> Get() => Session.GetString("Cart") is { } json ? JsonSerializer.Deserialize<Dictionary<int, int>>(json)! : []; public void Add(int productId, int quantity) { var cart = Get(); cart[productId] = cart.GetValueOrDefault(productId) + quantity; Session.SetString("Cart", JsonSerializer.Serialize(cart)); } public void Clear() => Session.Remove("Cart"); } // Services/OrderService.cs using Microsoft.EntityFrameworkCore; public record CheckoutResult(bool Success, int OrderId = 0, string? Error = null); public class OrderService(ShopContext db, CartService cart) { public async Task<CheckoutResult> PlaceOrderAsync(string customerName, string email) { var lines = cart.Get(); if (lines.Count == 0) return new(false, Error: "Your cart is empty."); var ids = lines.Keys.ToList(); var products = await db.Products.Where(p => ids.Contains(p.Id)).ToListAsync(); var order = new Order { CustomerName = customerName, Email = email, PlacedAt = DateTime.UtcNow }; foreach (var product in products) { var quantity = lines[product.Id]; if (product.Stock < quantity) return new(false, Error:
quot;Only
{product.Stock} x {product.Name} left.");
product.Stock -= quantity; order.Lines.Add(new OrderLine { ProductId = product.Id, ProductName = product.Name, UnitPrice = product.Price, Quantity = quantity }); } order.Total = order.Lines.Sum(l => l.UnitPrice * l.Quantity); db.Orders.Add(order); await db.SaveChangesAsync(); // order + stock in one transaction cart.Clear(); return new(true, order.Id); } } // Controllers/HomeController.cs using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; public record ProductCard(int Id, string Name, decimal Price, int Stock); public class HomeController(ShopContext db) : Controller { public async Task<IActionResult> Index() => View(await db.Products.AsNoTracking() .OrderBy(p => p.Id) .Select(p => new ProductCard(p.Id, p.Name, p.Price, p.Stock)) .ToListAsync()); } // Controllers/CartController.cs using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; public record CartLine(string Name, decimal Price, int Quantity) { public decimal Total => Price * Quantity; } public class CartController(CartService cart, ShopContext db) : Controller { [HttpPost] public IActionResult Add(int productId, int quantity = 1) { cart.Add(productId, Math.Clamp(quantity, 1, 10)); return RedirectToAction(nameof(Index)); } public async Task<IActionResult> Index() { var lines = cart.Get(); var ids = lines.Keys.ToList(); var products = await db.Products.AsNoTracking().Where(p => ids.Contains(p.Id)).ToListAsync(); return View(products.Select(p => new CartLine(p.Name, p.Price, lines[p.Id])).ToList()); } } // Controllers/CheckoutController.cs using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; public class CheckoutInput { [Required, StringLength(80)] public string CustomerName { get; set; } = ""; [Required, EmailAddress] public string Email { get; set; } = ""; } public class CheckoutController(OrderService orders) : Controller { [HttpGet] public IActionResult Index() => View(new CheckoutInput()); [HttpPost] public async Task<IActionResult> Index(CheckoutInput input) { if (!ModelState.IsValid) return View(input); var result = await orders.PlaceOrderAsync(input.CustomerName, input.Email); if (!result.Success) { ModelState.AddModelError(string.Empty, result.Error!); return View(input); } HttpContext.Session.SetInt32("LastOrderId", result.OrderId); // only this buyer may view it return RedirectToAction("Confirmation", "Orders", new { id = result.OrderId }); } } // Controllers/OrdersController.cs using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; public class OrdersController(ShopContext db) : Controller { public async Task<IActionResult> Confirmation(int id) { if (HttpContext.Session.GetInt32("LastOrderId") != id) return NotFound(); // no peeking at other orders var order = await db.Orders.AsNoTracking().Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id); return order is null ? NotFound() : View(order); } } // Controllers/AdminController.cs using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; [Authorize(Roles = "Admin")] public class AdminController(ShopContext db) : Controller { public async Task<IActionResult> Orders() => View(await db.Orders.AsNoTracking().OrderByDescending(o => o.PlacedAt).ToListAsync()); } @* Views/Home/Index.cshtml *@ @model List<ProductCard> @foreach (var p in Model) { <p class="product">@p.Name - Rs. @p.Price.ToString("0") (@p.Stock in stock)</p> <form asp-controller="Cart" asp-action="Add" method="post"> <input type="hidden" name="productId" value="@p.Id" /> <button>Add to cart</button> </form> } @* Views/Cart/Index.cshtml *@ @model List<CartLine> @if (Model.Count == 0) { <p class="empty">Your cart is empty</p> } @foreach (var line in Model) { <p class="line">@line.Name x@(line.Quantity) = Rs. @line.Total.ToString("0")</p> } @* Views/Checkout/Index.cshtml *@ @model CheckoutInput <form asp-action="Index" method="post"> <div asp-validation-summary="ModelOnly"></div> <input asp-for="CustomerName" /> <span asp-validation-for="CustomerName"></span> <input asp-for="Email" /> <span asp-validation-for="Email"></span> <button>Place order</button> </form> @* Views/Orders/Confirmation.cshtml *@ @model Order <p class="confirmation">Thank you @Model.CustomerName! Order #@Model.Id: @(Model.Lines.Sum(l => l.Quantity)) items, total Rs. @Model.Total.ToString("0")</p> @* Views/Admin/Orders.cshtml *@ @model List<Order> <ul>@foreach (var o in Model) {<li>#@o.Id @o.CustomerName Rs. @o.Total</li>}</ul>
Expected output
Catalogue: Wireless Mouse - Rs. 799 (10 in stock) | USB-C Hub - Rs. 1499 (5 in stock) | Bluetooth Speaker - Rs. 3299 (3 in stock)
Cart: Wireless Mouse x2 = Rs. 1598 | USB-C Hub x1 = Rs. 1499
Checkout (bad email): 200 The Email field is not a valid e-mail address.
Checkout: 302 -> /Orders/Confirmation/1
Confirmation: Thank you Asha! Order #1: 3 items, total Rs. 3097
Cart after order: Your cart is empty
Catalogue after order: Wireless Mouse - Rs. 799 (8 in stock) | USB-C Hub - Rs. 1499 (4 in stock) | Bluetooth Speaker - Rs. 3299 (3 in stock)
Another visitor opens /Orders/Confirmation/1: 404
Admin area (not signed in): 302 -> /Account/Login

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. The catalogue is a lightweight projection; each product card has an Add-to-cart form with an antiforgery token.
  2. CartService stores product ids and quantities in session; the cart page joins them with current prices.
  3. An invalid email is caught by model validation before the service runs; the valid checkout places the order, reduces stock and clears the cart in one transaction.
  4. The confirmation page only opens for the buyer’s own order — another visitor gets 404 for the same URL.
  5. The admin area redirects anonymous visitors to the login page; stock in the catalogue has gone down (10 → 8 and 5 → 4).

Real-world use case#

This is the skeleton of a real small shop. Add Identity accounts, migrations, payment (Stripe or Razorpay) in OrderService, emails, output caching for the catalogue, and deploy it with Docker or IIS from the previous lessons.

Common mistakes#

Trusting prices from the form or cart

Always re-read prices from the database at checkout. The cart only stores ids and quantities.

Confirmation pages by id only

/Orders/Confirmation/1, /2, /3… would leak other customers’ names and orders (an IDOR bug). Check ownership.

Saving stock and order separately

Two SaveChanges calls can leave stock reduced without an order if the second fails. Save once.

Best practices#

  • Thin controllers, rules in services
  • One transaction per checkout
  • Ownership checks on every personal page
  • Snapshot product name and price in order lines

Remember this#

Catalogue → Cart (session) → Checkout (validate) → OrderService (one transaction) → Confirmation.

Re-read prices on the server; cart holds only ids and quantities.

Check that the order belongs to the visitor.

Practice exercise#

Your task: Make OrderService reject an order when a product in the cart no longer exists (deleted), returning “Some items are no longer available.”.

Need a hint?
Compare products.Count with lines.Count after loading them.
Show solution
C# Illustrative
// Services/StockCheck.cs
public record CheckoutResult(bool Success, int OrderId = 0, string? Error = null);


public static class StockCheck
{
    public static CheckoutResult? EnsureAllAvailable(Dictionary<int, int> cartLines, IReadOnlyCollection<int> foundProductIds) =>
        cartLines.Keys.All(foundProductIds.Contains)
            ? null
            : new CheckoutResult(false, Error: "Some items are no longer available.");
}


// In OrderService.PlaceOrderAsync, after loading products:
// if (StockCheck.EnsureAllAvailable(lines, products.Select(p => p.Id).ToList()) is { } problem) return problem;

Mini quiz#

Complete E-Commerce Project quizMultiple choice Question 1 of 3

Why do order lines store ProductName and UnitPrice?

Score so far: 0/0

Want more? Practise all ASP.NET Core MVC MCQs · ASP.NET Core MVC interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.