На выходных я решил проверить, можно ли собрать нормальное ASP.NET Core Web API без файла .csproj. Не игрушечный пример, не Hello, World, а полноценный CRUD-сервис со слоистой архитектурой, EF Core, PostgreSQL, миграциями, OpenAPI, middleware и PATCH-эндпоинтом.

Результат лежит на GitHub: csharp-looks-like-go. Название репозитория честно передаёт суть. То, что получилось по итогам эксперимента, по ощущениям очень напоминало разработку небольшого сервиса на Go. Для языка, которым я пользуюсь с 2020 года, это, мягко говоря, не та фраза, которую я когда-либо ожидал написать.

В этом посте я разберу, что собой представляют file-based apps по состоянию на .NET 11 preview 3, как итоговый результат выглядит на практике и в каком месте эта модель всё ещё даёт сбой.

Что такое file-based app на практике

File-based apps появились в .NET 10 и заметно подросли по возможностям в .NET 11 preview 3. Если коротко, суть такая: вы можете запускать, публиковать и поставлять .cs-файл, не создавая ни .csproj, ни solution.

Всё, что нужно SDK, объявляется прямо в самом файле, вверху, через новый набор директив препроцессора:

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

Весь этот блок и есть main.cs. Запускается он через dotnet run main.cs. Если добавить shebang и выполнить chmod +x, его можно запускать напрямую, как обычный Python- или shell-скрипт.

Пять директив, которыми я пользовался чаще всего:

  • #:sdk выбирает SDK (Microsoft.NET.Sdk для консольных приложений, Microsoft.NET.Sdk.Web для ASP.NET Core).
  • #:package подтягивает NuGet-пакеты, при необходимости с фиксированной версией через @version.
  • #:property задаёт свойства MSBuild (TargetFramework, LangVersion, InvariantGlobalization и всё остальное, что обычно лежит в .csproj).
  • #:include подключает другой .cs-файл в единицу компиляции.
  • #:property ExperimentalFileBasedProgramEnableTransitiveDirectives=true позволяет этим директивам транзитивно распространяться из подключаемых файлов, чтобы не держать их все в main.cs.

Отдельно хочу выделить именно флаг транзитивных директив. Это та деталь, которая позволяет всей схеме масштабироваться дальше одного файла. Без него все package pin’ы и все #:include пришлось бы держать в main.cs. С ним проектные декларации можно спокойно вынести отдельно. В моём репозитории структура выглядит так:

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

Четыре файла наверху отвечают за проектную обвязку. Всё остальное, собственно приложение, разложено по зонам ответственности. main.cs начинается с четырёх строк, а весь его остальной код помещается в один экран.

Именно публикация делает эту модель убедительной

То, что реально заставило меня сесть за этот пост, не директивы как таковые. А момент, когда я выполнил dotnet publish main.cs -o bin.

dotnet publish main.cs -o bin

Эта команда сгенерировала один файл bin/main размером 33 МБ, нативный исполняемый Mach-O, скомпилированный через AOT. На целевой машине не нужно ставить runtime. Не нужен отдельный dotnet CLI. Не нужна папка из кучи DLL под видом self-contained deployment.

Если добавить несколько флагов, ориентированных на размер:

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

размер уменьшается примерно до 30 МБ.

Это полноценный сервис на ASP.NET Core. С EF Core, драйвером Npgsql, встроенным OpenAPI, валидацией. И всё это в одном бинарнике. Вы просто копируете его на Linux-машину через scp и запускаете. Больше ставить ничего не нужно.

Если вы пишете на Go, это хорошо знакомая вам история про go build. Если вы пишете на C#, то, возможно, это первый случай, когда dotnet publish не создал каталог минимум из сорока файлов.

Как выглядит сам проект

Репозиторий реализует вполне стандартную слоистую архитектуру: handler -> service -> repository -> db. Две сущности (User, Post) со связью one-to-many. По шесть эндпоинтов на каждую сущность. Генерация JSON source для совместимости с AOT. Централизованная обработка исключений через IExceptionHandler и ProblemDetails. Middleware для логирования запросов.

Чего я не ожидал, так это того, как много церемониального кода просто тихо испарилось.

Файлы названы в snake_case, потому что мне хотелось, чтобы структура каталога выглядела менее пугающе для человека, пришедшего из Python или Go. Пространства имён в нижнем регистре и в единственном числе (handler, service, repository), потому что Go-шный net/http выглядит лучше, чем Java-подобное com.example.web.api.http.handlers.v2. Сервисы используют 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 это небольшое расширение из util/string_utils.cs, написанное с использованием нового синтаксиса C# 14: extension(string str). Не this string str. Новый синтаксис позволяет объявлять не только методы экземпляра, но и extension properties, а также static extension members, но в моём случае хватило базового варианта:

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

В сервисах и хендлерах я убрал суффикс Async из названий методов. В этой кодовой базе асинхронны вообще все методы, и на уровне вызова этот суффикс не несёт никакой информации. Framework Design Guidelines по-прежнему рекомендуют Async для библиотечных API, и я считаю, что это правильно. Но это не библиотека. Это сервис.

В сущностях для свойств данных используется required. Больше не нужно врать компилятору через = null!;:

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

