12 помилок при побудові архітектури

В этой статье я хотел бы затронуть наиболее часто встречающиеся проблемы в построении архитектуры ПО. Я решил написать этот материал, очередной раз столкнувшись с тем, что люди, даже имеющие огромный опыт разработки ПО, продолжают путать модели, относящиеся к разным слоям приложения. Они не придают значения излишней связанности (костности) между частями приложения и прочим аспектам. В итоге эти аспекты оказываются, если не критичными, то существенно влияют на дальнейшую разработку программного продукта.

Почему возникают ошибки

Причина, по которой разработчики, имеющие за плечами довольно большой стаж разработки ПО и построения архитектуры, продолжают реализовывать жёстко связанные решения и решения с нечётко разделёнными моделями — отсутствие негативной «обратной связи» в краткосрочной перспективе. Часто последствия всего этого вылазят через время, после внесения множества других изменений. Те, кто построил эту архитектуру, либо уже не видят негативных результатов своих трудов, либо спихивают их на других людей (чаще всего на тех, кто непосредственно реализовывал).

Как говорит мой почти 20-летний опыт разработки ПО, архитектура приложения часто более важна, нежели конкретная реализация задачи. И когда разработчики реализовывают уже определённые задачи, обходя архитектуру, говорит только о том, что архитектура далеко не оптимальна и слишком сложна, если её проще обойти, нежели ей следовать.

Один из наиболее частых аргументов, который мне приходилось слышать относительно архитектуры: «Это просто разные подходы, можно сделать и так, и так, оба способа хороши». Аргумент в корне неправильный, но с которым спорить довольно тяжело. Очень сложно объяснить человеку, какие последствия в будущем принесёт его подход с хрупким, жёстко связанным кодом. И ещё тяжелее объяснить заказчику, для которого важна прежде всего «обёртка», функциональность, реализованная здесь и сейчас, а не какая-то там эфемерная «гибкость», которую он не чувствует, не осознаёт последствий, выражающихся в сложности модернизации и дальнейшего развития приложения, но за которую нужно заплатить «здесь и сейчас».

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

Что мы рассмотрим

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

Проект создан с помощью технологии .NET и языка C#, а также используется база данных MS SQL, но на самом деле это не имеет никакого значения. Точно также это может быть как другая реляционная БД, так и другая ООП технология, например Java.

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

В примерах кода я удалил все неактуальные моменты (например, различные проверки, если пример кода сфокусирован на другом) для повышения читабельности.

В этой статье я хочу рассмотреть следующие вопросы:

1. Использование одной и той же модели для базы данных и бизнес-логики.
2. Выборка записей из БД и их projection на DTO классы.
3. Использование enum или отдельной сущности (отдельной таблицы-списка в БД).
4. Нарушение SRP (Single Responsible Principle).
5. Использование Nullable types.
6. Проверка входных/выходных данных.
7. Использование Exceptions.
8. Вычисляемые поля и их хранение в БД.
9. «Исключительные случаи» и «дублирование» в архитектуре.
10. Интерфейсы и их реализация.
11. «Заглушки» и прочие способы подавления ошибок.
12. Неочевидность использования библиотечного кода.

Любые обсуждения, рекомендации и даже критика определённо приветствуются.

1. Использование одной и той же модели для базы данных и бизнес-логики

Один из самых распространённых подходов, с которым мне приходилось сталкиваться и который в итоге часто приводит к бардаку во всём проекте, — это «перемешивание» слоя базы данных и классов слоя бизнес-логики, которые отвечают за передачу данных (DTO классы). Почему они должны быть разделены? Как минимум потому, что, как мы знаем из азов программирования, классические БД представляют собой реляционную модель, а классы бизнес-логики оперируют объектами! И объектная модель — не тоже самое, что реляционная модель для представления одних и тех же данных. Одного этого аспекта должно быть достаточно, чтобы задуматься о том, что эти модели должны быть разными. И все попытки натянуть одно на другое ведут либо к избыточным и дублирующим полям в БД, либо к запросам, которые вытягивают целые сущности ради одного поля в каждой сущности.

