Back to Blog
.NETEF CoreASP.NET IdentityC#

Building a coffee shop API with .NET 10, EF Core TPT inheritance, and JWT auth

·7 min read

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:

TableContents
ProductsShared columns for every product
CoffeeBeansRoastLevel, TastingNotes, OriginId — joined via ProductId
EquipmentsBrand, EquipmentType — joined via ProductId

EF Core handles the join transparently. Querying DbSet<CoffeeBean> returns a join of Products and CoffeeBeans with no manual SQL.

CSHARP
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:

CSHARP
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.

CSHARP
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:

  1. Creates an ApplicationUser via UserManager.CreateAsync
  2. Assigns the Customer role via UserManager.AddToRoleAsync
  3. Creates a Membership record (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.

CSHARP
var token = new JwtSecurityToken(
    issuer: jwtSettings["Issuer"],
    audience: jwtSettings["Audience"],
    claims: claims,
    expires: DateTime.UtcNow.AddHours(24),
    signingCredentials: credentials
);

Two roles exist:

RoleGrantedAccess
CustomerAuto on registerAuthenticated endpoints
AdminManual via POST /api/auth/assign-roleProduct 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:

JSON
{
  "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.

CSHARP
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.

CSHARP
var subtotal = orderItems.Sum(i => i.UnitPrice * i.Quantity);
var shippingCost = subtotal >= 100 ? 0 : 10;

The full Orders API:

MethodRouteAuthDescription
POST/api/ordersBearerPlace an order, validate stock, deduct inventory
GET/api/ordersBearerList own orders, newest first
GET/api/orders/{id}BearerGet a single order by ID

Account management

ApplicationUser extends the Identity user with optional billing and contact fields:

CSHARP
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:

MethodRouteDescription
GET/api/account/profileReturn own profile and billing address
PUT/api/account/profileUpdate phone and billing address
PUT/api/account/change-passwordChange 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

ControllerBase routeEndpoints
Auth/api/authRegister, Login, Assign-role
Account/api/accountProfile (GET/PUT), Change-password
Products/api/productsList all, list by type, get by ID, create, update, delete
Orders/api/ordersCreate, list own, get by ID

Seeding and Scalar UI

On first launch, Program.cs calls two seeders:

  • RoleSeeder creates the Customer and Admin roles if they don't exist
  • DatabaseSeeder inserts 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.