DEV Community

Cover image for Building a Production-Ready .NET 8 Banking API: Clean Architecture, PostgreSQL & CI/CD on Render
Abegunde Oluwatobiloba
Abegunde Oluwatobiloba

Posted on

Building a Production-Ready .NET 8 Banking API: Clean Architecture, PostgreSQL & CI/CD on Render

There's a difference between an API that works on your machine and one that's genuinely production-ready. This article walks through both — building a Core Banking REST API in .NET 8 using Clean Architecture, then taking it all the way to a live deployment on Render with a full GitHub Actions CI/CD pipeline.

We'll cover the architecture decisions, the patterns that keep the codebase maintainable, and — just as importantly — the production failures we hit and exactly how we fixed them.


The Stack

Layer Technology
Framework ASP.NET Core 8 Web API
Architecture Clean Architecture (4 layers)
Messaging MediatR (CQRS pattern)
Validation FluentValidation + MediatR pipeline behavior
ORM Entity Framework Core 8 + Npgsql
Database PostgreSQL 16
Auth JWT Bearer + token blacklist
CI/CD GitHub Actions
Hosting Render (Docker, Linux containers)
Containerisation Docker multi-stage build

Project Structure

The solution follows Clean Architecture — four projects with a strict dependency rule: outer layers depend on inner layers, never the reverse.

Dependency rule enforced at compile time:

  • Domain → no dependencies
  • Application → depends only on Domain
  • Infrastructure → depends on Domain + Application
  • Api → depends on everything (composition root)

This means your business logic in Application has zero knowledge of HTTP, EF Core, or any infrastructure concern. Swapping PostgreSQL for another database or replacing JWT with OAuth only touches Infrastructure.


CQRS with MediatR

Every user action is modelled as either a Command (mutates state) or a Query (reads state). MediatR routes them to the right handler without the controller knowing anything about the implementation.

The Command

