Passei um fim de semana tentando montar uma Web API ASP.NET Core de verdade, sem um arquivo .csproj. Não um exemplo trivial, nem um Hello, World, e sim um serviço CRUD em camadas, com EF Core, PostgreSQL, migrations, OpenAPI, middleware e um endpoint PATCH.

O resultado está no GitHub: csharp-looks-like-go. E o nome do repositório já entrega o spoiler. O que saiu do experimento lembrou bastante escrever um pequeno serviço em Go, o que, para uma linguagem que eu uso desde 2020, definitivamente não é uma frase que eu esperava estar escrevendo.

Neste post vou mostrar o que são os file-based apps na versão .NET 11 preview 3, como fica o resultado final na prática, e o único ponto em que esse modelo ainda dói.

O que é, na prática, um file-based app

Os file-based apps são uma funcionalidade que apareceu no .NET 10 e ficou bem mais útil no .NET 11 preview 3. Resumindo: você consegue rodar, publicar e entregar um arquivo .cs sem nunca criar um .csproj ou uma solution.

Tudo que o SDK precisa é declarado inline, no topo do arquivo, usando um novo conjunto de diretivas de pré-processador:

#!/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();

Esse bloco inteiro é simplesmente o main.cs. Você roda com dotnet run main.cs. Se acrescentar o shebang e um chmod +x, dá para executar direto, como se fosse um script Python ou shell.

As cinco diretivas que eu mais acabei usando:

  • #:sdk escolhe o SDK (Microsoft.NET.Sdk para aplicações console, Microsoft.NET.Sdk.Web para ASP.NET Core).
  • #:package puxa pacotes NuGet, com versão opcionalmente fixada via @version.
  • #:property define propriedades do MSBuild (TargetFramework, LangVersion, InvariantGlobalization, e qualquer outra coisa que você normalmente colocaria dentro do .csproj).
  • #:include inclui outro arquivo .cs na mesma unidade de compilação.
  • #:property ExperimentalFileBasedProgramEnableTransitiveDirectives=true deixa essas diretivas se propagarem a partir dos arquivos incluídos, para que elas não precisem todas morar no main.cs.

Quero destacar especificamente essa flag de diretivas transitivas, porque é ela que faz tudo isso escalar para além de um único arquivo. Sem ela, cada package pin e cada #:include precisa ficar no main.cs. Com ela ligada, dá para extrair toda a parte de configuração do projeto para outro lugar. No meu repositório, a estrutura está assim:

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

Os quatro arquivos lá em cima são o encanamento do projeto. O restante é a aplicação propriamente dita, dividida por responsabilidade. O main.cs começa com quatro linhas, e o resto dele cabe numa tela só, do começo ao fim.

O ponto em que a coisa encaixa mesmo é no publish

O que realmente me fez sentar e escrever este post não foram as diretivas. Foi o momento em que eu rodei dotnet publish main.cs -o bin.

dotnet publish main.cs -o bin

Isso gerou um único arquivo bin/main, de 33 MB, um executável nativo no formato Mach-O compilado via AOT. Nada de runtime para instalar na máquina de destino. Nada de dotnet CLI separado. Nada daquela pasta self-contained cheia de DLLs que, no fim, nunca é totalmente self-contained.

Se você adicionar algumas flags focadas em tamanho:

dotnet publish main.cs -o bin \
  -p:OptimizationPreference=Size \
  -p:InvariantGlobalization=true \
  -p:DebuggerSupport=false \
  -p:EventSourceSupport=false \
  -p:HttpActivityPropagationSupport=false \
  -p:StripSymbols=true

o tamanho cai para algo em torno de 30 MB.

Isso é um serviço ASP.NET Core completo, com EF Core, driver Npgsql, OpenAPI nativo e validação, tudo em um binário só. Você faz scp dele para uma máquina Linux, e roda. Não precisa instalar mais nada.

Se você trabalha com Go, essa é a história do go build que já conhece de cor. Se trabalha com C#, é, provavelmente, a primeira vez que um dotnet publish não gerou um diretório com pelo menos uns quarenta arquivos dentro.

Como o projeto em si fica

O repositório implementa uma arquitetura em camadas bem tradicional: handler -> service -> repository -> db. Duas entidades (User, Post) com relacionamento one-to-many. Seis endpoints por entidade. Source generation de JSON para manter a compatibilidade com AOT. Tratamento centralizado de exceções via IExceptionHandler e ProblemDetails. Um middleware de logging de requisições.

O que eu não esperava era a quantidade de cerimônia que simplesmente desapareceu, sem eu nem perceber.

Os arquivos estão em snake_case porque eu queria que a pasta parecesse menos intimidadora para alguém vindo de Python ou Go. Os namespaces estão em minúsculo e no singular (handler, service, repository), porque o net/http do Go, honestamente, fica melhor do que um com.example.web.api.http.handlers.v2 à la Java. Os services usam 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 é um pequeno extension method que fica em util/string_utils.cs, escrito usando o novo bloco extension(string str) do C# 14. Não é mais this string str. A nova sintaxe também permite declarar extension properties e extension members estáticos, não apenas métodos de instância, mas no meu caso bastou o mais simples:

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;
        }
    }
}

Nos services e nos handlers eu removi o sufixo Async dos nomes dos métodos. Nessa base de código, todo método é assíncrono, então o sufixo não carrega nenhuma informação útil no ponto de chamada. As Framework Design Guidelines continuam recomendando o Async para APIs de biblioteca, e eu concordo. Só que isso aqui não é uma biblioteca. É um serviço.

