Back to blog
Backend Systemsbeginner

OrderFlow: Project Setup & Solution Architecture

Scaffold a production .NET 9 solution from scratch. Multi-project layout, dependency injection, environment configuration, Serilog, health checks — the right foundation before writing a single endpoint.

LearnixoApril 14, 20268 min read
.NETC#ArchitectureSolution StructureSerilogHealth Checks
Share:𝕏

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 movements

Order states:

Draft → Confirmed → Processing → Shipped → Delivered
                 ↘ Cancelled

Solution 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.Infrastructure

The dependency rule: inner layers never reference outer layers.

Domain          ← no dependencies
Application     ← Domain only
Infrastructure  ← Application + Domain
Api             ← Application + Infrastructure

Domain 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.Api

Docker 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 command

Key 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 keep Program.cs clean
  • 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.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.