.NET & C# Development · Lesson 2 of 92
Project: Scaffold OrderFlow from Zero to Running API
What We're Building
Throughout this course we'll build OrderFlow — a B2B order management API that a real company could use in production. By the end you'll have:
- A multi-project solution with clean separation of concerns
- REST endpoints for orders, products, and customers
- JWT authentication with refresh tokens
- CQRS with domain events
- Redis caching and real-time SignalR notifications
- Docker deployment with a GitHub Actions pipeline
Every lesson adds a real feature to this one project. Nothing is throwaway.
The Domain
OrderFlow
├── Customers — companies that place orders
├── Products — items with SKUs and stock levels
├── Orders — header + lines, with a state machine
└── Inventory — stock reservations and movementsOrder states:
Draft → Confirmed → Processing → Shipped → Delivered
↘ CancelledSolution Structure
Bash
dotnet new sln -n OrderFlow
cd OrderFlow
# Core business logic — no framework dependencies
dotnet new classlib -n OrderFlow.Domain
dotnet new classlib -n OrderFlow.Application
# Infrastructure — EF Core, Redis, email, external services
dotnet new classlib -n OrderFlow.Infrastructure
# API — the entry point
dotnet new webapi -n OrderFlow.Api --use-minimal-api
# Tests
dotnet new xunit -n OrderFlow.Tests.Unit
dotnet new xunit -n OrderFlow.Tests.Integration
# Add all to solution
dotnet sln add OrderFlow.Domain OrderFlow.Application \
OrderFlow.Infrastructure OrderFlow.Api \
OrderFlow.Tests.Unit OrderFlow.Tests.Integration
# Project references (dependency rule: always inward)
dotnet add OrderFlow.Application reference OrderFlow.Domain
dotnet add OrderFlow.Infrastructure reference OrderFlow.Application
dotnet add OrderFlow.Api reference OrderFlow.Application OrderFlow.Infrastructure
dotnet add OrderFlow.Tests.Unit reference OrderFlow.Application OrderFlow.Domain
dotnet add OrderFlow.Tests.Integration reference OrderFlow.Api OrderFlow.InfrastructureThe dependency rule: inner layers never reference outer layers.
Domain ← no dependencies
Application ← Domain only
Infrastructure ← Application + Domain
Api ← Application + InfrastructureDomain Layer — Pure C#
The Domain layer has zero NuGet packages. It's plain C# classes representing the business.
C#
// OrderFlow.Domain/Entities/Order.cs
namespace OrderFlow.Domain.Entities;
public class Order
{
public Guid Id { get; private set; } = Guid.NewGuid();
public string OrderNumber { get; private set; } = default!;
public Guid CustomerId { get; private set; }
public Customer Customer { get; private set; } = default!;
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public decimal Total => _lines.Sum(l => l.LineTotal);
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
public DateTime? ConfirmedAt { get; private set; }
// Private constructor — use factory method
private Order() { }
public static Order Create(Customer customer, string orderNumber)
{
ArgumentNullException.ThrowIfNull(customer);
ArgumentException.ThrowIfNullOrWhiteSpace(orderNumber);
return new Order
{
Customer = customer,
CustomerId = customer.Id,
OrderNumber = orderNumber,
};
}
public void AddLine(Product product, int quantity)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify a confirmed order.");
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
var existing = _lines.FirstOrDefault(l => l.ProductId == product.Id);
if (existing is not null)
existing.IncreaseQuantity(quantity);
else
_lines.Add(OrderLine.Create(Id, product, quantity));
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException($"Order is {Status}, not Draft.");
if (_lines.Count == 0)
throw new InvalidOperationException("Cannot confirm an empty order.");
Status = OrderStatus.Confirmed;
ConfirmedAt = DateTime.UtcNow;
}
public void Cancel()
{
if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
throw new InvalidOperationException("Cannot cancel a shipped order.");
Status = OrderStatus.Cancelled;
}
}
public enum OrderStatus { Draft, Confirmed, Processing, Shipped, Delivered, Cancelled }C#
// OrderFlow.Domain/Entities/OrderLine.cs
public class OrderLine
{
public Guid Id { get; private set; } = Guid.NewGuid();
public Guid OrderId { get; private set; }
public Guid ProductId { get; private set; }
public string ProductName { get; private set; } = default!;
public decimal UnitPrice { get; private set; }
public int Quantity { get; private set; }
public decimal LineTotal => UnitPrice * Quantity;
private OrderLine() { }
public static OrderLine Create(Guid orderId, Product product, int quantity) =>
new()
{
OrderId = orderId,
ProductId = product.Id,
ProductName = product.Name,
UnitPrice = product.Price,
Quantity = quantity,
};
public void IncreaseQuantity(int amount) => Quantity += amount;
}C#
// OrderFlow.Domain/Entities/Product.cs
public class Product
{
public Guid Id { get; private set; } = Guid.NewGuid();
public string Name { get; private set; } = default!;
public string Sku { get; private set; } = default!;
public decimal Price { get; private set; }
public int StockLevel { get; private set; }
public bool IsActive { get; private set; } = true;
private Product() { }
public static Product Create(string name, string sku, decimal price, int initialStock = 0)
{
if (price < 0) throw new ArgumentException("Price cannot be negative.");
return new Product { Name = name, Sku = sku.ToUpperInvariant(), Price = price, StockLevel = initialStock };
}
public void AdjustStock(int delta)
{
if (StockLevel + delta < 0)
throw new InvalidOperationException($"Insufficient stock. Available: {StockLevel}, Requested: {-delta}");
StockLevel += delta;
}
public void UpdatePrice(decimal newPrice)
{
if (newPrice < 0) throw new ArgumentException("Price cannot be negative.");
Price = newPrice;
}
}C#
// OrderFlow.Domain/Entities/Customer.cs
public class Customer
{
public Guid Id { get; private set; } = Guid.NewGuid();
public string Name { get; private set; } = default!;
public string Email { get; private set; } = default!;
public string Phone { get; private set; } = default!;
public bool IsActive { get; private set; } = true;
private readonly List<Order> _orders = [];
public IReadOnlyList<Order> Orders => _orders.AsReadOnly();
private Customer() { }
public static Customer Create(string name, string email, string phone) =>
new() { Name = name, Email = email.ToLowerInvariant(), Phone = phone };
}Application Layer — Interfaces
The Application layer defines what it needs. Infrastructure fulfils those contracts.
C#
// OrderFlow.Application/Abstractions/IOrderRepository.cs
namespace OrderFlow.Application.Abstractions;
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Order?> GetByNumberAsync(string orderNumber, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default);
void Add(Order order);
void Update(Order order);
}
public interface IProductRepository
{
Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default);
Task<IReadOnlyList<Product>> GetAllActiveAsync(CancellationToken ct = default);
void Add(Product product);
}
public interface ICustomerRepository
{
Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Customer?> GetByEmailAsync(string email, CancellationToken ct = default);
void Add(Customer customer);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}API Project Setup
C#
// OrderFlow.Api/Program.cs
using OrderFlow.Api.Extensions;
using OrderFlow.Infrastructure;
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((ctx, cfg) =>
cfg.ReadFrom.Configuration(ctx.Configuration));
builder.Services
.AddInfrastructure(builder.Configuration) // registers DbContext, repos
.AddApplication() // registers handlers, validators
.AddApiServices(); // swagger, cors, auth
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
await app.MigrateAndSeedAsync(); // dev-only migration
}
app.UseHttpsRedirection();
app.UseSerilogRequestLogging();
app.UseAuthentication();
app.UseAuthorization();
app.MapOrdersEndpoints();
app.MapProductsEndpoints();
app.MapCustomersEndpoints();
app.MapAuthEndpoints();
app.MapHealthChecks("/health");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "OrderFlow failed to start");
}
finally
{
Log.CloseAndFlush();
}Extension methods keep Program.cs clean
C#
// OrderFlow.Api/Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApiServices(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "OrderFlow API", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
In = ParameterLocation.Header,
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
[new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{ Type = ReferenceType.SecurityScheme, Id = "Bearer" }
}] = []
});
});
services.AddCors(opt => opt.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>()
.AddRedis(services.BuildServiceProvider()
.GetRequiredService<IConfiguration>()
.GetConnectionString("Redis")!);
return services;
}
}Configuration
JSON
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=OrderFlow;Trusted_Connection=True;TrustServerCertificate=True",
"Redis": "localhost:6379"
},
"Jwt": {
"SecretKey": "REPLACE_IN_PRODUCTION_WITH_KEY_VAULT",
"Issuer": "orderflow-api",
"Audience": "orderflow-clients",
"AccessTokenExpiryMinutes": 60,
"RefreshTokenExpiryDays": 30
},
"Serilog": {
"MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning" } },
"WriteTo": [{ "Name": "Console" }]
}
}JSON
// appsettings.Development.json — overrides for local dev
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=OrderFlow_Dev;Trusted_Connection=True;TrustServerCertificate=True"
},
"Serilog": {
"MinimumLevel": { "Default": "Debug" },
"WriteTo": [
{ "Name": "Console" },
{ "Name": "Seq", "Args": { "serverUrl": "http://localhost:5341" } }
]
}
}Infrastructure Layer Bootstrap
C#
// OrderFlow.Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
sql => sql.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name)));
services.AddStackExchangeRedisCache(opt =>
opt.Configuration = configuration.GetConnectionString("Redis"));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<ICustomerRepository, CustomerRepository>();
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());
return services;
}
}Database Context
C#
// OrderFlow.Infrastructure/Persistence/AppDbContext.cs
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options), IUnitOfWork
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
public DbSet<Product> Products => Set<Product>();
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}Bash
# Create and apply first migration
dotnet ef migrations add InitialCreate --project OrderFlow.Infrastructure --startup-project OrderFlow.Api
dotnet ef database update --project OrderFlow.Infrastructure --startup-project OrderFlow.ApiDocker Compose for Local Dev
YAML
# docker-compose.yml
services:
sql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "OrderFlow@Dev123"
ports: ["1433:1433"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
seq:
image: datalust/seq:latest
environment:
ACCEPT_EULA: "Y"
ports: ["5341:80"]Bash
docker compose up -d # start SQL Server, Redis, and Seq in one commandKey Takeaways
- Domain layer owns the entities and business rules — no EF Core, no HTTP, no NuGet packages
- Application layer defines interfaces — it tells Infrastructure what it needs without knowing how it's implemented
- Infrastructure implements those interfaces (EF Core, Redis, SMTP)
- API wires everything together and exposes HTTP endpoints
- The dependency rule flows inward — Domain knows nothing; API knows everything
- Use extension methods (
AddInfrastructure,AddApplication) to keepProgram.csclean - Docker Compose gives every developer the exact same local environment in seconds
In the next lesson, we build the first real REST endpoints for products and orders.
Lesson Checkpoint
Quick CheckQuestion 1 of 4
In Clean Architecture, which layer is allowed to depend on the Domain layer?