Алгебраические типы данных в .NET Framework и .NET — состояние и результат через тип

· Обновлено: · · .NET, .NET Framework, C#, F#, Алгебраические типы данных, Размеченные объединения, Доменное моделирование, Работа с существующим кодом

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619892)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Алгебраические типы данных в .NET Framework и .NET — состояние и результат через тип. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/06/09/003-dotnet-algebraic-data-types/

DOI (зарегистрированный архив)
10.5281/zenodo.21619892
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619893

1. С чего начать

В бизнес-приложениях на .NET часто встречаются такие возвращаемые значения и состояния.

public class CreateUserResult
{
    public bool IsSuccess { get; set; }
    public User User { get; set; }
    public string ErrorCode { get; set; }
    public string ErrorMessage { get; set; }
}

На первый взгляд всё понятно, но в этот тип легко пролезают «состояния, которых быть не должно».

Можно создать, например, такие значения:

  • IsSuccess == true, но User == null
  • IsSuccess == true, но заполнено ErrorCode
  • IsSuccess == false, но заполнено User
  • ErrorCode == "DuplicateEmail", но ErrorMessage == null
  • добавлен новый код ошибки, а код вызывающей стороны не обновлён

Такие типы сначала удобны, но по мере роста системы всё сильнее нагружают тех, кто читает и сопровождает код.

Здесь помогает идея алгебраических типов данных.

Название звучит формально. На практике его проще понять так:

Факт, что «у этого значения заранее известен набор возможных форм», выражается не комментариями и не соглашениями об именах, а самим типом.

Результат создания пользователя можно выразить как ровно один из вариантов:

CreateUserResult =
  Created(User)
  или DuplicateEmail(email)
  или WeakPassword(reason)
  или SystemFailure(message)

При успехе есть User. При повторном email есть email. При слабом пароле есть reason. При системной ошибке есть message.

Каждый кейс несёт только нужные ему данные. Успех и неудача не наступают одновременно. Состояние «успех, но без User» создать нельзя.

В .NET эту идею реализуют так: в F# — размеченные объединения, в C# — иерархия sealed-классов, иерархия record, библиотеки вроде OneOf, а в перспективе — union-типы C#.

В статье разбираем, как пользоваться алгебраическими типами данных и в .NET Framework, и в современном .NET, плюс практические плюсы и оговорки.

Код из статьи опубликован на GitHub как собираемый и запускаемый набор примеров: библиотека, демо каждого паттерна и модульные тесты на исчерпываемость Match, переходы состояний и преобразование в DTO.

dotnet-algebraic-data-types - komurasoft-blog-samples (GitHub)

Как читать эту статью

Статья длинная, читать с начала до конца не обязательно. Переходите к главам по задаче.

Задача Какие главы
Сначала понять, что такое ADT и зачем он нужен 1–3
Сравнить реализации на C# / F# и выбрать 4–10
На практике взять Option, Result, переходы состояний, границу API 11–14
Объяснить пользу команде или руководству 15–19
Внедрить в существующую систему, включая .NET Framework 20–21
Разобраться с выбором между enum, bool и наследованием 22–25
Заранее увидеть ловушки проектирования 26–29
Получить порядок правок существующего кода 30–31

Что предполагается известным

В примерах с главы 6 используются сопоставление с образцом в C# и выражение switch. Сначала зафиксируем термины.

Термин Смысл Пример записи
Выражение switch Ветвление само возвращает значение. В отличие от оператора switch, справа у каждой ветки — результат result switch { ... }
Образец типа Ветвление по типу значения с привязкой к переменной Created x => ...
Образец свойства Кроме типа смотрят значение свойства. Содержимое можно достать через var Created { User: var user } => ...
Образец отбрасывания Ветка, если ни один образец не подошёл _ => throw ...
Предложение when Дополнительное условие к образцу OutOfStock x when x.Available == 0 => ...

Первоисточники — Pattern matching overview и switch expression на Microsoft Learn.

Главы 4 и 5 специально записаны через as и if, без выражения switch, чтобы их можно было читать и на старом C#. Сопоставление с образцом для них не нужно.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Что такое алгебраический тип данных

Алгебраический тип данных по-английски Algebraic Data Type, сокращённо ADT.

Если упростить, ADT — сочетание двух видов типов:

  • Тип-произведение (product type): тип, который содержит и A, и B
  • Тип-сумма (sum type): тип, который является либо A, либо B

Классы, структуры и record в .NET чаще всего используют как «типы-произведения».

public sealed class Address
{
    public string PostalCode { get; }
    public string Prefecture { get; }
    public string City { get; }
    public string Street { get; }

    public Address(string postalCode, string prefecture, string city, string street)
    {
        PostalCode = postalCode;
        Prefecture = prefecture;
        City = city;
        Street = street;
    }
}

По смыслу это:

Address = PostalCode и Prefecture и City и Street

Тип-сумма — это «ровно один из вариантов».

PaymentResult =
  Succeeded(receiptNo)
  или InsufficientFunds(shortage)
  или Rejected(reason)
  или NetworkFailure(message)

По смыслу:

PaymentResult = Succeeded или InsufficientFunds или Rejected или NetworkFailure

Именно это «или», выраженное типом, — та часть ADT, которой на практике пользуются чаще всего.

В F# это записывается языковой возможностью.

type PaymentResult =
    | Succeeded of receiptNo: string
    | InsufficientFunds of shortage: decimal
    | Rejected of reason: string
    | NetworkFailure of message: string

В C# долго не было стандартного аналога размеченных объединений F#. Поэтому в C# их выражали иерархиями классов и библиотеками.

Сама идея в C# вполне применима.

Важен не конкретный синтаксис, а один принцип:

Сделать так, чтобы некорректное состояние нельзя было создать с самого начала.

3. Почему одних bool и enum недостаточно

Для небольшого фрагмента логики bool или enum иногда кажутся достаточными.

Например, такой возвращаемый тип:

public enum PaymentStatus
{
    Succeeded,
    InsufficientFunds,
    Rejected,
    NetworkFailure
}