// CoreBanking.Application/Commands/Auth/RegisterUserCommand.cs
public record RegisterUserCommand : IRequest<ApiResponse<RegisterUserResponse>>
{
    public string Username { get; init; } = string.Empty;
    public string Email    { get; init; } = string.Empty;
    public string Password { get; init; } = string.Empty;
    public UserRole Role   { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

The Handler


// CoreBanking.Application/Commands/Auth/RegisterUserCommandHandler.cs
public class RegisterUserCommandHandler
    : IRequestHandler<RegisterUserCommand, ApiResponse<RegisterUserResponse>>
{
    private readonly IUserRepository _userRepository;
    private readonly IOtpService _otpService;
    private readonly IPendingRegistrationRepository _pendingRegistrationRepository;

    public RegisterUserCommandHandler(
        IUserRepository userRepository,
        IOtpService otpService,
        IPendingRegistrationRepository pendingRegistrationRepository)
    {
        _userRepository = userRepository;
        _otpService = otpService;
        _pendingRegistrationRepository = pendingRegistrationRepository;
    }

    public async Task<ApiResponse<RegisterUserResponse>> Handle(
        RegisterUserCommand request,
        CancellationToken cancellationToken)
    {
        try
        {
            if (await _userRepository.EmailExistsAsync(request.Email))
                return ApiResponse<RegisterUserResponse>.Unauthorized("Email already exists.");

            var otpCode = _otpService.GenerateOtp();

            var pendingUser = new PendingRegistration
            {
                Username       = request.Username,
                Email          = request.Email,
                PasswordHash   = BCrypt.Net.BCrypt.HashPassword(request.Password),
                Role           = UserRole.Customer,
                OtpCode        = otpCode,
                ExpirationTime = DateTime.UtcNow.AddMinutes(5)
            };

            await _pendingRegistrationRepository.AddAsync(pendingUser);

            var response = new RegisterUserResponse
            {
                Id       = pendingUser.Id,
                Email    = pendingUser.Email,
                Username = pendingUser.Username,
                Role     = UserRole.Customer,
                Otp      = otpCode
            };

            return ApiResponse<RegisterUserResponse>
                .SuccessResponse(response, "OTP sent to your email. Please verify.");
        }
        catch (Exception ex)
        {
            return ApiResponse<RegisterUserResponse>
                .InternalServerError("Registration failed: " + ex.Message);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller becomes a thin dispatcher — it sends the command and returns the result:

[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Register(RegisterUserCommand command)
{
    var result = await _mediator.Send(command);
    return Ok(result);
}
Enter fullscreen mode Exit fullscreen mode

The Behavior

// CoreBanking.Application/Behaviors/ValidationBehavior.cs
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);

            var results = await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

            var failures = results
                .SelectMany(r => r.Errors)
                .Where(f => f != null)
                .ToList();

            if (failures.Count != 0)
                throw new ValidationException(failures);
        }

        return await next();
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it once in Program.cs:

builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserValidator>();
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

Enter fullscreen mode Exit fullscreen mode

Now write validators without touching handlers:

// CoreBanking.Application/Validators/RegisterUserValidator.cs
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{
    public RegisterUserValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required.")
            .EmailAddress().WithMessage("Invalid email format.");

        RuleFor(x => x.Password)
            .NotEmpty()
            .MinimumLength(8).WithMessage("Password must be at least 8 characters.")
            .Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.")
            .Matches("[0-9]").WithMessage("Password must contain at least one number.");

        RuleFor(x => x.Username)
            .NotEmpty()
            .MinimumLength(3).WithMessage("Username must be at least 3 characters.");
    }
}

Enter fullscreen mode Exit fullscreen mode

The flow:

HTTP Request
→ Controller.Register()
→ mediator.Send(command)
→ ValidationBehavior (runs RegisterUserValidator)
→ throws ValidationException if invalid ←── handler never reached
→ calls next() if valid
→ RegisterUserCommandHandler.Handle()

Global Exception Handling

ValidationException is thrown by the pipeline, not the handler. Your middleware needs to catch it explicitly and map it to a structured 400 response:

// CoreBanking.Api/Middleware/ExceptionMiddleware.cs
public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            var errors = ex.Errors.Select(e => e.ErrorMessage).ToList();
            await HandleValidationException(context, errors);
        }
        catch (BadRequestException ex)
        {
            await HandleException(context, HttpStatusCode.BadRequest, ex.Message);
        }
        catch (UnauthorizedAccessException ex)
        {
            await HandleException(context, HttpStatusCode.Unauthorized, ex.Message);
        }
        catch (Exception ex)
        {
            await HandleException(context, HttpStatusCode.InternalServerError, ex.Message);
        }
    }

    private static async Task HandleValidationException(HttpContext context, List<string> errors)
    {
        context.Response.ContentType = "application/json";
        context.Response.StatusCode  = (int)HttpStatusCode.BadRequest;

        await context.Response.WriteAsync(JsonSerializer.Serialize(new
        {
            status  = 400,
            message = "Validation failed",
            errors
        }));
    }

    private static async Task HandleException(
        HttpContext context, HttpStatusCode statusCode, string message)
    {
        context.Response.ContentType = "application/json";
        context.Response.StatusCode  = (int)statusCode;

        await context.Response.WriteAsync(JsonSerializer.Serialize(new
        {
            status = (int)statusCode,
            message
        }));
    }
}

Enter fullscreen mode Exit fullscreen mode

A validation failure now always returns:

{
  "status": 400,
  "message": "Validation failed",
  "errors": [
    "Password must contain at least one uppercase letter.",
    "Username must be at least 3 characters."
  ]
}

Enter fullscreen mode Exit fullscreen mode

Key insight: ValidationException must be caught before the generic Exception catch. C# evaluates catch blocks in order — the first matching type wins.

JWT Authentication with Token Blacklisting
Standard JWT is stateless — once issued, a token is valid until expiry. For logout to be meaningful in a banking context, you need to revoke tokens server-side.

