Перейти к содержимому

Паттерны проектирования

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

Перед этой темой: SOLID и принципы.

Junior+

Что такое паттерны проектирования и как они классифицируются?

Заголовок раздела «Что такое паттерны проектирования и как они классифицируются?»
Ответ

Что это

Паттерн проектирования — описание решения типовой задачи: какая проблема, какие классы участвуют, как они связаны и к чему это приводит. Это не библиотека и не код для копирования: паттерн каждый раз реализуют под свою задачу.

Зачем

  • Общий словарь: «оберни в декоратор» короче, чем объяснять схему классов.
  • Проверенные решения: не нужно заново придумывать, как добавить поведение, не меняя класс.

Как классифицируются

Книга «Design Patterns» (1994) Гаммы, Хелма, Джонсона и Влиссидеса — «банды четырёх», GoF — описывает 23 паттерна в трёх группах:

Группа Что решает Примеры
Порождающие (5) как создавать объекты Singleton, Factory Method, Builder
Структурные (7) как собирать объекты в структуры Adapter, Decorator, Facade
Поведенческие (11) как объекты взаимодействуют Strategy, Observer, Iterator

Частая ошибка

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

Как устроено

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

Где встретить в .NET

Паттерн Где в .NET
Builder StringBuilder, WebApplicationBuilder
Abstract Factory DbProviderFactory: CreateConnection, CreateCommand
Prototype выражение with у record
Decorator BufferedStream, GZipStream поверх Stream
Iterator IEnumerable<T>, yield return
Observer event, IObservable<T>
Strategy IComparer<T> в List<T>.Sort

Ловушки

  • Похожие схемы. Адаптер, декоратор и прокси выглядят одинаково — обёртка над объектом. Различаются они намерением, а не кодом.
  • Название — не паттерн. Статический метод Create — просто фабричный метод-конструктор, а не Factory Method из GoF: тот предполагает, что класс создаваемого объекта выбирает подкласс.

Правило

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

Под капотом

  • GoF классифицирует паттерны по двум осям: назначение (порождающие, структурные, поведенческие) и уровень (класса или объекта).
  • Паттерны уровня класса — Factory Method, Template Method, Interpreter, адаптер класса — строятся на наследовании и фиксируются при компиляции. Остальные строятся на композиции и меняются во время выполнения.

Паттерны и язык

Питер Норвиг ещё в 1996 году показал, что в динамических языках 16 из 23 паттернов становятся проще или исчезают. В C# то же сделали возможности языка: стратегия и команда — Func<T> и Action, итератор — yield return, наблюдатель — event, прототип — with, ленивый singleton — Lazy<T>.

Компромиссы

  • Каждый паттерн добавляет типы и косвенность: навигация по коду сложнее, стек вызовов длиннее. Взамен появляется точка расширения.
  • Точка расширения окупается, только если ею пользуются: вторая реализация, подмена в тесте, частые изменения в прошлом.
  • GoF — не единственный каталог: есть паттерны корпоративных приложений Фаулера (Repository, Unit of Work), интеграционные паттерны, облачные паттерны Microsoft (Retry, Circuit Breaker).

Как обсуждать в команде

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

Итог: паттерн — словарь для обсуждения дизайна; в C# многие паттерны GoF стали возможностями языка, а выбирать их стоит по последствиям, а не по каталогу.

Пример
using System.IO.Compression;
// Итератор: yield return генерирует IEnumerator<T>
foreach (var n in Evens(6)) Console.Write($"{n} "); // 0 2 4
Console.WriteLine();
// Стратегия: алгоритм сравнения передаётся снаружи
var words = new List<string> { "ccc", "a", "bb" };
words.Sort(Comparer<string>.Create((x, y) => x.Length.CompareTo(y.Length)));
Console.WriteLine(string.Join(",", words)); // a,bb,ccc
// Декоратор: GZipStream добавляет сжатие к любому Stream
using var buffer = new MemoryStream();
using (var gzip = new GZipStream(buffer, CompressionLevel.Optimal, leaveOpen: true))
gzip.Write(new byte[1000]);
Console.WriteLine(buffer.Length < 1000); // True
static IEnumerable<int> Evens(int max)
{
for (var i = 0; i < max; i += 2) yield return i;
}
Middle+

