Guides.NET

ASP.NET Core Minimal API with Dapper and PostgreSQL

Create a .NET Minimal API with Dapper, Npgsql, PostgreSQL, health checks, xUnit, and a Dockerfile using Better Fullstack.

Updated October 6, 2026aspnet-coredapperpostgresdotnet

Use this stack when you want an ASP.NET Core API that runs hand-written SQL through Dapper, without an EF Core model or migrations.

npm create better-fullstack@latest my-dapper-api -- \  --ecosystem dotnet \  --dotnet-web-framework aspnet-minimal \  --dotnet-orm dapper \  --dotnet-auth none \  --dotnet-api minimal-api \  --dotnet-testing xunit \  --dotnet-job-queue none \  --dotnet-realtime none \  --dotnet-observability health-checks \  --dotnet-validation none \  --dotnet-caching none \  --dotnet-deploy docker \  --dotnet-libraries none \  --database postgres

Several .NET options have non-none defaults (Identity, SignalR, and Serilog among them), so this command sets each one explicitly.

What this creates

  • An ASP.NET Core Minimal API targeting net10.0.
  • Dapper and Npgsql package references, with a TodoDapper helper that opens connections and creates the Todos table.
  • GET /api/todos and POST /api/todos endpoints that run SQL through Dapper.
  • ASP.NET Core health checks mapped at /health.
  • An xUnit test project that boots the app with WebApplicationFactory.
  • A two-stage Dockerfile that publishes the app.

Generated shape

The project name and namespace replace every character outside A-Za-z0-9_ with an underscore, so my-dapper-api produces my_dapper_api.

txt
my-dapper-api/
├── my_dapper_api.csproj
├── my_dapper_api.http
├── Program.cs
├── appsettings.json
├── appsettings.Development.json
├── Dockerfile
├── .env.example
└── my_dapper_api.Tests/
    ├── my_dapper_api.Tests.csproj
    └── ApiTests.cs

Endpoints, the TodoItem model, and the Dapper helper all live in Program.cs.

Prerequisites

  • .NET SDK 10 or newer.
  • A PostgreSQL server. appsettings.json points at localhost:5432 with the postgres user and password and a database named my-dapper-api, which the app does not create.

Run and verify

Create the database the connection string names before the first request. The generated code creates the Todos table, not the database:

bash
createdb -h localhost -U postgres my-dapper-api

Then restore and start the app:

bash
cd my-dapper-api
dotnet restore
dotnet run

The app listens on the URL it prints at startup, http://localhost:5000 by default, or on PORT when it is set. GET / returns a JSON status, GET /health returns Healthy, and the todo endpoints read and write PostgreSQL:

bash
curl -X POST http://localhost:5000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Write SQL"}'
curl http://localhost:5000/api/todos

Override the connection string with a real environment variable instead of editing the committed file:

bash
export ConnectionStrings__DefaultConnection="Host=localhost;Port=5432;Database=my-dapper-api;Username=postgres;Password=postgres"

.env.example lists a DATABASE_URL, but Program.cs never reads it. Dapper uses ConnectionStrings:DefaultConnection only.

No solution file is generated, so name the test project when running tests:

bash
dotnet test my_dapper_api.Tests/my_dapper_api.Tests.csproj

The generated test requests / and does not touch the database.

How the schema is created

Unlike the EF Core scaffold, this one needs no migration step. Each todo endpoint calls EnsureSchemaAsync before running its query:

csharp
public static async Task EnsureSchemaAsync(System.Data.Common.DbConnection db)
{
    const string sql = """
        CREATE TABLE IF NOT EXISTS Todos (
            Id SERIAL PRIMARY KEY,
            Title TEXT NOT NULL,
            Complete BOOLEAN NOT NULL DEFAULT FALSE
        )
        """;
    await db.ExecuteAsync(sql);
}

That keeps the first request working against a database with no tables, at the cost of one extra statement per call. Move the call to startup, or replace it with a migration tool, once the schema has more than one table.

Representative snippets

The insert returns the created row in one round trip with PostgreSQL's RETURNING clause:

csharp
app.MapPost("/api/todos", async (TodoInput input) =>
{
    await using var db = TodoDapper.CreateConnection(dapperConnectionString);
    await TodoDapper.EnsureSchemaAsync(db);
    const string sql = """
        INSERT INTO Todos (Title, Complete)
        VALUES (@Title, @Complete)
        RETURNING Id, Title, Complete
        """;
    var todo = await db.QuerySingleAsync<TodoItem>(sql, new { input.Title, Complete = false });
    return Results.Created($"/api/todos/{todo.Id}", todo);
});

PostgreSQL folds the unquoted identifiers to lowercase, and Dapper maps the id, title, and complete columns back onto TodoItem by name.

When to choose it

Choose Dapper when the team prefers to read and review the SQL that runs, or when queries are reporting-heavy and an ORM model would add little. Choose the EF Core guide when you want change tracking, migrations, and ASP.NET Identity stores.

Compatibility notes

  • --dotnet-orm dapper picks its driver from --database: Npgsql for postgres, SQLite otherwise.
  • --dotnet-observability health-checks registers AddHealthChecks() with no database check, so /health reports the process, not PostgreSQL.
  • Deployment for .NET comes from --dotnet-deploy, not the TypeScript --web-deploy and --server-deploy flags.

Deployment notes

The Dockerfile publishes with the .NET 10 SDK image and runs on the ASP.NET 10 runtime image. It sets ASPNETCORE_URLS=http://+:8080 and exposes port 8080. Because Program.cs only honors PORT when ASPNETCORE_URLS is unset, the container listens on 8080 regardless of PORT.

Troubleshooting

SymptomCheck
Failed to connect to 127.0.0.1:5432Start PostgreSQL or set ConnectionStrings__DefaultConnection.
database "my-dapper-api" does not existCreate it with createdb; the app creates only the Todos table.
DATABASE_URL has no effectThe app reads ConnectionStrings:DefaultConnection, not DATABASE_URL.
Container ignores PORTThe image sets ASPNETCORE_URLS, which takes precedence.
dotnet test from the root runs nothingPass the test project path; there is no solution file.
localhost:5000 returns 403 on macOSAirPlay Receiver also listens on port 5000. Start the app with another port, for example PORT=5080 dotnet run.
The generated README mentions TypeScript and npmThe .NET scaffold currently writes the TypeScript README. Use the commands in this guide.

Next steps

GitHub Sponsors