The approach: store revoked tokens in a TokenBlacklist table and check it on every validated request.

// Program.cs — JWT configuration
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer              = builder.Configuration["Jwt:Issuer"],
            ValidAudience            = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey         = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(
                    builder.Configuration["Jwt:Key"]
                        ?? throw new InvalidOperationException("Jwt:Key is not configured.")))
        };

        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = async context =>
            {
                var blacklistRepo = context.HttpContext.RequestServices
                    .GetRequiredService<ITokenBlacklistRepository>();

                var rawToken = context.Request.Headers["Authorization"]
                    .ToString()
                    .Replace("Bearer ", "");

                if (await blacklistRepo.IsTokenRevokedAsync(rawToken))
                    context.Fail("Token has been revoked.");
            }
        };
    });

Enter fullscreen mode Exit fullscreen mode

The OnTokenValidated event fires after signature verification passes but before the request proceeds — the ideal interception point.

Swagger with Full Documentation
Two things are needed for Swagger to show proper documentation: XML doc generation and endpoint attributes.

Enable XML generation in the .csproj

<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
  <NoWarn>$(NoWarn);1591</NoWarn>  <!-- suppress missing-XML-comment warnings -->
</PropertyGroup>

Enter fullscreen mode Exit fullscreen mode

Wire it into Swagger

builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title       = "CoreBanking API",
        Version     = "v1",
        Description = "Core Banking System REST API"
    });

    // Load XML comments
    var xmlPath = Path.Combine(AppContext.BaseDirectory,
        $"{Assembly.GetExecutingAssembly().GetName().Name}.xml");
    if (File.Exists(xmlPath))
        options.IncludeXmlComments(xmlPath);

    // JWT Bearer button
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Name        = "Authorization",
        Type        = SecuritySchemeType.Http,
        Scheme      = "bearer",
        BearerFormat = "JWT",
        In          = ParameterLocation.Header,
        Description = "Enter your JWT token"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                    { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
            },
            Array.Empty<string>()
        }
    });
});

Enter fullscreen mode Exit fullscreen mode

Document endpoints with XML comments and response types

/// <summary>
/// Registers a new user account.
/// </summary>
/// <remarks>
/// Creates a pending registration and sends a one-time password (OTP) to the provided email.
/// The account is not active until the OTP is verified via the verify-otp endpoint.
/// </remarks>
/// <response code="200">Registration initiated — OTP sent to email.</response>
/// <response code="400">Validation failed.</response>
/// <response code="401">Email already registered.</response>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Register(RegisterUserCommand command)
{
    var result = await _mediator.Send(command);
    return Ok(result);
}

Enter fullscreen mode Exit fullscreen mode

Production Problem #1: LocalDB Is Windows-Only
Production Problem #1: LocalDB Is Windows-Only

LocalDB is not supported on this platform.

LocalDB is a Windows-only SQL Server feature. Render runs Linux. The fix: switch to PostgreSQL.

1. Swap the NuGet package

<!-- Remove -->
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />

<!-- Add -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />

Enter fullscreen mode Exit fullscreen mode

2. Change the provider registration

// Before
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

// After
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString));

Enter fullscreen mode Exit fullscreen mode

3. Delete and regenerate migrations
SQL Server migrations use Windows-specific types (uniqueidentifier, nvarchar, datetime2, bit). PostgreSQL uses uuid, text, timestamp with time zone, boolean. They are not compatible.

# Delete old migrations
rm -rf CoreBanking.Infrastructure/Migrations

# Regenerate for PostgreSQL
dotnet ef migrations add InitialCreate \
  --project CoreBanking.Infrastructure \
  --startup-project CoreBanking.Api

Enter fullscreen mode Exit fullscreen mode

4. Update the connection string format
Npgsql uses key-value format — not the PostgreSQL URL format:

// WRONG — this is a PostgreSQL URL, not a Npgsql connection string
"DefaultConnection": "postgres://user:pass@host/db"