Singleton, фабрика и строитель: когда что и почему Singleton часто называют антипаттерном?

Заголовок раздела «Singleton, фабрика и строитель: когда что и почему Singleton часто называют антипаттерном?»
Ответ

Что это

Паттерн Что делает Пример в .NET
Singleton один экземпляр на приложение AddSingleton в DI
Фабрика создаёт объект и выбирает класс IHttpClientFactory.CreateClient()
Строитель собирает объект по шагам StringBuilder, WebApplicationBuilder

Зачем

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

Как пользоваться

  • Нужен один общий объект — зарегистрируйте его в DI как singleton, а не пишите статическое свойство Instance.
  • Класс выбирается по условию — фабрика.
  • Много необязательных настроек — строитель с цепочкой вызовов.

Частая ошибка

Писать singleton со static Instance для сервиса с зависимостями. Его нельзя подменить в тесте, и все тесты делят одно состояние.

Как устроено

  • Singleton. Приватный конструктор и статическое свойство. Потокобезопасно и лениво — через Lazy<T>: по умолчанию его фабрика выполняется ровно один раз даже при гонке потоков.
  • Factory Method. Базовый класс объявляет виртуальный метод создания, а подкласс решает, какой объект вернуть.
  • Abstract Factory. Интерфейс с несколькими методами создания для семейства согласованных объектов: DbProviderFactory создаёт соединение, команду и параметр одного провайдера.
  • Builder. Объект собирается вызовами методов, а Build() проверяет данные и отдаёт результат.

Когда что выбирать

Задача Решение
Один экземпляр на приложение AddSingleton в DI
Объект с параметрами времени выполнения фабрика-делегат или интерфейс фабрики
Семейство согласованных объектов Abstract Factory
Много необязательных частей и проверка Builder

Ловушки

  • Почему Singleton — антипаттерн. Глобальная точка доступа прячет зависимость, состояние переживает тесты, а класс сам управляет своим временем жизни — это лишняя обязанность.
  • Fluent API — не всегда Builder. Цепочка методов — стиль интерфейса. Builder — когда есть отдельный объект-сборщик и итоговый Build().

Правило

Время жизни решает контейнер, создание с параметрами — фабрика, сложную сборку — строитель.

Под капотом

  • Поле static readonly инициализирует конструктор типа, и CLR гарантирует, что он выполнится один раз. Но без явного статического конструктора момент инициализации выбирает рантайм, поэтому для настоящей ленивости берут Lazy<T>.
  • Lazy<T> по умолчанию работает в режиме ExecutionAndPublication: фабрика выполняется под блокировкой одним потоком. Исключение фабрики кешируется — каждый следующий Value выбросит его же. PublicationOnly исключения не кеширует, но при гонке может создать лишние экземпляры.
  • IHttpClientFactory — фабрика с пулом: HttpClient создаётся каждый раз, а HttpMessageHandler переиспользуется и пересоздаётся по таймеру, по умолчанию раз в две минуты.

Компромиссы

  • Статика против DI. Статический singleton доступен отовсюду, DI делает зависимость видимой и заменяемой. Статика оправдана для неизменяемых значений без зависимостей: Comparer<T>.Default, Array.Empty<T>().
  • Своя фабрика. Контейнер уже фабрика, и интерфейс фабрики на каждый сервис — лишний слой. Своя фабрика нужна, если объект зависит от данных запроса или должен жить меньше долгоживущего потребителя.
  • Builder против record. Builder даёт пошаговую проверку и неизменяемый результат, но удваивает число типов.

Как проверить в проде

  • Singleton с изменяемым состоянием выдаёт себя гонками под нагрузкой. На ревью ищут static-поля с изменяемыми коллекциями и свойства Instance.
  • Упавшая инициализация Lazy<T> видна как одно и то же исключение на каждом запросе до перезапуска процесса.

Итог: «один экземпляр» — решение composition root, фабрика — для создания во время выполнения, строитель — для сложной сборки, и за каждым стоит цена косвенности.

