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 onDomain -
Infrastructure→ depends onDomain+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; }
}
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);
}
}
}
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);
}
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();
}
}
Register it once in Program.cs:
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserValidator>();
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
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.");
}
}
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
}));
}
}
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."
]
}
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.");
}
};
});
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>
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>()
}
});
});
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);
}
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" />
2. Change the provider registration
// Before
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// After
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
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
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"
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
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;"
);
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();
}
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}");
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}");
}
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;
}
}
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
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"]
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();
}
}
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)