Я могу привести множество аргументов о вреде избыточных и дублирующих полей. Это целая тема для отдельной статьи. Избыточные, а особенно дублирующие поля — это зло, которое создаёт чуть ли не более половины всех проблем в проекте. Но если сказать вкратце, то дублирующие поля создают неопределённость и 2 (или более) «точек» для изменения, вместо одной. Например, если сущность User имеет поля FirstName и LastName, а сущность Driver является User, то если у Driver тоже будут поля FirstName/LastName — это создаст неоднозначность.

   public partial class AspNetUser
    {
        public string Id { get; set; }
        public string UserName { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public virtual Driver Driver { get; set; }
        //.....
    }

    public partial class Driver
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public string Nickname { get; set; }
        //.....
    }

this.HasOptional(c => c.Driver)
    .WithOptionalPrincipal(a => a.AspNetUser)
    .Map(m => m.MapKey("AspNetUserId"));

То есть если мы обновляем драйверу имя, то должны также обновить его и у пользователя. Если обновляем у пользователя, то должны также обновить и у драйвера. Причём, если мы забудем это сделать для какой-то из сущностей — никакой ошибки мы не получим! Ошибку мы получим тогда, когда одна часть приложения будет возвращать имя, взятое у пользователя, а вторая — взятое у драйвера! Причём ошибку получит клиент. С точки зрения разработчика всё будет компилироваться и вообще будет всё феншуй. А с клиентской точки зрения будет вообще неясно, какие данные валидны и цена этого «незнания» может быть очень высокой. Если развить тему, то пользователь может вообще увидеть данные, которые после момента редактирования не должны быть известны.

Почему я привёл этот пример? Потому что недавно столкнулся с этой ситуацией. Новый архитектор из Сербии, который по возрасту годится мне в отцы и который первые шаги в программировании сделал в начале 70-х, когда меня ещё и в проекте не было, а построением архитектуры ПО занимается с начала 80-х. И он поступил именно так, в рабочий проект добавил эти дублирующие поля для сущности Driver, несмотря на то, что сущности Driver и User находились в зависимости 0..1-1 друг к другу. То есть для каждой сущности Driver обязательно имела место быть сущность User и никакие мои аргументы, почему это делать нельзя и какие последствия это влечёт, не имели успеха. Он просто их не понял и не хотел понимать, потому что костность мышления и аргументы «я создаю архитектуру для приложений с времён, когда ты ещё пешком под стол ходил» ему кажутся железобетонными.

Аналогичным образом влияют избыточные поля, например, вычисляемое поле. Если есть поля A, B и C и по ним можно вычислить поле D, то без особой необходимости поля D не должно быть в БД! Иначе это всё влечёт за собой то же самое — это поле нужно сопровождать, менять при изменении одного из полей и прочие радости жизни.

Что же заставило этого «архитектора со стажем» добавить дублирующие поля? Оказывается, причина этого была банальной — он напрочь забраковал DTO классы! То есть роль DTO классов у него играют те же классы, которые участвуют в построении БД. И он реально не понимает, почему нужны ещё какие-то DTO классы, которые часто похожи на его классы и которые нужно поддерживать. Но вот когда клиентская часть запросила данные с 2-х таблиц, то он не нашёл ничего лучшего, чем добавить эти поля в таблицу Driver, чтобы «был один запрос вместо 2-х».

На самом деле эта проблема элементарно решается с помощью projection (не знаю, как сказать по-русски), когда механизм запросов к БД оптимизирует так, чтобы это был один запрос вместо двух (хоть и с INNER JOIN; в .NET это Linq-to-Entities, например). Результат сохраняется в DTO и далее пробрасывается уже в клиентскую часть. Ситуация становится совсем катастрофической, если клиентская часть хочет видеть список, данные которого формируются из данных из 5 таблиц, причём в каждой из них нас интересует только одно поле. Если у нас в результате будет 100 записей, то вместо одного (пусть и массивного запроса) мы вынуждены будем сделать 5 * 100 + 1 запрос.