Пример
// Singleton через Lazy<T>: фабрика выполнится один раз, даже из нескольких потоков
Parallel.For(0, 8, i => { var config = Config.Instance; });
Console.WriteLine(Config.Created); // 1
// Строитель: пошаговая сборка, проверка в Build()
var mail = new EmailBuilder().To("ann@example.com").Subject("Hi").Build();
Console.WriteLine(mail); // Email { To = ann@example.com, Subject = Hi }
public sealed class Config
{
private static readonly Lazy<Config> instance = new(() => new Config());
public static int Created;
public static Config Instance => instance.Value;
private Config() => Interlocked.Increment(ref Created);
}
public record Email(string To, string Subject);
public class EmailBuilder
{
private string? to, subject;
public EmailBuilder To(string value) { to = value; return this; }
public EmailBuilder Subject(string value) { subject = value; return this; }
public Email Build() => new(to ?? throw new InvalidOperationException("To is required"), subject ?? "");
}
Factory Method
Exporter exporter = new CsvExporter();
Console.WriteLine(exporter.Export([1, 2, 3])); // 1;2;3
// Базовый класс задаёт порядок работы, а подкласс выбирает, какой объект создать
public abstract class Exporter
{
public string Export(int[] rows) => CreateFormatter().Format(rows);
protected abstract IFormatter CreateFormatter(); // фабричный метод
}
public class CsvExporter : Exporter
{
protected override IFormatter CreateFormatter() => new CsvFormatter();
}
public interface IFormatter { string Format(int[] rows); }
public class CsvFormatter : IFormatter
{
public string Format(int[] rows) => string.Join(";", rows);
}
Middle+
Ответ

Что это

Все четыре паттерна оборачивают другой объект. Различие — в том, зачем.

Паттерн Зачем обёртка Пример в .NET
Адаптер привести чужой интерфейс к нужному StreamReader поверх Stream
Декоратор добавить поведение, интерфейс тот же BufferedStream, GZipStream
Фасад простой вход в сложную подсистему DbContext
Прокси управлять доступом, интерфейс тот же ленивая загрузка в EF Core

Как пользоваться

  • Подключаете библиотеку с неудобным API — напишите адаптер под свой интерфейс.
  • Нужны логирование или кеш без правки класса — декоратор.

Частая ошибка

Называть любую обёртку декоратором. Если интерфейс меняется — это адаптер. Если обёртка не добавляет поведение, а решает, вызывать ли объект, — прокси.

Как устроено

  • Адаптер. Реализует интерфейс клиента и переводит вызовы в API адаптируемого объекта. В своём коде это обычно обёртка над SDK внешнего сервиса.
  • Декоратор. Реализует тот же интерфейс и получает его в конструкторе, поэтому декораторы вкладываются друг в друга. DelegatingHandler — декоратор для HttpMessageHandler: он может что-то сделать до и после base.SendAsync.
  • Фасад. Свой интерфейс, за которым несколько объектов. DbContext скрывает соединение, отслеживание изменений и генерацию SQL, а свойство Database возвращает объект DatabaseFacade для операций с самой базой.
  • Прокси. Тот же интерфейс, но решает, вызывать ли реальный объект и когда: ленивое создание, удалённый вызов, проверка прав.

Когда что выбирать

Задача Паттерн
Чужой API не совпадает с вашим интерфейсом Адаптер
Добавить логирование, повторы, кеш Декоратор
Скрыть пять вызовов за одним Фасад
Отложить загрузку или вызвать удалённо Прокси

Ловушки

  • Прокси прячет стоимость. Обращение к навигационному свойству с ленивой загрузкой — запрос в базу, и в цикле получается N+1.
  • Порядок декораторов. Логирование снаружи повторов пишет одну запись, внутри — запись на каждую попытку.

Как зарегистрировать декоратор во встроенном контейнере, разобрано в теме Внедрение зависимостей.

Правило

Сформулируйте, что меняет обёртка: интерфейс, поведение, сложность или доступ, — от этого и название.

Под капотом

  • Динамический прокси — тип, сгенерированный во время выполнения через System.Reflection.Emit. Castle DynamicProxy создаёт подкласс с переопределёнными virtual-членами или реализацию интерфейса. Так работают моки Moq и прокси ленивой загрузки из Microsoft.EntityFrameworkCore.Proxies.
  • DispatchProxy из BCL создаёт тип, реализующий интерфейс, и направляет все вызовы в Invoke(MethodInfo, object[]).
  • Генерация кода во время выполнения недоступна в Native AOT, поэтому такие прокси там не работают.