public sealed class PaymentResponse
{
    public PaymentStatus Status { get; set; }
    public string ReceiptNo { get; set; }
    public decimal? Shortage { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

Связь между Status и остальными свойствами типом не выражена.

ReceiptNo нужен только при Status == Succeeded. Shortage — только при Status == InsufficientFunds. Reason — только при Status == Rejected. Message — только при Status == NetworkFailure.

Это правило живёт вне кода.

Оно опирается на комментарии, спецификации, тесты, негласные договорённости и память разработчика.

В результате разрастается защитный код вроде такого:

if (response.Status == PaymentStatus.Succeeded)
{
    if (string.IsNullOrEmpty(response.ReceiptNo))
    {
        throw new InvalidOperationException("ReceiptNo is required.");
    }

    return response.ReceiptNo;
}

Такой защитный код иногда нужен. Но во многих случаях его можно было бы не писать, если правильно спроектировать тип.

Если выразить это как ADT, каждый кейс несёт только нужные ему данные.

Succeeded содержит receiptNo
InsufficientFunds содержит shortage
Rejected содержит reason
NetworkFailure содержит message

При таком проектировании нельзя создать Succeeded без receiptNo.

Иначе говоря, вместо того чтобы потом проверять состояние, некорректное состояние нельзя создать с самого начала.

Список способов реализации (главы 4–10)

До главы 10 — разбор того, как этот тип-сумму реализовать в .NET. Сначала общая картина.

Способ Главы Целевое окружение Объём кода Зависимость от библиотеки Обнаружение пропуска кейса
Иерархия class (private-конструктор + вложенные sealed-классы + Match) 4, 5 И .NET Framework, и современный .NET Много. На каждый кейс вручную пишут класс, фабрику и Match Нет У Match появляется ещё один аргумент, поэтому при добавлении кейса вызывающая сторона не компилируется
Иерархия record 6 record — возможность C# 9 и новее. В статье рассчитываем на .NET 5 и новее Мало. Один кейс — одна строка Нет Одного выражения switch мало. Если самим завести Match, можно заставить обрабатывать все кейсы
Размеченное объединение F# 7 F#-проект. Подходит и для .NET Framework, и для современного .NET Минимум. Определение типа и есть список кейсов Нет (языковая возможность) Компилятор проверяет исчерпываемость match и предупреждает, если чего-то не хватает
OneOf 8 Широкий набор целей, включая .NET Framework и .NET Standard Мало. Отдельный базовый класс не нужен Есть (пакет NuGet) Match требует делегат на каждый кейс, поэтому добавление аргумента типа ломает компиляцию у вызывающей стороны
Семейство Source Generator 9 В основном современный .NET. Поддержку .NET Framework смотрите у конкретной библиотеки Мало. Достаточно атрибута Есть (пакет + окружение сборки) Часть библиотек вместе с анализатором предупреждает о пропуске обработки
Union-тип C# 15 10 Preview. Не для боевого кода Минимум Нет (языковая возможность) Спецификация ещё не зафиксирована, поэтому неизвестно

Отправная точка при выборе:

  • в существующую систему, включая .NET Framework, сегодня — иерархия class из глав 4 и 5;
  • если можно целиться только в современный .NET — иерархия record из главы 6;
  • если нужно выразить одно локальное возвращаемое значение — OneOf из главы 8.

4. Реализация, которая работает и в .NET Framework: иерархия sealed-классов

В существующих системах, включая .NET Framework, проще всего внедрить паттерн абстрактный базовый класс + вложенные sealed-классы + метод Match.

Он удобен и на старых версиях C# и не требует особых возможностей runtime.

В качестве примера выразим результат создания пользователя.

public abstract class CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed class Created : CreateUserResult
    {
        internal Created(User user)
        {
            if (user == null) throw new ArgumentNullException(nameof(user));
            User = user;
        }

        public User User { get; }
    }

    public sealed class DuplicateEmail : CreateUserResult
    {
        internal DuplicateEmail(string email)
        {
            if (email == null) throw new ArgumentNullException(nameof(email));
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class WeakPassword : CreateUserResult
    {
        internal WeakPassword(string reason)
        {
            if (reason == null) throw new ArgumentNullException(nameof(reason));
            Reason = reason;
        }

        public string Reason { get; }
    }

    public sealed class SystemFailure : CreateUserResult
    {
        internal SystemFailure(string message)
        {
            if (message == null) throw new ArgumentNullException(nameof(message));
            Message = message;
        }

        public string Message { get; }
    }

    public static CreateUserResult Ok(User user)
        => new Created(user);

    public static CreateUserResult EmailAlreadyUsed(string email)
        => new DuplicateEmail(email);

    public static CreateUserResult PasswordIsWeak(string reason)
        => new WeakPassword(reason);

    public static CreateUserResult Failed(string message)
        => new SystemFailure(message);

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        if (created == null) throw new ArgumentNullException(nameof(created));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (weakPassword == null) throw new ArgumentNullException(nameof(weakPassword));
        if (systemFailure == null) throw new ArgumentNullException(nameof(systemFailure));

        var c = this as Created;
        if (c != null) return created(c);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var w = this as WeakPassword;
        if (w != null) return weakPassword(w);

        var f = this as SystemFailure;
        if (f != null) return systemFailure(f);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

Вызывающая сторона пишет так:

CreateUserResult result = service.CreateUser(command);

string message = result.Match(
    created => "Пользователь создан: " + created.User.Id,
    duplicate => "Этот email уже используется: " + duplicate.Email,
    weak => "Пароль слишком слабый: " + weak.Reason,
    failure => "Не удалось создать пользователя: " + failure.Message);

Плюс такого вида — он работает и в .NET Framework, и в современном .NET.

Created, DuplicateEmail, WeakPassword и SystemFailure — всё это CreateUserResult, но данные у каждого свои.

User есть только у Created. Email есть только у DuplicateEmail. Reason есть только у WeakPassword. Message есть только у SystemFailure.

Значение, которое одновременно выражает и успех, и неудачу, создать нельзя.

Если приучить вызывающую сторону к Match, можно заставить её обрабатывать все кейсы.

Допустим, добавили кейс TemporaryBlocked.

public sealed class TemporaryBlocked : CreateUserResult
{
    internal TemporaryBlocked(DateTimeOffset until)
    {
        Until = until;
    }

    public DateTimeOffset Until { get; }
}

Тогда в параметры Match тоже добавляют Func<TemporaryBlocked, T>.

После этого существующие вызовы result.Match(...) перестают компилироваться. Это хорошая ошибка: на этапе компиляции видно, что «добавили новый кейс, а вызывающая сторона его ещё не обрабатывает».

5. Закрываем набор кейсов private-конструктором

Когда в C# выражают тип-сумму, важно как можно сильнее закрыть набор кейсов.

Если конструктор базового класса protected, снаружи всё ещё можно унаследоваться.

public abstract class PaymentResult
{
    protected PaymentResult()
    {
    }
}

В таком виде в другой сборке или в другом месте кода можно завести, например, такой тип:

public sealed class UnknownPaymentResult : PaymentResult
{
}

Набор кейсов PaymentResult перестаёт быть закрытым.

Хотели сказать: «этот тип — один из Succeeded / InsufficientFunds / Rejected / NetworkFailure», а появился ещё один кейс.

Практичный способ, который работает и в .NET Framework, — сделать конструктор базового класса private и определить типы кейсов как вложенные типы базового класса.

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    public sealed class Succeeded : PaymentResult
    {
        internal Succeeded(string receiptNo)
        {
            ReceiptNo = receiptNo;
        }

        public string ReceiptNo { get; }
    }

    public sealed class InsufficientFunds : PaymentResult
    {
        internal InsufficientFunds(decimal shortage)
        {
            Shortage = shortage;
        }

        public decimal Shortage { get; }
    }

    public static PaymentResult Success(string receiptNo)
        => new Succeeded(receiptNo);

    public static PaymentResult Insufficient(decimal shortage)
        => new InsufficientFunds(shortage);
}

Вложенные типы имеют доступ к private-членам внешнего типа. Поэтому унаследовать PaymentResult могут только вложенные типы кейсов.

С этим паттерном в C# тоже можно получить нечто близкое к «закрытому набору кейсов».

Но компилятор C# не делает такую же полную проверку исчерпываемости, как F#.

Поэтому в C# этот паттерн лучше использовать так: не разбрасывать switch по коду, а собрать обработку в методе Match.

6. В современном .NET иерархию record можно записать короче

Если можно рассчитывать на .NET 5 и новее, record в C# позволяет записать ориентированные на данные типы кейсов заметно короче.

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;
}

На стороне использования можно взять сопоставление с образцом и выражение switch.

static string ToMessage(CreateUserResult result)
{
    return result switch
    {
        CreateUserResult.Created { User: var user }
            => $"Пользователь создан: {user.Id}",

        CreateUserResult.DuplicateEmail { Email: var email }
            => $"Этот email уже используется: {email}",

        CreateUserResult.WeakPassword { Reason: var reason }
            => $"Пароль слишком слабый: {reason}",

        CreateUserResult.SystemFailure { Message: var message }
            => $"Не удалось создать пользователя: {message}",

        _ => throw new InvalidOperationException("Неизвестный результат.")
    };
}

Такой стиль органичен для C# и хорошо читается.

Есть и оговорки.

Иерархия record удобна, чтобы сократить шаблонный код сравнения значений и вывода. Но безопаснее не считать, что она закрывает набор кейсов так же строго, как паттерн предыдущей главы: обычный class + private-конструктор + вложенные sealed-кейсы.

Особенно у не-sealed record class в дело вступают специфичные для record сгенерированные члены, например конструктор копирования. Если нужно «категорически исключить наследование снаружи» или «строго закрыть набор кейсов», надёжнее иерархия class из предыдущей главы, размеченные объединения F# либо проверенная библиотека union / source generator.

Кроме того, _ в этом выражении switch создаёт впечатление, что можно принять и неизвестный производный тип. Если по проекту набор кейсов закрыт, ветка _ изначально должна быть «недостижимой».

В более старых стабильных версиях C# нельзя рассчитывать на такую же строгую проверку исчерпываемости, как у размеченных объединений F#. Поэтому и с иерархией record в C# безопаснее держаться одного из двух подходов:

  • завести метод Match и заставить вызывающую сторону обработать все кейсы;
  • локализовать switch, не разбрасывая его по коду.

Например, Match можно добавить и в иерархию record.

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        return this switch
        {
            Created x => created(x),
            DuplicateEmail x => duplicateEmail(x),
            WeakPassword x => weakPassword(x),
            SystemFailure x => systemFailure(x),
            _ => throw new InvalidOperationException("Неизвестный результат.")
        };
    }
}