Помимо всего прочего, добавление дублирующих полей, как практикует этот мой сербский друг, «решает» только данный конкретный случай. Завтра клиентской части потребуется ещё одно поле и его подход с добавлением дублирующего поля потребует внесения изменений в архитектуру БД (или же плодить дополнительные запросы к БД). Но БД никак не должна зависеть от перипетий на клиентской части. БД — это хранение данных, она вообще ничего не должна знать о клиентах, её использующих. Она только изменяет, хранит и отдаёт данные. Во что их преобразовать и как — задача клиента, её использующего.

Поэтому чётко разделяем классы, работающие с БД, и классы DTO. Классы БД имеют реляционную структуру, классы DTO привязаны к бизнес-модели (и часто очень похожи на конечную клиентскую часть). За это придётся заплатить тем, что нужно будет создать классы DTO, которые, возможно, будут иметь много общих полей с классами, используемыми для построения БД. Но у нас будет гибкая, независимая архитектура, чётко разграниченная по слоям.

Если классы БД должны работать с остальным приложением только через посредника (то есть через классы DTO), то, по идее, их хорошо было бы вообще сделать «невидимыми» для всех остальных частей приложения, кроме как слоя, работающего непосредственно с БД. Я в принципе так и предпочитаю делать. Эти классы изолированы в отдельной сборке вместе со всей инфраструктурой для работы с конкретной БД, и методы этой сборки возвращают уже DTO классы, а вся кухня работы с БД происходит внутри этой сборки.

Однако есть один аспект: часто бывает необходимым просто обновить объект. То есть клиентская часть передаёт новый объект, и нам нужно обновить текущий объект в БД. Мы не знаем, какие поля конкретно были изменены. Или же клиентская часть передаёт только те поля, которые были обновлены. Или же операция представляет собой только добавление объекта, без затрагивания других сущностей в БД. В этих случаях удобно напрямую работать с классами, работающими с БД и не плодить DTO классы, не отличающиеся от БД классов и заниматься их маппингом. То есть мы просто получаем запись из БД (которая отражается на классе, работающим с БД), вносим изменения в этот класс и сохраняем этот объект снова. Либо при добавлении объекта мы сразу заполняем класс БД и сохраняем его, минуя DTO.

       [HttpPost]
        private HttpResponseMessage CreatePrivate(int tenantId, LocationFormCreateAPI model) // LocationFormCreateAPI – класс слоя представления
        {
            var location = mapper.Map<Location>(model); // Location – класс, используемый в том числе и для генерации таблицы в БД, в данном случае рассматривается в том числе и как класс бизнес-логики
            _locationServiceEntity.InsertLocation(location); //передаём экземпляр этого класса и метод сервиса напрямую его добавляет к DbContext:

        public void InsertLocation(Location location)
        {
                _context.Locations.Add(location);
                _context.SaveChanges();
        }

В принципе такой подход имеет право на существование и не несёт за собой особых последствий при аккуратном использовании (в таком случае классы БД рассматриваются как часть бизнес-модели). В этом подходе есть плюс — отсутствие дублирующих DTO классов для простых операций. Но есть и жирные минусы — бизнес-модель имеет, по сути, реляционные классы, которые к ней не относятся. А также то, что открытые для бизнес-модели реляционные классы дают предпосылки для их использования «напрямую» даже в случаях, когда этого делать не стоит С изолированными классами в отдельной сборке такой номер вообще не провернёшь, не добавив ссылку на эту сборку. Исходя из сказанного, мы получаем, по сути, нарушение принципа SRP (Single Responsible Principle), о котором поговорим в 4-м разделе.

Стоит ли «открывать» эти классы для использования в простейших CRUD операциях или нет — решать вам. Это зависит от множества других факторов: размера приложения, количества таких операций, частоты изменения и модификации приложения и т.д. Но как только клиентская часть запрашивает данные, которые отличаются от тех, которые хранятся в одной таблице реляционной БД — то не нужно натягивать сову на глобус. Создавайте дополнительный слой с DTO классами и формируйте запрос к БД именно так, чтобы он был оптимизирован и не содержал лишних данных.

В интернете ведётся много дискуссий по поводу того, что классы DTO дублируют многие поля из классов других слоёв. Я не вижу в этом особой проблемы и не считаю, что это создаёт какую-либо избыточность. Это, скорее, наоборот, изолирует каждый слой от других слоёв и убирает ненужные зависимости. А для мэппинга классов между слоями есть различные тулзы, например Automapper (как в примере выше).

Резюме. Не стоит смешивать реляционную модель и доменную модель приложения. По возможности, стоит вообще изолировать каждую из моделей в своём слое.

2. Выборка записей из БД и их projection на DTO классы

Использование projection (не знаю, как правильно перевести на русский) вытекает из моментов, рассмотренных нами ранее. А точнее из того, что реляционная модель данных отличается от объектной бизнес-модели. На практике чаще всего получается так, что в реляционной модели данные, касающиеся одной сущности, разбросаны по нескольким таблицам. Например, у нас есть сущность Employee, и у каждого Employee есть Marital Status. Список статусов часто имеет смысл хранить в отдельной таблице, так как это позволит использовать их повторно, гарантировать их уникальность и избегать дублирования (вторая нормальная форма БД):

     public partial class MaritalStatus
    {
        public int Id { get; set; }
        public string Status { get; set; }
    }

    public partial class Employee
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public int MaritalStatusId { get; set; }
        public virtual MaritalStatus MaritalStatus { get; set; }
        //....
    }

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

   public partial class EmployeeDto
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public string MaritalStatus { get; set; }
        //....
    }