// CORRECT — Npgsql key-value format
"DefaultConnection": "Host=localhost;Port=5432;Database=corebankingdb;Username=postgres;Password=yourpassword"

Enter fullscreen mode Exit fullscreen mode

Production Problem #2: Migration Fails on Existing Data
When we converted the Role column from text to an integer enum, the migration crashed:

SqlState: 23502
column "Role" of relation "Users" contains null values
Enter fullscreen mode Exit fullscreen mode

Why: The USING CASE expression in PostgreSQL's ALTER COLUMN ... TYPE is evaluated row-by-row. If any row has NULL or an unrecognised string value, the CASE produces NULL. Because Role is NOT NULL, PostgreSQL rejects it.

The fix — two steps:

// Migration: ChangeUserRoleToEnum.cs

// Step 1: sanitise any NULL or unrecognised values before the type change
migrationBuilder.Sql(
    @"UPDATE ""Users""
      SET ""Role"" = 'Customer'
      WHERE ""Role"" IS NULL OR ""Role"" NOT IN ('Customer', 'Admin');"
);

// Step 2: convert with an ELSE fallback as a safety net
migrationBuilder.Sql(
    @"ALTER TABLE ""Users""
      ALTER COLUMN ""Role""
      TYPE integer
      USING CASE
            WHEN ""Role"" = 'Customer' THEN 0
            WHEN ""Role"" = 'Admin'    THEN 1
            ELSE 0
      END;"
);

Enter fullscreen mode Exit fullscreen mode

The UPDATE cleans the data first. The ELSE 0 is a defensive fallback for anything that slips through.

Production Problem #3: Auto-Migrate on Startup
On a PaaS like Render, you don't have CLI access to run dotnet ef database update. The solution: run migrations automatically on startup.

// Program.cs — after app.Build()
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Database.Migrate();
}

Enter fullscreen mode Exit fullscreen mode

Migrate() is idempotent — it checks __EFMigrationsHistory and only applies unapplied migrations. Safe to call on every startup.

Trade-off: This works well for a single-instance API. For horizontally scaled services, you'll want a dedicated migration job to avoid race conditions between instances running migrations simultaneously.

Production Problem #4: UseUrls Breaking Local Dev
Adding UseUrls unconditionally to support Render's PORT environment variable broke Visual Studio's HTTPS profile:

// WRONG — overrides launchSettings.json even in local dev
builder.WebHost.UseUrls($"http://+:{port}");

Enter fullscreen mode Exit fullscreen mode

The fix: only call UseUrls when the PORT env var is explicitly set by the platform:

// Program.cs
var port = Environment.GetEnvironmentVariable("PORT");
if (port is not null)
{
    builder.WebHost.UseUrls($"http://+:{port}");
}

Enter fullscreen mode Exit fullscreen mode

Local development falls through to launchSettings.json profiles as normal. Render sets PORT, so the deployed instance binds to the correct port.

Production Problem #5: Disable File Watching in Containers
By default, ASP.NET Core watches appsettings.json for changes at runtime using FileSystemWatcher. In a Linux container, this consumes inotify handles a limited OS resource. In production, config files never change at runtime anyway.

// Program.cs
if (builder.Environment.IsProduction())
{
    foreach (var source in builder.Configuration.Sources
                 .OfType<JsonConfigurationSource>())
    {
        source.ReloadOnChange = false;
    }
}

Enter fullscreen mode Exit fullscreen mode

GitHub Actions CI/CD Pipeline
The pipeline runs 4 stages on every push:

# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    name: Build & Test
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER:     postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB:       CoreBankingDb
        ports:
          - 5432:5432
        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 Corebankingapp.sln

      - name: Build
        run: dotnet build Corebankingapp.sln --no-restore --configuration Release

      - name: Test
        env:
          ConnectionStrings__DefaultConnection: >-
            Host=localhost;Port=5432;Database=CoreBankingDb;
            Username=postgres;Password=postgres
        run: >-
          dotnet test Corebankingapp.sln
          --no-build --configuration Release
          --logger trx --results-directory TestResults

  docker-build:
    name: Docker Build & Push
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest

