.NET 10 и C# 14: что поменяется в вашем коде
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
, null-conditional assignment слева от=
,nameof(List<>)
для открытых дженериков, модификаторы у параметров лямбд, partial-конструкторы/события, user-defined+=
/++
.SDK: file-based apps с Native AOT по умолчанию,
dotnet tool exec
, платформенные tools сany
RID,--cli-schema
, pruning framework-package-референсов,dotnet new sln
теперь делает.slnx
.Breaking changes: другое поведение cookie-аутентификации для API, депрекация
WithOpenApi
и старых OpenAPI-analyzers,IPNetwork
в ASP.NET Core помечен obsolete, поменялись правила overload resolution соSpan
, плюс новые нюансы в 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(IEnumerable source)
{
public bool IsEmpty => !source.Any();
public IEnumerable WhereNotNull() =>
source.Where(x => x is not null);
}
// Статические extension-члены
extension(IEnumerable)
{
public static IEnumerable Empty => Enumerable.Empty();
public static IEnumerable operator +(
IEnumerable left,
IEnumerable right) =>
left.Concat(right);
}
}
class Demo
{
static void Main()
{
var list = new[] { 1, 2, 3 };
// как будто это члены самого IEnumerable
if (!list.IsEmpty)
{
var combined = IEnumerable.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
/ReadOnlySpan
: появились дополнительные неявные конверсии и улучшения в generic-инференсе и overload resolution 2. В результате реже приходится явно писать .AsSpan()
.
static int IndexOfUpper(ReadOnlySpan 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 buffer = stackalloc char[] { 'a', 'B', 'c' };
_ = IndexOfUpper(s);
_ = IndexOfUpper(array);
_ = IndexOfUpper(buffer);
}
Главный практический эффект: перегрузки со Span
выбираются предсказуемее (и это фигурирует как отдельный breaking change в .NET 10). Если вы активно завозили span-перегрузки, при обновлении компилятора возможны "немые" изменения поведения - тесты здесь реально решают.
5. Модификаторы у параметров лямбд без явного типа
Теперь можно добавлять модификаторы (ref
, in
, out
, scoped
и т.п.) к параметрам простых лямбд, не выписывая типы вручную.
delegate bool TryParse(string text, out T value);
TryParse parse = (text, out result) =>
int.TryParse(text, out result);
Раньше нужно было писать:
TryParse 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
и т.п.).
Вероятно будет полезно для логирования, генерации кода и диагностики.
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 +
any
RID - можно паковать разные бинарники для разных платформ в один пакет и добавитьany
: linux-x64; linux-arm64; win-x64; win-arm64; any
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.
Отключается это так:
false
Если у вас вся инфраструктура построена вокруг сканирования 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
/ReadOnlySpan
;и вы вызываете их из 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 и посмотрите, не посыпалось ли что-нибудь неожиданное.
Как начать использовать всё это в живом проекте
Для нового проекта минимально достаточно:
net10.0
latest
Для существующего проекта обычно делаю так:
Обновляю SDK до .NET 10.
Меняю
TargetFramework
(net6.0
/net8.0
/net9.0
→net10.0
).Гоняю тесты и по чек-листу прохожусь по основным breaking changes, описанным выше (и смотрю полный список в документации 4).
Если коротко: .NET 10 и C# 14 - это не революция, но довольно заметный эволюционный шаг. Некоторые вещи действительно упрощают ежедневный код, а миграционные подводные камни лучше поймать в тестах, чем в проде.