Теперь для того, чтобы выбрать эти данные, мы можем использовать projection в Linq-to-Entities:

   public List<EmployeeDto> EmployeeList()
    {
        var list = (from i in Employee
                    select new EmployeeDto()
                    {
                        Id = i.Id,
                        FirstName = i.FirstName,
                        LastName = i.LastName,
                        MaritalStatus = i.MaritalStatus.Status,
                    }).ToList();

        return list;
    }

Есть разные инструменты, которые умеют маппить это автоматически, например AutoMapper.EF6. Но суть остаётся той же — нам не нужно выбирать 2 сущности вместо одной, нам не нужно добавлять какие-то дублирующие поля и прочие извращения, о которых мы говорили выше. В нашем случае Linq-to-Entities (в вашем это может быть что угодно, хоть ручное составление SQL-запроса) помогает одним запросом получить необходимые данные, которые соответствуют нашей бизнес-модели.

Резюме. Старайтесь использовать projection при выборе данных из нескольких таблиц в одном запросе. В таком случае ваш запрос к БД будет оптимизирован, что позволит избежать множества запросов к БД и выбора ненужных данных.

3. Использование enum или отдельной сущности (отдельной таблицы-списка в БД)

Довольно часто мы сталкиваемся с ситуацией, когда какая-то сущность представляет собой список. Это может быть список стран/штатов, список допустимых значений, список статусов, которые может иметь другая сущность. Для решения этой задачи можно использовать отдельную таблицу с ключом-значением, где каждая запись имеет уникальное значение, а полный набор записей представляет собой список всех допустимых значений. Либо создать отдельный enum и также задать список всех допустимых значений. Какой из подходов правильный? Правильные на самом деле оба, просто всё зависит от условий!

Давайте рассмотрим на примере. В текущем проекте используется список штатов. Мною он был спроектировал как отдельная сущность-таблица, первичный ключ записи которой используется как внешний ключ в таблицах, где необходима связь с каким-либо штатом. Моему сербскому другу это не понравилось, он всё забраковал, снёс эту таблицу, создал enum и везде, где был внешний ключ на эту таблицу, заменил на обычное значение типа int для хранения значения enum. Правильно ли он сделал? Нет!