Тогда вызывающая сторона всегда учитывает все кейсы.

var message = result.Match(
    created => $"Создан: {created.User.Id}",
    duplicate => $"Повтор: {duplicate.Email}",
    weak => $"Пароль слабый: {weak.Reason}",
    failure => $"Сбой: {failure.Message}");

Плюс record — меньше шаблонного кода для сравнения значений, вывода и копирования. В общих библиотеках, которые целятся и в .NET Framework, иногда удобнее писать обычными классами, чем насильно тянуть record или init-only свойства.

Приоритет не «использовать новый синтаксис», а «заключить выражаемое состояние в тип».

7. Размеченные объединения F#

Язык, который в .NET наиболее естественно работает с алгебраическими типами данных, — F#.

В F# размеченные объединения даны как возможность самого языка.

type CreateUserResult =
    | Created of user: User
    | DuplicateEmail of email: string
    | WeakPassword of reason: string
    | SystemFailure of message: string

Код использования тоже выглядит естественно.

let toMessage result =
    match result with
    | Created user -> $"Пользователь создан: {user.Id}"
    | DuplicateEmail email -> $"Этот email уже используется: {email}"
    | WeakPassword reason -> $"Пароль слишком слабый: {reason}"
    | SystemFailure message -> $"Не удалось создать пользователя: {message}"

Сильная сторона F# в том, что перечисление кейсов и сопоставление с образцом встроены в язык.

При добавлении кейса легче заметить пропуск обработки в match. Кроме того, типы вроде Option<'T>, которые выражают наличие или отсутствие значения, тоже естественно реализованы как размеченные объединения.

let tryFindUser id : User option =
    // Some user, если найден; None — если нет
    failwith "sample"

Возвращая option вместо null, мы отражаем в типе саму возможность «значения может не быть».

Размеченные объединения F# компилируются в обычные типы .NET, поэтому их можно использовать и в F#-проектах под .NET Framework, и в проектах под современный .NET.

Но если обращаться к размеченным объединениям F# напрямую из C#, это часто менее естественно, чем внутри самого F#.

На практике разумно такое разделение:

  • во внутренней доменной логике F# активно использовать размеченные объединения F#;
  • для публичных API, которые часто вызывают из C#, преобразовывать их в DTO или иерархии классов, удобные для C#;
  • на границах маппить в отдельное представление под JSON или БД.

Если удаётся разделить «строгие типы внутри домена» и «удобные типы на внешней границе», смешанное использование F# и C# становится заметно комфортнее.

8. Библиотека вроде OneOf

Если в C# хочется легко выразить тип-сумму, один из вариантов — библиотека вроде OneOf.

Возвращаемое значение можно выразить так:

using OneOf;

public sealed class DuplicateEmail
{
    public DuplicateEmail(string email)
    {
        Email = email;
    }

    public string Email { get; }
}

public sealed class WeakPassword
{
    public WeakPassword(string reason)
    {
        Reason = reason;
    }

    public string Reason { get; }
}

public OneOf<User, DuplicateEmail, WeakPassword> CreateUser(CreateUserCommand command)
{
    if (EmailExists(command.Email))
    {
        return new DuplicateEmail(command.Email);
    }

    if (!IsStrongPassword(command.Password))
    {
        return new WeakPassword("Используйте не менее 12 символов.");
    }

    return CreateUserCore(command);
}

Вызывающая сторона обрабатывает это через Match.

var result = service.CreateUser(command);

var message = result.Match(
    user => $"Создан: {user.Id}",
    duplicate => $"Повтор: {duplicate.Email}",
    weak => $"Пароль слабый: {weak.Reason}");

OneOf<User, DuplicateEmail, WeakPassword> означает: «это значение — ровно одно из User, DuplicateEmail или WeakPassword».

Плюс такого подхода — им удобно пользоваться для локальных возвращаемых значений, даже не заводя специальный базовый класс.

Он особенно хорошо ложится на такие возвращаемые значения в слое сервисов приложения или use case:

результат создания пользователя = User или DuplicateEmail или WeakPassword
результат получения товара = Product или NotFound или AccessDenied
результат платежа = Receipt или InsufficientFunds или PaymentRejected

Есть и оговорки.

Если выставить тип вроде OneOf<A, B, C> напрямую в публичном API, доменные имена могут поблекнуть.

Например, следующие две сигнатуры структурно выглядят одинаково, если смотреть только на аргументы типа:

OneOf<User, NotFound, AccessDenied> GetUser(...)
OneOf<Order, NotFound, AccessDenied> GetOrder(...)

В небольшом масштабе это удобно. Если важно явно выразить доменный смысл, читаемость выше у специального типа:

public abstract class GetUserResult
{
    // Found / NotFound / AccessDenied
}

Ориентир для выбора:

  • для локальных возвращаемых значений OneOf удобен;
  • для понятий, которые регулярно повторяются в домене, заводите специальный тип;
  • если важна стабильность публичного API, используйте именованный тип результата.

OneOf покрывает широкий набор целей, включая .NET Framework и .NET Standard, поэтому его легко внедрить и в существующий код на .NET Framework.

9. Библиотеки на основе Source Generator

В современном .NET есть и библиотеки, которые через Source Generator порождают типы в духе размеченных объединений.

Например, достаточно повесить атрибут, и генерируются Switch, Map, проверки и связка с сериализацией.

Концептуально это выглядит примерно так:

[Union]
public partial record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record Failure(string Error) : Result<T>;
}

Такие библиотеки сокращают объём вручную написанного шаблонного кода Match и Switch. Некоторые вместе с анализатором ещё и предупреждают о пропуске обработки.

Но если пользоваться ими в существующей системе, включая .NET Framework, проверьте следующее:

  • поддерживают ли целевые TFM .NET Framework;
  • готово ли окружение SDK / Visual Studio / MSBuild для Source Generator;
  • даёт ли CI тот же результат генерации;
  • можно ли отлаживать сгенерированный код;
  • работает ли связка с JSON / БД / OpenAPI на границах приложения так, как ожидаете.

В старых проектах на .NET Framework пакеты, рассчитанные на Source Generator, иногда нельзя взять как есть.

Если важна серьёзная поддержка .NET Framework, безопаснее начать с написанной вручную иерархии классов или с OneOf.

10. О union-типах C# 15

Эта глава — про предложение и preview. Сведения на июнь 2026 года. Синтаксис ниже, порождаемые типы и обращение с сопоставлением с образцом могут измениться до официального выпуска. Возможность могут и снять. Код в этой главе читайте не как «так уже можно писать», а как «в эту сторону идёт обсуждение». Опора реализации — предложение спецификации C# (Unions - C# feature specifications) и статья в .NET Blog; оба документа на стадии предложения.

Когда union-тип C# 15 выйдет официально, эту главу перепишем под зафиксированную спецификацию. До тех пор не опирайтесь на неё в проектных решениях. На что можно опираться — стабильные варианты глав 4–9.

В направлении preview можно объявить: «этот тип — ровно один из указанных типов».

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

public union Pet(Cat, Dog, Bird);

На стороне использования каждый кейс обрабатывают через сопоставление с образцом.

static string Describe(Pet pet)
{
    return pet switch
    {
        Cat cat => $"Cat: {cat.Name}",
        Dog dog => $"Dog: {dog.Name}",
        Bird bird => $"Bird: {bird.Name}",
        Pet { Value: null } => "Unknown pet"
    };
}

Когда возможность стабилизируется, в C# станет естественнее работать с «закрытым набором типов» и «исчерпывающим pattern matching».