Enter fullscreen mode Exit fullscreen mode

Critical: The integration test suite uses WebApplicationFactory which calls db.Database.Migrate() on startup. Without a real PostgreSQL instance in CI, the test run crashes immediately. The services: postgres: block provides one.

Dockerfile — Multi-Stage Build

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["CoreBanking.Api/CoreBanking.Api.csproj",             "CoreBanking.Api/"]
COPY ["CoreBanking.Application/CoreBanking.Application.csproj", "CoreBanking.Application/"]
COPY ["CoreBanking.Domain/CoreBanking.Domain.csproj",       "CoreBanking.Domain/"]
COPY ["CoreBanking.Infrastructure/CoreBanking.Infrastructure.csproj", "CoreBanking.Infrastructure/"]
RUN dotnet restore "CoreBanking.Api/CoreBanking.Api.csproj"
COPY . .
RUN dotnet publish "CoreBanking.Api/CoreBanking.Api.csproj" \
    -c Release -o /app/publish --no-restore

FROM base AS final
WORKDIR /app
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
COPY --from=publish /app/publish .
ENV PORT=8080
ENV ASPNETCORE_ENVIRONMENT=Production
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:${PORT}/health || exit 1
ENTRYPOINT ["dotnet", "CoreBanking.Api.dll"]

Enter fullscreen mode Exit fullscreen mode

Key decisions:

. Multi-stage: SDK image for build, runtime-only image for final — keeps the deployed image small
. HEALTHCHECK: Render uses /health to determine when a deploy is live
. No HTTPS redirect in production: Render terminates TLS at the load balancer; the container only needs HTTP

Integration Tests
Using WebApplicationFactory to test the real startup pipeline:

// CoreBanking.Tests/HealthCheckTests.cs
public class HealthCheckTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public HealthCheckTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task HealthEndpoint_ReturnsHealthy()
    {
        var response = await _client.GetAsync("/health");
        response.EnsureSuccessStatusCode();
    }
}

Enter fullscreen mode Exit fullscreen mode

The public partial class Program { } declaration at the bottom of Program.cs makes the class accessible to the test project across assembly boundaries.

Key Takeaways
Architecture

  • Clean Architecture enforces separation of concerns at the project level not just folder level
  • CQRS with MediatR keeps controllers thin and handlers focused on a single operation
  • Pipeline behaviors are the right place for cross-cutting concerns like validation and logging — not individual handlers

Validation

  • Never validate in the handler if you have a pipeline behavior — it's duplication that erodes the architecture
  • Catch FluentValidation.ValidationException explicitly in middleware before the generic Exception catch

PostgreSQL & EF Core

  • LocalDB is Windows-only; plan for PostgreSQL from day one if you're targeting Linux
  • SQL Server and PostgreSQL EF Core migrations are not compatible — regenerate when switching providers
  • When converting column types in PostgreSQL, sanitise data with UPDATE first, then use ALTER COLUMN ... USING CASE with an ELSE fallback
  • db.Database.Migrate() on startup is pragmatic for single-instance PaaS deployment

Platform & Containers

  • Only call UseUrls when a PORT env var is explicitly set — otherwise you'll break local dev profiles
  • Disable ReloadOnChange on config sources in production containers to avoid consuming inotify handles
  • Skip HTTPS redirect inside containers when TLS is terminated at the load balancer

CI/CD

  • Integration tests that touch the database need a real database in CI — not a mock, not SQLite
  • Add a services: postgres: block to your GitHub Actions job and pass the connection string as an env var to the test step

Built with .NET 8, PostgreSQL 16, GitHub Actions, and Render.

Repository link: https://github.com/adisaomoabegunde/CoreBankingApp

Top comments (0)