На связи Александр Кальницкий, ведущий разработчик Mindbox. Отвечаю за автоматизацию и надежность рассылок и преподаю во внутренней школе разработчиков Mindbox.
Даже в правильно работающем коде бывает сложно разобраться. Чем больше в нем абстракций, циклов и вложенных условий операторов, тем сложнее представлять в уме его поведение. Приходится перечитывать все несколько раз — ревью затягивается, при доработке легко пропустить баг.
Чтобы такого не происходило, у нас в Mindbox есть гайд по разработке. Основной посыл: человек не машина, которая может загрузить все инструкции в оперативную память и четко им следовать. Объем нашей кратковременной памяти ограничен и способен удерживать в моменте 5–9 единиц информации. Поэтому оформление кода должно быть простым: с неизменяемыми структурами данных, честными функциями, понятными абстракциями.
В этой статье делимся нашими принципами — пригодится всем, кто хочет, чтобы код в проекте был поддерживаемым и приносил пользу, а не боль.
Почему сложный код может стать проблемой
Прежде чем вносить изменения в систему, ее надо изучить. На мой взгляд, для этого есть два способа:
Ручное или автоматизированное тестирование. Оно позволяет наблюдать, как ведет себя система в определенных условиях.
Изучение исходников. Читаем код, пока не разберемся.
Когда код простой и предсказуемый, оба способа работают: тестами можно покрыть ключевые сценарии, а по исходникам быстро восстановить логику.
Сложный код ломает эту схему. Чтение исходников превращается в расследование, тесты получается писать только на очевидные случаи, и все равно остаются ветки, про которые ничего не известно. Любое изменение становится медленным и рискованным: легко не заметить зависимость, не проверить нужный сценарий и получить баг на проде. И причины этого далеко не всегда сводятся к плохому покрытию тестами.
Тесты и код должны идти рука об руку: чем проще код, тем проще писать тесты. Если забить на что‑то одно, неизменно получим ухудшение другого. Поэтому в этой статье разберемся с плохой читаемостью кода.
Из-за чего код становится сложным
Есть две главные проблемы, которые мешают быстро понять код:
структуры данных с излишней мутабельностью;
методы, вызывающие недоверие у разработчиков.
Мутабельность
Это возможность изменить уже существующий объект. Мутабельные переменные или свойства классов усложняют код, потому что разработчик на 100% не может быть уверен:
когда и где меняется значение,
как эти изменения работают в многопотоке.
Совсем плохо, если мутабельным является статическое поле.
В коде с мутабельными состояниями разработчик вынужден глубже «проваливаться» в исходники и смотреть, как и чем такие состояния управляются; анализировать это и учитывать при внесении изменений.
Как мутабельные сущности усложняют код. Создадим класс книги, у которого есть два поля: название (_title) и ключевые слова (_keywords). Нужно соблюсти правило: название валидно и должно автоматически попадать в список ключевых слов. Мутабельный класс может выглядеть так:
public class Book
{
private string _title;
private string[] _keywords;
public Book(string title, IEnumerable<string> keywords)
{
_title = ValidTitle(title);
_keywords = keywords.Concat(title.Split(' ')).ToArray();
}
public string Title
{
get => _title;
set => _title = ValidTitle(value);
}
private static string ValidTitle(string title) =>
string.IsNullOrWhiteSpace(title) ?
throw new ArgumentException(nameof(Title)) :
title.Trim();
}Тут есть баг, который не сразу бросается в глаза. Несмотря на валидацию названия, мы можем поменять его значение и получить неправильный массив ключевых слов, потому что он формируется один раз в конструкторе.
Починить баг можно, добавив в класс дополнительное поле с оригинальными ключевыми словами, и при изменении названия пересчитывать ключевые слова заново. Получается довольно сложный и объемный код.
Можно сделать ключевые слова вычисляемым полем. Но остается проблема с многопоточным использованием класса: что будет, если его закешировать, а потом кто‑то где‑то изменит значение поля Title?
А можно использовать record‑типы, неизменяемые by design. Получаем немутабельный и простой класс книги:
public record Book
{
private Book(string title, ImmutableList<string> keywords)
{
Title = string.IsNullOrWhiteSpace(title) ?
throw new ArgumentException(nameof(Title)) :
title.Trim();
Keywords = keywords.Concat(title.Split(' ')).ToImmutableList();
}
public string Title { get; }
private ImmutableList<string> Keywords { get; }
}Методы с непродуманной сигнатурой
Плохо оформленные методы снижают доверие разработчика, потому что вынуждают его исследовать внутреннюю реализацию.
Допустим, изучая код, мы наткнулись на такой вызов:
await messageSendingResultProcessingStrategy.ProcessAsync(
modelContext,
mailingSendingDecision.MailingMetadataDto,
customerMessage.TransactionId,
mailingSendingDecision.MailingDataSourceId,
mailingSendingDecision.MailingDataSourceType,
mailingSendingDecision.MailingExternalTransactionId,
MailingMessageSendingResult.SentToTransport);По сигнатуре этого метода непонятно, что происходит внутри него:
имя не дает полезной информации;
много аргументов;
async, значит внутри I/O;
метод вызывается у инстанса класса, значит внутри могут быть еще зависимости.
А вот вызов другого метода:
var deferDelay = DeferDelayCalculator.CalculateDelay(
sendResult,
messageToSendMetadata.SendRetryAttempt,
messageToSendMetadata.IsTransactional);У этого метода есть признаки, которые повышают доверие к нему:
понятное имя,
статический вызов,
нет асинхронщины.
В этом случае разработчику достаточно посмотреть на сигнатуру метода, чтобы понять суть происходящего в нем и не тратить силы на чтение его имплементации. Такое оформление значительно упрощает чтение и тестирование методов. Допустить ошибку уже сложнее.
Как писать понятный код
Эта часть гайда посвящена практикам, которые, на мой взгляд, помогают упростить структуру кода.
Использовать неизменяемые структуры данных и инварианты в конструкторе
Несмотря на то что немутабельные типы в .NET оптимизированы, они все еще остаются плохим решением в местах, где перфоманс критически важен. Но при разработке обычных бизнес‑процессов код, написанный с использованием таких типов, имеет ряд достоинств:
Легко достигается потокобезопасность без дополнительной синхронизации.
Он предсказуем, потому что состояние объектов не меняется после создания.
Его легко дебажить, потому что нет скрытых изменений состояния.
Просто поддерживать кэширование.
В сумме эти свойства облегчают разработчику чтение кода.
Когда нужно использовать немутабельные типы. Рекомендую использовать немутабельные типы для представления сущностей домена. Начинайте дизайнить код с продумывания типов от более простых к более сложным. Это поможет защититься от большого числа ошибок и сделает код более поддерживаемым.
1. Для обертки вокруг примитивных типов в домене. Посмотрим на сигнатуру метода:
//Работаем с примитивным типом
public void AddMailTo(string email);Точно ли тут приходит валидный email? Для ответа придется искать все вызовы этого метода в исходниках, чтобы убедиться, что там используется действительно корректная строка.
Но вместо строк можно использовать более выразительный тип:
public record EmailAddress(string Value)
{
public EmailAddress(string email)
{
EmailUtils.EnsureCorrectEmail(email); //throws
Value = email;
}
}Теперь сигнатура метода изменилась:
//Работаем с выразительным типом домена
public void AddMailTo(EmailAddress email);По этой сигнатуре видно: что бы сюда ни пришло, оно точно валидное и работает. Компилятор не даст использовать вместо email что‑то другое. Типу email можно доверять: он соблюдает инвариант. Для него не существует невалидных состояний. Он либо создастся сразу правильным, либо упадет с ошибкой. Можно идти дальше, не сомневаясь в честности этого кода.
2. Для конструирования более сложных типов. Если класс состоит из небольших инвариантных типов, то он уже:
имеет защиту от невалидных состояний,
отлично читается и вызывает доверие.
Если структура еще и неизменяемая, то разработчик получает дополнительные бонусы немутабельных типов и для нее. Можно, например, использовать record-типы, которые имеют очень много полезных фичей из коробки:
public record EmailMessage(EmailAddress From, EmailAddress To, Body Body, Dkim Dkim);
Добавив factory-метод или переопределив конструктор для дополнительной валидации всего вместе, можно получить надежный и простой в использовании объект.
Когда можно использовать мутабельные типы. Без изменяемости не существовало бы и программ: нужно же как‑то записывать информацию в БД, передавать ее в другие сервисы или логировать.
1. Для дизайна DTO. DTO — это объекты, которые передаются между сервисами по сети. Мы, как разработчики, не хотим распространять сложность нашего домена на другие микросервисы. Поэтому принимаем и передаем только простые обертки. Используем в них примитивные типы и встроенные инструменты .NET, например init-only свойства или модификатор required:
public sealed class SendMailingCommand
{
[JsonPropertyName("tenant")]
public required long Tenant { get; init; }
[JsonPropertyName("commandId")]
public required long CommandId { get; init; }
[JsonPropertyName("customerId")]
public required string CustomerId { get; init; }
[JsonPropertyName("mailingId")]
public required string MailingId { get; init; }
}Работать напрямую с DTO внутри сложного домена — плохая идея. Это допустимо разве что в суперпростых системах. Поэтому первым делом при получении DTO по сети мы должны распарсить его в доменную модель, чтобы все наши инварианты начали работать.
2. При работе с EF Core. В EF довольно скудный саппорт неизменяемых типов, потому что его основная фишка — change‑tracking объектов, основанный именно на изменении полей сущностей. Это не делает использование неизменяемых типов в EF невозможным. Но тогда придется явно сообщать EF, что новая копия объекта — та самая, которую нужно сохранить. Это приводит к засорению кода в репозиториях.
Писать чистые и честные функции
Чистая функция — это функция, которая:
при одинаковых входных данных всегда возвращает одинаковый результат;
не имеет сайд‑эффектов, то есть не изменяет состояние за пределами своей области видимости.
Посмотрим на функцию подсчета скидки:
public void CalculateDiscountedPrice(Price price, Discount discount)
{
var newPrice = price - (price * discountPercentage / 100);
_logger.Log($"New price is: {newPrice }");
order.Price = newPrice ;
repository.Save(order);
}Внутри целых две зависимости: на логи и репозиторий, что подразумевает наличие потенциальных сайд‑эффектов. Такие зависимости усложняют тестирование бизнес‑логики. Их придется мокировать. Они снижают доверие к коду, так что разработчик задаст сразу несколько вопросов к этому методу:
Какие ошибки могут быть при сохранении?
Если будут ошибки сохранения, будут ли они обрабатываться в вызывающем коде?
Код с такими зависимостями крайне трудно переиспользовать. Как улучшить? Сделать функцию подсчета скидки чистой: вынести бизнес‑логику в отдельный статический метод:
public static decimal CalculateDiscountedPrice(Price price, Discount discount)
{
return price - (price * discountPercentage / 100);
}Теперь этот метод можно легко покрыть unit‑тестами. Он статический, что повышает доверие к нему. И предсказуемый, потому что всегда дает один и тот же результат при одних и тех же значениях аргументов.
Методы с сайд‑эффектами можно вызывать из контроллеров. Под контроллером я подразумеваю класс или объект, который управляет потоком выполнения бизнес‑процесса в приложении. Например, это могут быть:
REST-контроллеры, которые управляют выполнением REST-запросов;
консьюмеры топиков Kafka.
Тогда код в контроллере будет состоять из поочередно вызываемых чистых функций с бизнес‑логикой и функций с сайд‑эффектами (логи, база, удаленные вызовы):
//Класс-контроллер
public async Task ProcessDiscount(int orderId, Discount discount){
var order = repository.GetOrder(orderId); //сайд-эффекты
var resultPrice = CalculateDiscountedPrice(order.Price, discount); //чистая функция
_logger.Log($"New price is: {price}"); //сайд-эффект логирования
order.Price = resultPrice; //Мутируем стейт (нужно для EF-сущности, например)
repository.Save(order); //сайд-эффект сохранения
}Я считаю контроллеры единственным местом, где длина метода может превышать сотню строк, и это не будет снижать читаемость кода. Если весь пайплайн состоит из последовательных вызовов I/O, логов, методов бизнес‑логики и в нем нет условных конструкций или циклов, то он легко читается сверху вниз как книга.
Эту технику также можно встретить под названиями Impureim Sandwitch или Move I/O to the Edges. Она достаточно популярна, особенно на фоне роста функциональщины в экосистеме.NET.
Честная функция — это функция, по сигнатуре которой разработчик может определить весь диапазон входных значений и результатов. Например:
public static Contact CreateContact(string name, int age)
{
if (age < 0) throw new ArgumentException("Invalid age");
return new Contact(name, age);
}Сигнатуру можно прочитать так: «Дай мне строку и целое число, и я создам тебе контакт». Звучит как правда, но могу ли я создать контакт с –1293 в аргументе age? Нет, функция выбросит исключение, значит сигнатура функции врет. Исправим.
1. Можно явно задокументировать исключение:
/// <exception cref="System.ArgumentException">
/// Выбрасывается, когда когда передается отрицательный возраст.
/// </exception>
public static Contact CreateContect(string name, int age)
{
if (age < 0) throw new ArgumentException("Invalid age");
return new Contact(name, age);
}Метод стал более честным: современные IDE типа Visual Studio будут подсказывать, в каких случаях мы получим исключение при вызове. Такая документация сильно повышает доверие к коду. Лучше всего подход работает для методов, которые вызывают I/O: REST‑клиенты, репозитории, продюсеры Kafka и так далее.
2. Можно использовать типы, которые отражают возможность ошибки, например Result, Either, Option и другие. Это специальные типы-обертки, которые позволяют возвращать либо успешный результат, либо ошибку. При этом выбор стратегии обработки полученного результата остается за программистом. Приведу пример с использованием Either<L, R> из библиотеки Language.Ext.Core:
public enum ContactError
{
InvalidAge //можно добавить других ошибок
}
public static Either<ContactError, Contact> CreateContact(string name, int age)
{
if (age < 0) return ContactError.InvalidAge; //implicit operator помогает снизить boilerplate
return new Contact(name, age);
}Теперь метод стал честным. Его сигнатура читается так: «Передай мне строку и число, и я, возможно, создам из них контакт». Если код состоит из честных функций, то разработчикам не придется изучать их реализацию вообще. Делать выводы о работе функций можно будет только по сигнатуре метода.
3. Можно уменьшить диапазон значений входных аргументов. Если сделать отдельный тип под возраст (age) клиента, который будет всегда соблюдать инвариант возраста, то функция CreateContact станет честной, как только мы заменим int на age в ее сигнатуре:
public static Contact CreateContect(string name, Age age) =>
new Contact(name, age);Читать функцию мы будем так: «Дай мне любое имя и валидный возраст, и я создам тебе контакт».
Чистые и честные функции не только упрощают работу с бизнес‑логикой, но и заставляют разработчика соблюдать простой и последовательный пайплайн выполнения операций. Читать такой код намного проще, а вероятность совершить ошибку снижается.
Выделять абстракции с учетом ролей в бизнес-процессе
Я создаю бизнес-процессы, разделяя код на следующие элементы:

