
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>latest</LangVersion>
</PropertyGroup>
</Project>
Для существующего проекта обычно делаю так:
Обновляю SDK до .NET 10.
Меняю
TargetFramework(net6.0/net8.0/net9.0→net10.0).Гоняю тесты и по чек-листу прохожусь по основным breaking changes, описанным выше (и смотрю полный список в документации 4).
Если коротко: .NET 10 и C# 14 - это не революция, но довольно заметный эволюционный шаг. Некоторые вещи действительно упрощают ежедневный код, а миграционные подводные камни лучше поймать в тестах, чем в проде.
Комментарии (6)

A1lfeG
20.11.2025 15:32Если кто вынужден использовать nHibernate, то таргет языка 13й версии пока что единственный способ продолжать им пользоваться.

TerekhinSergey
20.11.2025 15:32Явный каст к IEnumerable там, где используется Contains, не помогает? Типа такого:
Where(it => ((IEnumerable<long>)ids).Contains(it.Id))

Deosis
20.11.2025 15:32public static IEnumerable<T> Empty => Enumerable.Empty<T>();Насколько же лень некоторым писать скобки. Надо было просто разрешить опускать скобки при вызове метода без параметров.
gdt
Ага, и NU1510 почему-то срабатывает даже для проектов, которые не таргетятся на .NET 10 - https://developercommunity.visualstudio.com/t/Installing-VS2026-broke-my-VS2022-instal/10998270
makushevski Автор
Спасибо за комментарий. Да, NU1510 может появляться и в проектах под .NET 9 потому что поведение зависит не только от таргета, но и от версии установленного SDK. В .NET 9 pruning надо было включать вручную, а в .NET 10 похожее поведение стало частью конфигурации по умолчанию. Вот тут есть обсуждение: https://github.com/NuGet/Home/issues/14168
gdt
Да, оно стало частью конфигурации по умолчанию. Для проектов, которые таргетятся как мининимум на .NET 10. Спасибо за ссылку, теперь понятно, откуда ноги растут. Я почему-то другие ссылки находил - https://developercommunity.visualstudio.com/t/Installing-VS2026-broke-my-VS2022-instal/10998270 и https://github.com/NuGet/Home/issues/14641