Nas entidades, as propriedades de dados usam required. Acabou aquela mentirinha com = null!; para o compilador:

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; } = [];
}

Nada disso é ideia nova. São só coisas que o C# moderno já tinha, mas que se encaixam de maneira bem natural quando o XML e toda a encanação de projeto somem do caminho.

A arestinha: migrations do EF Core

Aqui é a parte em que eu preciso ser honesto.

No EF Core 11 preview 3, o dotnet ef ainda não entende file-based apps. O comando dotnet ef migrations add espera um .csproj, que simplesmente não existe. Se você passar --project main.cs, a ferramenta vai tentar interpretar o main.cs como XML do MSBuild e quebrar na hora. Testei tanto no EF 10 estável quanto no EF 11 preview. Mesmo resultado.

O workaround é feio, mas dá para conviver: gerar um .csproj descartável ao lado do código, rodar o EF em cima dele e, depois, apagar.

No repositório eu documentei um .tooling/tooling.csproj mínimo, que inclui via glob as entidades e o DbContext a partir da pasta-pai:

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

Junto com isso, uma IDesignTimeDbContextFactory<AppDbContext> bem enxuta, com a connection string. Aí é só:

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

Depois da primeira execução, também precisei copiar manualmente o AppDbContextModelSnapshot.cs atualizado da pasta de saída do tooling project de volta para a pasta migrations/, porque o EF grava o snapshot em um caminho calculado a partir da raiz do tooling project, e não do --output-dir. Na segunda ou terceira vez, isso já vira memória muscular. Na primeira, confunde bastante.

No README do repositório, eu descrevi essa parte como “uma arestinha remanescente, com issue aberta upstream”, o que, pensando bem, foi bem diplomático da minha parte. Na prática, esse é o principal motivo pelo qual eu ainda hesitaria em migrar um projeto de time para file-based apps hoje. Todo o resto já parece production-ready. As migrations, não.

Para quem isso serve, de fato, hoje

Eu não migraria amanhã uma plataforma com cinquenta serviços para file-based apps. Memória muscular demais acumulada, tooling demais envolvido, gente demais que já detesta qualquer mudança que quebre os atalhos favoritos da IDE.

Os cenários nos quais os file-based apps já são realmente bons hoje, pelo que vi neste fim de semana:

  • Microsserviços pequenos, em que a cerimônia de um .csproj mais um .sln sempre foi desproporcional em relação à quantidade de código dentro.
  • Utilitários de CLI e scripts que precisam usar um pacote NuGet. #:package mais um chmod +x substitui toda aquela dança do dotnet tool install.
  • Ensino. A primeira coisa que eu sempre achei ruim em ensinar C# para quem vinha de Python ou Go era que o hello world exigia um dotnet new console, uma pasta de projeto e quatro arquivos. echo 'Console.WriteLine("hi");' > hi.cs && dotnet run hi.cs dá uma primeira impressão bem melhor.
  • Repositórios de demo e posts de blog. Este repo é um bom exemplo. Dá para percorrer o projeto inteiro só com grep.

Os cenários em que o modelo ainda não está lá:

  • Qualquer coisa pesada em EF, com iteração rápida no schema.
  • Qualquer coisa que dependa de tooling que ainda parte do princípio de que existe um .csproj (parte dos analyzers, alguns recursos da IDE).
  • Qualquer coisa em que o time ainda não esteja no .NET 10 ou em uma versão mais recente.

O que de fato me surpreendeu

Tenho uma lista de momentos em que me peguei pensando “espera, isso funciona mesmo?”:

  • Rodar um arquivo .cs com #!/usr/bin/env dotnet run na primeira linha e depois chamar ./main.cs. Em retrospecto, é óbvio. Mesmo assim, a sensação foi de novidade.
  • O dotnet publish main.cs produzindo um único binário nativo já de primeira, com EF Core no grafo de dependências, com alguns trim warnings, mas sem erros fatais.
  • A sintaxe em bloco extension(string str) sendo, no fim das contas, uma simplificação em cima do velho public static string X(this string s). Treze anos de extension methods, e o novo jeito realmente é mais agradável.
  • Propriedades required fazendo cada definição de entidade ficar umas cinco linhas mais curta. Eu escrevia = null!; no piloto automático havia anos.
  • O quão pouco eu senti falta do .csproj. Esperava sentir falta do <ItemGroup> para alguma coisa. Não senti.

Se eu parar para olhar, quase nada disso, isoladamente, é novidade. Native AOT existe há um bom tempo. Primary constructors chegaram no C# 12. O required apareceu no C# 11. Extension members vieram com o C# 14. Mas os file-based apps são a primeira vez em que todas essas peças se encaixam em uma única configuração, em que um projeto em C#, à primeira vista, parece um projeto em Go. Isso muda a vibe. Não tenho palavra melhor para descrever.

Experimenta aí

O repositório está aqui: github.com/archie1602/csharp-looks-like-go.

São menos de vinte arquivos .cs. O grafo de dependências em runtime é ASP.NET Core, EF Core, Npgsql e JetBrains.Annotations. O pipeline de publish gera um binário de 30 MB. O caminho inteiro, do primeiro dotnet run até uma Web API no ar, leva cerca de dez segundos com o cache aquecido.

Se você tiver opinião sobre se esse modelo faz sentido para serviços em produção, ou se passou pelo fluxo de migrations do EF e encontrou uma solução mais limpa do que o .csproj descartável, me conte, vou gostar muito de ler.