Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 098 / 100

My production-ready .NET template: a step-by-step guide

Everything I set up before writing the first feature.

Expona Admin10 min read

Introduction

By day 98 of building production systems in .NET, I've learned that the first line of code matters less than the first hundred lines of infrastructure. Every project I've shipped—whether it's been an e-commerce platform processing thousands of transactions daily or a ride-hailing system coordinating real-time requests—started with the same foundational setup. My production-ready .NET template is the result of 5+ years of mistakes, late-night debugging sessions, and painful deployments that taught me what actually matters before you write your first feature.

In this article, I'm walking you through the exact template I use today. It covers observability, error handling, data access, security, and delivery—the five pillars that separate hobby projects from systems people depend on.

Observability: Serilog, OpenTelemetry, Health Checks and Correlation IDs

I learned early that if you can't see what's happening in production, you're essentially flying blind. Observability means logging, tracing, and monitoring are wired in from day one, not bolted on when things break.

I start with Serilog for structured logging. Every log entry is JSON, which makes filtering and searching in Seq (or CloudWatch, Datadog, etc.) infinitely easier than plain text. OpenTelemetry gives me distributed tracing across my services—critical when requests bounce between microservices and you need to track where time is actually spent.

Correlation IDs tie everything together. When a user reports an issue, I can search logs by their correlation ID and see every operation that touched their request across all services and all layers.

csharp
// Program.cs
using Serilog;
using OpenTelemetry.Logs;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

// Serilog configuration
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .WriteTo.Console()
    .WriteTo.Seq("http://localhost:5341")
    .Enrich.FromLogContext()
    .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
    .CreateLogger();

builder.Host.UseSerilog();

// OpenTelemetry tracing
builder.Services
    .AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddEntityFrameworkCoreInstrumentation()
        .AddJaegerExporter(options =>
        {
            options.AgentHost = "localhost";
            options.AgentPort = 6831;
        }));

// Health checks
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>()
    .AddCheck("self", () => HealthCheckResult.Healthy());

var app = builder.Build();

// Map health endpoint
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

app.Run();

Health checks serve two purposes: Kubernetes uses them to know if your pod is alive and ready to receive traffic. You use them to monitor dependencies—database connectivity, cache availability, external API health—in one place. When your database is down, /health/ready returns 503, and your orchestrator stops sending requests to that instance.

For correlation IDs, I use middleware that either reads an existing X-Correlation-ID header or generates a new one, then stores it in LogContext so Serilog includes it automatically.

csharp
// CorrelationIdMiddleware.cs
public class CorrelationIdMiddleware
{
    private readonly RequestDelegate _next;
    private const string CorrelationIdHeader = "X-Correlation-ID";

    public CorrelationIdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.Request.Headers.TryGetValue(CorrelationIdHeader, out var correlationId))
        {
            correlationId = Guid.NewGuid().ToString();
        }

        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            context.Response.Headers.Add(CorrelationIdHeader, correlationId.ToString());
            await _next(context);
        }
    }
}

// In Program.cs
app.UseMiddleware<CorrelationIdMiddleware>();

Errors and Validation: ProblemDetails, IExceptionHandler and FluentValidation

Standardized error responses save teams countless hours of back-and-forth between frontend and backend. I use ProblemDetails (RFC 7807) so every error looks the same: a consistent JSON structure the client can parse reliably.

In .NET 8+, IExceptionHandler is the clean way to handle exceptions globally. Instead of try-catch blocks sprinkled everywhere, one handler catches unhandled exceptions and returns a proper response.

csharp
// GlobalExceptionHandler.cs
public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "An unhandled exception occurred");

        var problemDetails = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An error occurred while processing your request",
            Detail = exception.Message,
            Instance = httpContext.Request.Path
        };

        httpContext.Response.StatusCode = problemDetails.Status.Value;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;
    }
}

// Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
app.UseExceptionHandler();

For validation, FluentValidation lets me write expressive rules that map directly to the problem response. I use endpoint filters so validation runs before the handler even executes, and validation failures become 400 responses automatically.

csharp
// CreateOrderValidator.cs
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty().WithMessage("Customer ID is required");

        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Order must contain at least one item")
            .Must(items => items.All(i => i.Quantity > 0))
            .WithMessage("All items must have quantity > 0");
    }
}

// In Program.cs
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
builder.Services.AddFluentValidationRulesToOpenApi();

Data: EF Core with Pooling, NoTracking Default and Migrations Bundle in CI

EF Core is powerful but slow by default if you're not intentional. Connection pooling, disabling change tracking when you don't need it, and bundling migrations as part of your CI pipeline turn it from a potential bottleneck into something reliable.

Connection pooling keeps database connections alive and ready. Without it, every request pays the cost of opening a new connection, which adds noticeable latency at scale.

csharp
// Program.cs
builder.Services.AddDbContextPool<AppDbContext>((provider, options) =>
{
    options.UseNpgsql(
        builder.Configuration.GetConnectionString("DefaultConnection"),
        npgOptions => npgOptions
            .EnableRetryOnFailure(maxRetryCount: 3)
            .CommandTimeout(30));
    
    options.UseLoggerFactory(LoggerFactory.Create(b => b.AddSerilog()));
}, poolSize: 128);

