Building a coffee shop API with .NET 10, EF Core TPT inheritance, and JWT auth
A walkthrough of the BeanWorks coffee shop backend: TPT inheritance for products, JWT auth with roles, order snapshots, server-side price enforcement, and account management.
Entity-relationship diagram
The full data model covers users, memberships, a product catalogue with two product subtypes, and orders.
TPT inheritance for products
The product catalogue has two concrete types: CoffeeBean and Equipment. They share common fields (name, SKU, price, stock, image) but differ in their type-specific attributes.
TPT (Table-Per-Type) maps this to three tables:
| Table | Contents |
|---|---|
Products | Shared columns for every product |
CoffeeBeans | RoastLevel, TastingNotes, OriginId — joined via ProductId |
Equipments | Brand, EquipmentType — joined via ProductId |
EF Core handles the join transparently. Querying DbSet<CoffeeBean> returns a join of Products and CoffeeBeans with no manual SQL.
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string SKU { get; set; } = string.Empty;
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public string? ImageUrl { get; set; }
public DateTime CreatedAt { get; set; }
}
public class CoffeeBean : Product
{
public Guid OriginId { get; set; }
public Origin Origin { get; set; } = null!;
public string RoastLevel { get; set; } = string.Empty;
public string TastingNotes { get; set; } = string.Empty;
}
public class Equipment : Product
{
public string Brand { get; set; } = string.Empty;
public string EquipmentType { get; set; } = string.Empty;
}
In CoffeeShopDbContext, TPT is declared by calling ToTable on each subtype:
modelBuilder.Entity<CoffeeBean>().ToTable("CoffeeBeans");
modelBuilder.Entity<Equipment>().ToTable("Equipments");
The Origin entity
CoffeeBean has a many-to-one relationship with Origin. This lets you store country and region separately from the bean, making it easy to filter or group by origin without parsing strings.
public class Origin
{
public Guid Id { get; set; }
public string Country { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
public ICollection<CoffeeBean> CoffeeBeans { get; set; } = [];
}
The seeder creates a set of Origins first, then references them when creating beans.
Auth and JWT
Authentication is built on ASP.NET Core Identity with a JWT Bearer token layer on top.
On registration, the API:
- Creates an
ApplicationUserviaUserManager.CreateAsync - Assigns the
Customerrole viaUserManager.AddToRoleAsync - Creates a
Membershiprecord (Bronze tier, 0 points)
On login, the API issues a signed JWT containing the user's ID, email, and roles. Protected endpoints read identity from the token.
var token = new JwtSecurityToken(
issuer: jwtSettings["Issuer"],
audience: jwtSettings["Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(24),
signingCredentials: credentials
);
Two roles exist:
| Role | Granted | Access |
|---|---|---|
Customer | Auto on register | Authenticated endpoints |
Admin | Manual via POST /api/auth/assign-role | Product management |
Orders: server-side price enforcement
The order flow is designed so the client never controls pricing. The POST /api/orders body only accepts product IDs and quantities:
{
"items": [
{ "productId": "...", "quantity": 2 }
],
"shipping": { ... },
"payment": { "cardNumber": "4111111111111111" }
}
The controller fetches the current price from the database for every product, validates stock, deducts inventory, and computes totals server-side. The client has no way to pass a price.
var product = await _context.Products.FindAsync(itemReq.ProductId)
?? throw new Exception($"Product {itemReq.ProductId} not found.");
if (product.StockQuantity < itemReq.Quantity)
throw new Exception($"{product.Name} has insufficient stock.");
product.StockQuantity -= itemReq.Quantity;
orderItems.Add(new OrderItem
{
ProductId = product.Id,
ProductName = product.Name,
ProductSku = product.SKU,
ProductImageUrl = product.ImageUrl,
ProductType = product.GetType().Name,
UnitPrice = product.Price,
Quantity = itemReq.Quantity,
});
OrderItem stores a snapshot of all product fields at the time of purchase: name, SKU, image URL, price, and type. The ProductId is stored as a logical reference only, with no enforced foreign key. This ensures historical orders stay accurate even if a product is later renamed, repriced, or deleted.
Shipping cost is computed from the subtotal: free on orders over $100, otherwise $10 flat.
var subtotal = orderItems.Sum(i => i.UnitPrice * i.Quantity);
var shippingCost = subtotal >= 100 ? 0 : 10;
The full Orders API:
| Method | Route | Auth | Description |
|---|---|---|---|
POST | /api/orders | Bearer | Place an order, validate stock, deduct inventory |
GET | /api/orders | Bearer | List own orders, newest first |
GET | /api/orders/{id} | Bearer | Get a single order by ID |
Account management
ApplicationUser extends the Identity user with optional billing and contact fields:
public class ApplicationUser : IdentityUser
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string? Phone { get; set; }
public string? BillingFirstName { get; set; }
public string? BillingLastName { get; set; }
public string? BillingAddress { get; set; }
public string? BillingCity { get; set; }
public string? BillingState { get; set; }
public string? BillingPostalCode { get; set; }
public string? BillingCountry { get; set; }
public DateTime CreatedAt { get; set; }
}
The AccountController exposes three endpoints:
| Method | Route | Description |
|---|---|---|
GET | /api/account/profile | Return own profile and billing address |
PUT | /api/account/profile | Update phone and billing address |
PUT | /api/account/change-password | Change password (requires current password) |
The frontend checkout page calls GET /api/account/profile on load and pre-fills the shipping form with the saved billing address, so returning users don't have to retype it.
API surface summary
| Controller | Base route | Endpoints |
|---|---|---|
| Auth | /api/auth | Register, Login, Assign-role |
| Account | /api/account | Profile (GET/PUT), Change-password |
| Products | /api/products | List all, list by type, get by ID, create, update, delete |
| Orders | /api/orders | Create, list own, get by ID |
Seeding and Scalar UI
On first launch, Program.cs calls two seeders:
RoleSeedercreates theCustomerandAdminroles if they don't existDatabaseSeederinserts a set of Origins and sample CoffeeBeans and Equipment products
The API is documented with both Swagger and Scalar. Scalar is the preferred explorer because it has a cleaner UI for testing bearer-token endpoints. It's available at /scalar/v1 while the server is running.