Но почему? Потому что теперь тяжело проконтролировать допустимое значение? Нет, это допустимое значение можно проконтролировать на уровне приложения при валидации модели. С внешним ключом это более изящно, но это не главная причина. Главный минус при использовании enum в том, что значения, по сути, захардкодены. При их изменении (добавлении/удалении) нужно изменять код, а это значит, что нужно дёргать разработчиков, а ведь проект может быть давным давно закончен и весь штат разработчиков распущен. Нужно перекомпилировать проект, а он может не компилироваться, так как чуть другой компилятор или какие-то версии связанных сборок уже недоступны, либо работать чуть не так, как ожидается. И потом его нужно опубликовать.

Представьте, что проект последний раз изменялся год назад, заказчик уже потерял все связи с разработчиками, которые его делали. Как развернуть проект на хостинге скудно описано (если описано) где-то там в документации. И вообще это тоже делал кто-то из разработчиков тоже год назад. Заказчику, по сути, нужно изменить то, что относится к данным, но он вынужден изменять код из-за данных. Код не должен зависеть от данных. Если мы вынуждены изменять код из-за данных, значит что-то не так в нашей архитектуре!

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

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

Допустим, у нас есть маршрут, который должен проехать водитель. У маршрута есть состояние. Это могут быть значения «Waiting», «Started», «In Progress», «Stopped», «Cancelled», «Completed». И у нас в зависимости от состояния выполняется разная логика! Например, если маршрут завершён успешно — то водитель должен загрузить счёт-фактуру. Если остановлен — то мы должны обработать другую логику и посмотреть, что произошло и т. д. То есть у нас логика в коде зависит от состояния маршрута.

switch (route.Status)
{
    case (RouteStatus.Waiting):
        // код, выполняющийся, когда статус у маршрута Waiting
        break;

    case (RouteStatus.Started):
        // код, выполняющийся, когда статус у маршрута Started
        break;
        // ......
}

Да, я знаю про принцип подстановки Лисков. Но, по-моему, здесь как раз тот случай, когда лучше использовать switch, а не плодить дочерние классы.

Если использовать для этого таблицу, то здесь мы наоборот вынуждены добавлять захардкоденые значения, соответствующие записям в таблице. Теперь значения в таблице нельзя трогать, нельзя удалять, переименовывать, что накладывает дополнительные ограничения и вводит исключительную ситуацию, которая где-то должна быть описана. Этот документ должен быть must have для чтения каждому по 3 раза на неделю, чтобы, не дай бог, он не забыл о том, что эту таблицу трогать нельзя и изменения в ней могут привести к непредсказуемым последствиям, которые могут вылезти только спустя время!

Резюмируя сказанное: если логика приложения не зависит от выбранных значений — то используем таблицу (или другой источник данных). Если зависит и в коде мы должны упоминать какое-то из значений — тогда создаём enum.

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

4. Нарушение SRP (Single Responsible Principle)

Как говорит мой опыт, наиболее часто встречаемое нарушение принципов SOLID в реальных проектах — это нарушение принципа SRP (Single Responsible Principle). И использование реляционной модели как модели для передачи данных между уровнями (вместо специального класса DTO) — одно из проявлений нарушения этого принципа. Даже если бизнес-модель полностью идентична реляционной, всё равно мы имеем 2 причины для изменения этой модели. Когда меняется модель представления, которая тянет за собой бизнес-модель, и когда меняется структура реляционной модели. Например, у нас был следующий класс, используемый для генерации БД:

   public partial class Employee
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public string MaritalStatus { get; set; }
        //....
    }

То есть его семейное положение было записано просто строкой в БД, со всеми вытекающими негативными последствиями. В результате было решено сделать рефакторинг и привести БД ко 2-й нормальной форме:

   public partial class MaritalStatus
    {
        public int Id { get; set; }
        public string Status { get; set; }
    }

    public partial class Employee
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public int MaritalStatusId { get; set; }
        public virtual MaritalStatus MaritalStatus { get; set; }
        //....
    }

