Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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.
12 changes: 7 additions & 5 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

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.
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.

## Tech Stack

Expand All @@ -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 @@ -181,7 +181,7 @@ Example: `feat(api): add player search endpoint (#123)`
- Production configurations or deployment secrets
- `.runsettings` coverage thresholds
- Port configuration (9000)
- Database type (SQLite — demo/dev only)
- Migration namespace constants in `ProviderSpecificMigrationsAssembly` — renaming breaks runtime provider filtering for one or both providers
- 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

### Creating Issues
Expand All @@ -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
20 changes: 19 additions & 1 deletion .sonarcloud.properties
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ sonar.exclusions=\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs

# =============================================================================
# Coverage exclusions
Expand All @@ -47,6 +53,12 @@ sonar.coverage.exclusions=\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Program.cs

Expand Down Expand Up @@ -77,6 +89,12 @@ sonar.cpd.exclusions=\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Utilities/PlayerData.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs,\
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,30 @@ 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

- Populate `BuildTargetModel` in Npgsql seed migration designer files so Npgsql's SQL generator can resolve column types when applying `InsertData` operations.
- 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.
- Normalize `DATABASE_PROVIDER` to lowercase in `entrypoint.sh` via `tr` so `POSTGRES`, `Postgres`, etc. are handled consistently with `AddDbContextPool`.
- 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.
- Move `Npgsql.EntityFrameworkCore.PostgreSQL` package from the "Development dependencies" `ItemGroup` to "Runtime dependencies".

### 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
Loading
Loading