Бизнес‑логика представлена чистыми статическими функциями с использованием немутабельных типов и инвариантов.
I/O — это обычные классы C#. С зависимостями из DI и так далее. В них удобно размещать логи и метрики, потому что мы логируем операции, как правило, на границах домена. Если бизнес‑логика чистая, то и логировать там нечего.
Контроллер — это либо REST-контроллер .NET, либо консьюмер кафки, либо какой-то другой обработчик команд/событий, стоящий на границе домена и внешнего мира.
Весь пайплайн — это последовательный вызов I/O‑запросов и передача их результатов в бизнес‑логику для принятия решения. И так до тех пор, пока не достигнем конечного результата. Оркестрирует это все контроллер.

В чем преимущества разделения по ролям
Плоский и последовательный пайплайн. В нем нет сложных вложенных конструкций и зависимостей. Нет дурацкой ситуации, где одна стратегия использует репозиторий, который использует другую стратегию. А та, в свою очередь, является еще и фабрикой. Где-то внутри всего этого логи, метрики, и вообще «разбирайтесь сами».
Заставляет разработчика писать код проще и выразительнее, не занимаясь вложением зависимостей друг в друга. Его можно просто читать сверху вниз. Его легче тестировать.
Как покрыть пайплайн тестами
Есть много разных категорий тестов, про них будет отдельная статья. Тут я остановлюсь на трех: unit, integrational, workflow.
Стратегия следующая:
Unit‑тесты пишем для бизнес‑логики. Они будут очень простыми и маленькими: передал аргументы на вход, проверил, что результат равен ожидаемому, и все. Никаких моков.
Integrational‑тесты пишутся для I/O классов типа редис‑клиента. Они защищают от проблем взаимодействия с удаленным компонентом. Могут даже проверять такие свойства, как идемпотентность вызываемой операции. Такие тесты сложнее, но и меняются реже.
Если дизайн блоков I/O удачный и получилось выделить большой базовый функционал с целью переиспользования кода, то и набор тестов можно будет переиспользовать.
Правильное отделение I/O от бизнес‑логики дает четкое понимание, где какие тесты нужно писать. И избавляет от необходимости мокировать ненужные зависимости.
Workflow‑тесты пишутся для проверки всего пайплайна. Один тест проверяет работу одного бизнес‑сценария от начала и до конца. Workflow‑тест�� помогают отловить ошибки с некорректной конфигурацией приложения. Например, неполное дерево DI или отсутствие ключа в каком‑нибудь словаре.
Такие тесты очень сложны в написании и долго выполняются, однако здорово повышают уровень покрытия. Для написания workflow‑тестов я поднимаю все приложение через WebApplicationFactory<T>, а зависимости — с использованием Testcontainers или WireMock. Я держу хотя бы один такой на каждый пайплайн.

Чек-лист самопроверки
Value Objects. Если есть несколько одинаковых проверок примитивного типа в разных местах кода, нужно обернуть примитив в immutable value object с валидацией в конструкторе. После этого проверки можно убрать.
Чистые функции. Вся бизнес‑логика должна быть в статических методах без сайд‑эффектов (I/O, логи, изменение состояния). Сайд‑эффекты нужно оставлять в контроллерах.
Unit‑тесты. Чистые функции нужно покрывать простыми тестами без моков. Если для unit‑теста нужен мок, это может быть code smell и стоит порефакторить.
Integrational‑тесты. I/O классы (Redis, БД, HTTP‑клиенты) покрываются тестами с реальными зависимостями через Testcontainers. Корректность взаимодействия с внешними системами необходимо проверять.
Workflow‑тесты. На каждый бизнес‑сценарий нужен минимум один тест, который проверяет весь пайплайн от начала до конца. Такие тесты сложные и медленные, но отлавливают проблемы с конфигурацией и DI.
Чтобы быть уверенным в своем коде, нужно научиться писать хорошие тесты. Подробнее расскажу о них в следующей статье «Надежный код: как писать тесты, чтобы запускать фичи в продакшене одним днем».
