Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Database provider: sqlite (default, no Docker dependency) | postgres (opt-in, requires Docker)
DATABASE_PROVIDER=sqlite

# Required only when DATABASE_PROVIDER=postgres
# For Docker Compose, "postgres" resolves to the postgres service container
DATABASE_URL=Host=postgres;Database=players;Username=postgres;Password=P0579r35_p455W0rd!

# Required only when DATABASE_PROVIDER=postgres (used by the postgres service in compose.yaml)
POSTGRES_PASSWORD=P0579r35_p455W0rd!
Comment thread
nanotaboada marked this conversation as resolved.
8 changes: 5 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ REST API for managing football players built with ASP.NET Core 10. Implements CR
| Language | C# (.NET 10 LTS) |
| Framework | ASP.NET Core (MVC controllers) |
| ORM | Entity Framework Core 10 |
| Database | SQLite |
| Database | SQLite (default) · PostgreSQL 17 (opt-in) |
| Mapping | AutoMapper |
| Validation | FluentValidation |
| Caching | `IMemoryCache` (1-hour TTL) |
Expand Down Expand Up @@ -207,7 +207,9 @@ This project uses Spec-Driven Development (SDD): discuss in Plan mode first, cre

**Add an endpoint**: Add DTO in `Models/` → update `PlayerMappingProfile` in `Mappings/` → add repository method(s) in `Repositories/` → add service method in `Services/` → add controller action in `Controllers/` → add/update validator rule set in `Validators/` → add tests in `test/.../Unit/` → run pre-commit checks.

**Modify schema**: Update `Player` entity → update DTOs → update AutoMapper profile → update `HasData()` seed data in `OnModelCreating` if needed → run `dotnet ef migrations add <Name>` → update tests → run `dotnet test`.
**Modify schema**: Update `Player` entity → update DTOs → update AutoMapper profile → update `HasData()` seed data in `OnModelCreating` if needed → run `dotnet ef migrations add <Name>` twice (once for each provider: output goes to `Migrations/` for SQLite, `Migrations/Npgsql/` for PostgreSQL) → update tests → run `dotnet test`.

**Switch database provider**: Set `DATABASE_PROVIDER=postgres` (plus `DATABASE_URL`) to use PostgreSQL, or leave unset for SQLite (default). `ProviderSpecificMigrationsAssembly` filters migration discovery to the active provider's namespace at runtime — no code changes needed to switch.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Architecture Decision Records (ADRs)

Expand All @@ -216,7 +218,7 @@ Load `#file:adr/README.md` when:
- Proposing changes to core architecture or dependencies
- Historical context for past decisions is needed

ADRs are in `adr/` (0001–0012). Each file is self-contained.
ADRs are in `adr/` (0001–0014). Each file is self-contained.