Во всём этом нет ничего концептуально нового. Это просто вещи, которые в современном C# уже были, но они удивительно естественно складываются в цельную картину, когда из проекта исчезают XML и лишняя проектная обвязка.

Шероховатое место: миграции EF Core

А вот здесь нужно быть честным.

На момент EF Core 11 preview 3 команда dotnet ef не понимает file-based apps. Команда dotnet ef migrations add ожидает .csproj, а его просто не существует. Если передать --project main.cs, инструмент попытается интерпретировать main.cs как MSBuild XML и сразу же упадёт. Я проверял и на стабильном EF 10, и на preview-версии EF 11. Результат одинаковый.

Обходной путь некрасивый, но рабочий: рядом с кодом нужно временно сгенерировать вспомогательный .csproj, прогнать через него EF и затем удалить.

В репозитории я описал минимальный .tooling/tooling.csproj, который подхватывает сущности и DbContext из родительской папки:

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

Плюс небольшой IDesignTimeDbContextFactory<AppDbContext> со строкой подключения. Дальше:

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

После первого запуска мне также пришлось вручную скопировать обновлённый AppDbContextModelSnapshot.cs из выходного каталога tooling-проекта обратно в папку migrations/, потому что EF записывает snapshot по пути, вычисленному относительно корня tooling-проекта, а не относительно --output-dir. На второй или третий раз это уже делается на уровне мышечной памяти. В первый выглядит довольно запутанно.

В README репозитория я описал это как «one remaining rough edge, tracked upstream», что с моей стороны, пожалуй, довольно дипломатично. На практике именно это главная причина, по которой я пока не спешил бы переводить командный проект на file-based apps. Всё остальное ощущается вполне production-ready. Миграции нет.

Для кого это вообще сейчас подходит

Я бы не стал завтра переводить на file-based apps платформу из пятидесяти сервисов. Слишком много накопленной привычки, слишком много tooling’а, слишком много людей, которые и так не любят любые изменения, ломающие привычные IDE-shortcuts.

Но вот где file-based apps уже сейчас действительно хороши, по моим ощущениям после этих выходных:

  • Небольшие микросервисы, где наличие .csproj и .sln всегда выглядело непропорционально объёму самого кода.
  • CLI-утилиты и скрипты, которым нужен NuGet-пакет. #:package плюс chmod +x заменяют целый танец с dotnet tool install.
  • Обучение. Больше всего в преподавании C# разработчикам из Python или Go мне не нравилось то, что даже hello world требовал dotnet new console, отдельную папку проекта и четыре файла. echo 'Console.WriteLine("hi");' > hi.cs && dotnet run hi.cs это уже совсем другое первое впечатление.
  • Демонстрационные репозитории и посты в блогах. Этот репозиторий хороший пример. Весь проект легко просматривается через grep.

А вот где модель пока ещё не дотягивает:

  • Всё, что сильно завязано на EF и активно меняет схему.
  • Всё, что зависит от tooling’а, который по-прежнему предполагает наличие .csproj (часть анализаторов, некоторые функции IDE).
  • Всё, где команда ещё не работает на .NET 10 или новее.

Что меня действительно удивило

У меня накопился список моментов, в которых я ловил себя на мысли: «Подождите, это и правда работает?»:

  • Запуск .cs-файла с #!/usr/bin/env dotnet run в первой строке, а затем через ./main.cs. Задним числом это кажется очевидным, но ощущалось всё равно как нечто новое.
  • То, что dotnet publish main.cs с первой попытки выдаёт один нативный бинарник, даже при наличии EF Core в графе зависимостей, с trim warnings, но без критических ошибок.
  • Синтаксис extension(string str) действительно оказался проще и приятнее, чем public static string X(this string s). Тринадцать лет extension methods, и новый синтаксис на самом деле лучше.
  • Свойства required, которые делают каждое определение сущности короче строк на пять. Я годами писал = null!; уже просто на автопилоте.
  • То, насколько мало мне не хватало .csproj. Я ожидал, что в какой-то момент соскучусь по <ItemGroup>. Но нет.

Если присмотреться, почти ничего из этого по отдельности не является чем-то новым. Native AOT существует уже некоторое время. Primary constructors появились в C# 12. required в C# 11. Extension members пришли в C# 14. Но именно file-based apps впервые собрали все эти элементы в одной конфигурации, где проект на C# по первому впечатлению начинает напоминать проект на Go. Это меняет общее ощущение от разработки. У меня нет для этого лучшего слова, чем «вайб».

Попробуйте сами

Репозиторий находится здесь: github.com/archie1602/csharp-looks-like-go.

В нём меньше двадцати .cs-файлов. Граф runtime-зависимостей включает ASP.NET Core, EF Core, Npgsql и JetBrains.Annotations. Пайплайн публикации выдаёт бинарник на 30 МБ. Весь путь от первого dotnet run до запущенного Web API занимает около десяти секунд при прогретом кэше.

Если у вас есть мнение о том, насколько такая модель уместна для production-сервисов, или если вы сталкивались с workflow миграций EF и нашли более чистое решение, чем временный .csproj, мне будет интересно об этом услышать.