
11 ноября 2025 вышел .NET 10 - очередной LTS-релиз, который будет жить до ноября 2028 года (см. таблицу поддержки на сайте .NET 1).
За это время многие проекты успеют мигрировать с .NET 6/8/9, а значит, нас ждут не только новые плюшки, но и немного боли от breaking changes.
В этой статье постарался собрать всё самое важное чтобы за раз всё поднять:
фичи C# 14, которые реально пригодятся в повседневном коде;
полезные новшества в SDK/CLI;
breaking changes, которые вы почти гарантированно поймаете при миграции с .NET 6/8/9.
TL;DR
Если совсем коротко:
C# 14: extension-блоки,
field-свойства, более дружелюбныйSpan<T>, null-conditional assignment слева от=,nameof(List<>)для открытых дженериков, модификаторы у параметров лямбд, partial-конструкторы/события, user-defined+=/++.SDK: file-based apps с Native AOT по умолчанию,
dotnet tool exec, платформенные tools сanyRID,--cli-schema, pruning framework-package-референсов,dotnet new slnтеперь делает.slnx.Breaking changes: другое поведение cookie-аутентификации для API, депрекация
WithOpenApiи старых OpenAPI-analyzers,IPNetworkв ASP.NET Core помечен obsolete, поменялись правила overload resolution соSpan<T>, плюс новые нюансы в NuGet/CLI и переход на.slnx.
Зачем вообще смотреть на .NET 10
Немного сухих фактов:
.NET 10 - LTS, поддержка до 14 ноября 2028 1.
.NET 8 - тоже LTS, но до ноября 2026.
.NET 9 - STS, до ноября 2026.
.NET 6 уже вышел из поддержки в ноябре 2024 1.
Если вы всё ещё на 6-ке, миграция на 10-ку - это уже не "хочу", а "надо". Особо если речь про прод, где безопасность и обновления важнее чем "мне лень трогать рабочий код".
C# 14: фичи, которые меняют стиль кода
1. Extension members: когда ваши helpers с��ановятся почти частью BCL
Одна из главных фич C# 14 - extension-блоки, которые позволяют объявлять:
extension-методы;
extension-свойства (инстансные);
статические extension-члены;
даже user-defined оператор
+как расширение типа.
По сути, это способ аккуратно "допилить" существующие типы, а не городить очередной SomeTypeNewExtensions.
using System; using System.Collections.Generic; using System.Linq; public static class EnumerableExtensions { // Инстансные extension-члены extension<T>(IEnumerable<T> source) { public bool IsEmpty => !source.Any(); public IEnumerable<T> WhereNotNull() => source.Where(x => x is not null); } // Статические extension-члены extension<T>(IEnumerable<T>) { public static IEnumerable<T> Empty => Enumerable.Empty<T>(); public static IEnumerable<T> operator +( IEnumerable<T> left, IEnumerable<T> right) => left.Concat(right); } } class Demo { static void Main() { var list = new[] { 1, 2, 3 }; // как будто это члены самого IEnumerable<T> if (!list.IsEmpty) { var combined = IEnumerable<int>.Empty + list; Console.WriteLine(string.Join(", ", combined)); } } }
Ощущение довольно приятное: читаешь код и видишь "псевдо-члены" типа, а не утилитарный класс где-то сбоку. Подробности - в разделе про extension members в доке C# 14 2.
2. field-backed свойства: минус один приватный backing-field
field - контекстное ключевое слово, которое позволяет не объявлять руками приватное поле в аксессоре свойства.
До:
private string _name = string.Empty; public string Name { get => _name; set => _name = value ?? throw new ArgumentNullException(nameof(value)); }
Теперь:
public string Name { get; set => field = value ?? throw new ArgumentNullException(nameof(value)); }
Компилятор сам создаёт скрытое поле и подставляет вместо field.
Самое приятное - не нужно каждый раз придумывать очередное _name.
Нюанс: если у вас уже есть идентификатор field (например, поле с таким именем), придётся разруливать:
public string field; // старое поле public string Name { get => field; set => this.field = value; // this.field = старое поле }
Ну или просто переименовать старое поле - будущий читатель вам правда спасибо скажет.
3. Null-conditional assignment: ?. наконец можно ставить слева от =
Теперь можно писать так:
customer?.Order = GetCurrentOrder();
GetCurrentOrder() вызовется только если customer не null.
Если customer == null, правая часть даже не будет вычислена.
Работает и с compound-операторами:
metrics?.RequestsPerMinute += 1; cart?.Items[index] ??= CreateDefaultItem();
То есть можно безопасно мутировать объект, если он есть, и вообще ничего не делать, если его нет.
Логика ожидаемая, но раньше такой синтаксис был просто запрещён.
Чего нельзя: customer?.Age++ и -- - инкремент/декремент с ?. по-прежнему не разрешён.
4. First-class Span: меньше .AsSpan() в generic-коде
C# 14 подтягивает поддержку Span<T>/ReadOnlySpan<T>: появились дополнительные неявные конверсии и улучшения в generic-инференсе и overload resolution 2. В результате реже приходится явно писать .AsSpan().
static int IndexOfUpper(ReadOnlySpan<char> span) { for (var i = 0; i < span.Length; i++) if (char.IsUpper(span[i])) return i; return -1; } void Demo() { string s = "helloWorld"; char[] array = "fooBar".ToCharArray(); Span<char> buffer = stackalloc char[] { 'a', 'B', 'c' }; _ = IndexOfUpper(s); _ = IndexOfUpper(array); _ = IndexOfUpper(buffer); }
Главный практический эффект: перегрузки со Span<T> выбираются предсказуемее (и это фигурирует как отдельный breaking change в .NET 10). Если вы активно завозили span-перегрузки, при обновлении компилятора возможны "немые" изменения поведения - тесты здесь реально решают.
5. Модификаторы у параметров лямбд без явного типа
Теперь можно добавлять модификаторы (ref, in, out, scoped и т.п.) к параметрам простых лямбд, не выписывая типы вручную.
delegate bool TryParse<T>(string text, out T value); TryParse<int> parse = (text, out result) => int.TryParse(text, out result);
Раньше нужно было писать:
TryParse<int> parse = (string text, out int result) => int.TryParse(text, out result);
Мелочь, но все эти (string text, out int value) в TryParse-паттернах начинают исчезать из кода, и читается он чуть легче.
6. Partial-конструкторы и partial-события
Теперь можно делить конструкторы и события между partial-частями типа 2. Это, по сути, прямой подарок для source generators.
// Модель, сгенерированная source-generator’ом public sealed partial class User { public string Name { get; } public int Age { get; } // Определение конструктора public partial User(string name, int age); } // Вручную написанный кусок public sealed partial class User { // Реализация конструктора public partial User(string name, int age) { Name = name ?? throw new ArgumentNullException(nameof(name)); Age = age; } }
Раньше приходилось либо генерировать фабрики, либо лезть в уже сгенерированный код.
7. nameof и открытые дженерики
Теперь nameof умеет работать с unbound generic types:
var typeName = nameof(List<>); // "List"
Без необходимости указывать конкретный тип (List<int> и т.п.).
Вероятно будет полезно для логирования, генерации кода и диагностики.
8. User-defined compound assignment и ++/--
C# 14 позволяет перегружать compound-операторы (+=, -=, *=, …) и инкремент/декремент через новые синтаксические формы 6. Главное - такие операторы могут обновлять состояние объекта in-place, без лишних аллокаций.
public struct Counter { public int Value { get; private set; } // Инстанс-оператор compound assignment public void operator +=(int delta) { Value += delta; } // Инстанс-оператор инкремента public void operator ++() { Value++; } public override string ToString() => Value.ToString(); } class Demo { static void Main() { var c = new Counter(); c += 10; // вызывает operator += ++c; // вызывает operator ++ Console.WriteLine(c); // 11 } }
Для тяжёлых структур и high-perf типов это прям очень приятно: можно избавиться от лишних копий/аллокаций, при этом сохранив привычный синтаксис c += 10; и ++c.
SDK и CLI: изменения в рабочем процессе
File-based apps: "скрипты на C#", но с AOT и publish
File-based apps в .NET 10 стали заметно взрослее 3:
dotnet publish app.cs- публикует одиночный.csкак нативный exe (Native AOT по умолчанию для file-based apps).Поддерживаются директивы
#:project,#:property, shebang; путь к файлу и директории доступен черезAppContext.
Пример "однофайлового" утилитарного скрипта:
#!/usr/bin/env dotnet #:property PublishAot=true #:project ../Tools.Common/Tools.Common.csproj using Tools.Common; Console.WriteLine("Hello from file-based app!"); Console.WriteLine($"Args: {string.Join(", ", args)}"); var configPath = AppContext.GetData("appContext:appPath"); Console.WriteLine($"App located at: {configPath}");
Типовые сценарии:
внутренние CLI-утилиты внутри репозитория;
миграционные скрипты;
"быстрый прототип" без полноценного
.csproj.
То, что раньше часто делали на bash + dotnet run, теперь можно сделать одним C#-файлом.
.NET tools: dotnet tool exec, dnx и any RID
Вокруг tools появились несколько приятных штук 3:
dotnet tool exec- запускает инструмент без предварительной установки:dotnet tool exec --source ./artifacts/package/ dotnetsay "Hello"Удобно в CI и для внутренних тулов: не нужно засорять глобальные установки.
Платформенные tools +
anyRID - можно паковать разные бинарники для разных платформ в один пакет и добавитьany:<PropertyGroup> <RuntimeIdentifiers> linux-x64; linux-arm64; win-x64; win-arm64; any </RuntimeIdentifiers> </PropertyGroup>anyдаёт fallback на обычный framework-dependent DLL, который запустится на любой поддерживаемой платформе с .NET 10.dnx- маленький скрипт-обёртка:dnx dotnetsay "Hello"просто прокидывает всё вdotnet. Сначала кажется игрушкой, но в повседневных командах экономит немного клавиатуры.
--cli-schema: introspection CLI-дерева
Любой dotnet-командой можно получить JSON-описание её схемы 3:
dotnet clean --cli-schema
На выходе - дерево аргументов/опций, которое удобно:
использовать для генерации shell-completion;
писать свои фронтенды над CLI;
кормить тулзам, которые хотят понимать, какие флаги вообще бывают.
Если вы пишете обёртки над dotnet (например, в CI), вещь может пригодиться.
Pruning framework-package references и NuGet-audit
В .NET 10 включили фичу, которая обрезает неиспользуемые package-референсы, уже поставляемые вместе с фреймворком 3:
меньше мусора в
deps.json;меньше ложных срабатываний в NuGet Audit;
возможны предупреждения вида
NU1510, если пакет был "обрезан" как лишний 4.
Отключается это так:
<PropertyGroup> <RestoreEnablePackagePruning>false</RestoreEnablePackagePruning> </PropertyGroup>
Если у вас вся инфраструктура построена вокруг сканирования deps.json и точного списка пакетов - это то самое место, где нужно быть внимательным при миграции.
Breaking changes при миграции с .NET 6/8/9
Теперь к неприятному, но нужному. Даже если вы перескакиваете прямо с 6/8 сразу на 10, вы упрётесь в breaking changes из 10-ки (и по ASP.NET, и по core-библиотекам, и по SDK).
ASP.NET Core
1. Cookie-аутентификация и API: больше никаких редиректов на /Account/Login
Для известных API-эндпоинтов (контроллеры с [ApiController], минимальные API с JSON-телом, SignalR и т.п.) теперь по умолчанию 5:
было: при неавторизованном запросе cookie-хэндлер делал 302 Redirect на login / access-denied (кроме XHR);
стало: для API - честные 401/403.
Если у вас SPA, которая почему-то рассчитывает именно на редирект на страницу логина, - поведение поменяется.
В Postman / фронтовых клиентах это, наоборот, выглядит логичнее: никаких HTML-редиректов там и не ждёшь.
Вернуть старый стиль можно так:
builder.Services.AddAuthentication() .AddCookie(options => { options.Events.OnRedirectToLogin = context => { context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; options.Events.OnRedirectToAccessDenied = context => { context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; });
2. IPNetwork и KnownNetworks в forwarded headers - obsolete
Microsoft.AspNetCore.HttpOverrides.IPNetwork и ForwardedHeadersOptions.KnownNetworks помечены устаревшими. Вместо них - System.Net.IPNetwork и KnownIPNetworks 7.
До:
app.UseForwardedHeaders(new ForwardedHeadersOptions { KnownNetworks = { new IPNetwork(IPAddress.Loopback, 8) } });
Теперь:
using System.Net; app.UseForwardedHeaders(new ForwardedHeadersOptions { KnownIPNetworks = { new IPNetwork(IPAddress.Loopback, 8) } });
Если у вас кастомная конфигурация reverse proxy (особенно в Kubernetes / behind nginx), миграция без этого изменения не соберётся без предупреждений.
3. OpenAPI: WithOpenApi, analyzers и компания
В .NET 10 помечены deprecated 3:
WithOpenApiдля минимальных API;IncludeOpenAPIAnalyzers;пакет
Microsoft.Extensions.ApiDescription.Clientи т.п.
Смысл в том, что экосистема уезжает в сторону новых OpenAPI-пакетов и генераторов. При миграции есть смысл:
поискать по коду
WithOpenApi()и посмотреть, чем вы сейчас пользуетесь;проверить, не завязан ли билд на старые analyzers.
C# / Core-библиотеки
1. Overload resolution со Span
В статье по breaking changes для .NET 10 отдельно выделен пункт C# 14 overload resolution with span parameters 4. Суть:
если у вас есть перегрузки
T[]vsSpan<T>/ReadOnlySpan<T>;и вы вызываете их из generic-кода или с "интересными" аргументами -
компилятор может выбрать другую перегрузку, чем раньше. Компилироваться всё будет, но поведение способно тихо поменяться.
Рецепт: прогоняем тесты и внимательно смотрим на hot-path-методы, где вы явно добавляли span-перегрузки "для производительности".
2. AsyncEnumerable в core-библиотеках
System.Linq.AsyncEnumerable перекочевал в стандартные библиотеки .NET 10 4. Это может конфликтовать с вашими собственными типами/extension-методами с тем же именем (если вы их когда-то заводили).
Сценарий редкий, но если "прилетело" неожиданное конфликтующее имя - искать стоит именно тут.
SDK и CLI
1. dotnet new sln → .slnx по умолчанию
Теперь dotnet new sln создаёт SLNX-формат решения, а не классический .sln 8:
новый формат проще читать и диффить;
поддерживается VS, Rider и остальными основными IDE 8.
Если нужно старое поведение:
dotnet new sln --format sln
2. NuGet/CLI: более строгие ошибки и аудиты
Из полезного (и иногда неприятного) 4:
dotnet package listтеперь делаетrestoreи может падать на проблемных фидах;HTTP-предупреждения чаще превращаются в ошибки;
dotnet restoreзапускает аудит транзитивных пакетов;project.jsonокончательно выкинули;local-tools (
dotnet tool install --local) по умолчанию создают manifest.
Если у вас вокруг этих команд накручены скрипты - просто прогоните их на новом SDK и посмотрите, не посыпалось ли что-нибудь неожиданное.
Как начать использовать всё это в живом проекте
Для нового проекта минимально достаточно:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <LangVersion>14.0</LangVersion> </PropertyGroup> </Project>
Для существующего проекта обычно делаю так:
Обновляю SDK до .NET 10.
Меняю
TargetFramework(net6.0/net8.0/net9.0→net10.0).Гоняю тесты и по чек-листу прохожусь по основным breaking changes, описанным выше (и смотрю полный список в документации 4).
Если коротко: .NET 10 и C# 14 - это не революция, но довольно заметный эволюционный шаг. Некоторые вещи действительно упрощают ежедневный код, а миграционные подводные камни лучше поймать в тестах, чем в проде.