// DbContext configuration
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
    }
}

Setting NoTracking by default means EF won't track entities returned from queries, saving memory and CPU. When you actually need to update something, you explicitly opt in with AsTracking().

For migrations, I bundle them as a separate executable in CI. This lets me apply database changes during deployment without keeping migration code in the runtime application.

csharp
// Program.cs (in migrations bundle project)
var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseNpgsql(args[0]));
    })
    .Build();

using (var scope = host.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
}

Security: JWT Auth, Authorization Policies, Rate Limiting and Security Headers

Security isn't optional. JWT authentication is stateless and plays well with microservices and mobile clients. Authorization policies let me declare what users can do in one place, separate from business logic. Rate limiting and security headers round out a baseline defense.

csharp
// Program.cs
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"])),
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience = builder.Configuration["Jwt:Audience"],
            ValidateLifetime = true,
            ClockSkew = TimeSpan.Zero
        };
    });

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("AdminOnly", policy => policy.RequireRole("admin"))
    .AddPolicy("CanViewOrders", policy => policy
        .RequireAuthenticatedUser()
        .RequireClaim("permission", "orders:view"));

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("default", limiter =>
    {
        limiter.PermitLimit = 100;
        limiter.Window = TimeSpan.FromMinutes(1);
    });
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

var app = builder.Build();

// Security headers
app.Use(async (context, next) =>
{
    context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
    context.Response.Headers.Add("X-Frame-Options", "DENY");
    context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
    context.Response.Headers.Add("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
    await next();
});

app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();

Never store secrets in appsettings.json. Use user secrets in development, environment variables in containers, and a secrets manager (AWS Secrets Manager, Azure Key Vault) in production.

Delivery: Dockerfile, GitHub Actions and Integration Tests with Testcontainers

A production system must be deployable reliably, repeatedly, and quickly. Multi-stage Dockerfiles keep images lean, GitHub Actions automate the build pipeline, and Testcontainers let me run real databases during testing instead of mocking them away.

csharp
// Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

COPY ["MyApp.csproj", "./"]
RUN dotnet restore "MyApp.csproj"

COPY . .
RUN dotnet build "MyApp.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=publish /app/publish .

EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

This three-stage Dockerfile builds in the SDK image, publishes the release build, then only copies the runtime binaries into the final image. Final image size is typically 200-300 MB instead of 4+ GB.

csharp
// .github/workflows/ci.yml (simplified)
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'
      
      - name: Restore
        run: dotnet restore
      
      - name: Build
        run: dotnet build --configuration Release --no-restore
      
      - name: Test
        run: dotnet test --configuration Release --no-build
      
      - name: Docker build and push
        if: github.ref == 'refs/heads/main'
        run: |
          docker build -t myapp:latest .
          docker push myapp:latest

For tests, Testcontainers spins up real PostgreSQL, Redis, or other containers during test execution, so your tests exercise actual database behavior, not mocked abstractions.

csharp
// OrderRepositoryTests.cs
[Collection("Database collection")]
public class OrderRepositoryTests
{
    private readonly PostgresContainer _container;

    public OrderRepositoryTests()
    {
        _container = new PostgresBuilder()
            .WithImage("postgres:16")
            .Build();
    }

    [Fact]
    public async Task CreateOrder_WithValidData_ReturnsOrderId()
    {
        await _container.StartAsync();

        var connectionString = _container.GetConnectionString();
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(connectionString)
            .Options;

        using (var context = new AppDbContext(options))
        {
            await context.Database.MigrateAsync();
            
            var repository = new OrderRepository(context);
            var order = new Order { CustomerId = 1, Total = 100m };
            
            var id = await repository.CreateAsync(order);
            
            Assert.NotEqual(0, id);
        }

        await _container.StopAsync();
    }
}

Common Mistakes

  • Skipping observability until production. By then, it's painful to retrofit. Start day one.
  • Using AsNoTracking() selectively instead of making NoTracking the default. It's easier to opt in than to hunt down performance issues later.
  • Mixing authentication and authorization logic into handlers. Keep authorization policies centralized and declarative.
  • Storing database credentials in code. Use secrets management. Period.
  • Mocking everything in tests. Unit tests catch logic bugs; integration tests (with real databases) catch the bugs that actually break production.

Key Takeaways

  • Build observability (logging, tracing, health checks, correlation IDs) into the template, not as an afterthought.
  • Standardize error responses with ProblemDetails and handle exceptions globally with IExceptionHandler.
  • Use EF Core connection pooling and NoTracking by default for predictable performance.
  • Bundle migrations in CI and apply them during deployment, keeping runtime code clean.
  • Declare authorization policies centrally; never scatter security logic through handlers.
  • Store secrets in a proper secrets manager, never in config files or environment variables in the repo.
  • Use multi-stage Dockerfiles and automate builds with GitHub Actions or similar CI systems.
  • Test with real dependencies (Testcontainers) to catch integration bugs before production.
  • Security headers and rate limiting are table stakes, not nice-to-haves.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles