Привет, Хабр! 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 пока не подходят

  1. Multi-file проекты. В .NET 10 поддерживается ровно один .cs-файл на app, и это сознательное ограничение. Microsoft объявила, что multi-file support пойдёт в .NET 11, чтобы в .NET 10 успели отполировать single-file опыт. Пока что если код вырастает за пределы одного файла, нужно мигрировать в обычный проект.

  2. Сложная конфигурация сборки. Если проекту нужны кастомные MSBuild-таргеты, генерация кода через source generators с настройками, специфические target frameworks, директив #:property иногда недостаточно. В таких случаях имеет смысл сразу делать csproj.

  3. Библиотеки. File-based apps это executable-only формат. Создать NuGet-пакет с библиотекой из .cs-файла нельзя, для этого по-прежнему нужен проект.

  4. Большие команды со строгими 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, их особенности и принципы выбора подходящей структуры данных под задачу.