Если на этапе preview порождённый тип оказывается struct, может встретиться и значение вроде default(Pet), у которого внутреннее поле Value равно null. Публичным методам, которые принимают union-значение, нужно защитно обрабатывать и такие значения по умолчанию.

Preview не стоит класть в боевой код без осторожной оценки.

Спецификация языка, поддержка в IDE, вспомогательные типы в runtime, анализаторы и связка с сериализаторами могут измениться до официального выпуска.

Поэтому сегодня в практике разумна такая расстановка:

  • для новых экспериментов и технического исследования C# union попробовать стоит;
  • для кода, который предстоит долго сопровождать в продакшене, берите стабильные варианты: DU F#, иерархии class / record, OneOf, Source Generator;
  • чтобы упростить будущий переход на C# union, уже сейчас организуйте возвращаемые значения и состояния как типы «ровно один из вариантов».

Иначе говоря, проектирование в стиле ADT можно начать уже сегодня, не дожидаясь C# union.

Более того, если уже сейчас навести порядок в типах Result, Option, типах состояний и типах доменных событий, позже будет проще перейти на языковую возможность.

11. Тип Option: «нет значения» вместо null

Классический пример алгебраического типа данных — Option<T>.

Option<T> выражает один из двух вариантов:

Some(value)
None

В C# «отсутствие» часто выражают через null, но у null есть проблема: его не видно на уровне типа.

User user = repository.FindById(id);

// Вызывающая сторона должна помнить, может ли user быть null
Console.WriteLine(user.Name);

Если использовать Option<User>, возможность «не найдено» проявляется прямо в типе.

Простая реализация, которая работает и в .NET Framework:

public abstract class Option<T>
{
    private Option()
    {
    }

    public sealed class Some : Option<T>
    {
        internal Some(T value)
        {
            Value = value;
        }

        public T Value { get; }
    }

    public sealed class None : Option<T>
    {
        internal None()
        {
        }
    }

    private static readonly None NoneValue = new None();

    public static Option<T> Of(T value)
    {
        if (object.Equals(value, null))
        {
            return NoneValue;
        }

        return new Some(value);
    }

    public static Option<T> Empty()
    {
        return NoneValue;
    }

    public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
    {
        if (some == null) throw new ArgumentNullException(nameof(some));
        if (none == null) throw new ArgumentNullException(nameof(none));

        var s = this as Some;
        if (s != null) return some(s.Value);

        return none();
    }
}

Использование выглядит так:

Option<User> user = repository.FindById(id);

string displayName = user.Match(
    some: u => u.Name,
    none: () => "Гость");

Полностью избавляться от null не нужно. Существующие API .NET, базы данных и JSON всё равно порождают null.

Но внутри доменной логики Option<T> во многих случаях выражает намерение яснее, чем null.

Option<T> особенно хорошо подходит для таких методов:

Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);

Важно выражать возможность «отсутствия» не только приставкой Try в имени метода, но и самим типом возвращаемого значения.

12. Тип Result: ожидаемые сбои возвращаем типом

Ещё один часто используемый тип — Result<TSuccess, TError>.

Он выражает один из двух вариантов:

Success(value)
Failure(error)

Исключения хорошо подходят для непредвиденных сбоев и для ситуаций, которые не хочется проводить через обычный поток управления. С другой стороны, сбои, которые в бизнесе случаются регулярно, часто читаются понятнее, если возвращать их как тип.

Например, в процессе входа ожидаемы такие сбои:

  • пользователь не существует;
  • пароль неверный;
  • учётная запись заблокирована;
  • нужна многофакторная аутентификация.

Если выразить это только исключениями, вызывающая сторона пишет бизнес-ветвление внутри catch.

try
{
    var session = auth.Login(userName, password);
    return Ok(session);
}
catch (InvalidPasswordException)
{
    return Unauthorized();
}
catch (AccountLockedException)
{
    return Forbid();
}

Так тоже работает, но бизнес-ветвление легко теряется в обработке исключений.

В стиле ADT это выглядит так:

public abstract class LoginResult
{
    private LoginResult()
    {
    }

    public sealed class Succeeded : LoginResult
    {
        internal Succeeded(Session session)
        {
            Session = session;
        }

        public Session Session { get; }
    }

    public sealed class InvalidPassword : LoginResult
    {
        internal InvalidPassword()
        {
        }
    }

    public sealed class AccountLocked : LoginResult
    {
        internal AccountLocked(DateTimeOffset until)
        {
            Until = until;
        }

        public DateTimeOffset Until { get; }
    }

    public sealed class MfaRequired : LoginResult
    {
        internal MfaRequired(string challengeId)
        {
            ChallengeId = challengeId;
        }

        public string ChallengeId { get; }
    }

    public static LoginResult Success(Session session)
        => new Succeeded(session);

    public static LoginResult WrongPassword()
        => new InvalidPassword();

    public static LoginResult Locked(DateTimeOffset until)
        => new AccountLocked(until);

    public static LoginResult RequireMfa(string challengeId)
        => new MfaRequired(challengeId);