Но ведь этот же класс использовался для передачи данных в слой представления и теперь всё поломалось! Нужно переписывать выборку (в идеале, добавлять класс DTO).

Аналогичным образом мы будем вынуждены изменить этот класс, если изменится слой представления. Это и есть нарушение принципа Single Responsible Principle, который вытекает из использования одного класса для разных слоёв модели. Старайтесь, без особой на это необходимости, не делать так, даже если модели разных слоёв на данный момент идентичны.

Резюме. Если есть несколько причин для изменения класса — то нарушен принцип SRP. Стоит посмотреть в сторону выделения ещё (как минимум) одного класса, чтобы разделить ответственность на каждого из них.

5. Использование Nullable types

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

Например, в проекте есть сущность Trip, в котором есть поле CalculatedDistance. Это расстояние вычисляется сторонним компонентом, который иногда может быть недоступен или по каким-то внутренним причинам не может посчитать это расстояние, но в целом сущность Trip должна быть создана даже в этом случае (такое бизнес-правило в проекте). Это поле было объявлено как nullable (decimal?).

     public class Trip
    {
        public int Id { get; set; }
        public decimal? CalculatedDistance { get; set; }
        //....
    }

Мой сербский друг переделал его на not nullable:

   public class Trip
    {
        public int Id { get; set; }
        public decimal CalculatedDistance { get; set; }
        //....
    }

И оно стало инициироваться значением по умолчанию, то есть нулём. Я спросил его, почему он так сделал. Он мне ответил, что инициировать нулём — правильно и привёл мне пример из жизни, что когда мы покупаем новую машину — то на одометре мы имеем ноль, и потом, в процессе эксплуатации, это значение увеличивается в зависимости от пройдённых автомобилем километров.

Его аргумент относительно новой машины правильный, но это совсем другая ситуация. В новом автомобиле действительно это значение должно быть ноль, потому что машина проехала 0 километров! А вот если мы покупаем машину на еврономерах где-нибудь в Риге у русскоязычного продавца, который продаёт «Опель» без одометра середины девяностых с почему-то переваренной рамой и кузовом и мамой клянётся, что на машине почти не ездили и точно знает, что машина прошла не более нескольких десятков тысяч км и вообще «мы, гусские, никогда своих не обманываем» ©... Это явно тот случай, когда мы должны были бы инициировать значение одометра как null — неизвестно, отсутствие значения!

Ноль — это тоже число! Например, если мы пришли в магазин купить себе пиво, достали кошелёк и вспомнили, что мы только вчера его купили, и вообще он ещё в упаковочной плёнке, то мы можем определённо сказать, что там нет денег, там ноль! А если мы вчера положили в кошелёк 1000 долларов, сегодня достали его в пивном магазине и вспомнили, что сегодня утром наш кошелёк брала с собой жена на шоппинг, то, не открыв его, мы не знаем, сколько там денег, и не знаем, хватит ли нам на пиво. Там null — неизвестно! 0 — точно означает, что денег на пиво не хватит, и мы не можем сделать транзакцию, с null — это неизвестно. Там может быть как 1 доллар, так и 10, 100, 1000 долларов и даже больше (но это вряд ли). Инициализация нулём неизвестных значений — такое же magic string, как и любое захардкоденное стринговое значение! Мало того, оно может создавать неопределённость, как в примере с кошельком.

Ещё один распространённый пример, когда используется дефолтное значение вместо null. Сущность имеет 2 поля: дату (время) создания и последнюю дату изменения. При этом дата изменения устанавливается вместе с датой создания, например, в конструкторе:

       public class Trip
        {
            public Trip()
            {

                CreatedOnUtc = DateTime.UtcNow;
                UpdatedOnUtc = DateTime.UtcNow;
            }
            // ....

            public DateTime CreatedOnUtc { get; set; }
            public DateTime UpdatedOnUtc { get; set; }
        }

