На выходных я решил проверить, можно ли собрать нормальное 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, мне будет интересно об этом услышать.