Цена

  • Каждый слой — лишний вызов через интерфейс или виртуальный метод. На фоне ввода-вывода это незаметно, в горячем цикле — заметно.
  • DispatchProxy на каждый вызов создаёт массив аргументов и упаковывает значимые типы.

Компромиссы

  • Ручной декоратор явный и проверяется компилятором, но его пишут под каждый интерфейс. Прокси-перехватчик один на все интерфейсы, но его поведение не видно в коде.
  • Source generators — третий путь: обёртка генерируется при сборке, видна в отладчике и совместима с AOT.
  • Фасад на границе модуля снижает связанность, но толстый фасад со временем собирает всю логику.

Как проверить в проде

  • Ленивая загрузка видна как поток мелких одинаковых запросов в логе EF Core на одну операцию.
  • Типы вида Castle.Proxies.OrderProxy в стеке исключения или в дампе показывают, что вызов шёл через перехватчик.

Итог: обёртки различаются намерением и контрактом; динамический прокси мощнее ручного декоратора, но прячет поведение и несовместим с AOT.

Пример
INotifier notifier = new LoggingNotifier(new SmsAdapter(new LegacySmsClient()));
notifier.Send("Order shipped");
// log: Order shipped
// SMS: ORDER SHIPPED
public interface INotifier { void Send(string message); }
// Чужой класс с неудобным API: менять его нельзя
public class LegacySmsClient
{
public void Transmit(string upperText) => Console.WriteLine($"SMS: {upperText}");
}
// Адаптер: интерфейс INotifier поверх чужого API
public class SmsAdapter(LegacySmsClient client) : INotifier
{
public void Send(string message) => client.Transmit(message.ToUpperInvariant());
}
// Декоратор: тот же интерфейс плюс логирование
public class LoggingNotifier(INotifier inner) : INotifier
{
public void Send(string message)
{
Console.WriteLine($"log: {message}");
inner.Send(message);
}
}
Динамический прокси через DispatchProxy
using System.Reflection;
var greeter = DispatchProxy.Create<IGreeter, LoggingProxy>();
((LoggingProxy)(object)greeter).Target = new Greeter();
Console.WriteLine(greeter.Greet("Ann"));
// call Greet
// Hello, Ann
public interface IGreeter { string Greet(string name); }
public class Greeter : IGreeter
{
public string Greet(string name) => $"Hello, {name}";
}
// Тип прокси генерируется во время выполнения, все вызовы приходят в Invoke
public class LoggingProxy : DispatchProxy
{
public IGreeter? Target { get; set; }
protected override object? Invoke(MethodInfo? method, object?[]? args)
{
Console.WriteLine($"call {method!.Name}");
return method.Invoke(Target, args);
}
}
Middle+

Strategy, Observer, Command, Chain of Responsibility и Template Method: где они в повседневном коде?

Заголовок раздела «Strategy, Observer, Command, Chain of Responsibility и Template Method: где они в повседневном коде?»
Ответ

Что это

Поведенческие паттерны описывают, как объекты делят обязанности и общаются.

Паттерн Идея Где в .NET
Стратегия алгоритм передаётся снаружи IComparer<T>, лямбды в LINQ
Наблюдатель объект оповещает подписчиков event, IObservable<T>
Команда запрос — это объект ICommand в WPF, обработчики команд
Цепочка обязанностей запрос проходит звенья по очереди middleware ASP.NET Core
Шаблонный метод скелет алгоритма в базовом классе BackgroundService.ExecuteAsync

Как пользоваться

  • list.Sort(comparer) — вы передаёте стратегию сравнения.
  • button.Click += OnClick — подписываетесь как наблюдатель.
  • Наследуете BackgroundService и пишете ExecuteAsync — заполняете шаг шаблонного метода.

Частая ошибка

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

