Привет, Хабр! C# исторически был языком для больших проектов: solution-файлы, проектная структура, csproj с десятками настроек, дисциплина ceremony для запуска даже простой утилиты. Для маленьких скриптов, которыми решают рутинные задачи в CI или DevOps, обычно проще написать на Python или Bash, чем разворачивать целый проект ради двадцати строк кода.
В .NET 10, который вышел в ноябре 2025 года и получил статус LTS до ноября 2028, этот пробел наконец закрыли. Команда dotnet run app.cs теперь работает без csproj, без sln, без папки с проектной структурой: один файл, одна команда, готовый запускаемый код.
Подход называется file-based apps. Разберём, как он устроен, что умеет и где вообще полезен.
Базовый сценарий
Создаём файл hello.cs с одной строкой:
Console.WriteLine("Hello from a single file!");
Запускаем:
$ dotnet run hello.cs Hello from a single file!
Никаких csproj, папок, каких-то NuGet-packages.json. Под капотом .NET SDK создаёт временный проект, разрешает зависимости, компилирует и запускает программу, после чего убирает временные артефакты в кэш. Для пользователя это выглядит как запуск Python-скрипта, только с компиляцией под капотом.
Top-level statements, появившиеся в C# 9, тут используются по умолчанию: не нужно писать класс Program с методом Main, можно сразу писать код первой строкой. Это и раньше работало в обычных проектах, но требовало явного выбора шаблона. В file-based apps это поведение стандартное.
Директивы внутри файла
Если бы single-file apps умели только Console.WriteLine, ценность была бы небольшой. Реальная сила появляется через директивы, которые позволяют объявлять NuGet-зависимости, выбирать SDK и задавать MSBuild-свойства прямо в исходнике.
Директива #:package подключает NuGet-package:
#:package Spectre.Console@0.49.1 using Spectre.Console; AnsiConsole.Write(new FigletText("Hello").Color(Color.Blue)); AnsiConsole.MarkupLine("[green]File-based apps[/] work like Python scripts.");
Это запустится, скачает Spectre.Console во временный кэш SDK, скомпилирует и покажет красивый ASCII-арт прямо в терминале. Версия пакета фиксируется через @, можно указать диапазон или опустить версию для последней стабильной.
Директива #:sdk выбирает SDK для проекта. По умолчанию используется Microsoft.NET.Sdk, что соответствует обычному консольному приложению. Для web-приложения указываем веб-SDK:
#:sdk Microsoft.NET.Sdk.Web #:package Microsoft.Extensions.Hosting@9.0.0 var builder = WebApplication.CreateBuilder(); var app = builder.Build(); app.MapGet("/", () => "Hello from minimal API in a single file!"); app.MapGet("/health", () => Results.Ok(new { status = "healthy" })); app.Run();
Запускаем командой dotnet run server.cs, и поднимается полноценный ASP.NET Core minimal API на дефолтном порту 5000.
Директива #:property задаёт MSBuild-свойства:
#:property LangVersion preview #:property Nullable enable #:property TreatWarningsAsErrors true Console.WriteLine("C# preview features enabled");
Так можно включить новые версии языка, включить nullable references, выставить уровень оптимизации и так далее. Всё то, что обычно живёт в csproj, доступно через директивы.
Конфигурация через appsettings и Web SDK
Когда file-based app использует Web SDK, рядом с файлом можно положить appsettings.json и appsettings.Development.json, и они автоматически подхватятся при запуске. Это превращает single-file сервис в нормальное приложение с конфигурацией:
#:sdk Microsoft.NET.Sdk.Web var builder = WebApplication.CreateBuilder(); var greeting = builder.Configuration["Greeting"] ?? "Hello"; var app = builder.Build(); app.MapGet("/", () => $"{greeting} from configured app"); app.Run();
Файл appsettings.json рядом:
{ "Greeting": "Welcome" }
Запуск как привычно: конфигурация читается, среды разделяются, environment variables перекрывают значения. Различие только в том, что нет проектного файла, который объявляет эту структуру.
Native AOT по умолчанию
Когда вы публикуете file-based app командой dotnet publish app.cs, по умолчанию включается Native AOT-компиляция. На выходе получается один self-contained бинарник под целевую платформу: на Linux это ELF, на Windows это exe, на macOS это Mach-O. Размер обычно лежит в районе 8-15 мегабайт для типичной утилиты, время холодного старта измеряется единицами миллисекунд.
$ dotnet publish hello.cs -o ./out $ ls -la ./out/hello -rwxr-xr-x 1 user user 12M hello $ ./out/hello Hello from a single file!
Так C# видится даже как альтернатива Go и Rust для написания CLI-утилит, которые потом раздаются пользователям одним файлом без зависимостей. До .NET 10 такого результата можно было достичь, но требовалось вручную конфигурировать PublishAot, TrimMode, IlcOptimizationPreference и десяток других свойств в csproj. Сейчас всё работает из коробки.
Если для конкретной утилиты Native AOT не подходит (например, используется библиотека с интенсивной рефлексией, которую AOT-аналайзер не сможет обработать), его отключают через директиву:
#:property PublishAot false
После этого публикация переключается на обычный self-contained режим с runtime в составе бинарника.
Что было раньше: CS-Script, dotnet-script, Cake, LINQPad
File-based apps это не первая попытка сделать C# скриптовым. Список предшественников длинный и каждый из них занимал свою нишу.
CS-Script появился в 2004 году. Это глобальный инструмент, который умел запускать .cs-файлы через
cscs script.cs, поддерживал препроцессорные директивы для подключения сборок и работал поверх обычного .NET runtime. Активно использовался для автоматизации в Windows-окружении.Dotnet-script появился в 2017 году как .NET Global Tool. Использовал расширение
.csxдля скриптов, поддерживал директивы#rдля NuGet-пакетов, имел встроенный REPL. Стал популярным для написания CLI-утилит и скриптов автоматизации в DevOps-окружениях.Cake (C# Make) появился в 2014 году как DSL для сборки проектов. Файлы
build.cakeэто в сущности C#-скрипты с дополнительными хелперами для работы с MSBuild, NuGet, Docker и прочей инфраструктурой. До сих пор используется во многих open-source проектах вместо classic MSBuild.LINQPad появился в 2007 как интерактивное окружение для написания LINQ-запросов, потом превратился в полноценный C#-scratchpad с подключением к базам, дебаггером, профайлером и REPL. Платный, но имеет бесплатную версию.
Каждый из этих инструментов решал свою часть задачи, но требовал отдельной установки и имел свой формат. File-based apps впервые делают эту функциональность частью стандартного SDK, который уже стоит у каждого .NET-разработчика. Это критическая разница: чтобы написать скрипт на dotnet-script, нужно сначала установить dotnet-script, узнать про его существование, прочитать документацию. Чтобы написать скрипт на .NET 10, достаточно создать файл и набрать команду, которую все знают.
Сами по себе старые инструменты остаются актуальными. Cake до сих пор лучший выбор для сложных build-pipeline. LINQPad незаменим для интерактивной работы с базами. Dotnet-script хорош для skip-ahead сценариев, где file-based apps пока недотягивают. File-based apps занимают пустовавшую нишу: быстрый старт без установки чего-либо.
Где file-based apps пока не подходят
Multi-file проекты. В .NET 10 поддерживается ровно один .cs-файл на app, и это сознательное ограничение. Microsoft объявила, что multi-file support пойдёт в .NET 11, чтобы в .NET 10 успели отполировать single-file опыт. Пока что если код вырастает за пределы одного файла, нужно мигрировать в обычный проект.
Сложная конфигурация сборки. Если проекту нужны кастомные MSBuild-таргеты, генерация кода через source generators с настройками, специфические target frameworks, директив
#:propertyиногда недостаточно. В таких случаях имеет смысл сразу делать csproj.Библиотеки. File-based apps это executable-only формат. Создать NuGet-пакет с библиотекой из .cs-файла нельзя, для этого по-прежнему нужен проект.
Большие команды со строгими CI/CD-пайплайнами. File-based apps дружат с CI, но конвенции и обвязка часто построены вокруг проектной структуры: пути к csproj, артефакты сборки, версионирование через Directory.Build.props. Прежде чем массово переходить на file-based apps в проде, стоит обдумать, как это уложится в существующий tooling.
Миграция в полный проект
Когда file-based app перерастает рамки одного файла, есть встроенная команда конвертации:
$ dotnet project convert app.cs
SDK создаёт папку, кладёт туда сгенерированный csproj с зависимостями из директив #:package, переносит исходный файл и приводит структуру к каноническому виду. После этого можно добавлять новые файлы, расширять конфигурацию и работать как с обычным проектом. Обратная конвертация (из проекта в file-based app) не предусмотрена и вряд ли появится: смысл перехода обычно односторонний.
Если в вашем рабочем процессе живёт bash-скрипт на двадцать строк или Python-утилита, попробуйте переписать на file-based app: получите типобезопасность, поддержку IDE, NuGet и single-binary вывод. Расскажите в комментах, для каких задач уже перевели или планируете перевести? Особенно интересно услышать, кто столкнулся с ограничением one-file-per-app и пришлось ли возвращаться в csproj раньше времени.
А если хотите глубже разобраться в C#, приходите на бесплатные уроки OTUS:
2 июля, 20:00 — «Методы, их перегрузка и расширения». Записаться
Поговорим о том, как работают методы в C#, когда использовать перегрузку и чем полезны методы расширения в реальных проектах.16 июля, 20:00 — «Коллекции и структуры данных на C#». Записаться
Разберем основные коллекции .NET, их особенности и принципы выбора подходящей структуры данных под задачу.