Я также встречал варианты, когда устанавливают для этого поля не текущую дату, а, например, 1 января 1970 года, не в том суть. Суть в том, что изменений ещё не было, а дата последнего изменения уже есть, что противоречит логике! И для того, чтобы понять, было ли реально редактирование, нам нужно сравнить дату изменения с датой создания (или с 01.01.1970, или ещё с какой-то «магической» датой). Но ведь можно просто хранить в этом поле null, что будет правильно с логической и удобно с технической точки зрения!

Если вы всё же ещё не уверены, стоит ли использовать null вместо значений по умолчанию там, где логически должен быть null, и вам до сих пор кажется, что это просто «вопрос удобства» и не может нести каких-либо негативных последствий, то подумайте о том, что логика приложения (даже не сейчас, в будущем) может подразумевать деление на это значение, и если там будет 0 — то мы можем получить ошибку, связанную с делением на 0. Если же там будет какое-то другое «магическое число», то мы просто получим неправильное значение. Это, кстати, ещё хуже, так как ошибку с делением на ноль мы поймаем и оттестируем, а вот арифметическую ошибку — вряд ли.

Резюмируя: nullable поля используем для значений, которые могут быть неизвестны (а могут быть и известны, зависит от бизнес-логики), но сущность может быть инициализирована без этих значений, и в таких случаях используем только nullable, а не 0, ?1, ?100500, int.MaxValue и прочий изврат. Аналогично и для стринговых значений: если бизнес-логика говорит, что может быть пустая строка, тогда пустая строка означает пустую строку, а null означает отсутствие установленного значения!

Резюме. Если бизнес-логика разрешает нам иметь неинициализированное поле класса после создания объекта этого класса — то нужно использовать nullable-тип, а не придумывать различные невероятные значения, которые в нашем представлении должны выполнять ту же функцию, что и null.

6. Проверка входных/выходных данных

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

Если какой-то уровень (например, слой бизнес-логики) получает данные из внешнего источника в широком понимании (это может быть и слой представления, передающий введённые пользователем данные с формы, и внешний веб-сервис, данные с которого также попадают в слой бизнес-логики через слой представления, и база данных, из которой выбираются данные), то этот слой должен быть уверен, что данные валидны. Если же он не может быть уверен, что они валидны, он должен провести их валидацию «на входе» (которую можно выделить в отдельный «подслой») и только «на входе». Никакой валидации внутри самих методов, которые обрабатывают бизнес-логику, быть не должно! Если метод производит вычисления, то он должен делать только это и априори считать, что входные данные валидны и там не будет, скажем, деления на 0 из-за неправильных входных данных. А если будет брошено такое исключение — то это должно означать, что ошибка в самой логике метода.

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

Как только в нашу БД может писать ещё одно приложение, которое мы не контролируем — мы должны задуматься о валидации данных, полученных из этой БД. Хорошо, если мы можем написать правила валидации на уровне БД, но, честно сказать, MS SQL не позволяет легко и гибко писать сложные правила. Если правило чуть сложнее примитивной логики (которая контролируется ключами и constraints) — нужно писать триггер, который необходимо уже писать «в довесок», если мы используем Code-First подход, со всеми вытекающими сложностями.

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

Резюме. Все данные, пришедшие от «ненадёжного» источника (будь то пользовательский интерфейс или БД, доступ к которой имеют другие приложения), всегда должны быть проверены настолько тщательно, насколько это возможно. Это позволит в будущем избежать трудноуловимых и тяжело исправляемых ошибок, связанных с поврежденными данными.

7. Использование Exceptions

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

Самый «тяжёлый» и один из самых распространённых случаев — это нечто подобное этому:

А. Вариант «заглушки»

try
{
    // наша логика
}
catch
{
}

Или

try
{
    // наша логика
}
catch (Exception exp)
{
}

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

Б. Вариант бессмысленного исключения

try
{
    // наша логика
}
catch (Exception exp)
{
    throw new TmsException(exp.Message);
}

Это то, что сделал мой сербский друг © в проекте — ввёл какое-то своё исключение, причём общее для всех случаев. И он с гордым видом, что он правильно, как ему кажется, использует исключения, везде и всюду начал бросать это исключение с сообщением из исходного исключения. Какой смысл в этом подходе? Смысла нет никакого. Это его общее исключение TmsException абсолютно ничего нам не даёт, и этот префикс Tms для обозначения принадлежности к проекту TMS абсолютно ни о чём. Как всё же правильно использовать исключения?

Прежде всего, нужно понять смысл разных типов исключений: смысл в том, чтобы донести приложению, которое вызвало наш метод и поймало исключение, как правильно обработать это исключение! Если разницы в обработке 2-х исключений нет — тогда нет смысла бросать 2 разных типа исключения! Если же исключение, вызванное невозможностью выполнить какую-то арифметическую операцию по причине того, что результат не попадает в ожидаемый диапазон, должно обрабатываться иначе, нежели исключение, вызванное отсутствием пользователя с таким ID — тогда мы должны бросать 2 разных типа исключения, а не писать умные и лаконичные сообщения об ошибках, обрамлённые в одно общее исключение. Давайте рассмотрим на примере.

У нас есть метод

await geoService.GetPlaceByCoordinatesAsync(lat, lng);

который бросает exception:

   public class InvalidGeoCoordinatesException : Exception
    {
        public double Lat { get; set; }
        public double Lng { get; set; }
        public InvalidGeoCoordinatesException(double lat, double lng) : base()
        {
            Lat = lat;
            Lng = lng;
        }
    }

Если переданы неправильные значения lat или(и) lng. Это необязательно должны быть значения, не попадающие в диапазон ?90..90 и ?180..180. Это может быть случай, когда мы должны проверить через какой-то внешний источник, что данная точка не является, например, водной поверхностью.

Бросает exception:

   public class GeoCoordinatesNotResolvedException : Exception
    {
    }

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

И бросает exception:

   public class GeoCoordinatesDisabledException : Exception
    {
    }

Если такие координаты запрещено использовать нашему пользователю.

Теперь рассмотрим клиентов, которые используют наш метод GetPlaceByCoordinatesAsync. Это может быть Web API. Тогда мы должны вернуть конечному клиенту в первом случае ошибку 400 (Bad Request), во втором случае — 500 (Internal Server error), а в третьем — 403 (Forbidden):

try
{
    await geoService.GetPlaceByCoordinatesAsync(lat, lng);
    //...
}
catch (InvalidGeoCoordinatesException geo_ex)
{
    return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ...);
}
catch (GeoCoordinatesNotResolvedException notResolved_ex)
{
    return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, ...);
}
catch GeoCoordinatesDisabledException disabled_ex)
{
    return Request.CreateErrorResponse(HttpStatusCode.Forbidden, ...);
}

То есть каждый тип исключения мы анализируем в своём блоке catch и результат обрабатывается по-разному. Если у нас это будет MVC приложения, то в первом случае мы можем записать какую-то ошибку в ModelState и вернуть страницу с этой ошибкой. Во втором случае — сделать редирект на какую-то особую страницу, предназначенную именно для этого, а в третьем — ещё как-то обработать. Никаких magic strings, записанных в сообщении общего исключения, которые нужно анализировать, парсить (а если есть локализация — то вообще ужас), никаких общих исключений. Каждому

Опубліковано: 06/02/19 @ 08:05
Розділ Різне

Рекомендуємо:

По-стартаперски і по-багатому: як робити customer research і чому це корисно будь-якій компанії
Ruby/Rails дайджест #26: реліз Ruby 2.6.1 і Hanami 2.0.0.alpha1, представлена перша бета-версія фреймворку Ruby on Rails 6
Визначаємо вартість декоратора в Golang
Финстрип за Січень 2019, інфо-сайти. Місяць приніс відкат майже в 2 рази
React Hooks — огляд можливостей нового API