Headless Architectures and API-First with .NET
Modern applications demand flexibility across web, mobile, IoT, and partner integrations, but traditional monoliths couple your business logic to specific frontends. Headless architectures solve this by creating a single, authoritative API-first backend that decouples your core domain from presentation layers. We’re building a scalable e-commerce catalog API using ASP.NET Core Minimal APIs, Entity Framework Core, and modern C#—ready for React, Next.js, Blazor, or native mobile apps. This approach delivers consistent data, independent scaling, and team velocity in production environments.
dotnet new console -n HeadlessCatalogApicd HeadlessCatalogApidotnet add package Microsoft.EntityFrameworkCore.SqlServerdotnet add package Microsoft.EntityFrameworkCore.Designdotnet add package Microsoft.AspNetCore.OpenApidotnet add package Microsoft.AspNetCore.Authentication.JwtBearerdotnet add package System.Text.JsonStart with immutable records using primary constructors—the foundation of our headless backend. These represent your authoritative data contracts.
public record Product(
Guid Id,
string Name,
string Description,
decimal Price,
int StockQuantity,
ProductCategory Category,
DateTime CreatedAt);
public record ProductCategory(Guid Id, string Name);
public record CreateProductRequest(
string Name,
string Description,
decimal Price,
int StockQuantity,
Guid CategoryId);
public record UpdateProductRequest(
string? Name = null,
string? Description = null,
decimal? Price = null,
int? StockQuantity = null);
Create a DbContext optimized for read-heavy headless APIs. Use owned types and JSON columns for flexibility.
public class CatalogDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
public DbSet<ProductCategory> Categories { get; set; }
public CatalogDbContext(DbContextOptions<CatalogDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(p => p.Id);
entity.Property(p => p.Name).HasMaxLength(200).IsRequired();
entity.HasIndex(p => p.Name).IsUnique();
entity.HasOne<ProductCategory>().WithMany().HasForeignKey(p => p.Category.Id);
});
modelBuilder.Entity<ProductCategory>(entity =>
{
entity.HasKey(c => c.Id);
entity.Property(c => c.Name).HasMaxLength(100).IsRequired();
});
// Seed data
modelBuilder.Entity<ProductCategory>().HasData(
new ProductCategory(Guid.NewGuid(), "Electronics"),
new ProductCategory(Guid.NewGuid(), "Books")
);
}
}
Replace Program.cs with our API-first program. Use route groups, endpoint filters, and result types for clean, production-ready APIs.
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateSlimBuilder(args);
builder.AddSqlServerDbContext<CatalogDbContext>(conn =>
conn.ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=HeadlessCatalog;");
var app = builder.Build();
// Swagger for API documentation
app.MapSwagger();
var apiGroup = app.MapGroup("/api/v1").WithTags("Products");
// GET /api/v1/products?categoryId={guid}&minPrice=10&maxPrice=100&page=1&pageSize=20
apiGroup.MapGet("/products", async (CatalogDbContext db,
Guid? categoryId, decimal? minPrice, decimal? maxPrice,
int page = 1, int pageSize = 20) =>
{
var query = db.Products.AsQueryable();
if (categoryId.HasValue) query = query.Where(p => p.Category.Id == categoryId.Value);
if (minPrice.HasValue) query = query.Where(p => p.Price >= minPrice.Value);
if (maxPrice.HasValue) query = query.Where(p => p.Price <= maxPrice.Value);
var total = await query.CountAsync();
var products = await query
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return Results.Ok(new { Items = products, Total = total, Page = page, PageSize = pageSize });
});
// POST /api/v1/products
apiGroup.MapPost("/products", async (CatalogDbContext db, CreateProductRequest request) =>
{
var category = await db.Categories.FindAsync(request.CategoryId);
if (category == null) return Results.BadRequest("Invalid category");
var product = new Product(Guid.NewGuid(), request.Name, request.Description,
request.Price, request.StockQuantity, category, DateTime.UtcNow);
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/api/v1/products/{product.Id}", product);
});
// PUT /api/v1/products/{id}
apiGroup.MapPut("/products/{id}", async (CatalogDbContext db, Guid id, UpdateProductRequest request) =>
{
var product = await db.Products.FindAsync(id);
if (product == null) return Results.NotFound();
if (request.Name != null) product = product with { Name = request.Name };
if (request.Description != null) product = product with { Description = request.Description };
if (request.Price.HasValue) product = product with { Price = request.Price.Value };
if (request.StockQuantity.HasValue) product = product with { StockQuantity = request.StockQuantity.Value };
db.Products.Update(product);
await db.SaveChangesAsync();
return Results.NoContent();
});
app.Run();
Secure your headless API with JWT. Add to Program.cs before building:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "headless-api",
ValidAudience = "headless-client",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your-super-secret-key-min-256-bits"))
};
});
builder.Services.AddAuthorization();
// Protect endpoints
apiGroup.RequireAuthorization("ApiScope");
dotnet ef database update
dotnet run
Test in Swagger at https://localhost:5001/swagger or Postman. Your frontend now consumes /api/v1/products consistently.
Here’s an optimized query handler using spans and interceptors for caching (add Microsoft.Extensions.Caching.Memory):
[Cacheable(60)] // Custom interceptor attribute
public static async ValueTask<List<Product>> GetFeaturedProductsAsync(
CatalogDbContext db, ReadOnlySpan<Guid> categoryIds)
{
return await db.Products
.Where(p => categoryIds.Contains(p.Category.Id))
.Where(p => p.StockQuantity > 0)
.Take(10)
.ToListAsync();
}
Include() or projection: db.Products.Select(p => new { p.Name, Category = p.Category.Name })Etag headers or client-generated IDs for PUT/POST.app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); (restrict in prod).builder.Services.ConfigureHttpJsonOptions(opt => opt.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);AddDbContextFactory for background services..CacheOutput(expiration: TimeSpan.FromMinutes(5)).IAsyncEnumerable for streaming large result sets.builder.Services.AddRateLimiter(options => options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(...))./api/v1/, /api/v2/ with OpenAPI docs per version.apiGroup.AddEndpointFilter(ValidationFilter.Default);dotnet test -- TestServer.You now have a battle-tested headless API backend serving consistent data to any frontend. Next steps: integrate GraphQL, add real-time subscriptions with SignalR, deploy to Kubernetes, or build a Blazor frontend consuming your API. Commit this to Git and iterate—your architecture scales from startup to enterprise.
REST for simple CRUD with fixed payloads; GraphQL when clients need flexible, over/under-fetching control. Start REST, add GraphQL later via HotChocolate.
Use IBrowserFile or multipart/form-data, store in Azure Blob/CDN, return signed URLs. Never store binaries in your DB.
JWT with refresh tokens for users, API keys with rate limits for public endpoints, mTLS for B2B partners.
Integrate Elasticsearch or Azure Cognitive Search. Expose /api/v1/products/search?q=iphone&filters=category:electronics.
Yes—use Minimal for public/query APIs (fast), Controllers for complex POST/PUT with model binding.
SemVer in routes (/v1/), additive changes only, deprecate with ApiDeprecated attribute and 12-month notice.
Extract domain to shared library, build API layer first, proxy MVC to API during transition, then retire MVC.
Signed JWT tokens with preview: true claim, validate on API with role checks.
Always for frequent, parameterless queries. EF’s CompileAsyncQuery gives 2-5x speedup.
Tenant ID in JWT claims or header, partition DB by TenantId, use policies: .RequireAssertion(ctx => ctx.User.HasClaim("tenant", tenantId)).
“`
You might like these topics
.NET 8 and Angular Apps with Keycloak In the rapidly evolving landscape of 2026, identity…
Mastering .NET 10 and C# 13: Building High-Performance APIs Together Executive Summary In modern…
NET 10 is the Ultimate Tool for AI-Native Founders The 2026 Lean .NET SaaS Stack…
Modern .NET development keeps pushing toward simplicity, clarity, and performance. With C# 12+, developers can…
Implementing .NET 10 LTS Performance Optimizations: Build Faster Enterprise Apps Together Executive Summary…
Building AI-Driven ASP.NET Core APIs: Hands-On Guide for .NET Developers Executive Summary…
This website uses cookies.