.net 10 logo
.net 10 logo

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 с 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<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:

  1. dotnet tool exec - запускает инструмент без предварительной установки:

    dotnet tool exec --source ./artifacts/package/ dotnetsay "Hello"
    

    Удобно в CI и для внутренних тулов: не нужно засорять глобальные установки.

  2. Платформенные tools + any RID - можно паковать разные бинарники для разных платформ в один пакет и добавить any:

    <PropertyGroup>
      <RuntimeIdentifiers>
        linux-x64;
        linux-arm64;
        win-x64;
        win-arm64;
        any
      </RuntimeIdentifiers>
    </PropertyGroup>
    

    any даёт fallback на обычный framework-dependent DLL, который запустится на любой поддерживаемой платформе с .NET 10.

  3. 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[] vs Span<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>

Для существующего проекта обычно делаю так:

  1. Обновляю SDK до .NET 10.

  2. Меняю TargetFramework (net6.0 / net8.0 / net9.0net10.0).

  3. Гоняю тесты и по чек-листу прохожусь по основным breaking changes, описанным выше (и смотрю полный список в документации 4).

Если коротко: .NET 10 и C# 14 - это не революция, но довольно заметный эволюционный шаг. Некоторые вещи действительно упрощают ежедневный код, а миграционные подводные камни лучше поймать в тестах, чем в проде.


Комментарии (6)


  1. gdt
    20.11.2025 15:32

    Ага, и NU1510 почему-то срабатывает даже для проектов, которые не таргетятся на .NET 10 - https://developercommunity.visualstudio.com/t/Installing-VS2026-broke-my-VS2022-instal/10998270


    1. makushevski Автор
      20.11.2025 15:32

      Спасибо за комментарий. Да, NU1510 может появляться и в проектах под .NET 9 потому что поведение зависит не только от таргета, но и от версии установленного SDK. В .NET 9 pruning надо было включать вручную, а в .NET 10 похожее поведение стало частью конфигурации по умолчанию. Вот тут есть обсуждение: https://github.com/NuGet/Home/issues/14168


      1. gdt
        20.11.2025 15:32

        Да, оно стало частью конфигурации по умолчанию. Для проектов, которые таргетятся как мининимум на .NET 10. Спасибо за ссылку, теперь понятно, откуда ноги растут. Я почему-то другие ссылки находил - https://developercommunity.visualstudio.com/t/Installing-VS2026-broke-my-VS2022-instal/10998270 и https://github.com/NuGet/Home/issues/14641


  1. A1lfeG
    20.11.2025 15:32

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


    1. TerekhinSergey
      20.11.2025 15:32

      Явный каст к IEnumerable там, где используется Contains, не помогает? Типа такого:

      Where(it => ((IEnumerable<long>)ids).Contains(it.Id))



  1. Deosis
    20.11.2025 15:32

    public static IEnumerable<T> Empty => Enumerable.Empty<T>();

    Насколько же лень некоторым писать скобки. Надо было просто разрешить опускать скобки при вызове метода без параметров.