I spent a weekend trying to build a real ASP.NET Core web API without a .csproj file. Not a toy example, not a Hello, World, but a layered CRUD service with EF Core, PostgreSQL, migrations, OpenAPI, middleware, and a PATCH endpoint.

The result is on GitHub: csharp-looks-like-go. And the title of that repo is honest about the punchline. What came out of the experiment felt a lot like writing a small Go service, which for a language I have been using since 2016 is not a sentence I expected to type.

This post walks through what file-based apps are as of .NET 11 preview 3, what the end result actually looks like, and the one place the model still hurts.

What a file-based app actually is

File-based apps are a feature that appeared in .NET 10 and got more useful in .NET 11 preview 3. The short version is this: you can run, publish, and ship a .cs file without ever creating a .csproj or a solution.

Everything the SDK needs is declared inline, at the top of the file, using a new set of preprocessor directives:

#!/usr/bin/env dotnet run

#:property TargetFramework=net11.0
#:property LangVersion=preview
#:sdk Microsoft.NET.Sdk.Web
#:package Npgsql.EntityFrameworkCore.PostgreSQL@10.0.1
#:include domain/user.cs

var app = WebApplication.CreateBuilder(args).Build();
app.MapGet("/", () => "hello from a file-based app");
app.Run();

That whole block is just main.cs. You run it with dotnet run main.cs. If you add the shebang and chmod +x, you can run it directly, like a Python or shell script.

The five directives I ended up using the most:

  • #:sdk picks the SDK (Microsoft.NET.Sdk for console apps, Microsoft.NET.Sdk.Web for ASP.NET Core).
  • #:package pulls NuGet packages, optionally pinned with @version.
  • #:property sets MSBuild properties (TargetFramework, LangVersion, InvariantGlobalization, anything else you would normally stuff into a .csproj).
  • #:include pulls another .cs file into the compilation unit.
  • #:property ExperimentalFileBasedProgramEnableTransitiveDirectives=true lets those directives propagate from included files, so they do not all have to live in main.cs.

I want to call out the transitive directives flag specifically, because it is the one that makes all of this scale past a single file. Without it, every package pin and every file include has to sit in main.cs. With it on, you can factor the project-level stuff out. In my repo, the layout looks like this:

csharp-looks-like-go/
├── main.cs          # entry point, properties, route table
├── packages.cs      # #:sdk + #:package pins
├── includes.cs      # flat list of #:include for the rest of the code
├── globals.cs       # global using directives
├── config/
│   ├── appsettings.json
│   ├── config.cs
│   └── json_context.cs
├── db/
│   ├── db_context.cs
│   ├── user_config.cs
│   └── post_config.cs
├── domain/
│   ├── user.cs
│   └── post.cs
├── handler/
│   ├── user_handler.cs
│   ├── post_handler.cs
│   └── exception_handler.cs
├── middleware/
│   └── request_logger.cs
├── migrations/
│   ├── 20260418143606_initial_create.cs
│   ├── 20260418213829_add_posts.cs
│   └── snapshot.cs
├── model/
│   ├── user_models.cs
│   └── post_models.cs
├── repository/
│   ├── user_repository.cs
│   └── post_repository.cs
├── service/
│   ├── user_service.cs
│   └── post_service.cs
└── util/
    └── string_utils.cs

The four files at the top are the project-level plumbing. Everything else is the actual application, laid out by concern. main.cs opens with four lines and you can read the rest of it end to end in one screen.

The publish story is where it clicks

The thing that actually made me write this post was not the directives. It was the moment I ran dotnet publish main.cs -o bin.

dotnet publish main.cs -o bin

That produced a single bin/main file, 33 MB, a native Mach-O executable compiled with AOT. No runtime to install on the target machine. No separate dotnet CLI. No self-contained-but-not-really folder of DLLs.

If you add 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

It drops to around 30 MB.

That is a full ASP.NET Core service, with EF Core, with the Npgsql driver, with built-in OpenAPI, with validation, as one binary. You scp it to a Linux box, you run it. There is nothing else to install.

If you write Go, this is the go build story you already know. If you write C#, it is probably the first time a dotnet publish command did not produce a directory with at least 40 files in it.

What the actual project looks like

The repo implements a pretty standard layered architecture: handler -> service -> repository -> db. Two entities (User, Post) with a one-to-many relationship. Six endpoints per entity. JSON source generation for AOT compatibility. Central exception handling through IExceptionHandler and ProblemDetails. Request logging middleware.

What I did not expect was how much ceremony just quietly went away.

The files are snake_case because I wanted the folder to look less intimidating to someone coming from Python or Go. The namespaces are lowercase singular (handler, service, repository) because Go’s net/http looks better than Java’s com.example.web.api.http.handlers.v2. The services use primary constructors:

class UserService(UserRepository repository)
{
    public Task<IReadOnlyList<User>> List() => repository.List();

    public async Task<User> Create(CreateUserRequest request)
    {
        var name = request.Name.RequireNonEmpty(nameof(request.Name));
        var email = request.Email.RequireNonEmpty(nameof(request.Email));
        await EnsureEmailIsUnique(email);

        return await repository.Create(name, email);
    }
    // ...
}

