Skip to content

Commit 22deb02

Browse files
authored
Merge pull request #490 from nanotaboada/feat/249-configurable-database-provider
feat(data): add configurable database provider via DATABASE_PROVIDER (#249)
2 parents 0672369 + 9a513b4 commit 22deb02

25 files changed

Lines changed: 1196 additions & 26 deletions

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Database provider: sqlite (default, no Docker dependency) | postgres (opt-in, requires Docker)
2+
DATABASE_PROVIDER=sqlite
3+
4+
# Required only when DATABASE_PROVIDER=postgres
5+
# For Docker Compose, "postgres" resolves to the postgres service container
6+
DATABASE_URL=Host=postgres;Database=players;Username=postgres;Password=P0579r35_p455W0rd!
7+
8+
# Required only when DATABASE_PROVIDER=postgres (used by the postgres service in compose.yaml)
9+
POSTGRES_PASSWORD=P0579r35_p455W0rd!

.github/copilot-instructions.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Overview
44

5-
REST API for managing football players built with ASP.NET Core 10. Implements CRUD operations with a layered architecture, EF Core + SQLite persistence, FluentValidation, AutoMapper, and in-memory caching. Part of a cross-language comparison study (Go, Java, Python, Rust, TypeScript). Primarily a learning and reference project — clarity and educational value take precedence over brevity.
5+
REST API for managing football players built with ASP.NET Core 10. Implements CRUD operations with a layered architecture, EF Core persistence (SQLite by default, PostgreSQL opt-in via `DATABASE_PROVIDER`), FluentValidation, AutoMapper, and in-memory caching. Part of a cross-language comparison study (Go, Java, Python, Rust, TypeScript). Primarily a learning and reference project — clarity and educational value take precedence over brevity.
66

77
## Tech Stack
88

@@ -11,7 +11,7 @@ REST API for managing football players built with ASP.NET Core 10. Implements CR
1111
| Language | C# (.NET 10 LTS) |
1212
| Framework | ASP.NET Core (MVC controllers) |
1313
| ORM | Entity Framework Core 10 |
14-
| Database | SQLite |
14+
| Database | SQLite (default) · PostgreSQL 17 (opt-in) |
1515
| Mapping | AutoMapper |
1616
| Validation | FluentValidation |
1717
| Caching | `IMemoryCache` (1-hour TTL) |
@@ -181,7 +181,7 @@ Example: `feat(api): add player search endpoint (#123)`
181181
- Production configurations or deployment secrets
182182
- `.runsettings` coverage thresholds
183183
- Port configuration (9000)
184-
- Database type (SQLite — demo/dev only)
184+
- Migration namespace constants in `ProviderSpecificMigrationsAssembly` — renaming breaks runtime provider filtering for one or both providers
185185
- CD pipeline tag format (`vX.Y.Z-stadium`) or the stadium name sequence — names are assigned sequentially A→Z from the list in `CHANGELOG.md`; the next name is always the next unused letter
186186

187187
### Creating Issues
@@ -207,7 +207,9 @@ This project uses Spec-Driven Development (SDD): discuss in Plan mode first, cre
207207

208208
**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.
209209

210-
**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`.
210+
**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`.
211+
212+
**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.
211213

212214
## Architecture Decision Records (ADRs)
213215

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

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

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

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ bin/
33
obj/
44
TestResults/
55
storage/*.db
6+
.env
67
.claude/settings.local.json

.sonarcloud.properties

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ sonar.exclusions=\
2323
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.Designer.cs,\
2424
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
2525
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
26-
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs
26+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
27+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
28+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
29+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
30+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
31+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
32+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs
2733

2834
# =============================================================================
2935
# Coverage exclusions
@@ -47,6 +53,12 @@ sonar.coverage.exclusions=\
4753
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
4854
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
4955
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
56+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
57+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
58+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
59+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
60+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
61+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs,\
5062
src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs,\
5163
src/Dotnet.Samples.AspNetCore.WebApi/Program.cs
5264

@@ -77,6 +89,12 @@ sonar.cpd.exclusions=\
7789
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
7890
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
7991
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
92+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
93+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
94+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
95+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
96+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
97+
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs,\
8098
src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs,\
8199
src/Dotnet.Samples.AspNetCore.WebApi/Utilities/PlayerData.cs,\
82100
src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs,\

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,30 @@ This project uses famous football stadiums (A-Z) that hosted FIFA World Cup matc
4444

4545
### Added
4646

47+
- `DATABASE_PROVIDER` environment variable (`sqlite` default, `postgres` opt-in) to select the database engine at startup (issue #249).
48+
- 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`).
49+
- `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.
50+
- `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.
51+
- `.env.example` documenting `DATABASE_PROVIDER`, `DATABASE_URL`, and `POSTGRES_PASSWORD`.
52+
- `.env` added to `.gitignore`.
53+
- ADR-0014 (`adr/0014-configurable-database-provider.md`) documenting the decision; supersedes ADR-0003.
54+
4755
### Changed
4856

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

65+
- Populate `BuildTargetModel` in Npgsql seed migration designer files so Npgsql's SQL generator can resolve column types when applying `InsertData` operations.
66+
- Suppress `PendingModelChangesWarning` for the postgres provider path — hand-crafted designer files cannot replicate Npgsql-injected runtime annotations (`Relational:MaxIdentifierLength`, `UseIdentityByDefaultColumn`), causing a false-positive that aborted `MigrateAsync()` at startup.
67+
- Normalize `DATABASE_PROVIDER` to lowercase in `entrypoint.sh` via `tr` so `POSTGRES`, `Postgres`, etc. are handled consistently with `AddDbContextPool`.
68+
- Trim and normalize `DATABASE_PROVIDER` in `AddDbContextPool` before the provider switch; add explicit `sqlite`/empty case; throw `InvalidOperationException` for unrecognized values so typos no longer silently fall through to SQLite.
69+
- Move `Npgsql.EntityFrameworkCore.PostgreSQL` package from the "Development dependencies" `ItemGroup` to "Runtime dependencies".
70+
5171
### Removed
5272

5373
---

README.md

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

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

158+
## Database
159+
160+
The database engine is selected via the `DATABASE_PROVIDER` environment variable.
161+
162+
| Value | Engine | Requires |
163+
| ----- | ------ | -------- |
164+
| `sqlite` (default) | SQLite — file-based, zero infrastructure | Nothing — clone and run |
165+
| `postgres` | PostgreSQL 17 — server-based, production-parity | Docker |
166+
167+
### SQLite mode (default)
168+
169+
```bash
170+
# Local run — no setup required
171+
dotnet run --project src/Dotnet.Samples.AspNetCore.WebApi
172+
173+
# Docker Compose
174+
docker compose up
175+
```
176+
177+
### PostgreSQL mode (opt-in)
178+
179+
```bash
180+
# Docker Compose with the postgres profile
181+
DATABASE_PROVIDER=postgres docker compose --profile postgres up
182+
```
183+
184+
When using PostgreSQL you can override the connection string and password:
185+
186+
```bash
187+
DATABASE_URL=Host=localhost;Database=players;Username=postgres;Password=P0579r35_p455W0rd!
188+
POSTGRES_PASSWORD=P0579r35_p455W0rd!
189+
```
190+
191+
Copy `.env.example` to `.env` and edit as needed — `.env` is git-ignored.
192+
158193
## Prerequisites
159194

160195
Before you begin, ensure you have the following installed:

adr/0003-use-sqlite-for-data-storage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Date: 2026-04-02
44

55
## Status
66

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

99
## Context
1010

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# 0014. Configurable Database Provider
2+
3+
Date: 2026-05-02
4+
5+
## Status
6+
7+
Accepted — supersedes [ADR-0003](0003-use-sqlite-for-data-storage.md)
8+
9+
## Context
10+
11+
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.
12+
13+
However, a fixed SQLite-only setup has limits:
14+
15+
- SQLite does not support concurrent writes, so multi-instance deployments are not possible.
16+
- 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.
17+
- 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.
18+
19+
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.
20+
21+
## Decision
22+
23+
We will introduce a `DATABASE_PROVIDER` environment variable that selects the database engine at startup:
24+
25+
- **`DATABASE_PROVIDER=sqlite`** (default): SQLite everywhere. Zero infrastructure required. Works on any machine without Docker. Clone and run.
26+
- **`DATABASE_PROVIDER=postgres`**: PostgreSQL everywhere. Requires Docker. Opt-in for developers who want a server-based engine or full production parity.
27+
28+
The default is `sqlite` to keep the barrier to entry as low as possible.
29+
30+
### Provider selection at startup
31+
32+
`ServiceCollectionExtensions.AddDbContextPool` reads `DATABASE_PROVIDER` and wires the appropriate EF Core provider:
33+
34+
```csharp
35+
switch (provider.ToLowerInvariant())
36+
{
37+
case "postgres":
38+
options.UseNpgsql(Environment.GetEnvironmentVariable("DATABASE_URL"));
39+
break;
40+
default:
41+
options.UseSqlite($"Data Source={dataSource}");
42+
break;
43+
}
44+
```
45+
46+
### Provider-specific EF Core migrations
47+
48+
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:
49+
50+
- SQLite migrations remain in `Migrations/` (namespace `...Migrations`).
51+
- PostgreSQL migrations are placed in `Migrations/Npgsql/` (namespace `...Migrations.Npgsql`).
52+
- 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>()`.
53+
54+
`MigrateAsync()` at startup continues to work for both providers; each sees only its own migration set.
55+
56+
### Docker Compose profiles
57+
58+
The `postgres` service is declared under the `postgres` Compose profile so it only starts when explicitly requested:
59+
60+
```bash
61+
# SQLite (default — no extra Docker service)
62+
docker compose up
63+
64+
# PostgreSQL (opt-in)
65+
DATABASE_PROVIDER=postgres docker compose --profile postgres up
66+
```
67+
68+
## Consequences
69+
70+
### Positive
71+
72+
- Developers choose their engine once and use it consistently across all environments.
73+
- SQLite remains the zero-friction default — clone, run, done.
74+
- PostgreSQL is a first-class option for production-parity testing without requiring project-wide changes.
75+
- The Compose profile approach prevents accidental PostgreSQL containers from starting in SQLite mode.
76+
- `dotnet run` continues to work unchanged with SQLite.
77+
78+
### Negative
79+
80+
- Two parallel migration sets must be maintained. Adding a schema change requires migrations in both `Migrations/` and `Migrations/Npgsql/`.
81+
- `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.
82+
- PostgreSQL support is not tested in CI (no Testcontainers integration yet — tracked in issue #353).
83+
84+
### Neutral
85+
86+
- The `DATABASE_URL` connection string format follows the Npgsql convention (`Host=...;Database=...;Username=...;Password=...`).
87+
- `.env.example` documents all three new environment variables (`DATABASE_PROVIDER`, `DATABASE_URL`, `POSTGRES_PASSWORD`). `.env` is git-ignored.

adr/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This directory contains Architecture Decision Records (ADRs) for this project. A
88
|-----|-------|--------|
99
| [0001](0001-adopt-traditional-layered-architecture.md) | Adopt Traditional Layered Architecture | Accepted (Under Reconsideration) |
1010
| [0002](0002-use-mvc-controllers-over-minimal-api.md) | Use MVC Controllers over Minimal API | Accepted |
11-
| [0003](0003-use-sqlite-for-data-storage.md) | Use SQLite for Data Storage | Accepted |
11+
| [0003](0003-use-sqlite-for-data-storage.md) | Use SQLite for Data Storage | Superseded by ADR-0014 |
1212
| [0004](0004-use-uuid-as-database-primary-key.md) | Use UUID as Database Primary Key | Accepted |
1313
| [0005](0005-use-squad-number-as-api-mutation-key.md) | Use Squad Number as API Mutation Key | Accepted |
1414
| [0006](0006-use-rfc-7807-problem-details-for-errors.md) | Use RFC 7807 Problem Details for Errors | Accepted |
@@ -19,6 +19,7 @@ This directory contains Architecture Decision Records (ADRs) for this project. A
1919
| [0011](0011-use-docker-for-containerization.md) | Use Docker for Containerization | Accepted |
2020
| [0012](0012-use-stadium-themed-semantic-versioning.md) | Use Stadium-Themed Semantic Versioning | Accepted |
2121
| [0013](0013-testing-strategy.md) | Testing Strategy | Accepted |
22+
| [0014](0014-configurable-database-provider.md) | Configurable Database Provider | Accepted |
2223

2324
## When to Create an ADR
2425

compose.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,34 @@ services:
1212
volumes:
1313
- storage:/storage/
1414
environment:
15+
- DATABASE_PROVIDER=${DATABASE_PROVIDER:-sqlite}
16+
- DATABASE_URL=${DATABASE_URL:-Host=postgres;Database=players;Username=postgres;Password=postgres}
1517
- STORAGE_PATH=/storage/players-sqlite3.db
18+
depends_on:
19+
postgres:
20+
condition: service_healthy
21+
required: false
22+
restart: unless-stopped
23+
24+
postgres:
25+
image: postgres:17-alpine
26+
container_name: aspnetcore-postgres
27+
profiles: [postgres]
28+
environment:
29+
POSTGRES_DB: players
30+
POSTGRES_USER: postgres
31+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
32+
volumes:
33+
- postgres-data:/var/lib/postgresql/data
34+
healthcheck:
35+
test: ["CMD-SHELL", "pg_isready -U postgres"]
36+
interval: 10s
37+
timeout: 5s
38+
retries: 5
1639
restart: unless-stopped
1740

1841
volumes:
1942
storage:
2043
name: dotnet-samples-aspnetcore-webapi_storage
44+
postgres-data:
45+
name: dotnet-samples-aspnetcore-webapi_postgres-data

0 commit comments

Comments
 (0)