[{"content":"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.\nO 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.\nNeste 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.\nO 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.\nTudo que o SDK precisa é declarado inline, no topo do arquivo, usando um novo conjunto de diretivas de pré-processador:\n#!/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(\u0026#34;/\u0026#34;, () =\u0026gt; \u0026#34;hello from a file-based app\u0026#34;); 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.\nAs cinco diretivas que eu mais acabei usando:\n#: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:\ncsharp-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.\nO 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.\ndotnet 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.\nSe você adicionar algumas flags focadas em tamanho:\ndotnet 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.\nIsso é 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.\nSe 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.\nComo o projeto em si fica O repositório implementa uma arquitetura em camadas bem tradicional: handler -\u0026gt; service -\u0026gt; repository -\u0026gt; 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.\nO que eu não esperava era a quantidade de cerimônia que simplesmente desapareceu, sem eu nem perceber.\nOs 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:\nclass UserService(UserRepository repository) { public Task\u0026lt;IReadOnlyList\u0026lt;User\u0026gt;\u0026gt; List() =\u0026gt; repository.List(); public async Task\u0026lt;User\u0026gt; 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:\nnamespace util; static class StringExtensions { extension(string str) { public string RequireNonEmpty(string fieldName) { var trimmed = str.Trim(); if (string.IsNullOrWhiteSpace(trimmed)) throw new ArgumentException($\u0026#34;{fieldName} is required\u0026#34;); 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.\nNas entidades, as propriedades de dados usam required. Acabou aquela mentirinha com = null!; para o compilador:\nclass User { public long Id { get; set; } public required string Name { get; set; } public required string Email { get; set; } public ICollection\u0026lt;Post\u0026gt; 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.\nA arestinha: migrations do EF Core Aqui é a parte em que eu preciso ser honesto.\nNo 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.\nO 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.\nNo repositório eu documentei um .tooling/tooling.csproj mínimo, que inclui via glob as entidades e o DbContext a partir da pasta-pai:\n\u0026lt;Project Sdk=\u0026#34;Microsoft.NET.Sdk\u0026#34;\u0026gt; \u0026lt;PropertyGroup\u0026gt; \u0026lt;TargetFramework\u0026gt;net10.0\u0026lt;/TargetFramework\u0026gt; \u0026lt;RootNamespace\u0026gt;minimal_web_api\u0026lt;/RootNamespace\u0026gt; \u0026lt;/PropertyGroup\u0026gt; \u0026lt;ItemGroup\u0026gt; \u0026lt;PackageReference Include=\u0026#34;Microsoft.EntityFrameworkCore.Design\u0026#34; Version=\u0026#34;10.0.0\u0026#34;\u0026gt; \u0026lt;PrivateAssets\u0026gt;all\u0026lt;/PrivateAssets\u0026gt; \u0026lt;/PackageReference\u0026gt; \u0026lt;PackageReference Include=\u0026#34;Npgsql.EntityFrameworkCore.PostgreSQL\u0026#34; Version=\u0026#34;10.0.1\u0026#34; /\u0026gt; \u0026lt;/ItemGroup\u0026gt; \u0026lt;ItemGroup\u0026gt; \u0026lt;Compile Remove=\u0026#34;**/*.cs\u0026#34; /\u0026gt; \u0026lt;Compile Include=\u0026#34;design_time_factory.cs\u0026#34; /\u0026gt; \u0026lt;Compile Include=\u0026#34;..\\domain\\*.cs\u0026#34; /\u0026gt; \u0026lt;Compile Include=\u0026#34;..\\db\\*.cs\u0026#34; /\u0026gt; \u0026lt;Compile Include=\u0026#34;..\\migrations\\*.cs\u0026#34; /\u0026gt; \u0026lt;/ItemGroup\u0026gt; \u0026lt;/Project\u0026gt; Junto com isso, uma IDesignTimeDbContextFactory\u0026lt;AppDbContext\u0026gt; bem enxuta, com a connection string. Aí é só:\ncd .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.\nNo README do repositório, eu descrevi essa parte como \u0026ldquo;uma arestinha remanescente, com issue aberta upstream\u0026rdquo;, 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.\nPara 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.\nOs cenários nos quais os file-based apps já são realmente bons hoje, pelo que vi neste fim de semana:\nMicrosserviç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(\u0026quot;hi\u0026quot;);' \u0026gt; hi.cs \u0026amp;\u0026amp; 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á:\nQualquer 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 \u0026ldquo;espera, isso funciona mesmo?\u0026rdquo;:\nRodar 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 \u0026lt;ItemGroup\u0026gt; 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.\nExperimenta aí O repositório está aqui: github.com/archie1602/csharp-looks-like-go.\nSã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.\nSe 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.\n","permalink":"https://makarchie.com/pt-br/posts/csharp-that-looks-like-go-file-based-apps/","summary":"Montei uma Web API CRUD completa, em camadas, usando um único file-based app em C#. Sem arquivos de projeto, sem XML. O resultado publica em um binário nativo de 30 MB, a sensação lembra muito entregar um serviço em Go, e só apareceu uma arestinha pelo caminho.","title":"C# que parece Go: construindo uma Web API sem .csproj"},{"content":"Olá, eu sou o archie (Artem) Desenvolvedor backend .NET pleno.\nStack: C#, ASP.NET Core, Docker, GitHub Actions, PostgreSQL. Por que este blog? Compartilho experiência de verdade: padrões que funcionaram, armadilhas que doeram e pequenos trechos de código que economizam horas.\nOnde me encontrar https://github.com/archie1602 https://www.linkedin.com/in/makarchie/ Email: contact@makarchie.com ","permalink":"https://makarchie.com/pt-br/about/","summary":"\u003ch2 id=\"olá-eu-sou-o-archie-artem\"\u003eOlá, eu sou o archie (Artem)\u003c/h2\u003e\n\u003cp\u003eDesenvolvedor backend .NET pleno.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eStack:\u003c/strong\u003e C#, ASP.NET Core, Docker, GitHub Actions, PostgreSQL.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"por-que-este-blog\"\u003ePor que este blog?\u003c/h3\u003e\n\u003cp\u003eCompartilho experiência de verdade: padrões que funcionaram, armadilhas que doeram e pequenos trechos de código que economizam horas.\u003c/p\u003e\n\u003ch3 id=\"onde-me-encontrar\"\u003eOnde me encontrar\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"https://github.com/archie1602\"\u003ehttps://github.com/archie1602\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://www.linkedin.com/in/makarchie/\"\u003ehttps://www.linkedin.com/in/makarchie/\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003eEmail: \u003ccode\u003econtact@makarchie.com\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e","title":"Sobre"}]