Как устроено

  • Стратегия. Интерфейс или делегат с одним методом; контекст вызывает его, не зная реализацию.
  • Наблюдатель. event — список делегатов. IObservable<T> добавляет сигналы завершения и ошибки и отписку через IDisposable.
  • Команда. Запрос с данными — отдельный объект, исполняет его обработчик. На этом строятся очереди и CQRS-обработчики, подробнее — в теме Архитектурные стили.
  • Цепочка обязанностей. Звено получает запрос и ссылку на следующее и решает: обработать, передать дальше или оборвать цепочку. Так устроены middleware, подробнее — в теме Хостинг и конвейер.
  • Шаблонный метод. Невиртуальный метод вызывает виртуальные шаги. BackgroundService реализует запуск и остановку и вызывает ваш ExecuteAsync.

Когда что выбирать

Задача Паттерн
Алгоритм меняется по настройке Стратегия
Несколько реакций на одно событие Наблюдатель
Запрос надо отложить, повторить, залогировать Команда
Последовательные проверки и обработчики Цепочка
Общий порядок шагов, разные детали Шаблонный метод

Ловушки

  • Исключение в одном обработчике event прерывает вызов остальных.
  • Шаблонный метод с десятком хуков делает базовый класс хрупким: изменение порядка шагов ломает наследников.

Правило

Одна операция — делегат, несколько операций или состояние — интерфейс, общий скелет — шаблонный метод, если композиции не хватает.

Под капотом

  • Событие-поле компилируется в приватный делегат и методы add и remove. Они атомарно заменяют неизменяемый многоадресный делегат через Interlocked.CompareExchange, а вызов идёт по снимку списка, поэтому отписка во время оповещения безопасна.
  • Лямбда-стратегия, захватившая переменные, — это аллокация замыкания. Лямбду без захвата компилятор кеширует.
  • Конвейер middleware собирается один раз при старте: каждое звено оборачивает следующее, и запрос идёт по готовой цепочке делегатов.

Цена

  • Подписка держит подписчика сильной ссылкой: объект, подписанный на событие singleton, живёт, пока живёт singleton.
  • Команда-объект и диспетчер добавляют аллокацию и косвенный вызов, а поиск обработчиков через рефлексию — ещё и время старта.

Компромиссы

  • Шаблонный метод против стратегии. Наследование гарантирует порядок шагов, композиция позволяет менять и тестировать шаги независимо.
  • event против IObservable<T> и каналов. Событие синхронно и без обратного давления; для асинхронных потоков данных есть Rx и System.Threading.Channels.
  • Свой диспетчер против MediatR. Библиотека даёт общий конвейер обработчиков, но прячет связь «команда — обработчик» от навигации по коду. С версии 13 у неё коммерческая лицензия.

Как проверить в проде

  • Утечку через события видно в dotnet-gcdump: путь удержания подписчика идёт через делегат в поле издателя.
  • Порядок middleware фиксируют интеграционным тестом на WebApplicationFactory.

Итог: поведенческие паттерны в .NET — это в основном делегаты и конвейеры; их цена — неявный поток управления и сильные ссылки подписок.

Пример
// Стратегия: правило скидки передаётся делегатом
var checkout = new Checkout(price => price * 90 / 100);
// Наблюдатель: подписчики узнают об оплате и не знают друг о друге
checkout.Paid += total => Console.WriteLine($"email: paid {total}");
checkout.Paid += total => Console.WriteLine($"audit: {total}");
checkout.Pay(200);
// email: paid 180
// audit: 180
// Шаблонный метод: порядок шагов задан в базовом классе
new CsvReport().Print(); // header | csv rows | footer
public class Checkout(Func<int, int> discount)
{
public event Action<int>? Paid;
public void Pay(int price) => Paid?.Invoke(discount(price));
}
public abstract class Report
{
public void Print() => Console.WriteLine($"header | {Body()} | footer");
protected abstract string Body(); // шаг, который задаёт подкласс
}
public class CsvReport : Report
{
protected override string Body() => "csv rows";
}
Senior+

Какие антипаттерны встречаются чаще всего и как от них уходить?

Заголовок раздела «Какие антипаттерны встречаются чаще всего и как от них уходить?»
Ответ

Что это

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