**After completing work**: Suggest a branch name (e.g. `feat/add-player-search`) and a commit message following Conventional Commits including co-author line:

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ bin/
obj/
TestResults/
storage/*.db
.env
.claude/settings.local.json
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,22 @@ This project uses famous football stadiums (A-Z) that hosted FIFA World Cup matc

### Added

- `DATABASE_PROVIDER` environment variable (`sqlite` default, `postgres` opt-in) to select the database engine at startup (issue #249).
- PostgreSQL 17 support via `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.1; migrations in `Migrations/Npgsql/` use proper PostgreSQL column types (`uuid`, `boolean`, `timestamp with time zone`).
- `ProviderSpecificMigrationsAssembly` that filters the EF Core migration set to the active provider's namespace, ensuring `MigrateAsync()` applies the correct migrations for both SQLite and PostgreSQL.
- `postgres` Docker Compose profile and service (`postgres:17-alpine`), started only when `DATABASE_PROVIDER=postgres` is set; the API service uses `depends_on` with `required: false` so SQLite mode incurs no dependency on the postgres service.
- `.env.example` documenting `DATABASE_PROVIDER`, `DATABASE_URL`, and `POSTGRES_PASSWORD`.
- `.env` added to `.gitignore`.
- ADR-0014 (`adr/0014-configurable-database-provider.md`) documenting the decision; supersedes ADR-0003.

### Changed

- `AddDbContextPoolWithSqlite` renamed to `AddDbContextPool` in `ServiceCollectionExtensions`; now reads `DATABASE_PROVIDER` and wires either `UseSqlite` or `UseNpgsql` accordingly.
- `compose.yaml`: `api` service receives `DATABASE_PROVIDER` and `DATABASE_URL` environment variables; `postgres-data` named volume added.
- `scripts/entrypoint.sh`: SQLite file-presence check is skipped when `DATABASE_PROVIDER=postgres`.
- ADR-0003 status updated to "Superseded by ADR-0014".
- `README.md`: added Database section documenting SQLite and PostgreSQL modes.

### Fixed

### Removed
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Proof of Concept for a RESTful API built with .NET 10 (LTS) and ASP.NET Core. Ma
| **API Documentation** | [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) (OpenAPI 3.0) |
| **Validation** | [FluentValidation 12](https://github.com/FluentValidation/FluentValidation) |
| **Mapping** | [AutoMapper 14](https://github.com/AutoMapper/AutoMapper) |
| **Database** | [SQLite 3](https://github.com/sqlite/sqlite) |
| **Database** | [SQLite 3](https://github.com/sqlite/sqlite) (default) · [PostgreSQL 17](https://github.com/postgres/postgres) (opt-in) |
| **ORM** | [Entity Framework Core 10.0](https://github.com/dotnet/efcore) |
| **Logging** | [Serilog 9](https://github.com/serilog/serilog) |
| **Testing** | [xUnit](https://github.com/xunit/xunit), [Moq](https://github.com/devlooped/moq), [FluentAssertions](https://github.com/fluentassertions/fluentassertions) |
Expand Down Expand Up @@ -155,6 +155,41 @@ Error codes: `400 Bad Request` (validation failed) · `404 Not Found` (player no

For complete endpoint documentation with request/response schemas, explore the [interactive Swagger UI](https://localhost:9000/swagger/index.html).

## Database

The database engine is selected via the `DATABASE_PROVIDER` environment variable.

| Value | Engine | Requires |
| ----- | ------ | -------- |
| `sqlite` (default) | SQLite — file-based, zero infrastructure | Nothing — clone and run |
| `postgres` | PostgreSQL 17 — server-based, production-parity | Docker |

### SQLite mode (default)

```bash
# Local run — no setup required
dotnet run --project src/Dotnet.Samples.AspNetCore.WebApi

# Docker Compose
docker compose up
```

### PostgreSQL mode (opt-in)

```bash
# Docker Compose with the postgres profile
DATABASE_PROVIDER=postgres docker compose --profile postgres up
```

When using PostgreSQL you can override the connection string and password:

```bash
DATABASE_URL=Host=localhost;Database=players;Username=postgres;Password=P0579r35_p455W0rd!
POSTGRES_PASSWORD=P0579r35_p455W0rd!
```

Copy `.env.example` to `.env` and edit as needed — `.env` is git-ignored.

## Prerequisites

Before you begin, ensure you have the following installed:
Expand Down
2 changes: 1 addition & 1 deletion adr/0003-use-sqlite-for-data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Date: 2026-04-02

## Status

Accepted
Superseded by [ADR-0014](0014-configurable-database-provider.md)

## Context

Expand Down
87 changes: 87 additions & 0 deletions adr/0014-configurable-database-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 0014. Configurable Database Provider

Date: 2026-05-02

## Status

Accepted — supersedes [ADR-0003](0003-use-sqlite-for-data-storage.md)

## Context

The project used SQLite as its only database engine (ADR-0003). This meant all environments — local development, Docker Compose, and any deployment — ran the same SQLite file-based database. For the majority of contributors this is ideal: zero infrastructure required.

However, a fixed SQLite-only setup has limits:

- SQLite does not support concurrent writes, so multi-instance deployments are not possible.
- Developers who want to validate production-parity behaviour (e.g. PostgreSQL-specific query plans, type semantics, or constraint handling) have no path to do so without modifying the project.
- A fixed SQLite-in-dev / PostgreSQL-in-prod split (a common alternative) introduces a different problem: subtle behavioral differences between environments that are hard to catch locally.

The goal is to make the database engine fully configurable with a single environment variable, so the same stack is used consistently across every environment a developer chooses to run.

## Decision

We will introduce a `DATABASE_PROVIDER` environment variable that selects the database engine at startup:

- **`DATABASE_PROVIDER=sqlite`** (default): SQLite everywhere. Zero infrastructure required. Works on any machine without Docker. Clone and run.
- **`DATABASE_PROVIDER=postgres`**: PostgreSQL everywhere. Requires Docker. Opt-in for developers who want a server-based engine or full production parity.

The default is `sqlite` to keep the barrier to entry as low as possible.

### Provider selection at startup

`ServiceCollectionExtensions.AddDbContextPool` reads `DATABASE_PROVIDER` and wires the appropriate EF Core provider:

```csharp
switch (provider.ToLowerInvariant())
{
case "postgres":
options.UseNpgsql(Environment.GetEnvironmentVariable("DATABASE_URL"));
break;
default:
options.UseSqlite($"Data Source={dataSource}");
break;
}
```

### Provider-specific EF Core migrations

SQLite and PostgreSQL require different column type mappings (`TEXT`/`INTEGER` vs `uuid`/`boolean`/`timestamp with time zone`). A single migration set cannot satisfy both providers. To resolve this:

- SQLite migrations remain in `Migrations/` (namespace `...Migrations`).
- PostgreSQL migrations are placed in `Migrations/Npgsql/` (namespace `...Migrations.Npgsql`).
- A custom `ProviderSpecificMigrationsAssembly` (implementing EF Core's `IMigrationsAssembly`) filters the discovered migrations to only those in the active provider's namespace. It is registered via `options.ReplaceService<IMigrationsAssembly, ProviderSpecificMigrationsAssembly>()`.

`MigrateAsync()` at startup continues to work for both providers; each sees only its own migration set.

### Docker Compose profiles

The `postgres` service is declared under the `postgres` Compose profile so it only starts when explicitly requested:

```bash
# SQLite (default — no extra Docker service)
docker compose up

# PostgreSQL (opt-in)
DATABASE_PROVIDER=postgres docker compose --profile postgres up
```

## Consequences

### Positive

- Developers choose their engine once and use it consistently across all environments.
- SQLite remains the zero-friction default — clone, run, done.
- PostgreSQL is a first-class option for production-parity testing without requiring project-wide changes.
- The Compose profile approach prevents accidental PostgreSQL containers from starting in SQLite mode.
- `dotnet run` continues to work unchanged with SQLite.

### Negative

- Two parallel migration sets must be maintained. Adding a schema change requires migrations in both `Migrations/` and `Migrations/Npgsql/`.
- `ProviderSpecificMigrationsAssembly` uses an EF Core internal API (`MigrationsAssembly` from `Microsoft.EntityFrameworkCore.Migrations.Internal`), annotated with `#pragma warning disable EF1001`. This may require updates on major EF Core version upgrades.
- PostgreSQL support is not tested in CI (no Testcontainers integration yet — tracked in issue #353).

### Neutral

- The `DATABASE_URL` connection string format follows the Npgsql convention (`Host=...;Database=...;Username=...;Password=...`).
- `.env.example` documents all three new environment variables (`DATABASE_PROVIDER`, `DATABASE_URL`, `POSTGRES_PASSWORD`). `.env` is git-ignored.
3 changes: 2 additions & 1 deletion adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This directory contains Architecture Decision Records (ADRs) for this project. A
|-----|-------|--------|
| [0001](0001-adopt-traditional-layered-architecture.md) | Adopt Traditional Layered Architecture | Accepted (Under Reconsideration) |
| [0002](0002-use-mvc-controllers-over-minimal-api.md) | Use MVC Controllers over Minimal API | Accepted |
| [0003](0003-use-sqlite-for-data-storage.md) | Use SQLite for Data Storage | Accepted |
| [0003](0003-use-sqlite-for-data-storage.md) | Use SQLite for Data Storage | Superseded by ADR-0014 |
| [0004](0004-use-uuid-as-database-primary-key.md) | Use UUID as Database Primary Key | Accepted |
| [0005](0005-use-squad-number-as-api-mutation-key.md) | Use Squad Number as API Mutation Key | Accepted |
| [0006](0006-use-rfc-7807-problem-details-for-errors.md) | Use RFC 7807 Problem Details for Errors | Accepted |
Expand All @@ -19,6 +19,7 @@ This directory contains Architecture Decision Records (ADRs) for this project. A
| [0011](0011-use-docker-for-containerization.md) | Use Docker for Containerization | Accepted |
| [0012](0012-use-stadium-themed-semantic-versioning.md) | Use Stadium-Themed Semantic Versioning | Accepted |
| [0013](0013-testing-strategy.md) | Testing Strategy | Accepted |
| [0014](0014-configurable-database-provider.md) | Configurable Database Provider | Accepted |

## When to Create an ADR

Expand Down
25 changes: 25 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,34 @@ services:
volumes:
- storage:/storage/
environment:
- DATABASE_PROVIDER=${DATABASE_PROVIDER:-sqlite}
- DATABASE_URL=${DATABASE_URL:-Host=postgres;Database=players;Username=postgres;Password=postgres}
- STORAGE_PATH=/storage/players-sqlite3.db
depends_on:
postgres:
condition: service_healthy
required: false
restart: unless-stopped

postgres:
image: postgres:17-alpine
container_name: aspnetcore-postgres
profiles: [postgres]
environment:
POSTGRES_DB: players
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped

volumes:
storage:
name: dotnet-samples-aspnetcore-webapi_storage
postgres-data:
name: dotnet-samples-aspnetcore-webapi_postgres-data
16 changes: 11 additions & 5 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ log() {

log "✔ Starting container..."

VOLUME_STORAGE_PATH="${STORAGE_PATH:-/storage/players-sqlite3.db}"
DATABASE_PROVIDER="${DATABASE_PROVIDER:-sqlite}"

if [ ! -f "$VOLUME_STORAGE_PATH" ]; then
log "⚠️ No existing database file found in volume."
log "🗄️ EF Core migrations will initialize the database on first start."
if [ "$DATABASE_PROVIDER" = "postgres" ]; then
log "✔ Using PostgreSQL database."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
else
log "✔ Existing database file found at $VOLUME_STORAGE_PATH."
VOLUME_STORAGE_PATH="${STORAGE_PATH:-/storage/players-sqlite3.db}"

if [ ! -f "$VOLUME_STORAGE_PATH" ]; then
log "⚠️ No existing database file found in volume."
log "🗄️ EF Core migrations will initialize the database on first start."
else
log "✔ Existing database file found at $VOLUME_STORAGE_PATH."
fi
fi

log "✔ Ready!"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<ItemGroup Label="Runtime dependencies">
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="AutoMapper" Version="[16.1.1,17.0.0)" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
using Dotnet.Samples.AspNetCore.WebApi.Configurations;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Dotnet.Samples.AspNetCore.WebApi.Mappings;
using Dotnet.Samples.AspNetCore.WebApi.Migrations;
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
using Dotnet.Samples.AspNetCore.WebApi.Services;
using Dotnet.Samples.AspNetCore.WebApi.Utilities;
using Dotnet.Samples.AspNetCore.WebApi.Validators;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.OpenApi;
using Serilog;

Expand All @@ -19,35 +21,55 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Extensions;
public static partial class ServiceCollectionExtensions
{
/// <summary>
/// Adds DbContextPool with SQLite configuration for PlayerDbContext.
/// Adds DbContextPool for PlayerDbContext, selecting the database provider based on the
/// <c>DATABASE_PROVIDER</c> environment variable (<c>sqlite</c> by default, <c>postgres</c>
/// to opt in to PostgreSQL).
/// </summary>
/// <param name="services">The IServiceCollection instance.</param>
/// <param name="environment">The web host environment.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection AddDbContextPoolWithSqlite(
public static IServiceCollection AddDbContextPool(
this IServiceCollection services,
IWebHostEnvironment environment
)
{
services.AddDbContextPool<PlayerDbContext>(options =>
{
var storagePath = Environment.GetEnvironmentVariable("STORAGE_PATH");
var dataSource = !string.IsNullOrWhiteSpace(storagePath)
? storagePath
: Path.Combine(AppContext.BaseDirectory, "storage", "players-sqlite3.db");
var provider = Environment.GetEnvironmentVariable("DATABASE_PROVIDER") ?? "sqlite";

var storageDir = Path.GetDirectoryName(dataSource);
if (!string.IsNullOrWhiteSpace(storageDir))
switch (provider.ToLowerInvariant())
{
Directory.CreateDirectory(storageDir);
case "postgres":
var connectionString =
Environment.GetEnvironmentVariable("DATABASE_URL")
?? throw new InvalidOperationException(
"DATABASE_URL is required when DATABASE_PROVIDER=postgres."
);
options.UseNpgsql(connectionString);
break;

default:
var storagePath = Environment.GetEnvironmentVariable("STORAGE_PATH");
var dataSource = !string.IsNullOrWhiteSpace(storagePath)
? storagePath
: Path.Combine(AppContext.BaseDirectory, "storage", "players-sqlite3.db");

var storageDir = Path.GetDirectoryName(dataSource);
if (!string.IsNullOrWhiteSpace(storageDir))
{
Directory.CreateDirectory(storageDir);
}
options.UseSqlite($"Data Source={dataSource}");
break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
options.UseSqlite($"Data Source={dataSource}");

if (environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.LogTo(Log.Logger.Information, LogLevel.Information);
}

options.ReplaceService<IMigrationsAssembly, ProviderSpecificMigrationsAssembly>();
});

return services;
Expand Down
Loading
Loading