A small ASP.NET Core CRUD service built as a file-based app (no .csproj, no .sln/.slnx), targeting .NET 11. The goal is to show how far modern C# can go in looking and feeling like a Go or Python web service, while still giving you EF Core, DI, and the full ASP.NET Core pipeline.
File-based apps (new in .NET 10 / refined in .NET 11 preview 3) let you ship a real web app as a handful of .cs files with directives at the top:
#:sdk Microsoft.NET.Sdk.Web
#:package Npgsql.EntityFrameworkCore.PostgreSQL@10.0.1
#:include domain/user.csNo XML, no project metadata files, no ceremony. You can run it with dotnet run main.cs, publish it with dotnet publish main.cs, and chmod +x main.cs to execute it like a script. This repo is a proof-of-concept that a layered CRUD service (entities, DTOs, EF Core, migrations, OpenAPI, middleware) fits comfortably inside that model.
- .NET 11 (preview 3):
#:sdk,#:package,#:include,#:propertydirectives - ASP.NET Core Minimal APIs
- EF Core 10 + Npgsql (PostgreSQL)
- Built-in OpenAPI (
Microsoft.AspNetCore.OpenApi) - JetBrains.Annotations for DI-flow hints
Client -> route -> handler -> service -> repository -> db -> PostgreSQL
config/ app settings + JSON source-gen context
domain/ entities (User, Post)
model/ request and error DTOs
db/ EF Core DbContext and entity configurations
repository/ data access (async EF calls)
service/ business logic and validation
handler/ HTTP handlers and exception handler
middleware/ request pipeline middleware
migrations/ EF Core migrations
main.cs entry point (MSBuild properties, includes, route table)
packages.cs NuGet pins and global usings
includes.cs central file list (transitive from main.cs)
globals.cs global using directives
The codebase intentionally leans toward Go/Python aesthetics:
- snake_case file names (
user_handler.cs,post_repository.cs): matches Go/Python, easy to grep. - lowercase, singular folder names (
handler, notHandlers): Go stdlib style (net/http,encoding/json). - Lowercase, singular namespaces (
handler,service,repository): follows the folder convention; namespaces read as Go packages (handler.UserHandler). - No Async suffix on methods. Every method here is async, so the suffix carries no information.
- Primary constructors everywhere:
class UserService(UserRepository repo)replaces the old Java-style ceremony. - Layered architecture, not vertical slices. Each concern (handler/service/repository) has its own folder. Simple enough to navigate, teaches the pipeline explicitly.
- Central JSON source-gen context (
config/json_context.cs): AOT-friendly, one place to register all serialized types. - Central exception handling via
IExceptionHandler+ProblemDetails. Handlers don't try/catch. - Flat
#:includemanifest. All file includes live inincludes.cs, referenced once frommain.cs.main.csstays a short preamble (properties, packages, includes, entry point).
# users
GET /api/users/
GET /api/users/{id}
POST /api/users/
PUT /api/users/{id}
PATCH /api/users/{id}
DELETE /api/users/{id}
# posts (nested under user)
GET /api/users/{userId}/posts/
GET /api/users/{userId}/posts/{id}
POST /api/users/{userId}/posts/
PUT /api/users/{userId}/posts/{id}
PATCH /api/users/{userId}/posts/{id}
DELETE /api/users/{userId}/posts/{id}
# openapi
GET /openapi/v1.jsondotnet run main.csOr, with the shebang and execute permission:
chmod +x main.cs
./main.csFile-based apps publish with Native AOT by default. One command, one native executable. Same distribution story Go gives you with go build.
dotnet publish main.cs -o binOutput: a single native binary at bin/main, around 33 MB out of the box.
For a leaner binary, pass a few size-oriented flags:
dotnet publish main.cs -o bin \
-p:OptimizationPreference=Size \
-p:InvariantGlobalization=true \
-p:DebuggerSupport=false \
-p:EventSourceSupport=false \
-p:HttpActivityPropagationSupport=false \
-p:StripSymbols=true| Out of the box | Size-optimized |
|---|---|
| ~33 MB | ~30 MB |
That's a full ASP.NET Core web service with EF Core, PostgreSQL driver, validation, OpenAPI, and a PATCH API, in one file weighing roughly the same as a modern Electron app's splash screen.
- No .NET SDK
- No .NET runtime
- No ASP.NET Core shared framework
- No dotnet CLI
- No container, no glibc shim, no sidecar
Just the binary. scp bin/main user@server:/opt/app/main, ./main, done. Exactly like shipping a Go binary, except you're writing C#.
./bin/mainMigrations are checked in under migrations/ (snake_case filenames matching the file-naming convention).
As of .NET 11 preview 3, dotnet ef tool still expects an SDK-style .csproj and can't target a file-based app directly. To add a migration:
- Create a temporary
.tooling/tooling.csprojthat globs the entity + DbContext + migration files from the repo via<Compile Include="..\domain\*.cs" />etc., and referencesMicrosoft.EntityFrameworkCore.Design+ the Postgres provider. - Add a small
IDesignTimeDbContextFactory<AppDbContext>in the tooling folder. - Run
dotnet ef migrations add <name> --output-dir ../migrations --namespace minimal_web_api.migrationsfrom inside.tooling/. - Rename the generated
<name>.Designer.csto<name>.designer.csto match the file-naming convention. - Delete
.tooling/.
This is the one remaining rough edge of the file-based-app model and is tracked upstream.