    public T Match<T>(
        Func<Succeeded, T> succeeded,
        Func<InvalidPassword, T> invalidPassword,
        Func<AccountLocked, T> accountLocked,
        Func<MfaRequired, T> mfaRequired)
    {
        if (succeeded == null) throw new ArgumentNullException(nameof(succeeded));
        if (invalidPassword == null) throw new ArgumentNullException(nameof(invalidPassword));
        if (accountLocked == null) throw new ArgumentNullException(nameof(accountLocked));
        if (mfaRequired == null) throw new ArgumentNullException(nameof(mfaRequired));

        var s = this as Succeeded;
        if (s != null) return succeeded(s);

        var i = this as InvalidPassword;
        if (i != null) return invalidPassword(i);

        var l = this as AccountLocked;
        if (l != null) return accountLocked(l);

        var m = this as MfaRequired;
        if (m != null) return mfaRequired(m);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

В таком виде вызывающая сторона пишет код, глядя на «все возможные результаты входа».

var result = auth.Login(userName, password);

return result.Match(
    succeeded => Ok(succeeded.Session),
    invalidPassword => Unauthorized(),
    accountLocked => StatusCode(423),
    mfaRequired => Accepted(new { mfaRequired.ChallengeId }));

Дело не в том, чтобы отказаться от исключений.

Ожидаемое бизнес-ветвление — через Result, непредвиденные аномалии — через исключения.

Уже одно это разделение заметно проясняет слой сервисов приложения и API.

13. Переходы состояний тоже выражают типом

ADT подходит не только для возвращаемых значений, но и для состояния.

Возьмём состояния заказа.

public enum OrderStatus
{
    Draft,
    Submitted,
    Paid,
    Shipped,
    Cancelled
}

Одним enum трудно выразить данные, нужные каждому состоянию.

  • для Draft нужен создатель;
  • для Submitted — дата и время отправки;
  • для Paid — номер платежа;
  • для Shipped — номер отправления;
  • для Cancelled — причина отмены.

Если выражать это через OrderStatus и отдельные свойства, снова растёт число nullable-свойств.

public sealed class Order
{
    public OrderStatus Status { get; set; }
    public DateTimeOffset? SubmittedAt { get; set; }
    public string PaymentNo { get; set; }
    public string TrackingNo { get; set; }
    public string CancelReason { get; set; }
}

При таком проектировании можно создать состояние, где Status == Draft, но заполнено TrackingNo.

В стиле ADT само состояние становится типом.

public abstract class OrderState
{
    private OrderState()
    {
    }

    public sealed class Draft : OrderState
    {
        internal Draft(UserId createdBy)
        {
            CreatedBy = createdBy;
        }

        public UserId CreatedBy { get; }
    }

    public sealed class Submitted : OrderState
    {
        internal Submitted(DateTimeOffset submittedAt)
        {
            SubmittedAt = submittedAt;
        }

        public DateTimeOffset SubmittedAt { get; }
    }

    public sealed class Paid : OrderState
    {
        internal Paid(string paymentNo)
        {
            PaymentNo = paymentNo;
        }

        public string PaymentNo { get; }
    }

    public sealed class Shipped : OrderState
    {
        internal Shipped(string trackingNo)
        {
            TrackingNo = trackingNo;
        }

        public string TrackingNo { get; }
    }

    public sealed class Cancelled : OrderState
    {
        internal Cancelled(string reason)
        {
            Reason = reason;
        }

        public string Reason { get; }
    }
}

Заказ хранит OrderState.

public sealed class Order
{
    public OrderId Id { get; }
    public OrderState State { get; private set; }

    public Order(OrderId id, UserId createdBy)
    {
        Id = id;
        State = new OrderState.Draft(createdBy);
    }
}

Дальше переходы состояний заключают в методы.

public void Submit(IClock clock)
{
    if (!(State is OrderState.Draft))
    {
        throw new InvalidOperationException("Отправить можно только заказ в состоянии черновика.");
    }

    State = new OrderState.Submitted(clock.Now);
}

public void MarkAsPaid(string paymentNo)
{
    if (!(State is OrderState.Submitted))
    {
        throw new InvalidOperationException("Отметить как оплаченный можно только отправленный заказ.");
    }

    State = new OrderState.Paid(paymentNo);
}

В таком виде данные каждого состояния и правила переходов читаются легче.

При сохранении это иногда всё равно раскладывают на OrderStatus и вспомогательные столбцы.

Даже тогда внутри домена можно оперировать OrderState, а преобразование делать на границе с БД.

представление в БД
  status = "Paid"
  payment_no = "PAY-001"

представление внутри домена
  OrderState.Paid("PAY-001")

Не обязательно ослаблять доменную модель под схему БД.

14. На границе API конвертируем в DTO

Типы в стиле ADT очень удобны внутри домена.

В JSON API, БД, очередях сообщений, OpenAPI и внешних интеграциях нужна осторожность.

Допустим, этот ADT сериализуют в JSON как есть.

public abstract record PaymentResult
{
    public sealed record Succeeded(string ReceiptNo) : PaymentResult;
    public sealed record Rejected(string Reason) : PaymentResult;
    public sealed record NetworkFailure(string Message) : PaymentResult;
}

В JSON, возможно, хотят такую форму:

{
  "type": "succeeded",
  "receiptNo": "R-001"
}

Для неудачи — такую:

{
  "type": "rejected",
  "reason": "card_expired"
}

Это поле type — дискриминатор на стороне JSON.

Доменный ADT и представление в JSON похожи, но это не одно и то же.

Поэтому на внешней границе безопаснее конвертировать в DTO.

public sealed class PaymentResultDto
{
    public string Type { get; set; }
    public string ReceiptNo { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

В преобразовании DTO создают для каждого кейса ADT.

public static PaymentResultDto ToDto(PaymentResult result)
{
    return result switch
    {
        PaymentResult.Succeeded x => new PaymentResultDto
        {
            Type = "succeeded",
            ReceiptNo = x.ReceiptNo
        },

        PaymentResult.Rejected x => new PaymentResultDto
        {
            Type = "rejected",
            Reason = x.Reason
        },

        PaymentResult.NetworkFailure x => new PaymentResultDto
        {
            Type = "network_failure",
            Message = x.Message
        },

        _ => throw new InvalidOperationException("Неизвестный результат платежа.")
    };
}

Разумеется, есть и вариант полиморфной сериализации System.Text.Json или собственных конвертеров.

Но для API, которые предстоит долго сопровождать, часто безопаснее не связывать форму JSON с внутренней структурой доменного типа.

Рекомендуемое разделение:

внутри домена
  PaymentResult.Succeeded
  PaymentResult.Rejected
  PaymentResult.NetworkFailure

на границе API
  PaymentResultDto
  type: "succeeded" | "rejected" | "network_failure"

Доменный тип сосредотачивается на бизнес-смысле, внешнее представление стабилизирует DTO.

При таком разделении легче сохранять совместимость API, даже улучшая внутреннюю часть домена.

15. Плюс 1: некорректные состояния труднее создать

Главный плюс ADT — некорректные состояния становится труднее создать.

В такой тип легко заложить некорректную комбинацию:

public sealed class Reservation
{
    public bool IsCancelled { get; set; }
    public DateTimeOffset? CancelledAt { get; set; }
    public string CancelReason { get; set; }
    public DateTimeOffset? ConfirmedAt { get; set; }
}

В этом типе можно создать такие состояния:

  • не отменено, но заполнено CancelledAt;
  • отменено, но нет CancelReason;
  • уже отменено, но заполнено ConfirmedAt;
  • дата подтверждения есть ещё до самого подтверждения.

В стиле ADT данные разделяют по состояниям.

public abstract class ReservationState
{
    private ReservationState()
    {
    }

    public sealed class Requested : ReservationState
    {
        internal Requested(DateTimeOffset requestedAt)
        {
            RequestedAt = requestedAt;
        }

        public DateTimeOffset RequestedAt { get; }
    }

    public sealed class Confirmed : ReservationState
    {
        internal Confirmed(DateTimeOffset confirmedAt)
        {
            ConfirmedAt = confirmedAt;
        }

        public DateTimeOffset ConfirmedAt { get; }
    }

    public sealed class Cancelled : ReservationState
    {
        internal Cancelled(DateTimeOffset cancelledAt, string reason)
        {
            CancelledAt = cancelledAt;
            Reason = reason;
        }

        public DateTimeOffset CancelledAt { get; }
        public string Reason { get; }
    }
}

Теперь дату отмены и причину имеет только состояние «отменено».

Некорректные комбинации сокращают уже на этапе проектирования, а не проверяют потом.

Это важно и для тестов.

По мере роста числа bool и nullable-свойств число комбинаций взрывается. С ADT множество кейсов, которые нужно протестировать, сводится к «определённым кейсам».

16. Плюс 2: вызывающая сторона видит пропущенные кейсы

ADT показывает вызывающей стороне, какие кейсы вообще возможны у этого значения.

Глядя на следующий тип, вызывающая сторона понимает, что нужно обработать Found, NotFound и Forbidden.

public abstract class GetDocumentResult
{
    private GetDocumentResult()
    {
    }

    public sealed class Found : GetDocumentResult
    {
        internal Found(Document document)
        {
            Document = document;
        }

        public Document Document { get; }
    }

    public sealed class NotFound : GetDocumentResult
    {
        internal NotFound(DocumentId id)
        {
            Id = id;
        }

        public DocumentId Id { get; }
    }

    public sealed class Forbidden : GetDocumentResult
    {
        internal Forbidden(UserId userId)
        {
            UserId = userId;
        }

        public UserId UserId { get; }
    }

    public static GetDocumentResult DocumentFound(Document document)
        => new Found(document);

    public static GetDocumentResult DocumentNotFound(DocumentId id)
        => new NotFound(id);

    public static GetDocumentResult AccessForbidden(UserId userId)
        => new Forbidden(userId);

    public T Match<T>(
        Func<Found, T> found,
        Func<NotFound, T> notFound,
        Func<Forbidden, T> forbidden)
    {
        if (found == null) throw new ArgumentNullException(nameof(found));
        if (notFound == null) throw new ArgumentNullException(nameof(notFound));
        if (forbidden == null) throw new ArgumentNullException(nameof(forbidden));

        var f = this as Found;
        if (f != null) return found(f);

        var n = this as NotFound;
        if (n != null) return notFound(n);

        var d = this as Forbidden;
        if (d != null) return forbidden(d);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

Если просто вернуть null, непонятно: «не существует», «нет прав» или «не удалось получить».

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

Если выразить это как GetDocumentResult, сигнатура метода сама становится спецификацией.

GetDocumentResult GetDocument(UserId userId, DocumentId documentId);

Этот метод не просто возвращает документ.

Он несёт контракт API: вернуть одно из «найден», «не найден» или «нет прав».

К тому же с Match легче заметить непроработанные случаи.

return result.Match(
    found => Ok(found.Document),
    notFound => NotFound(),
    forbidden => Forbid());

Когда добавляется новый кейс и растёт число параметров Match, на этапе компиляции легче увидеть, что вызывающая сторона не обновлена.

Это очень выручает при долгом сопровождении.

17. Плюс 3: доменные слова остаются в коде

Если выражать состояние только через bool, int, string и null, бизнес-смысл исчезает из кода.

return false;

Что означает этот false?

  • не найдено;
  • ввод некорректен;
  • нет прав;
  • внешний сервис был недоступен;
  • уже обработано.

Без знания контекста вызывающая сторона этого не поймёт.

С ADT бизнес-терминология сохраняется в виде типа.

return GetDocumentResult.DocumentNotFound(documentId);
return GetDocumentResult.AccessForbidden(userId);
return SubmitOrderResult.AlreadySubmitted(orderId);
return SubmitOrderResult.CreditLimitExceeded(limit);

Эта разница существенна.

Доменные слова становятся видны и в код-ревью, и в журналах, и в тестах.

Естественными становятся даже имена тестов.

[Fact]
public void ПовторнаяОтправкаУжеОтправленногоЗаказаВозвращаетAlreadySubmitted()
{
    var result = service.Submit(orderId);

    Assert.IsType<SubmitOrderResult.AlreadySubmitted>(result);
}

Это не просто приём реализации, а способ оставить бизнес-спецификацию в коде.

18. Плюс 4: меньше злоупотреблений исключениями

Исключения в .NET — мощный инструмент.

Но если исключениями оформлять и рутинные бизнес-ветвления, читаемость обработки может пострадать.

Возьмём резервирование товара на складе.

Нехватка товара — не аномалия системы. Это обычный, ожидаемый в бизнесе результат.

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<OutOfStock, T> outOfStock)
    {
        if (reserved == null) throw new ArgumentNullException(nameof(reserved));
        if (outOfStock == null) throw new ArgumentNullException(nameof(outOfStock));

        var r = this as Reserved;
        if (r != null) return reserved(r);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

Если выразить это так, нехватка товара становится обычным результатом OutOfStock.

var result = stock.Reserve(sku, quantity);

return result.Match(
    reserved => Ok(reserved.ReservationId),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

А вот обрыв соединения с БД, повреждённый файл конфигурации или неожиданная нарушенная целостность — уместные поводы для исключения.

Практичная граница такая:

то, что вызывающая сторона должна обрабатывать как обычное ветвление
  => возвращать через Result / ADT

то, от чего обычная обработка не восстанавливается
  => оформлять исключением

При таком разделении try-catch не подменяет бизнес-ветвление.

19. Плюс 5: тесты писать легче

С ADT тестируемые кейсы становятся явными.

Допустим, есть такой тип результата:

SubmitOrderResult =
  Submitted(orderId)
  или AlreadySubmitted(orderId)
  или InvalidOrder(reason)
  или CreditLimitExceeded(limit)

Тесты тогда естественно делятся по кейсам:

для корректного заказа возвращается Submitted
для уже отправленного заказа возвращается AlreadySubmitted
для некорректного заказа возвращается InvalidOrder
при превышении кредитного лимита возвращается CreditLimitExceeded

Если выражать состояние комбинацией nullable-свойств, тестовому коду тоже приходится разбираться, какая комбинация вообще допустима.

С ADT сами кейсы становятся точками зрения для тестирования.

Проще и создавать тестовые данные.

var result = SubmitOrderResult.CreditLimitExceeded(limit);

Эта одна строка создаёт данные со смыслом «превышен кредитный лимит».

Это яснее, чем собирать правдоподобный объект из комбинации Status, ErrorCode, Message и Limit.

20. Как внедрять в .NET Framework

Когда в существующую систему на .NET Framework внедряют проектирование в стиле ADT, не стоит резко и масштабно всё менять.

Рекомендуем начать с возвращаемых значений. В существующем коде ищите следующее:

  • bool TryXxx(...), которому уже понадобилась причина неудачи;
  • возврат null, хотя причин «не найдено» несколько;
  • растущая связка enum Status и вспомогательных nullable-свойств;
  • бизнес-ветвление, выраженное через исключения;
  • разрастающееся сравнение строк ErrorCode.

В таких местах переход на ADT даёт заметный эффект.

Дальше заводят специальный тип результата.

public abstract class RegisterMemberResult
{
    private RegisterMemberResult()
    {
    }

    public sealed class Registered : RegisterMemberResult
    {
        internal Registered(MemberId memberId)
        {
            MemberId = memberId;
        }

        public MemberId MemberId { get; }
    }

    public sealed class DuplicateEmail : RegisterMemberResult
    {
        internal DuplicateEmail(string email)
        {
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class InvalidInvitationCode : RegisterMemberResult
    {
        internal InvalidInvitationCode(string code)
        {
            Code = code;
        }

        public string Code { get; }
    }

    public T Match<T>(
        Func<Registered, T> registered,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<InvalidInvitationCode, T> invalidInvitationCode)
    {
        if (registered == null) throw new ArgumentNullException(nameof(registered));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (invalidInvitationCode == null) throw new ArgumentNullException(nameof(invalidInvitationCode));

        var r = this as Registered;
        if (r != null) return registered(r);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var i = this as InvalidInvitationCode;
        if (i != null) return invalidInvitationCode(i);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

Затем на существующей границе API сразу конвертируют в DTO или старый формат.

var result = service.Register(command);

return result.Match(
    registered => new RegisterMemberResponse
    {
        Success = true,
        MemberId = registered.MemberId.Value
    },
    duplicate => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "DuplicateEmail",
        ErrorMessage = duplicate.Email + " уже используется."
    },
    invalidCode => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "InvalidInvitationCode",
        ErrorMessage = "Код приглашения недействителен."
    });

Не меняя сразу внешний интерфейс, можно сначала укрепить только внутреннюю логику.

Это критично для существующих систем.

требования внешнего API и экранов
  сохраняем существующий формат ответа

внутренняя доменная логика
  безопасно оперируем типами в стиле ADT

Даже одно преобразование на границе уже заметно упорядочивает внутреннее ветвление.

21. Общая библиотека на .NET Standard

Для библиотеки, которой пользуются и .NET Framework, и современный .NET, есть вариант целиться в .NET Standard.

Если важна широкая совместимость, реалистичный кандидат — .NET Standard 2.0.

Доменную модель и типы результатов кладут, например, в такую конфигурацию.

MyApp.Domain
  TargetFramework: netstandard2.0

MyApp.LegacyWeb
  TargetFramework: net472
  ссылается на MyApp.Domain

MyApp.Api
  TargetFramework: net8.0
  ссылается на MyApp.Domain

Тогда старое приложение на .NET Framework и новое приложение на .NET проще разделяют одни и те же доменные типы.

Но если цель — .NET Standard 2.0, не стоит слишком сильно опираться на новые API C# / .NET.

В общей библиотеке такие решения иногда лучше не принимать:

  • сильно зависеть от record и init;
  • напрямую вызывать API .NET 6 и новее;
  • широко публиковать код, который предполагает Source Generator;
  • тащить в доменный слой типы, специфичные для ASP.NET Core.

В общей библиотеке проще жить с простыми class, объектами-значениями и типами результатов в стиле ADT.

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    // Обычный class, удобный и в .NET Framework, и в .NET
}

В прикладном слое, который целится только в новый .NET, уже можно брать record и switch expression.

общий доменный слой
  обычные типы, которые читаются и в старом окружении

новый прикладной слой
  record / pattern matching / minimal API

Такое разделение помогает балансировать существующий код и новую разработку.

22. Насколько широко применять ADT

ADT удобен, но не всё стоит делать ADT.

Он хорошо ложится на набор кейсов, который с точки зрения бизнеса почти закрыт. Например:

  • результат обработки;
  • результат проверки ввода;
  • состояние заказа;
  • результат платежа;
  • результат аутентификации;
  • результат вызова внешнего сервиса;
  • доменное событие;
  • вид команды;
  • состояние экрана.

Есть и случаи, где нужна осторожность:

  • виды, которые снаружи наращивают плагины;
  • виды, которые наращивает пользователь;
  • то, что в работе растёт как мастер-данные БД;
  • типы интеграции с фреймворком, которые предполагают расширение наследованием;
  • простые DTO для CRUD.

Если набор кейсов расширяется снаружи, лучше интерфейс или обычная иерархия наследования, а не закрытый ADT.

Если форматы вывода отчёта наращивают плагинами, естественнее такой дизайн:

public interface IReportExporter
{
    string FormatName { get; }
    void Export(Report report, Stream output);
}

Если здесь сделать закрытый тип-сумму вроде PdfExporter | ExcelExporter | CsvExporter, внешнее расширение станет труднее.

ADT силён в «закрытом мире».

Действительно ли набор закрыт с точки зрения бизнеса? Может ли он позже расти снаружи?

Это стоит оценить заранее.

23. Когда достаточно enum

enum сам по себе не плох.

enum уместен, когда у кейсов нет дополнительных данных и достаточно простой метки. Например:

public enum Gender
{
    Unknown,
    Male,
    Female,
    Other
}

Или уровни журнала.

public enum LogLevel
{
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Critical
}

Если у каждого кейса свои данные, стоит рассмотреть тип в стиле ADT.

PaymentStatus enum
  Succeeded
  Rejected
  Failed

PaymentResult ADT
  Succeeded(receiptNo)
  Rejected(reason)
  Failed(message)

Критерий простой.

достаточно знать только кейс
  => enum

у каждого кейса свои данные
  => ADT

у каждого кейса своё поведение или свои ограничения
  => ADT или иерархия class

Когда начинает расти связка enum + группа nullable-свойств, это сигнал к переходу на ADT.

24. Когда достаточно bool

bool тоже не плох.

Если смысл действительно исчерпывается yes / no, bool достаточно.

bool IsEnabled { get; }
bool IsDeleted { get; }

Но если причин неудачи несколько, bool становится слабым.

bool TryCreateUser(CreateUserCommand command);

У этого метода при неудаче нет причины.

Можно дополнить аргументами out.

bool TryCreateUser(CreateUserCommand command, out User user, out string errorCode);

Но со временем это усложняется.

В таком случае читаемее тип результата.

CreateUserResult CreateUser(CreateUserCommand command);

Вызывающая сторона тогда оперирует не только успехом и неудачей, но и видом неудачи как типом.

return result.Match(
    created => Ok(created.User),
    duplicate => Conflict(),
    weak => BadRequest(),
    failure => StatusCode(500));

Критерий такой.

действительно два варианта, и дополнительная информация не нужна
  => bool

два варианта, но нужны значение успеха или причина неудачи
  => Result

три и больше вариантов, или у каждого кейса свои данные
  => ADT

25. Чем ADT отличается от наследования

Когда в C# делают тип в стиле ADT, внешне это похоже на обычное наследование.

public abstract class PaymentResult
{
}

public sealed class Succeeded : PaymentResult
{
}

public sealed class Rejected : PaymentResult
{
}

Цель, однако, чуть другая.

Обычное объектно-ориентированное наследование чаще используют, чтобы подменять поведение.

public abstract class Shape
{
    public abstract double Area();
}

public sealed class Circle : Shape
{
    public override double Area() => ...;
}

Наследование в стиле ADT используют, чтобы выразить «возможные формы данных».

public abstract class PaymentResult
{
    public sealed class Succeeded : PaymentResult
    {
        public string ReceiptNo { get; }
    }

    public sealed class Rejected : PaymentResult
    {
        public string Reason { get; }
    }
}

Речь не о том, что одно правильно, а другое нет.

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

public abstract class Notification
{
    public abstract void Send();
}

Если вызывающая сторона хочет ветвиться, глядя на все кейсы, лучше ADT + pattern matching / Match.

return notification.Match(
    email => SendEmail(email),
    sms => SendSms(sms),
    push => SendPush(push));

В бизнес-приложениях понятное разделение такое: возвращаемые значения и состояния — ADT, подмена поведения — интерфейс.

26. Не разбрасывайте сопоставление с образцом

Начав пользоваться ADT, хочется писать switch и Match повсюду.

Но если одно и то же ветвление размазано по нескольким местам, при добавлении кейса растёт число правок.

Допустим, PaymentResult переключают switch в разных местах.

преобразование ответа API
запись в журнал
сообщение на экране
запись метрик
аудит-журнал

При добавлении кейса придётся править все switch.

Иногда этого не избежать. Но если собрать ответственность за ветвление в одном месте, сопровождать проще.

public static class PaymentResultMapper
{
    public static PaymentResultDto ToDto(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }

    public static string ToLogMessage(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }
}

Иногда лучше не ветвиться снаружи, а отдать обработку самому кейсу.

public abstract class PaymentResult
{
    public abstract bool IsSuccess { get; }
}

Но если навешивать на кейс слишком много обработки, доменный тип начинает знать про API и UI.

Такую обработку чаще лучше не класть прямо в доменный тип:

  • преобразование в HTTP-статус;
  • преобразование в JSON DTO;
  • сообщение для экрана;
  • формат журнала;
  • представление для OpenAPI.

Доменный тип выражает бизнес-смысл. Преобразование на границе кладут в Mapper.

Если держать это разделение, ADT проще сопровождать долго.

27. Как называть

У типов в стиле ADT имя важно.

Одни только общие имена вроде Result, Error, Response размывают смысл.

Часто используют такие имена:

CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState

Имена кейсов подтягивают к бизнес-терминам.

Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked

Одни Error1, Error2, Failed вызывающей стороне понять трудно.

Данные, которые несёт кейс, тоже по возможности делают бизнес-типами.

public sealed class CreditLimitExceeded : SubmitOrderResult
{
    public Money Limit { get; }
    public Money RequestedAmount { get; }
}

decimal и string тоже работают. Но в связке с объектами-значениями вроде Money, Email, UserId, OrderId намерение становится ещё яснее.

ADT и объекты-значения хорошо сочетаются.

объект-значение
  смысл и ограничения одного значения

ADT
  несколько возможных форм

Вместе ими проще заключить бизнес-правила в тип.

28. Следите за версионированием

ADT явно задаёт набор кейсов, поэтому добавление кейса затрагивает вызывающую сторону.

Это и плюс, и оговорка.

Во внутреннем коде ошибка компиляции при добавлении кейса — желаемое поведение. Так находят пропуск обработки.

Но у типа, который отдают наружу как пакет NuGet или публичный API, добавление кейса иногда близко к ломающему изменению.

Допустим, потребитель библиотеки обрабатывает все кейсы так:

var text = result.Match(
    success => ...,
    validationError => ...,
    permissionDenied => ...);

Если библиотека добавит кейс RateLimited и изменит сигнатуру Match, код потребителя перестанет компилироваться.

Это безопасно, но с точки зрения совместимости публичного API имеет последствия.

Поэтому в публичной библиотеке думают так:

  • если добавление кейса допустимо, поднимайте версию и оформляйте это как ломающее изменение;
  • если внешним потребителям нужна обработка «по умолчанию», закрытый ADT — не тот дизайн;
  • внутри домена будьте строгими, во внешнем API — DTO и версионированный контракт.

Внутри бизнес-приложения ошибка компиляции при добавлении кейса скорее помогает.

В публичном API нужно отдельно проектировать совместимость.

29. О производительности

Проектирование в стиле ADT ради выразительности иногда увеличивает число объектов.

Если в .NET Framework используют иерархию class, объект создаётся на каждый кейс.

return PaymentResult.Success(receiptNo);

В обычном бизнес-приложении это чаще всего не проблема.

Но в таких местах стоит быть внимательными:

  • низкоуровневая обработка, которую вызывают очень часто;
  • потоковая обработка большого числа событий;
  • игры и обработка в реальном времени;
  • обработка, где аллокации нужно сокращать радикально;
  • огромные коллекции, в которых хранят много ADT.

Если производительность важна, есть несколько вариантов:

  • взять Result на основе struct;
  • рассмотреть struct discriminated union в F#;
  • снизить аллокации через Source Generator;
  • на горячем пути использовать enum и выделенные поля, а в ADT преобразовывать на границе;
  • оптимизировать только после измерений.

С самого начала чрезмерно оптимизировать не нужно.

Во многих бизнес-системах ясность проектирования, которую даёт ADT, ценнее небольших затрат на создание объектов.

Но там, где требования к производительности строги, проектирование и измерения стоит рассматривать вместе.

30. Пример рефакторинга существующего кода

Напоследок типичный путь переработки существующего кода в стиле ADT.

Исходный код выглядит так. Чтобы потом было проще сравнить, места правок пронумерованы.

public bool TryReserveStock(string sku, int quantity, out string errorCode)
{
    // (1) Успех/неудача — bool, причина — out string. Связь между ними в типе не видна
    errorCode = null;

    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        errorCode = "SKU_NOT_FOUND"; // (2) Причина неудачи — строковый литерал
        return false;
    }

    if (stock.Available < quantity)
    {
        errorCode = "OUT_OF_STOCK"; // (3) Сколько не хватает, вызывающая сторона не узнает
        return false;
    }

    stock.Reserve(quantity);
    return true; // (4) При успехе не возвращается, какая именно резервация создана
}

В этом коде причина неудачи выражена строкой string. Вызывающая сторона сравнивает строки.

string errorCode;
if (!service.TryReserveStock(sku, quantity, out errorCode))
{
    if (errorCode == "SKU_NOT_FOUND")
    {
        ...
    }
    else if (errorCode == "OUT_OF_STOCK")
    {
        ...
    }
}

Преобразуем это в тип результата.

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class SkuNotFound : ReserveStockResult
    {
        internal SkuNotFound(Sku sku)
        {
            Sku = sku;
        }

        public Sku Sku { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public static ReserveStockResult Success(ReservationId reservationId)
        => new Reserved(reservationId);

    public static ReserveStockResult NotFound(Sku sku)
        => new SkuNotFound(sku);

    public static ReserveStockResult NotEnough(Sku sku, int requested, int available)
        => new OutOfStock(sku, requested, available);

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<SkuNotFound, T> skuNotFound,
        Func<OutOfStock, T> outOfStock)
    {
        var r = this as Reserved;
        if (r != null) return reserved(r);

        var n = this as SkuNotFound;
        if (n != null) return skuNotFound(n);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("Неизвестный результат резервирования товара.") ;
    }
}

Метод сервиса становится таким. Номера соответствуют исходному коду.

public ReserveStockResult ReserveStock(Sku sku, int quantity)
{
    // (1) Один возвращаемый тип выражает все возможные результаты. out не нужен
    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        return ReserveStockResult.NotFound(sku); // (2) Не строка, а тип кейса
    }

    if (stock.Available < quantity)
    {
        // (3) Кейс несёт и запрошенное количество, и доступный остаток
        return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
    }

    var reservationId = stock.Reserve(quantity);
    return ReserveStockResult.Success(reservationId); // (4) Идентификатор резервации есть только у кейса успеха
}

Изменения рядом:

# Было Стало
(1) возвращаемое значение bool + out string errorCode один тип результата ReserveStockResult
(2) строка "SKU_NOT_FOUND" кейс SkuNotFound (несёт Sku)
(3) только "OUT_OF_STOCK", без величины нехватки кейс OutOfStock (несёт Requested и Available)
(4) при успехе возвращается только true кейс Reserved несёт ReservationId

Вызывающая сторона может отказаться от сравнения строк.

var result = service.ReserveStock(sku, quantity);

return result.Match(
    reserved => Ok(new { reserved.ReservationId }),
    notFound => NotFound(new { sku = notFound.Sku.Value }),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

Суть этого рефакторинга в том, что внутренний смысл можно перенести в тип, не меняя внешнего поведения.

Сначала укрепляют возвращаемое значение. Затем переводят вызывающую сторону на Match. Наконец, постепенно убирают строковые коды ошибок и вспомогательные nullable-свойства.

В таком порядке это можно внедрять поэтапно даже в существующей системе.

31. Чек-лист при внедрении

Когда заводите тип в стиле ADT, проверьте следующее.

Выражает ли этот тип «ровно один из вариантов»
Закрыт ли набор кейсов с точки зрения бизнеса
Разные ли данные нужны каждому кейсу
Не утратился ли смысл при bool / enum / null / строковом коде ошибки
Хотите ли вы, чтобы вызывающая сторона осознанно обрабатывала все кейсы
Не влияет ли это на совместимость публичного API
Есть ли политика преобразования в JSON / БД / DTO экрана
Если нужен и .NET Framework, достаточно ли обычного class
Если только современный .NET, стоит ли брать record или Source Generator

Стратегию реализации можно выбрать так.

проект на F#
  размеченные объединения F#

C# в .NET Framework
  abstract class + private constructor + nested sealed classes + Match

C# в .NET 5 и новее
  abstract record + sealed record cases + pattern matching

локальное возвращаемое значение
  библиотека вроде OneOf

хотим сократить шаблонный код в современном .NET
  библиотеки на основе Source Generator

эксперименты на перспективу
  C# 15 union preview

Какой бы способ вы ни выбрали, цель одна.

Правило, которое раньше держалось на комментарии, теперь держит тип.

В этом главный смысл ADT.

32. Итог

Алгебраические типы данных — не привилегия функциональных языков.

Даже в C# на .NET Framework их вполне практично использовать через абстрактные классы и sealed-классы. В C# на современном .NET можно писать короче через record и pattern matching. В F# доступна сама языковая возможность — размеченные объединения. С библиотеками даже в C# легко работать с OneOf и Result.

Важен не синтаксис, а подход к проектированию.

Пересмотрите то, что раньше выражалось через bool, null, enum + nullable-свойства и string ErrorCode, и задайте себе такие вопросы:

Какой именно из кейсов представляет это значение
Какие данные нужны каждому кейсу
Какие данные не должны существовать вне этого кейса
Что обязательно должна обработать вызывающая сторона

Если строить тип, отвечая на эти вопросы, некорректных состояний становится меньше, ветвление читается яснее, а бизнес-терминология остаётся в коде.

В существующих системах рекомендуем начинать с возвращаемых значений.

Попробуйте заменить специальным типом результата те места, где накопилось бизнес-ветвление через TryXxx, null, ErrorCode и исключения.

Уже одно это заметно меняет читаемость и безопасность кода.

Справочные материалы

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

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что такое алгебраический тип данных (ADT)?
Алгебраический тип данных — сочетание типа-произведения (тип, который содержит и A, и B) и типа-суммы (тип, который является либо A, либо B). На практике это удобно понимать так: факт, что «у этого значения заранее известен набор возможных форм», выражается не комментариями и не соглашениями об именах, а самим типом. Чаще всего используют именно типы-суммы: результат платежа можно выразить как «ровно одно из Succeeded(receiptNo), Rejected(reason) или NetworkFailure(message)». Некорректное состояние тогда нельзя создать с самого начала.
Как в C# реализовать размеченное объединение (тип-сумму)?
В существующих системах, включая .NET Framework, проще всего внедрить паттерн «абстрактный базовый класс с private-конструктором + вложенные sealed-классы + метод Match». Наследовать базовый класс могут только вложенные типы, поэтому набор кейсов получается закрытым. С .NET 5 то же самое записывают компактнее иерархией abstract record и sealed record. Для локального возвращаемого значения подойдёт библиотека вроде OneOf. В F# размеченные объединения — языковая возможность.
Когда вместо enum или bool стоит взять алгебраический тип данных?
Если достаточно знать только сам кейс — хватит enum. Если это действительно два варианта без дополнительной информации — достаточно bool. Если у каждого кейса свои данные (при успехе — receiptNo, при нехватке средств — shortage), лучше ADT. Сигнал к переходу — растущая связка enum и группы nullable-свойств, а также сравнение причин ошибки по строковым кодам. Если набор кейсов расширяется извне, например плагинами, вместо закрытого ADT лучше интерфейс.
Для бизнес-сбоев что выбрать — исключения или тип Result?
На практике роли разделяют так. Ожидаемые сбои, которые вызывающая сторона должна обрабатывать как обычное ветвление (нехватка на складе, повтор email, неверный пароль), возвращают через Result/ADT. Непредвиденные аномалии, от которых обычная обработка не восстанавливается (обрыв соединения с БД, повреждённый файл конфигурации), выражают исключениями. Если исключениями оформлять и рутинные бизнес-ветвления, try-catch подменяет бизнес-логику, и код хуже читается. Уже одно это разделение заметно проясняет слой сервисов приложения и API.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог