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:
#:sdkescolhe o SDK (Microsoft.NET.Sdkpara aplicações console,Microsoft.NET.Sdk.Webpara ASP.NET Core).#:packagepuxa pacotes NuGet, com versão opcionalmente fixada via@version.#:propertydefine propriedades do MSBuild (TargetFramework,LangVersion,InvariantGlobalization, e qualquer outra coisa que você normalmente colocaria dentro do.csproj).#:includeinclui outro arquivo.csna mesma unidade de compilação.#:property ExperimentalFileBasedProgramEnableTransitiveDirectives=truedeixa essas diretivas se propagarem a partir dos arquivos incluídos, para que elas não precisem todas morar nomain.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
.csprojmais um.slnsempre foi desproporcional em relação à quantidade de código dentro. - Utilitários de CLI e scripts que precisam usar um pacote NuGet.
#:packagemais umchmod +xsubstitui toda aquela dança dodotnet 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.csdá 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
.cscom#!/usr/bin/env dotnet runna primeira linha e depois chamar./main.cs. Em retrospecto, é óbvio. Mesmo assim, a sensação foi de novidade. - O
dotnet publish main.csproduzindo 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 velhopublic static string X(this string s). Treze anos de extension methods, e o novo jeito realmente é mais agradável. - Propriedades
requiredfazendo 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.