Самые частые

  • God object. Один OrderManager на 3000 строк делает всё: проверки, расчёт, SQL, отправку писем.
  • Магические числа. if (status == 3) — что такое 3, знает только автор.
  • Копипаста. Одна и та же логика в пяти местах, а исправили её в четырёх.
  • Service Locator. provider.GetService<T>() внутри методов вместо параметров конструктора.

Как уходить

  1. Покрыть код тестами, чтобы рефакторинг ничего не сломал.
  2. Менять малыми шагами: вынести константу, вынести метод, вынести класс.

Частая ошибка

Переписать всё с нуля. Старый код хранит знания о крайних случаях, которые новый потеряет.

Как устроено

Запах кода — видимый симптом: длинный метод, большой класс, длинный список параметров. Антипаттерн — диагноз, который за ним стоит. Каталог запахов и рефакторингов против них собрал Мартин Фаулер в книге «Refactoring», второе издание вышло в 2018 году.

Антипаттерн Симптом Рефакторинг
God object Large Class, десятки зависимостей Extract Class
Магические числа литералы в условиях Replace Magic Literal
Примитивная одержимость string для email, decimal для денег Replace Primitive with Object
Shotgun Surgery одна правка — много файлов Move Function, Combine Functions into Class
Паттерн ради паттерна Speculative Generality Inline Class, Collapse Hierarchy
Service Locator GetService в бизнес-коде параметр конструктора

Ловушки

  • Анемичная модель — спорный ярлык. Для CRUD без правил это нормально. Проблемой она становится, когда одни и те же проверки дублируются по сервисам; подробнее — в теме Архитектурные стили.
  • Лечение порождает новую болезнь. Если раздробить God object на мелкие классы без смысла, получится Shotgun Surgery.

Правило

Рефакторинг — отдельный коммит без изменения поведения: малые шаги и зелёные тесты после каждого.

Как возникают

  • Антипаттерны редко пишут намеренно. God object растёт, потому что добавить метод в существующий класс дешевле, чем придумать новый: каждое решение локально разумно, а сумма — нет.
  • Big Ball of Mud (Foote и Yoder, 1997) — результат давления сроков, смены людей и роста без рефакторинга.

Цена

  • God object и Shotgun Surgery дорожают с каждым изменением: больше конфликтов слияния, регрессий и времени на ревью.
  • Примитивная одержимость переносит проверки в рантайм: email проверяют в десяти местах или ни в одном.

Компромиссы

  • Не каждый ярлык — приговор. Анемичная модель приемлема для CRUD, а TimeSpan.FromSeconds(30) читается и без константы.
  • «Паттерн ради паттерна» и YAGNI тянут в одну сторону, DRY и расширяемость — в другую. Абстракцию вводят на втором-третьем случае, а не на первом.
  • Большой рефакторинг — это проект: его режут на шаги, каждый из которых можно выпустить, например через Strangler Fig.

Как проверить в проде

  • Анализаторы Roslyn и SonarQube ловят часть запахов: сложность методов, дублирование, магические числа.
  • Частота изменений файлов в истории git показывает горячие точки: большой файл, который меняется почти в каждом коммите, — кандидат в God object.
  • Архитектурные тесты (NetArchTest, ArchUnitNET) фиксируют границы слоёв, чтобы Big Ball of Mud не вернулся.

Итог: антипаттерны вырастают из локально разумных решений; их ловят метриками и ревью, а лечат постепенным рефакторингом, а не переписыванием.

Пример
// До: if (status == 3 && email.Contains('@')) — магическое число и строка вместо типа
// После: именованное состояние и тип с проверкой при создании
var email = Email.Parse("ann@example.com");
Console.WriteLine(CanShip(email, OrderStatus.Paid)); // True
try { Email.Parse("not-an-email"); }
catch (ArgumentException e) { Console.WriteLine(e.Message); } // Invalid email
static bool CanShip(Email email, OrderStatus status) => status == OrderStatus.Paid;
public enum OrderStatus { New = 1, Reserved = 2, Paid = 3 }
// Replace Primitive with Object: неверный email не создать
public sealed record Email
{
public string Value { get; }
private Email(string value) => Value = value;
public static Email Parse(string value) =>
value.Contains('@') ? new Email(value) : throw new ArgumentException("Invalid email");
}