RequireNonEmpty is a small extension living in util/string_utils.cs, written using the new C# 14 extension(string str) block syntax. Not this string str. The new syntax lets you declare extension properties and static extension members too, not just instance methods, but I only needed the simple case:

namespace util;

static class StringExtensions
{
    extension(string str)
    {
        public string RequireNonEmpty(string fieldName)
        {
            var trimmed = str.Trim();
            if (string.IsNullOrWhiteSpace(trimmed))
                throw new ArgumentException($"{fieldName} is required");
            return trimmed;
        }
    }
}

Services and handlers drop the Async suffix from their method names. Every method in this codebase is async, so the suffix carries zero information at every call site. The Framework Design Guidelines still recommend Async for library APIs, and I think that is correct. This is not a library. It is a service.

Entities use required on data properties. No more = null!; lies to the compiler:

class User
{
    public long Id { get; set; }
    public required string Name { get; set; }
    public required string Email { get; set; }
    public ICollection<Post> Posts { get; set; } = [];
}

None of those are new ideas. They are just things that modern C# already had, but that fall into place very naturally once the XML and the project-level plumbing are gone.

The rough edge: EF Core migrations

This is the part where I have to be honest.

dotnet ef does not understand file-based apps as of EF Core 11 preview 3. The dotnet ef migrations add command parses a .csproj, which does not exist. Passing --project main.cs makes it try to load main.cs as MSBuild XML and fail immediately. I tried with both EF 10 stable and EF 11 preview. Same result.

The workaround is ugly but manageable: generate a throwaway .csproj alongside the code, run EF against it, delete it.

In the repo I documented a minimal .tooling/tooling.csproj that globs the entity and DbContext files from the parent folder:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>minimal_web_api</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
  </ItemGroup>

  <ItemGroup>
    <Compile Remove="**/*.cs" />
    <Compile Include="design_time_factory.cs" />
    <Compile Include="..\domain\*.cs" />
    <Compile Include="..\db\*.cs" />
    <Compile Include="..\migrations\*.cs" />
  </ItemGroup>
</Project>

Plus a tiny IDesignTimeDbContextFactory<AppDbContext> with a connection string. Then:

cd .tooling
dotnet ef migrations add AddPosts \
  --output-dir ../migrations \
  --namespace minimal_web_api.migrations
cd ..
rm -rf .tooling

After the first run I also needed to copy the updated AppDbContextModelSnapshot.cs from the tooling project’s output path back into the migrations/ folder, because EF writes the snapshot at a path computed from the tooling project root, not from --output-dir. The second or third time you do this it becomes muscle memory. The first time it is confusing.

I framed this in the repo’s README as “one remaining rough edge, tracked upstream,” which is diplomatic of me. In practice it is the main reason I would still hesitate to move a team project to file-based apps today. Everything else feels production-ready. Migrations do not.

Who this is actually for

I would not move a 50-service monolithic platform to file-based apps tomorrow. Too much muscle memory, too much tooling, too many people already hate any change that breaks their IDE shortcuts.

Where file-based apps are genuinely great right now, based on this weekend:

  • Small microservices where the ceremony of a .csproj plus a .sln was always disproportionate to the code inside.
  • CLI utilities and scripts that want to use a NuGet package. #:package plus chmod +x replaces an entire dotnet tool install dance.
  • Teaching. The first thing I always hated about teaching C# to a Python or Go developer was that hello world required dotnet new console, a project folder, and four files. echo 'Console.WriteLine("hi");' > hi.cs && dotnet run hi.cs is a much better first impression.
  • Demo repos and blog posts. This repo is a good example. The whole thing is greppable.

Where it still is not quite there:

  • Anything EF-heavy that iterates quickly on the schema.
  • Anything that depends on tooling that still assumes a .csproj (some analyzers, some IDE features).
  • Anything where your team is not on .NET 10 or newer.

What actually surprised me

I have a list of the moments where I caught myself thinking “wait, this works?”:

  • Running a .cs file with #!/usr/bin/env dotnet run at the top and then ./main.cs. It is obvious in retrospect. It still felt new.
  • dotnet publish main.cs producing a single native binary on the first try, with EF Core in the dependency graph, with trim warnings but no hard errors.
  • The extension(string str) block syntax being a net simplification over public static string X(this string s). Thirteen years of extension methods, and the new syntax is actually nicer.
  • required properties making every entity definition five lines shorter. I had been writing = null!; on autopilot for years.
  • How little I missed the .csproj. I expected to miss <ItemGroup> for something. I did not.

If I squint, most of this is not new at all. Native AOT has existed for a while. Primary constructors shipped in C# 12. required shipped in C# 11. Extension members arrived with C# 14. But file-based apps are the first time all of those pieces landed in a single configuration where a C# project looks, at a glance, like a Go project. That changes the vibe. I do not have a better word for it.

Try it

The repo is here: github.com/archie1602/csharp-looks-like-go.

It is under 20 .cs files. The runtime dependency graph is ASP.NET Core, EF Core, Npgsql, JetBrains.Annotations. The publish pipeline produces a 30 MB binary. The whole thing, from first dotnet run to a running web API, takes about ten seconds on a warm cache.

If you have an opinion on whether this model makes sense for production services, or if you hit the EF migrations workflow and found a cleaner solution than the throwaway .csproj, I would be happy to hear it. There is an issue tracker on the repo and a contact link on this blog.