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

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

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)

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

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

В 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;
}

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

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

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

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

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

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

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

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

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

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

По состоянию на июнь 2026 года union-типы union в C# 15 представлены как функция предварительного просмотра (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».

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

Тем не менее прежде чем внедрять функцию превью в боевой код, её стоит тщательно оценить.

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

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

  • Для новых экспериментов и технических исследований стоит попробовать C# union
  • Для кода, который предстоит долго поддерживать в продакшене, используйте стабильные варианты: F# DU, иерархии 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

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

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

  • 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 типов в доменный слой

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

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    // Выражаем обычным классом, удобным как для .NET Framework, так и для .NET
}

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

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

Новый прикладной слой
  Используем 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 и ADT

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 и ADT

bool тоже не плохой вариант.

Если смысл действительно исчерпывается вариантами да/нет, 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 отличается от наследования

Если создавать типы в стиле ADT в C#, внешне они похожи на обычное наследование.

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. Не разбрасывайте pattern matching повсюду

Начав использовать 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, добавление кейса может фактически означать breaking change.

Допустим, потребитель библиотеки написал исчерпывающую обработку вот так:

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

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

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

Поэтому для публичных библиотек стоит рассуждать так:

  • Если добавление кейсов допустимо, повышайте версию и относитесь к этому как к breaking change
  • Если хотите разрешить внешним потребителям обработку по умолчанию (default), выбирайте не закрытый ADT, а другое проектирование
  • Внутри домена будьте строги, а для внешнего API используйте DTO и версионированный контракт

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

А для публичного API нужно продумывать проектирование совместимости вместе с этим.

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

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

При использовании иерархии class в .NET Framework объект создаётся для каждого кейса.

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)
{
    errorCode = null;

    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        errorCode = "SKU_NOT_FOUND";
        return false;
    }

    if (stock.Available < quantity)
    {
        errorCode = "OUT_OF_STOCK";
        return false;
    }

    stock.Reserve(quantity);
    return true;
}

В этом коде причина неудачи выражена строкой 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)
{
    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        return ReserveStockResult.NotFound(sku);
    }

    if (stock.Available < quantity)
    {
        return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
    }

    var reservationId = stock.Reserve(quantity);
    return ReserveStockResult.Success(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. Сигналом к переходу на ADT служит появление растущей связки enum и группы nullable-свойств, а также сравнение причин ошибки по строковым кодам. Однако если набор кейсов расширяется извне, например через плагины, вместо закрытого ADT лучше подойдёт интерфейс.
Что лучше выбрать для бизнес-ошибок — исключения или тип Result?
На практике удобно разделять роли так: ожидаемые сбои, которые вызывающая сторона должна обрабатывать как обычное ветвление (нехватка товара на складе, дублирующийся email, неверный пароль и т. п.), возвращают через Result/ADT, а непредвиденные аномалии, от которых обычная обработка не может восстановиться (обрыв соединения с БД, повреждённый файл конфигурации и т. п.), выражают исключениями. Если исключениями оформлять даже рутинные бизнес-ветвления, try-catch превращается в замену бизнес-логики ветвления, и код становится труднее читать. Одно только это разделение заметно улучшает прозрачность слоя сервисов приложения и API.

Об авторе

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

Го Комура

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

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

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

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