Сучасна диджитал-освіта для дітей — безоплатне заняття в GoITeens ×
Mazda CX 30
×

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, записанных в сообщении общего исключения, которые нужно анализировать, парсить (а если есть локализация — то вообще ужас), никаких общих исключений. Каждому случаю, который может быть обработан по-особенному — своё исключение!

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

8. Вычисляемые поля и их хранение в БД

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

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

Но что же за острая необходимость иногда хранить эти вычисляемые поля в БД, о которых я обмолвился ранее? Это те случаи, когда вычисления либо слишком сложные и, соответственно, слишком затратные по ресурсам, либо слишком затратные по времени, а результат должен быть возвращён здесь и сейчас. Этакая форма кэша.

Например, у нас есть сущность Trip, общее расстояние которой вычисляется внешним сервисом. Сама сущность меняется довольно редко, но её расстояние запрашивается часто. Было бы нерационально каждый раз делать запрос к внешнему сервису (который, к тому же, может быть ещё и платным), чтобы снова, в сотый раз, получить то же самое значение, потому что сама сущность не менялась и будет ли меняться — неизвестно. Но в таком случае у нас есть момент, который мы не должны упустить. В случае изменения записи, мы обязательно должны пересчитать и переписать её расстояние, иначе у нас просто будут неправильные данные. Это как раз тот случай с острой необходимостью. А хранить в отдельном поле каждой записи общее количество записей, относящихся к данному пользователю, не то, что бессмысленно, но и вредно!

Вот один из примеров, как не нужно делать.

Есть сущность Trip:

    public partial class Trip 
    {
	 //....
        private ICollection<TripStop> _tripStops;
        public decimal CalculatedDistance { get; set; }

        public virtual ICollection<TripStop> TripStops
        {
            get => _tripStops ?? (_tripStops = new List<TripStop>());
            protected set => _tripStops = value;
        }
    }

Где CalculatedDistance — расстояние всего маршрута, от начальной до конечной точки.

И сущность TripStop:

    public partial class TripStop
    {
        public int TripId { get; set; }

        public decimal DistanceFromPreviousStop { get; set; }

        public virtual Trip Trip { get; set; }
    }

Где DistanceFromPreviousStop — расстояние от предыдущей точки. Так как весь маршрут составляется из таких вот контрольних точек-стопов и у каждой задано расстояние от предыдущей, то нет никакого смысла иметь общее расстояние в сущности Trip, потому что общее расстояние высчитывается без особых ресурсозатрат. Мало того, это ещё и вредно, так как необходимо следить за тем, чтобы значение всегда было обновлено при каких-то изменениях, касающихся расстояния (по сути, это тоже нарушение SRP (Single Responsible Principle), о котором мы говорили ранее).

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

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

9. «Исключительные случаи» и «дублирование» в архитектуре

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

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

Например, если разработчик «А» создал интерфейс в своей сборке:

    public partial interface IDriverService
    {
        void InsertDriver(Driver driver);
        void UpdateDriver(Driver driver);
        //...
    }

А разработчик «Б» создал интерфейс с точно таким же именем в своей сборке:

    public partial interface IDriverService
    {
        DriverDto GetDriver(int driverId);
        //...
    }

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

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

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

10. Интерфейсы и их реализация

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

Вот что я увидел в реальном проекте:

Обратите внимание, что интерфейсы и их реализации лежат в одной сборке. Это тот случай, как делать не стоит!

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

11. «Заглушки» и прочие способы подавления ошибок

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

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

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

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

Почему лучше получить крах приложения «здесь и сейчас», нежели подавление ошибки каким-либо способом? Потому что отловленная ошибка — это уже больше, нежели половина дела. Это также какая-то гарантия того, что приложение работает или правильно, или никак! Это гарантия того, что приложение не продолжит работать с заведомо неправильными данными! Это гарантия того, что ошибка не прошла незамеченной!

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

Резюме. По возможности не смешивайте абстракции (интерфейсы или базовые классы) с их реализацией. Обычно это не приводит к каким-то непоправимым последствиям, но в таком случае часто приходится тянуть ненужные зависимости при использовании только абстракций. Например, при модульном тестировании.

12. Неочевидность использования библиотечного кода

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

Давайте посмотрим, что может произойти, если мы создаём класс с полями, которые не обязательно заполняются данными (когда эти данные есть).

Например, в Entity Framework (да и наверняка и в других ORM системах) для доступа к данным есть Lazy Loading подход. То есть данные загружаются «по требованию», когда к ним идёт непосредственно обращение. Это опция может быть как включена, так и отключена. Но если вы хотите сделать доступными извне (что уже не очень хорошо) некоторые из классов, имеющие подобные виртуальные свойства с загрузкой «по запросу», то уже делайте так, чтобы эта опция была включена!

Например, у вас есть класс:

   public partial class User 
    {
        private IList<UserRole> _userRoles;

        //....

        public virtual IList<UserRole> UserRoles
        {
            get => _userRoles ?? (_userRoles = UserUserRoleMappings.Select(mapping => mapping.UserRole).ToList());
        }
    }

Есть интерфейс со следующим методом:

    public partial interface IUserService
    {
        //....
        User GetUserByEmail(string email);
    }

Реализация представляет собой что-то вроде этого (весь дополнительный код и проверки откинуты) и находится в отдельной сборке, как и полагается реализации:

    public partial class UserService : IUserService
    {
        public virtual User GetUserByUsername(string username)
        {
            var user = _dbContext.Users
                .Where(u => u.Username == username)
                .FirstOrDefault();

            return user;
        }
    }

Теперь я хочу использовать этот метод в своём проекте:

var user = _userService.GetUserByUsername(model.Username);
       foreach (var role in user.UserRoles)
       {
             claims.Add(new Claim(ClaimsIdentity.DefaultRoleClaimType, role.SystemName));
       }

Всё компилируется и даже, может быть, всё работает. А может и нет. Точнее работает, но неправильно. Зависит от того, включили ли в «настройках» эту lazy loading. И она (эта lazy loading) может быть даже и была включена, причём случайно. Это, кстати, худший вариант. Потому что точно также ещё «случайно» и выключат, и никто сразу даже и не поймёт, что случилось. Всё будет также компилироваться, никаких эксепшенов бросаться не будет. Только не будут установлены необходимые claims, что, вероятно, будет замечено уже на продакшене (потому что, вроде как, ничего и не сломалось). Причём может быть с непоправимыми последствиями, если из-за этих неустановленных claims кто-то не получил доступ, куда нужно, или наоборот получил, куда не стоило.

Получается, что корректность работы библиотеки зависит от настроек, которые включаются на стороне клиента. Но библиотека не должна зависеть от клиента и его прихотей. Она должна или работать корректно, или валиться с исключением, в котором должно быть чётко указано, что не так (например, включите lazy loading, хотя в таком случае уже лучше включить на уровне библиотеки). Это и есть та «неочевидность использования», когда библиотека корректно работает тогда и только тогда, когда на клиенте выполняются условия A, B и C. В остальных же случаях она просто работает некорректно.

Резюме. Выполнение кода не должно зависеть от каких-либо настроек, либо эта разница в выполнении должна быть очевидна. Код должен либо выполняться правильно, либо не выполняться вообще.

Выводы

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

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

Все про українське ІТ в телеграмі — підписуйтеся на канал DOU

👍ПодобаєтьсяСподобалось1
До обраногоВ обраному5
LinkedIn

Схожі статті




Найкращі коментарі пропустити

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

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

325 коментарів

Підписатись на коментаріВідписатись від коментарів Коментарі можуть залишати тільки користувачі з підтвердженими акаунтами.

@olegsh Большое спасибо за статью и за то, что проделали большую работу, поделившись опытом.

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

В данном случае многое могло зависеть от формы, в которой была выражена Ваша идея. Возможно, были причины, по которым Ваш коллега не мог принять прямую критику, особенно, если её каким либо образом слышали окружающие. Принятие критики могло бы означать потерю имиджа для ряда лиц и/или компаний. А это в свою очередь могло повлиять на перспективы сотрудничества и возможности вести бизнес для третьих лиц. И, понимая это, Ваш коллега был просто не в состоянии принять чьё-то предложение, сколь бы конструктивным оно ни было. Могли быть и другие причины.

В первой части статьи проблема скорее с непониманием правил нормализации БД. Было бы неплохо в начале статьи описать, какую архитектуру вообще пытались создать, ибо описанное верно для N-tier и нужно понимать, что описанные рекомендации не являются серебряной пулей и в целом не имеют смысла в случае, скажем, event sourcing или CQRS. Проблема хранения калькулируемых полей верна в данном конкретном случае, но не является железным правилом (рассмотрите паттерн Eager Read Derivation, например: martinfowler.com/...​/EagerReadDerivation.html). В общем, о успешности архитектуры можно судить только при понимании предметной области, требований к доступности, согласованности данных, устойчивости к разделению и т.д. Всегда интереснее читать, когда сначала описывается задача, которую нужно решить, а потом само архитектурное решение или его ошибки. В любом случае спасибо, годный контент, хоть и не про архитектуру, что бы не имелось ввиду под этим словом :)

Яке щастя що можливо використовувати DI без інтерфейсів і тестувати бібліотеки в цілому.
Дивіться яке неподобство github.com/...​oreLib/System/Collections
Негайно створюйте issue і вимагайте негайного відокремлення інтерфейсів в окрему сбірку!

Сказ о харкождених энумах.
Я теж намагався створити довідникову таблицю для таких «потенційнолегкоконфігуруємих» значень. Навіть э такий архітектурний псевдо Data/driven approach на деяких проектах.
А потім побачив проект, де такі данні не те що переведені в энуми, а ще й двідчі захардкоджені — на серверах, та в кліентах.
Щелепу підіймав довго, довго бив себе по руках щоб все не «переробитиякпотрібно!», а потім закохався!
От же ж програмісти хитрі, не бажають мабуть щоб їх якийсь DBA підсидів!
А на справді, ці дані э частиною контракту. І зміна в даних вимагає перегляду пов‘язанного коду, можливо зміни версій або розширенню контрактів, можливо зміни клієнтських додатків

Намагайтесь використовувати стандартний projection ef і тоді «ваші волосся будуть завжди шовковисті»...
Це той який LINQ-to-XML, EF6, EF core, EF dotConnect, EF MySuperImplementation?
Дуже змістовна та вичерпна порада!

DTO?
Це той що мінімізує число викликів зв‘язанних сервісів на фасадах видалених інтерфейсів?
Тоді так, не гоже його з класами бази данних міксувати.
(Ну може окрім випадків mongo, couch, cache та інших DB, — застарий я вже, може й помиляюсь)

Вот очень достойная вещь наконец-то в ленте на Dou

Немножко похоже на кашу.

Что понравилось. Пример с null хорош.

Что не понравилось.
Безкомпромисная критика папередников.

Термин «архитектура» — слишком общий. Data Architecture, Microservices Architecture, Database Architecture, Software Architecture, Enterprise Architecture, Business Architecture — можно продолжать очень долго.

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

Этакая форма кэша

Материализованное представление.

Часть про интерфейсы — напишу отдельным комментом.

Интерфейсы.

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

Интерфейс — это контракт. Договор. Он не содержит реализаций, только описание возможностей и того, что нам понадобится для реализации этих возможностей. Всё.

Это как каркас для крыши дома, которую потом можно покрыть шифером, черепицей, металлочерепицей или ещё чем-нибудь

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

При написании юнит-тестов для самого интерфейса

А что, так можно было? Писать тесты на интерфейсы? Серьезно? Я, конечно, не знаток C#, но тут явно что-то не так.

Вы вообще-то пишете тоже, что и я, только другими словами, Вам так не кажется?

вы смешиваете понятие интерфейса и абстрактного класса. У них разное назначание. С тестами на интерфейсы — я не понимаю, зачем такое делать

Во-первых, и те, и другие могут использоваться для передачи зависимостей, причём в некоторых случаях вообще равноценно.
Во-вторых, почему Вам не понравилась моя абстракция с домом? А можно ведь посмотреть так, что интерфейс — это именно каркас, а абстрактный класс — это когда уже заложена основа под крышу и теперь можно покрыть только битумной черепицей, хотите красной, хотите зелёной. Можно так?
По поводу тестов Вам ниже Владимир Чернышев отлично написал, даже добавить нечего.

вы смешиваете понятие интерфейса и абстрактного класса.

Теперь смотрим, что в оригинальном тексте:

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

Переход от «абстракции, описывающей конкретный класс» к абстрактному классу — Ваш чистейший домысел без оснований на то.

Интерфейс — это часть контракта обычно. Тесты на интерфейс — закрепление других частей контракта. Естественно это абстрактные тесты, на базе которых делаются конкретные тесты конкретной реализации.

А вот это звучит, мягко говоря, странно.
Пусть интерфейс — часть контракта (а скорее таки это сам контракт). Чего вы добьётесь тестом интерфейса (который даже инстанцировать нельзя)? Что в нём есть конкретный метод с нужной сигнатурой, и что его вызов скомпилировался?
Я бы понял, если бы такое было в языке, в котором можно за слоями макросов и шаблонов не увидеть, что же конкретно генерируется — как в C++ — но в C#, вроде бы, таких проблем не замечено.
Далее, что такое «абстрактные тесты»? Тесты есть, но их нельзя запустить?
Для их прохождения достаточно успешной компиляции?
Прошу подтверждения такой интерпретации.

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

Абстрактный тест, дергающий методы интерфеса. От него наследуются конкретные тесты, которые подставляют конкретный класс.

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

Что не понравилось.
Безкомпромисная критика папередников.

Мабуть, наболело.

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

эээ, а я разве не тоже самое написал?

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

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

Конкретно валидацию на уникальные значения я предпочитаю делать read запросом к хранилищу. Это именно валидация, проверка перед обработкой, закрывающая большинство кейсов. А SQL исключение или 409 HTTP статус или ещё что-то типа того преобразуется в исключение домена и только в самых крайних случаях, если не удалось убедить PO, оно преобразуется в ошибку валидации UI слоя.

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

так и я тоже. Я о том, что между этой проверкой и собственно добавлением(обновлением) в БД могут произойти изменения. И тогда я уже бросаю эксепшн, если значение найдено.

ЗЫ. И вообще, как говорится, it depends :) Если лишний запрос к БД довольно дорогостоящий в текущем случае — обхожусь без предварительной проверки.

валидацию на уникальные значения я предпочитаю делать read запросом к хранилищу

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

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

Ништа — случайно не сербская фамилия?

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

статья такая очевидная

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

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

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

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

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

Где я такое писал? Отсутствие значения — ещё один пример для его использования.

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

где тут двусмысленность? Если нет значения или оно нам неизвестно, но объект может быть инициализирован без этого значения — тут место для null.

Где я такое писал? Отсутствие значения — ещё один пример для его использования

нигде, это я тебе пишу, отсутствие значения не должно решаться с помощью нулов

где тут двусмысленность? Если нет значения или оно нам неизвестно, но объект может быть инициализирован без этого значения — тут место для null.

нет, если объект может существовать без какого-то поля, то это обычно значит, что srp нарушен. Выдумыватели использования нула все по разному себе это видят, только их виденье обычно не может работать вместе. Есть даже случай из жизни, один считал что нул это не заданное значение и спроектировал так бд, в последствии оказалось что нужно иногда очищать определенные поля и он возвращался нулы и был доволен собой. Но был и другой, который считал что в апи будут приходить апдейты для сущностей, а если в каком-то поле, будет нул, то чем заменять поле не известно и соответственно его надо пропускать, таску делал третий, у него с двух сторон были одинаковые по внутренним типам сущности, ну и как следствие таска легко сделалась, но работала неправильно и ушла на прод, т.к. тестеры проверяли задачи такого же плана и использование нула уже было отработано и все работало правильно, но при совмещении все обломалось. Не было бы мудрил, не было бы и бага. Фикс кстати был странного вида и оба варианта нулов получили кучу оговорок и определить что произойдет если будет нул надо было смотреть каждый раз для каждого случая

нигде, это я тебе пишу, отсутствие значения не должно решаться с помощью нулов

прям всегда не должно?

нет, если объект может существовать без какого-то поля, то это обычно значит, что srp нарушен.

с какой стати? Если у нас есть база авто с пробегом, а у некоторых авто пробег неизвестен, то это значит что у нашего объекта «авто» нарушен SRP ? :)

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

Вообще, логика шикарная, если нулл — то пропускать. Может, тогда поля в переданном объекте вообще не должно быть, а если оно всё-таки есть — значит подразумевается, что передан нулл? А если кто-то решит, что если передать число 100 — то значит поле нужно пропускать, то будет ли это означать, что 100 — «плохое» число и его использование чревато двусмысленностью?

прям всегда не должно?

я считаю что всегда

с какой стати?

объект должен быть всегда полноценным, если что-то ему для этого не нужно, то обычно это отдельный объект

Вообще, логика шикарная, если нулл — то пропускать

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

объект должен быть всегда полноценным, если что-то ему для этого не нужно, то обычно это отдельный объект

В теории это обычно звучит красиво (тот же шаблон состояние, когда очередной, более дополненый объект выражен новым классом, из серии Созданный Платеж без подписи и даты проводки / Подписанный Платеж уже с подписью но все еще без даты прводки / Проведенный Платеж с подписью и датой проводки). На практике малодостижимо, т.к. когда таких свойств стает более двух-трех и переходы не последовательные по нарастающей, количество классов стейтов начнет расти в геометрической прогрессии. null конечно использовать не стоит, но Optional AKA Maybe вполне себе разумный компромисс.

Про ваш пример с обновлять / не обновлять по null, тут, скорее всего, проблема не с null как таковым, а в том, что вместо введения конкретных команд, каждая из которых как раз бы и обладала только специфичным для нее набором свойств, начали использовать эдакий универсальный DTO на все случаи жизни.

На каждом слое был свой класс иногда совпадали, иногда нет

объект должен быть всегда полноценным, если что-то ему для этого не нужно, то обычно это отдельный объект

а если оно может появиться для этого самого объекта?

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

Объясни, почему мой подход это приводит к проблемам и почему ты его сравниваешь с каким-то случаем, где какой-то девелопер решил игнорировать поле, если оно имеет какое-то значение?

Единственное назначение нулейбл полей — это маппинг класса на бд

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

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

и всё же, хочу пример :)

а если оно может появиться для этого самого объекта?

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

Объясни, почему мой подход это приводит к проблемам и почему ты его сравниваешь с каким-то случаем, где какой-то девелопер решил игнорировать поле, если оно имеет какое-то значение?

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

А сравниваю я их потому что у них общее есть: использование нулов не по назначению, неясность и проблемы в будущем

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

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

и всё же, хочу пример :)

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

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

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

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

Так это его была кривая реализация, причём тут null?

А сравниваю я их потому что у них общее есть: использование нулов не по назначению, неясность и проблемы в будущем

У него это явно не по назначению. Повторюсь, если кто-то будет юзать не по назначению число 100, то виновато число или такая реализация? И причём тут неясность в тех случаях, что описываю я? Где именно неясность? Там как раз чёткая ясность того, что значение не известно!

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

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

ЗЫ. Это же просто капец какой-то, если в переданном объекте поле имеет значение нулл — значит его игнорировать, я бы до такого и не додумался. Ты кстати 2 сообщениями выше написал один постулат очень к месту — объект должен быть всегда полноценным и не должны «передаваться половинчатые» объекты, уже отсюда нужно было копать в том проекте

Тут место для Option[T] а не для null. Присутствие null в коде часто приводит к ошибкам.
Если таких кейсов много — лушче вовсе использовать гетерогенный список.

Тут место для Option[T] а не для null. Присутствие null в коде часто приводит к ошибкам.

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

вся суть вот в этом:

Присутствие null в коде часто приводит к ошибкам.

сам по себе нул, конечно же, ошибок не делает

Просто потому что работая с опшеном никто и никогда не забудет что это либо Some, либо None, а когда тебе прилетает null, ты сидишь и думаешь, это кто-то забыл что-то проинициализировать или так и должно быть? Кроме того это просто становится заметнее, из опшена значение можно вытащить явным образом при помощи .get, а действия с ним проводить через map и flatMap, и это делает 2 выгоды: 1) нет уродливого if (_ == null) и ему подобных `??`, `.?` - вместо него вышеуказанные операции (на самом деле их много) и лямбды 2) Об намеренном отсутствии значения все предупреждены зарание и NPE отправляется в небытие.

«фигня с нулом» обычно начинается, когда люди говорят о нём в разных контекстах. Самый частый случай: null в ЯОП и NULL в SQL — семантически по умолчанию это разные вещи. Более того, даже в разных контекстах в рамках одного языка может быть разное значение, особенно когда в языке нет значений/механизмов для optional/maybe, undefined и т. п., только null.

Я примерно это и писал, только сумбурно

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

если рассматривать конкретно то что ты написал, то все условно в порядке, «неопределенность» стало «не заданно» — все будет хорошо (как, впрочем, и при любом другом приравнивании неопределенности к чему-то) до тех пор пока не найдется тот, кто придумал нул по другому использовать, а он всегда найдется.

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

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

если рассматривать конкретно то что ты написал, то все условно в порядке, «неопределенность» стало «не заданно» — все будет хорошо (как, впрочем, и при любом другом приравнивании неопределенности к чему-то) до тех пор пока не найдется тот, кто придумал нул по другому использовать, а он всегда найдется.

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

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

Да мне пох на эти митинги, я вон в прямую заказчику качу бочку на всю тиму, сформированную вокруг этого сербского архитектора. И мне пох, если завтра заказчик примет решение убрать меня вообще с этого проекта, значит так будет лучше и для меня. Но пока что не убрал и очень даже прислушивается. Причём его тима сидит всей толпой в одном месте у него в офисе, а я где-то там, далеко в Украине

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

что значит православный метод использования нулов? У нулов один правильный метод использования, как и у числа 69 которое означает число 69.

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

я понимаю, но мне обычно приходится отвечать за других

Да мне пох на эти митинги, я вон в прямую заказчику качу бочку на всю тиму, сформированную вокруг этого сербского архитектора. И мне пох, если завтра заказчик примет решение убрать меня вообще с этого проекта, значит так будет лучше и для меня. Но пока что не убрал и очень даже прислушивается. Причём его тима сидит всей толпой в одном месте у него в офисе, а я где-то там, далеко в Украине

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

что значит православный метод использования нулов? У нулов один правильный метод использования, как и у числа 69 которое означает число 69.

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

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

я понимаю, но мне обычно приходится отвечать за других

да ну нафиг. Пусть каждый сам отвечает за себя и свои действия

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

ну так это и не повод держаться за проект с говноархитектурой, даже если нравятся и условия, и отношение заказчика к тебе :)

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

Ну что значит в тоталитарном порядке? Разработка ПО это всё же не художественное искусство, где старший по званию сказал типа «это говно, а конфетка» и баста. Всё же она основана на логике, а не только на субъективных ощущениях.
Если на проекте переживают за то, кто на кого обидится из-за принятия архитектурных решений, то что-то не то в управлении.

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

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

да ну нафиг. Пусть каждый сам отвечает за себя и свои действия

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

ну так это и не повод держаться за проект с говноархитектурой, даже если нравятся и условия, и отношение заказчика к тебе :)

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

Ну что значит в тоталитарном порядке? Разработка ПО это всё же не художественное искусство, где старший по званию сказал типа «это говно, а конфетка» и баста. Всё же она основана на логике, а не только на субъективных ощущениях. 

то и значит, сидишь ты такой, а к тебе заваливается соседняя тима и говорит: «Олег у нас херня с нулами! Расскажи как надо, а то мы тут нул используем чтобы отметить какие поля не надо заменять», ты им рассказываешь как надо и манагер говорит: «вот Олег умный пацан с серьезной репутацией у нас в компании, сказал так правильно, будем делать так, вон у него какой проект крутой и все хорошо» вот как-то так

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

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

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

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

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

то ты топишь за то, ..., то за то

это так кажется, на самом деле я даже собственное мнение не выражал, я говорю то что видел много раз

не отступать от правильных решений, то за то, что главное — это сплочённые работяги

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

а правильность архитектурных решений уже где-то там, сзади :)

архитектура с разработкой так себе пересекаются, как стратегия и тактика

Вот ещё ошибка в копилку. Вернее не столько ошибка, сколько «универсальность» вызывающая кучу проблем на сложніх и(или) нагруженніх системах: нежелание вводить раздельные модели для записи и чтения, пускай не сквозной CQRS вплоть до двух хранилищ, а тупо для read запросов использовать анемичные read-only модели (DTO :)) а не поднимать на каждый «F5» полный граф модельной области, со всеми UnitOfWork, IdentityMap и прочими умными словами, готовый (конечно, не бесплатно) отследить любое изменение и сохранить его.

нежелание вводить раздельные модели для записи и чтения

эээ, а почему Вам так показалось? :)
Я почти всегда ввожу раздельные модели и даже имею небольшой психологический дискомфорт, т.к. в названиях начинает фигурировать Form, что в бизнес-модели немного не кстати :)

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

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

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

Очень хорошая предпосылка для написания ещё одной заметки в общую копилку :) Ведь действительно, это довольно распространённая проблема, когда перемешивают read/write, а ведь с разделёнными read/write архитектура куда стройнее. Если будет время — напишу, спасибо за идею :)

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

А потом, если оба неймспейса «разрешены» (ох не люблю я это слово в данном контексте) то потом конфликт имён. А если разрешён один — то сходу и не скажешь, какой из них. Поэтому обычно добавляю View or Form в название

В целом я поддерживаю все пункты и плюсую статью.
Есть несколько своих замечаний
1. Использование объектов БД и DTO порождает очень много сложности и в большинстве случает превращаете в то, что вы одни данные перекладываете от БД ---> Entity —> DTO —> Model и обратно. В слоистой архитектуре это оправдано, при использовании скажем CQRS я бы этого избегал как с точки зрения производительности и экономии памяти, так и размазывание логики между различными объектов.

В слоистой архитектуре это оправдано, при использовании скажем CQRS

я имел ввиду именно слоистую архитектуру, завтра попрошу внести правки

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

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

Write модель: DTO на входе -> создаем доменные объекты, выполняем бизнес логику -> передаем доменный объект слою data access, который либо мапирует его на базу напрямую, либо мапирует на внутренние промежуточные классы, уже представляющие реальную структуру данных которые в свою очередь мапируются на базу (или даже несколько баз, если по каким либо причинам доменный объект представляющий единое целое с точки зрения доменной модели надо по частям хранить в разных базах).

Read модeль: На выходе сервиса DTO, представляющий собой проекцию, его либо мапим на базу напрямую, если мапинг простой, либо мапим внутри слоя data access опять же на внутренние классы соответствующие структуре базы, если мапинг сложный или надо собрать данные из нескольких баз. В этом случае, чаще всего доменная модель (в ее классическом понимании) не требуется.

Обратите внимание, что интерфейсы и их реализации лежат в одной сборке. Это тот случай, как делать не стоит!

It depends... К примеру вы пишите библиотеку, которую собираете в одну сборку и публикуете на nuget. В этом случае может быть оправдано размещение интерфейса и его *дефолтовой* реализации в одной сборке. Клиент библиотеки может использовать как дефолтовую реализацию, так и определить свою собственную, зарегистрировав ее при конфигурации библиотеки, или скажем в IoC контейнере.

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

В принципе Вы правы, просто часто, если мы не используем DDD, эти DTO объекты можно приравнять к объектам бизнес-логики. А так да, по сути DTO означает только объект для трансфера данных. С нижеописанными read/write моделями полностью согласен

It depends... К примеру вы пишите библиотеку, которую собираете в одну сборку и публикуете на nuget. В этом случае может быть оправдано размещение интерфейса и его *дефолтовой* реализации в одной сборке. Клиент библиотеки может использовать как дефолтовую реализацию, так и определить свою собственную, зарегистрировав ее при конфигурации библиотеки, или скажем в IoC контейнере.

по-моему, куда лучше разбить на 2 nuget packages, один из них будет что-то типа *.Core, второй *.Implementation.Default

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

Согласен.
Я думал, что это как бы и подразумевается, что интерфейс только «для внутреннего использования», то вопрос про раздельные сборки вообще встать не может, т.к. это алогично. И если я буду писать все «исключительные случаи», то это будет похоже на юридический документ не на 22-х, а на 222-х страницах.

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

Упомянутые промежуточные классы по сути являются local DTO, и если, например, внедрить предложенный ниже DBaaS, эти классы практически без усилий превращаются в обычные DTO. При этом бизнес-логика будет жить себе, как и раньше. Просто в случае с DBaaS транспортом будет, к примеру, JSON, а драйвер БД будет использовать свой формат — концептуально то же самое.

Да тут уже как угодно можно делать... Самое главное чтобы ISomeRepository объявлялся на уровне доменной модели и по контракту принимал и возвращал объекты доменной модели. А как там уже конкретная реализация репозитория напилена, это ее личные сложности. Может через ORM мапить на базу напрямую / через промежуточные «DB DTO», или в json серилиализировать и куда то дальше скармливать, или все вместе комбинировать. Главное что бы знания о структуре / механизмах хранения не вылазили наружу за пределы конкретной реализации репозитория. При измененении схемы хранения не должна поменяться доменная модель и не должен измениться IRepository. Подход очень удобен когда залетает что нибудь типа GDPR на вчера. Многие как мантру повторяют мол «зачем вам дополнительные абстракции в виде репозиториев, юзайте DBContext / IDbSet напрямую, вы хоть когда нибудь видели что бы ОРМ менялся». При этом забывая, что такая абстракция не только для смены ОРМ может понадобиться, а еще и для безболезненого изменения схемы хранения, которое не приведет к переделыванию половины бизнес логики.

Отдельный «клинический» случай когда не только DbContext напрямую используют, а еще и делают его лайфтайм наподобии per Application Request. А потом в разных местах по стеку вызовов начинают в него что то пихать. И когда доходит дело до сохранения изменений, одному лесшему известно кто, где и чем в него нагадил....

Шикарная статья, спасибо :)

Очень достойный материал, большое спасибо !

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

Спасибо за статью. Есть вопрос по поводу «10. Интерфейсы и их реализация». То есть вы в своих проектах всегда помещаете интерфейсы и реализации в разные сборки?

Если они (интерфейс и реализация) относятся к разным слоям — то да. В данном случае интерфейс лежит в бизнес-логике, реализации — каждая реализация в своей сборке. В таком случае мы можем легко и быстро заменить одну реализацию на другую, а также тестировать, не таща ненужные зависимости. Ну и с точки зрения инкапсуляции так логичнее.

Возьмем несколько другой пример(он будет из Java). У нас есть интерфейсы, которые обеспечивают доступ к данным(паттерн Repository). И есть их реализация на основе ORM(Hibernate). Должны ли интерфейсы и реализация быть в одной сборке?

Лучше в разных, тогда Вы можете не тащить реализацию на основе Hibernate, если хотите использовать какую-то другую реализацию либо протестировать только саму абстракцию.
На самом деле, это в принципе не столь значимо и каких-либо катастрофических последствий не понесёт. Но лучше избегать ненужных зависимостей (зависимость тянет за собой не только её саму, но и все связанные с ней зависимости, их нужно потом поддерживать, например обновлять версии и т.д. и т.п.)

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

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

и дальше мы тянем в юнит тесты этот Хибернейт? Зачем? Какие недостатки мы получим, разнеся по разным сборкам?
Ещё может быть ситуация, когда какой-то класс в другом проекте косвенно зависит от этого интерфейса, но не использует его (это уже правда проблема с архитектурой, но вдруг это уже есть и написано не Вами?), то тогда и туда потянется этот Хибернейт, хотя он там не используется

Одну сборку, а не несколько :) Если что, сборка для меня синоним пакета/модуля где-то в репозитории пакетов (пускай и приватном, чисто для одного проекта). Обычно усилия по разбиению на такие пакеты/модули (включая, но не ограничиваясь, выделение отдельного репозитория (если не монореп), создание отдельных пайплайнов и флоу в CI/CD автоматизации и т. п.) не оправдываются для приватных проектов с маловероятными использованиями в других проектах.

Просто обычно в проекте используется только одна реализация какого-то ключевого интерфейса(например, к доступ к данным). Вряд ли может быть такое, что проект использует и Hibernate, и JDBC для доступа к одному и тому же источнику данных

Да причём тут какая и сколько реализаций используется :)

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

public partial class UserRegistrationService : IUserRegistrationService
{

#region Ctor
public UserRegistrationService(UserSettings userSettings,
IUserService userService,
IEncryptionService encryptionService,
ILocalizationService localizationService,
IWorkContext workContext)
{
//...
}

Потом каждая из этих зависимостей ещё имеет 100500 зависимостей, итого я их все тяну в проект (это всего лишь ВебАПИ из того же общего проекта, которое я должен запустить отдельно от общего сайта), где мне нужно использовать только один метод из IUserRegistrationService. Понятное дело, что зависимости распределены неправильно, но это было бы только половина беды, но рядом с каждой из этих IUserService , IEncryptionService и прочими лежат их реализации, которые стучатся куда-то в БД, в какие-то облака и прочее, что мне нах не нужно в моём ВебАПИ проекте, но я вынужден тянуть все эти фреймворки конкретной реализации туда, где нужны тупо заглушки!

И, повторюсь уже не знаю в какой раз, эти же все фреймворки тянутся и в юнит-тесты

10 Tips for failing badly at Microservices by David Schmitz
www.youtube.com/watch?v=X0tjziAQfNQ

Щодо DTO та інших моделей .... ви, звісно, праві, що їх є сенс розділяти .... в сферичному проекті у вакуумі :)

Зі свого досвіду можу сказати, що не варто множити різних моделей дуже багато і є сенс використовувати те ж DTO до тієї міри, поки в нього не треба вносити суттєвих змін (якщо роль DTO — це data class, а не mega-orm-super-smart-proxy-object). В такому випадку на інших шарах логіки, нових моделей стає значно менше, ніж в БД.

Додам, що всупереч сказаному, в одному з андроїд додатку, який ми робили, були два повністю окремих паралельних набори моделей: для роботи з серверним API та для локальної БД. В теорії, потрібен був ще третій: для представлення сутностей в «самому додатку», але в 90% таких випадків з цим чудово впорались моделі локальної БД.

Хоч і в статті є до чого придертися (наприклад, коментар щодо Liskov substitution та switch не зовсім коректний). Але в цілому досить непогано описано досвід.

В деяких моментах, де хаотично перераховуються можливі проблеми, автору вдаєть передати той біль, який було пережито із-за тих чи інших рішень. Це буває корисно почитати.

Спасибо!

А что не так с моим замечанием про подстановку Лисков? Ведь везде и всюду любят говорить, что если у Вас получается case, то подумайте про LSP с подклассами, переопределяющими базовое поведение. Как по мне, оно в там случае и даром не нужно (точнее, с ним даже будет изврат) и чтобы отсечь упоминание о том, «как надо», я и добавил этот комментарий :)

Ну ... з вікіпедії принцип каже: if S is a subtype of T, then objects of type T may be replaced with objects of type S.

Тобто до використання switch це немає відношення.

З рештою вашого коментаря я згоден. Але ви згадуєте тут про гіпотетичну можливість отримати проблему, якої практично ніколи і не вивикне.

если у Вас получается case, то подумайте про LSP с подклассами

Якщо у вас по природі сутність є скінченним автоматом, то що тут зробиш? Switch (або краще if/else... хехе) — наше все. Ну ... або є шаблон state, коли обробка стану завелика для одного switch-а. Наврядчи хтось скаже, що зробити коректне наслідування для блоку класів зі шаблону state буде легше, ніж для одного класу зі switch-ом.

Доречі ... для тих, хто занадто полюбляє мудрити в архітектурі, є дуже гарний спосіб трохи «освіжити погляд»: спробувати навчитися програмувати на C (без плюсів) для мікроконтроллерів чи для ядра якоїсь системи. Після того як вдається вникнути в підходи, які працюють там, деякі архітектурні речі вже сприймаються по іншому.

Наврядчи хтось скаже, що зробити коректне наслідування для блоку класів зі шаблону state буде легше, ніж для одного класу зі switch-ом.

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

Я би сказав, що це класичний приклад порушення Open-Closed, а не Liskov.
В даному випадку, якщо додасться новий стан, то треба змінювати існуючий клас замість того, щоб дописати окремий.

Наскільки це порушення є критичним — інше питання

Я би сказав, що це класичний приклад порушення Open-Closed, а не Liskov.

Хм, Вы наверное правы, я как-то даже не задумался об этом. Мне сразу показалось, что Лисков тут не при чём, но многие почему-то, видя цепочку if-else, сразу начинают кричать о нарушении LSP, поэтому я решил перестраховаться, чтобы не слушать в комментариях про нарушение LSP, а по сути это да, махровое нарушение OCP :)

Спасибо!

перепутать OCP и LSP, предъявляя претензии другим архитекторам — это даа

Да что Вы говорите? Вы никогда ничего не путаете (особенно если это в данном конкретном случае десятистепенное и я не то, что не задумался, я даже толком не глянул, потому что это вообще не относится к делу)?
Блин, не укажешь на что-то — так придерутся к какой-то мелочи, которая не имеет никакого отношения к вопросу, скажешь — найдут в этой мелочи ошибку и возведут её в ранг первостепенной.

Поради може й гарні (читав про діагоналі), але стиль написання статті «я дартаньян а всі навколо не дають дупля в архітектуру» не викликає нічого окрім негативу. Я б не здивувався якби довідався що «наш сербський друг» теж написав статтю як український архітектор вирішив вчити його проектувати системи. У своїх аргументах ви так само рано чи пізно в кожному пункті скочуєтесь до «мій досвід показує що треба робити так». Ну а його досвід показував інакше, тому довіри вашим словам аж ніяк не більше.

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

как об этом любят тактично писать, после чего в проект, спустя пару лет активной разработки, уже невозможно внести даже мельчайшее изменение, не зацепив работоспособность в целом

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

И так происходит в большинстве случаем, когда надо вносить изменения через несколько лет& По современным меркам пару лет — это очень долго, ибо это где-то 50 итераций.

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

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

И так происходит в большинстве случаем, когда надо вносить изменения через несколько лет& По современным меркам пару лет — это очень долго, ибо это где-то 50 итераций.

50 итераций чего?

Но с перемешанными между слоями классами это будет наверняка.

Почему? Как выбор документ-ориентированой БЛ вместо РСУБД или отказ от разбивания кода на несколько слоев влияет на правильность модели? По второму пункту вообще не понятно почему более простой подход должен породить архитектуру с большим количеством абстракций.

нарушение принципов SOLID (прежде всего, SRP), это не «альтернативный подход», это говнопроектирование как оно есть, со всеми вытекающими последствиями.

ActiveRecord — говнопроектирование (бизнес-модель, работа с БД и иногда ДТО для веб), но при этом есть куча случаев когда его достаточно.

50 итераций чего?

Разработки, внесения правок.

Почему? Как выбор документ-ориентированой БЛ вместо РСУБД или отказ от разбивания кода на несколько слоев влияет на правильность модели? По второму пункту вообще не понятно почему более простой подход должен породить архитектуру с большим количеством абстракций.

блин, я уже устал об одном и том же. Если Вы выбираете совсем другой подход, нежели «классическая» архитектура со слоями — то его и нужно следовать, со всеми его особенностями. В данном конкретном случае мы говорим об архитектуре со слоями. Если так — то проброс реляционных моделей до уровня представления нарушает её. Это по сути и есть ActiveRecord , о котором Вы упомянули ниже и он вполне успешно нарушает SRP

ActiveRecord — говнопроектирование (бизнес-модель, работа с БД и иногда ДТО для веб), но при этом есть куча случаев когда его достаточно.

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

ІМХО не відчув такого стилю, можливо Ви накрутили себе або впізнали у тому «сербському другові» ;)

Резюме из пункта 11 относится к пункту 10.

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

Если не использовать sql базу то не будет проблем с ORM
Если не писать монолит приложение то не нужны слои

Некоторые советы хорошие, но они подразумевают что «Архитектура» — это SQL база, ORM, три слоя кода. Это в 2019 году я бы сказал устаревший подход.

Микросервисы на nosql хранилищах в клауде for the win

Некоторые советы хорошие, но они подразумевают что «Архитектура» — это SQL база, ORM, три слоя кода. Это в 2019 году я бы сказал устаревший подход.

Микросервисы на nosql хранилищах в клауде for the win

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

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

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

Но это не отменяет того, что фраза

Это в 2019 году я бы сказал устаревший подход.

все-таки звучит странно. Скорее «В 2019-м уже есть достаточно эффективных альтернативных подходов».

"энтерпрайз архитекторы"

100%

Не потому универсальный, а потому что:

нет, он как раз универсальный, с помощью него можно эффективно решить большее количество типовых задач, нежели с помощью какого-либо другого :)
А так да, согласен с тем, что его популярность, отчасти, обусловлена костностью мышления

Судя по тому сколько комментов — мой троллинг удался =)
Если серьезно — пишите еще. На доу нужно больше технического контента всякого разного. Я думаю у вас есть опыт в разных вещах о которых стоит писать. Меня просто задел заголовок статьи и я откомментил что есть другая «архитектура». Хотя я понимаю что сиквел с монолитом тоже иногда нужны. Не поминайте лихом ;)

Микросервисы на nosql хранилищах в клауде for the win

Угу, а потом после cool kids на проекте в 30к LOC борись с data redundancy, транзакционностью, дикими миграциями и, конечно же, с некорректно определенными service boundary, которые никто не рефакторил, а требования менялись адово.

транзакционностью, дикими миграциями

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

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

Тю, піднімаєте класику СУБД та читаєте як організувати транзакції, повторюєте у власному коді.

И миграции оттуда же, когда обновление базы микросервиса приводит к каскадной миграции по другим сервисам.

Винесіть бази за DBaaS middleware та не мудруйте.

Пане, непагано б було читати всю гілку дискусії, а не лише останній комент, перед тим, як писати віповідь, нє?

ВІдповідно, збс вам допоможе все це, коли service boundary невідповідні. Особливо розважила пропозиція повторювати транзакції на рівні декількох мікросервісів.

Цьомки.

Тю, піднімаєте класику СУБД та читаєте як організувати транзакції, повторюєте у власному коді.

Удачи с реализацией распределенных транзакций :)

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

С учетом граничных условий

сервис баундари просрали и спасибо не сказали.

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

це задача, яку принципово неможливо виконати

Реализуема, но противоречит самой сути микросервисов, не так ли? И таки лютый геморрой. Но таки встречается именно из-за того, что хипстеры и пионеры пхают, пхают и еще раз пхают их куда ни попадя с криками, подобными тем, что меня стриггерили:

Некоторые советы хорошие, но они подразумевают что «Архитектура» — это SQL база, ORM, три слоя кода. Это в 2019 году я бы сказал устаревший подход.

Микросервисы на nosql хранилищах в клауде for the win

dou.ua/...​ign=reply-comment#1510132

Реализуема, но противоречит самой сути микросервисов, не так ли?

Не впевнений. Транзакції будуть вимагати від сервісів працювати за певним протоколом. Він не змінює сутність мікросервіса, просто накладає певні обмеження.

И таки лютый геморрой.

З чим?

en.m.wikipedia.org/wiki/CAP_theorem

Ошибочность теоремы, доказана одним постом на доу? ;)

Note that consistency as defined in the CAP theorem is quite different from the consistency guaranteed in ACID database transactions[4].

Либо транзакции, либо микросервисы — Вы определитесь. А если у Вас возникают какие-то «каскадные миграции» — Вы не умеете делать микросервисы.

Распределённые транзакции совместимы с MSA.

[Вырезана саркастическая шутка про разные странные вещи, которые в общем совместимы друг с другом]

Да, Вы правы — совместимы. Но зачем? Я могу себе представить требования к продукту, которые приводят к реализации транзакций на микросервисах,.. но во-первых, в большинстве случаев, по моему опыту, эти требования удавалось переформулировать, а во-вторых, оставшийся процент — это настолько редкие и специфические случаи, что я бы вернулся к вопросу — а действительно ли верно выбран архитектурный подход?

А мы о транзакциях в принципе или о распределённых?

мы о транзакциях в принципе или о распределённых?
Распределённые транзакции совместимы с MSA.

Да, мы о распределённых транзакциях.

Простой пример распределенной транзакции: сохранение файла на диск и запись каких-то метаданных о нём в базу.

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

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

Ну простейший пример, мы пишем файл куда-то в облако и потом заносим запись об этом файле в базу. Можете придумать надёжное и простое решение, что если запись в БД не была сделана — то удалить файл? Мне приходит в голову только через внешнего наблюдателя, который гарантированно всегда работает и не зависит от обоих. Но при этом его нужно научить опрашивать в нашем случае базу на наличие записи (потому что если база не уведомила его об успешности операции — это ещё ни о чём не говорит, вдруг она внесла запись и дальше что-то пошло не так), тут сразу возникает вопрос через сколько считать неуспешным и что будет, если вдруг внесётся после этого срока. И это будет только частное и довольно сложное решение.

по-моему, это очень распространённый кейс

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

Как именно оно будет реализовано — отдельный вопрос. Мне кажется, можно придумать массу вариантов. Если запись в БД — самый стрёмный момент, поменяйте действия местами, и пишите сначала туда, а потом загружайте файл. Или разбейте это на три шага — оформление «запроса на upload», сам upload и формирование сущности «мета + файл». Вобщем. сделайте три микросервиса вместо одного, сделайте два раунда запрос/ответ на клиенте вместо одного, и всё равно это будет лучше (= надёжнее и дешевле), чем транзакции.

Тут дело в чём, на вопрос «а точно мы не можем допустить наличие файла без записи или записи без файла» они 99% скажут «конечно не можем». То есть формально требования постоянной консистентности у бизнеса есть. Чтобы они его убрали нужно или манипуляционные вопросы готовить, или несколько вариантов решения с ТЭО по каждому.

Умение правильно формулировать вопрос для того, чтоб получить точный ответ — это один из основных навыков архитектора. И я бы не называл это манипуляцией.

несколько вариантов решения с ТЭО по каждому

Так и я об этом же. Сделайте сравнение ТЭО с транзакцией и без один раз — и этот вопрос больше вообще никогда не будет подниматься.

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

как я могу это перепроектировать?

Если запись в БД — самый стрёмный момент, поменяйте действия местами, и пишите сначала туда, а потом загружайте файл. Или разбейте это на три шага — оформление «запроса на upload», сам upload и формирование сущности «мета + файл». Вобщем. сделайте три микросервиса вместо одного, сделайте два раунда запрос/ответ на клиенте вместо одного, и всё равно это будет лучше (= надёжнее и дешевле), чем транзакции.

что даст смена местами?
Я могу разбить на 3 шага, могу переделать ещё как-то, но в любом случае это будет целая подсистема, со своими потенциальными проблемами и т.д.

как я могу это перепроектировать?

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

это будет целая подсистема
и всё равно это будет лучше (= надёжнее и дешевле), чем транзакции.
Например, как я уже написал, Вы можете разбить операцию по загрузке файла и метаданных на два шага, и попросить клиента две операции.

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

Мне нужен один, как единая и неделимая транзакция

Если бизнесу это не надо, то зачем оно Вам?

Я Вам о том, что реализовать без подсистемы легко и просто невозможно

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

Можете придумать надёжное и простое решение, что если запись в БД не была сделана — то удалить файл?

Можем. Писать файл непосредственно в БД (если БД поддерживает, конечно). Плюсы такого подхода: полная транзакционность. Минусы тоже очевидны — распухание БД, бекапов, времени восстановления после сбоя и т.д.
Как только у нас задействуется два и более источника для сохранения данных, которые (источники) никак не связаны друг с другом и не имеют интерфейсов для работы друг с другом, мы в любом случае рано или поздно получим конфликт в данных, который нужно разруливать в рамках «стороннего наблюдателя». У нас в компании на такие случаи существуют мониторинги, которые фиксируют такие случаи и помещают их на ручную обработку. Человек потом должен проанализировать, есть ли проблемы, и пофиксить их. Причем удаление чего-либо автоматически не очень хорошее решение: рано или поздно будет удалено совсем не то, что ожидалось. И проблема некорректной записи чего-либо усугубится некорректным удалением чего-попало...

Ну это уже совсем другая задача, писать файл в БД, Вы же понимаете. Мало ли почему нужно писать в облако, может, кто-то ещё использует эти файлы...
Хорошо, есть просто внешний сервис и нам нужно фиксировать, что бы сделали к нему запрос с определёнными параметрами.

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

так и я о том же

А що не так з WAL, RCU, Sagas, etc.
Нема «нормального» рішення, є рішення з обумовленими вимогами та обмеженнями до atomicity, consistency, linearizability, isolation

Либо транзакции, либо микросервисы — Вы определитесь.

А вы, как я посмотрю, принципиально не читаете ветку, а только отвечаете на последний коммент, да? В принципе, настолько узкий скоуп внимания характерен, скорее, для рыбки, а не для техлида, поэтому я верю, что вы перечитаете дискуссию, чтобы осознать ошибочность своих выводов.

А вы, как я посмотрю, принципиально не читаете ветку, а только отвечаете на последний коммент, да?

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

Машу хвостом — хорошего Вам дня.
Поплыл делать свои ошибочные микросервисы без всего этого гемора.
Бульк.

Если не использовать sql базу то не будет проблем с ORM

ахахахаха
...

фух ;’)

Аж трясе. Ви самі багато писали мікросервісів на nosql сховищах в клауді?

А что с ними не так?

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

Но, будем честны, часто ли встречаются такие кейсы, а не когда взяли пионеры микросервисы на nosql, т.к. это модно и спасет мир (а монолиты и реляционки is so not 2019), а потом еще и джойны на nosql начали фигачить?

Да фиг с ними джоинами, когда далеко не всякий nosql аггрегацию внятно умеет

Да фиг с ними джоинами, когда далеко не всякий nosql аггрегацию внятно умеет

А я вот возьму и плюсану.

Если человек не может сагрегировать данные без sql, а денормализацию без join сделать, то это проблема не микросервиса или nosql.

ну круто да, тащить по сети данные для SUM или AVG, когда всякие column store обещают неимоверный перформанс и как бы вроде и затачивались для быстрых колоночных операций без необходимости чтения всей строки

ну круто да, тащить по сети данные для SUM или AVG,

считайте при записи и не тащите.

Прошел Column store на своем опыте, ничего особенного. Выпили супер дорогой(360 ядер) new sql in-memory кластeр для чтения помирающий постоянно и заменили десятком дешевых вм в облаке с кодом на C# и in-memory distributed key-value cache в том же C#, что держит нагрузку в 5 раз больше.

На личном опыте — заменял in-memmory column store 360 ядерный кластер с 16 TB RAM и загрузкой CPU под 60% постоянной — на 5 VMs 4х ядерных с 16 RAM с C# кодом — in-memory distributed key-value

И это вы говорите, что нет проблем?)
кликхаус, например, может — но там есть другие неприятные ограничения, типа write-only ориентированности. Многие не могут этого в принципе.

а то так я и врукопашную могу

Мерять зрелость no sql технологий возможностями РСУБД апи абсолютно бессмысленно. Любая no-sql технология решающая свою задачу хорошо всегда будет уметь больше в своей области по сравнению с РСУБД.

для column store — это естественная задача

Поэтому она и кверит аналитку лучше чем row-store.

как оказалось, не всегда и не везде)

насколько я знаю кверит лучше row-store любой, но утверждать однозначно не буду. Я сперва не так понял — думал вы противоставляете SQL + Column store технологиям NoSql.

Я сперва не так понял — думал вы противоставляете SQL + Column store технологиям NoSql.

не, я просто хочу нужную мне функциональность с минимумом телодвижений с моей стороны. А нужно GROUP BY по геохэшу и AVG по гео-точкам пошустрее с учетом WHERE a = b AND c = d и т.п. без транзакций, джоинов, но на мутабельных данных, иначе я бы просто прекалькулировал бы скажем раз в сутки и не парился. MySQL пока справляется, но скоро ему станет плохо

ElasticSearch вроде умеет, но пока руки не дошли пощупать поплотнее

Если инженегр не умеет в дизайн то никакой транзакцонный монолит не спасет 🙂.

Те, кто написали wordpress, с вами не согласятся)

Если не писать монолит приложение то не нужны слои

для тех людей, которым не нужны слои в микросервисах, так же не нужны слои и в монолите, инфа сотка

Слои никому не нужны. Нужны порты. И адаптеры.

Для Mongo нужен ODM, разве что какие-то объектные базы типа Orient не потребуют маппинга и то не факт. Микросервисам тоже, как ни странно, нужна архитектура, и у луковичного подхода особых недостатков нет, прямо вот несовместимых с микросервисами нет.

Для Mongo нужен ODM

Вы не шарите. Правильные пацаны говорят, что монга и иже с нею ртебуют, чтобы вы проектировали базу под задачи гуя и на каждый чих ее перепроектировали. А ваши подход is so not 2019, что их аж трисет.

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

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

Жаль конечно что реляционное представление приравнивается к бизнес логике.

это «перемешивание» слоя базы данных и классов слоя бизнес-логики, которые отвечают за передачу данных

Есть ещё VIEW’шки и сам MSSQL умеет в JSON, но довольно посредственно.
Про MATERIALIZED VIEWS история умалчивает, так как кроме Oracle18 их ни одна СУБД нормально не умеет.

Вам не нужен DTO что бы отдать JSON c вьюшки.
Вам не нужны аннотации классов для валидации сущностей если у вас созданы домены www.postgresql.org/...​ent/sql-createdomain.html
... список можно продолжить

Надо знать SQL, а ORM’ы такое мапить не умеют от слова Вообще.

Контролируемую денормализацию и более-менее жизнеспособные в дизайне схем СУБД обычно обходят стороной, из-за лени и/или невежества...

Мир на .net’e не сошёлся, и к сожалению .net во многом отстаёт.
Не хотите много СRUD boilerplate’a — пробуйте PostgreSQL под Node.js или Golang’ом, есть большая вероятность что объёмы кода будут гораздо меньше.

По крайней мере по функционалу у MSSQL сейчас есть очень много пробелов.

Очень советую почитать Фонтана что бы лучше понить сабж.
tapoueh.org/...​ion-development-launches

Вам не нужен DTO что бы отдать JSON c вьюшки.

А завтра мне нужен не json, а что-то другое и вся стройная картина мира завалилась. Ах, ну да, можно парсить json.

есть большая вероятность что объёмы кода будут гораздо меньше.

А где-то стояла цель уменшить именно объём кода?

ЗЫ. Ваш подход имеет право на жизнь и в некоторых случаях предпочтительнее. Но вот этот подход с «прямым пробросом из низов на самый верх», как показывает практика, очень плохо тестируемый, сопровождаемый и т.д. Что будете делать, если для сущности часть данных нужно выгребсти из БД, а часть из внешнего источника? Где вся эта логика будет? И чем ваши вьюхи по сути отличаются от запросов LinqToEntities?Я их с таким же успехом могу писать в слое представления, минуя слой бизнес-логики.

имеет право на жизнь

Так работает Skype и Instagram (конкретно на PostgreSQL’e)

А завтра мне нужен не json

Ничего не мешает использовать любой другой сериализатор.

уменшить именно объём кода

Меньше кода — проще поддерживать и тестировать решение.

очень плохо тестируемый, сопровождаемый

Вот как раз с тестами и сопровождением проблем не возникает.

а часть из внешнего источника?

Можно завести для этого Foreign Data Wrapper, например.

по сути отличаются от запросов LinqToEntities

Тем что в них можно конктролировать уровни изоляции транзакций и консистентность хранилища в случае с масштабированием базы на чтение/запись.

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

Опять же будет много boilerplate кода, а также overhead на коммуникацию между бэкендом и базой.

Серьёзно, — почитайте Фонтана.

Так работает Skype и Instagram (конкретно на PostgreSQL’e)

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

Ничего не мешает использовать любой другой сериализатор.

зачем Вы о нём тогда вспомнили?

Меньше кода — проще поддерживать и тестировать решение.

Увы, нет. Мой опыт говорит о том, что мало кода — легко поддерживать маленькие проекты, много кода — легко добавлять новые фичи в проект-гигант

Вот как раз с тестами и сопровождением проблем не возникает.

да, там где нет абстрактных слоёв?

Можно завести для этого Foreign Data Wrapper, например.

можно, это и будет именно тот слой, который Вы хотите «перепрыгнуть»

Тем что в них можно конктролировать уровни изоляции транзакций и консистентность хранилища в случае с масштабированием базы на чтение/запись.

у Вас мухи с котлетами перепутаны :)

Опять же будет много boilerplate кода, а также overhead на коммуникацию между бэкендом и базой.

у Вас цель именно избежать этого boilerplate кода при CRUDах.

Серьёзно, — почитайте Фонтана.

Если я правильно понял, то этому подходу 100 лет в обед и ещё на SQL 2005-м я его использовал в проекте, где делался уклон на вьюхи как источник данных, перебрасываемых прямо в представление

Эх... украинское IT — та ещё помойка.

это говорит тот, который писал, что:

Контролируемую денормализацию и более-менее жизнеспособные в дизайне схем СУБД обычно обходят стороной, из-за лени и/или невежества...

Мир на .net’e не сошёлся, и к сожалению .net во многом отстаёт.

Ну Вы же понятия не имеете о чём вообще речь... и нет ни малейшего желания понять — сплошные суждения и переход на личности...

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

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

А у человека, который говорит, что .NET во многом отстаёт в этой области, есть какие-то ещё мнения, кроме этих двух? :)

А что делать в ситуации когда пришел на проект, а там просто идеальная архитектура?

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

Ничего абсолютно идеального не бывает — вопрос перспективы, приоритетов и мотивации.

... А замовник помер та залишив спадок на підтримання проекту без будь-яких функціональних змін у майбутньому.

он... добавил эти дублирующие поля для сущности Driver, несмотря на то, что сущности Driver и User находились в зависимости 0..1-1 друг к другу. То есть для каждой сущности Driver обязательно имела место быть сущность User и никакие мои аргументы, почему это делать нельзя и какие последствия это влечёт, не имели успеха

Это может быть ошибкой в дизайне, а может и не быть — зависит от логики приложения. Скажем, на имя юзера выбранное при регистрации всем вообще плевать слюной (и это имя может быть изменено юзером в любое время), а имя драйвера должно строго соответствовать тому, что указано в документах (и может быть изменено только после верификации сканов документов). Или представь себе сферическую в вакууме платформу паблишинга (типа Leanpub но с уклоном в художественную литературу) где авторы публикуются под псевдонимом, но выплаты априори должны быть адресованы физлицу с реальным именем (то есть грубо говоря author name и business name могут отличаться)...

Если бы это был именно такой случай (допустимы разные значения для пользователя как пользователя и пользователя как драйвера) — я бы этот вопрос и не поднимал. Если я его поднял — значит бизнес-логика позволяет одно и только одно значение :)

По п.3 — ENUM — абсолютно згідний. За використання енумів в БД треба висмикувать ноги і тими ногами бить по голові. Одного енума може вистачить, аби всі дані втратили консистентність після дрібненької непомітної зміни в структурі. Давно вже розписано, чому енуми в БД є абсолютне рафіноване зло: komlenic.com/...​s-enum-data-type-is-evil

По п.8 — про зберігання обчислюваних величин в БД — це чиста дихотомія, яка сильно залежить від характеру як самих даних, так і від характеру запитів до них. Інколи правильно НЕ зберігати те, що можна порахувать, а інколи навпаки — правильно саме зберігати проміжні розрахункові дані, окрім первинних даних.
Найпростіший приклад — це величина пробігу автомобіля. Ми з даних ЖПС отримуємо поточні координати корита і зберігаємо ці первинні дані. А що робити, коли в нас запит вимагає пройденої дистанції між двома точками? Можна, звичайно, одним запитом робить SUM() геодезичних відстаней між послідовним набором точок. Але це дуже повільно і дуже сумно.

Найпростіший спосіб — це одразу при отриманні поточних координат вираховувать відстань від попередньої точки та роллапить показник одометра, який і зберігати там же, в таблиці первинних даних. Якщо комусь закортить дізнатися відстань, пройдену між двома точками, досить лише відняти початкове значення одометра від кінцевого. Швидкість виконання запиту збільшується на 4-5 порядків.

Тому не можна казати, що зберігати розрахункові дані неправильно або небажано. В деяких задачах це і правильно, і бажано, і необхідно. Треба лише вміти відрізняти такі задачі від інших.

По п.8 — про зберігання обчислюваних величин в БД — це чиста дихотомія, яка сильно залежить від характеру як самих даних, так і від характеру запитів до них. Інколи правильно НЕ зберігати те, що можна порахувать, а інколи навпаки — правильно саме зберігати проміжні розрахункові дані, окрім первинних даних.

именно об этом я и пишу. Без необходимости не стоит хранить то, что можно посчитать, при необходимости — храним.

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

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

ПС: резюме п. 10 переползло в п. 11

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

А можна трошки розкрити тему? Так би мовити, для узгодження дефініцій?

Отличный вопрос. Второй день думаю как сформулировать лаконичный и точный ответ, не написав при этом ещё одну статью ). Далее — имхо.

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

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

Тезисно, кажется, как-то так.

Скажем даже так — они другие! Реляционная модель и доменная модель — разные модели. И скольким архитекторам я пытался объяснить это...

Если «доменную модель» писал БА, толком ничего кроме SQL не знающий, то она будет вполне реляционной. Проверено практикой. На базу ложится идеально, маппится на объекты тоже более-менее нормально, а проблемы лежат где-то на уровне «а как с этим внутри приложения работать?»

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

Большинство проблем с архитектурой не технические, и упераются в умение архитектора правильно оценить трейдоффы и понять задачу которую необходимо решить.

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

Вы уверены что таблица позволит добавлять штаты __из других стран__ без изменения кода? Если ответ да, то скорее всего вам хватит и энума, поскольку «штат» будет не сущностью. а типом «домен» (или как-то так). И тут задача архитектора не построить наиболее гибкую архитектуру, а понять насколько гибкую архитектуру нужно строить.

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

2 точки входа — это проблема. Дублирование полей — это не проблема, если ваша СУБД, поддерживает транзакции.

Очень часто архитекторы грешат «усложнением задачи»:
— для самореализации (если делаем инет магазин, то минимум размаха амазон);
— защищаются от проблем которые былы на прошлых проектах, не пытась понять с какой вероятностью такие проблемы могут повторится в текущем проекте;
— иногда просто по причине отсутсвия всех знаний о задаче: заказчик забыл что-то сказать, БА забыл чего-то описать, сам архитектор забыл чего-то спросить.

P.S. Статья пока ок, пойду дочитывать.

Дублирование полей — это не проблема, если ваша СУБД, поддерживает транзакции.

Завжди. Все, що може поламатися — поламається. Питання лише — коли.

Завжди. Все, що може поламатися — поламається. Питання лише — коли.

Угу, например запросы по нескольким таблицам могут поломаться, так же как и инсерты в несколько таблиц :)

Ви знаєте, навіщо випускають RAM з ECC? Екстраполюйте це знання на випадок достатньо репрезентативної вибірки інсертів в декілька таблиць.

Ви знаєте, навіщо випускають RAM з ECC? Екстраполюйте це знання на випадок достатньо репрезентативної вибірки інсертів в декілька таблиць.

Спасибо за демонстрацию, той проблемы, которую я описал в начале ветки :)

А хіба я кажу щось всупереч вашому твердженню про трейд-офи?

Я лише кажу, що помилки неминучі, і або ми заздалегідь готуємося до неминучого факапу, або з міркувань економії плюємо на надійність і погоджуємося жертвувати даними by design. Інколи така стратегія цілком прийнятна. Але в будь-якому випадку слід пам’ятати: все, що може поламатися — поламається. І добре, якщо впродовж гарантійного терміну поламається щось несуттєве, чим ви готові пожертвувати.

Між іншим, помилки RAM, спричинені іонізуючим випроміненням аж надто часті, аби ними тотально нехтувати. Подеколи RAM не тільки доточують ЕСС, але й будують з RAM рейд-масиви з кворумами, журналюванням та BBU. Аж настільки люди цінують констистентність своїх даних.

Я лише кажу, що помилки неминучі, і або ми заздалегідь готуємося до неминучого факапу, або з міркувань економії плюємо на надійність і погоджуємося жертвувати даними by design

Первая проблема в слове «неминучого». Трейдоффы в архитектуре не про забывание на проблему, а про способность оценить вероятность появления проблемы.
Вторая проблема в формулировке «плюємо на надійність і погоджуємося жертвувати даними». В большинстве систем мы не можем жертвовать данные, но при этом мы можем взять риски недоступности/не актуальности данных какое-то время.

Про САР я й сам можу довго розповідать :)

Вы уверены что таблица позволит добавлять штаты __из других стран__ без изменения кода? Если ответ да, то скорее всего вам хватит и энума, поскольку «штат» будет не сущностью. а типом «домен» (или как-то так). И тут задача архитектора не построить наиболее гибкую архитектуру, а понять насколько гибкую архитектуру нужно строить.

Ничего не понял :)
Если штаты «равнозначны» для приложения, то, на мой взгляд, нужно использовать «сущность», а не энум. Если же штат Алабама требует одной логики в коде, а Кентукки другой — тогда следует посмотреть на энумы. Если приложению пофиг, какой штат и оно со вмеми штатами работает одинаково, хоть штатами США, хоть Мексики, то при необходимости добавить Мексиканские штаты изменения коснуться только слоя БД, на приложении это никак не отразится, для него всё также будет ИД штата и все те характеристики, которые подтягиваются.

2 точки входа — это проблема. Дублирование полей — это не проблема, если ваша СУБД, поддерживает транзакции.

Даже если СУБД поддерживает транзакции — это всё равно дублирование полей. Это означает, что завтра Вы (или другой девелопер) забудете о том, что обновляя поле А, нужно обновлять поле Б и в результате получите повреждённые данные. Архитектура должна быть простой и ясной настолько, насколько возможно. Чтобы сделать неправильные действия или повредить данные нужно было ещё постараться, а не наоборот, нужно предпринять дополнительные действия, чтобы их не повредить

Очень часто архитекторы грешат «усложнением задачи»:
— для самореализации (если делаем инет магазин, то минимум размаха амазон);
— защищаются от проблем которые былы на прошлых проектах, не пытась понять с какой вероятностью такие проблемы могут повторится в текущем проекте;
— иногда просто по причине отсутсвия всех знаний о задаче: заказчик забыл что-то сказать, БА забыл чего-то описать, сам архитектор забыл чего-то спросить.

я даже добавлю, что также часто неправильно разграничивают ответственность и связи между частями, делая сильную связанность там, где нужна слабая и наоборот. Уже ещё один пункт просится :)

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

«Сущность» нужно использовать, когда вы работаете с ... сущностью. Если «Штат» — это (условно статический) набор неизменяемых полей (например, код и полное имя), то почему он не может быть доменом? Если же у нас у штата есть, например, максимальная разрешенная скорость, столица и еще 100500 параметров которые нужны БЛ, то надо делать сущность.

оно со вмеми штатами работает одинаково, хоть штатами США, хоть Мексики, то при необходимости добавить Мексиканские штаты изменения коснуться только слоя БД, на приложении это никак не отразится

И конечно же нам не прийдется менять код чтобы поддержать наличие страны, отличие условных обозначений штатов в Мексике и США. А если будем добавлять Украину с Киевом, Крымом и Севастополем (2 города и 1 АР)? А если какую-то Великоблитанию с териториями?

Если же вы знаете что делаете ПО для США и штатов там ограниченое количество и они очень вряд ли будут как-то менятся, то зачем увеличивать сложность кода, добавлять затраты на тестирование (вы уверены что система будет нормально работать, когда появится новый штат или пропадет существующий), усложнять разработку (поскольку девелоперам теперь надо БД с какими-то дефолтными данными)?

«Сущность» нужно использовать, когда вы работаете с ... сущностью. Если «Штат» — это (условно статический) набор неизменяемых полей (например, код и полное имя), то почему он не может быть доменом? Если же у нас у штата есть, например, максимальная разрешенная скорость, столица и еще 100500 параметров которые нужны БЛ, то надо делать сущность.

Где я писал, что он не может быть доменом? Я как раз говорю о том, что в большинстве случаев и должен быть доменом :)

И конечно же нам не прийдется менять код чтобы поддержать наличие страны, отличие условных обозначений штатов в Мексике и США. А если будем добавлять Украину с Киевом, Крымом и Севастополем (2 города и 1 АР)? А если какую-то Великоблитанию с териториями?

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

Если же вы знаете что делаете ПО для США и штатов там ограниченое количество и они очень вряд ли будут как-то менятся, то зачем увеличивать сложность кода, добавлять затраты на тестирование (вы уверены что система будет нормально работать, когда появится новый штат или пропадет существующий), усложнять разработку (поскольку девелоперам теперь надо БД с какими-то дефолтными данными)?

Как в таком случае Вы предлагаете делать?
Кстати, меняться они вполне могут, для нас! Если штат «закрыт», в нём бизнес не ведётся — вполне может быть удалён. И, аналогично, добавлен.

Где я писал, что он не может быть доменом? Я как раз говорю о том, что в большинстве случаев и должен быть доменом :)

Ок, возможноя не правильно запомнил из универа что такое домен. Отображение энума на поле в БД (строку или инт) — вот это я назвал доменом.

Кстати, меняться они вполне могут, для нас! Если штат «закрыт», в нём бизнес не ведётся — вполне может быть удалён. И, аналогично, добавлен.

Тогда не понятно, как ваш коллега смог заменить его энумом. Банально должны были упасть тесты создающие новою сущность, ибо ее не будет в энуме.

Отображение энума на поле в БД (строку или инт) — вот это я назвал доменом.

Это имеет смысл, если нам нужно:
1) иметь разную логику для разных значений
2) иметь какие-то дополнительные данные, например максимально допустимую скорость, которые мы храним в БД.

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

Я стараюсь выносить в enum и записывать в таблицу не полные значения типа имени или код штата, а подтипы, имена стратегий, которые влияют на поведение и недоступны пользователю для изменения. Ну, например, штаты с ограничением скорости и штаты без ограничения. Пользователь может добавить или удалить штат, изменить его тип, то есть изменить есть или нет ограничение скорости в штате, но не может добавить новый тип через UI.

Чтоб минимизировать риски несогласованных с кодом изменений «ручками» в базе, использовать и enum тип БД — тут не просто права на UPDATE/INSERT должны быть, но и на изменение схемы, которые обычно пользователю базы, от которого работает приложение не нужны, только пользователю от которого исполняются миграции.

Тогда не понятно, как ваш коллега смог заменить его энумом. Банально должны были упасть тесты создающие новою сущность, ибо ее не будет в энуме.

как-как, переписал со всем куском :)

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

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

не понял, по-моему, нужно наоборот :)

не понял, в смысле наоборот? Сущностью делать набор связанных данных, которые никогда не меняются? Или сущностью делать набор несвязанных данных? :)

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

Хочу дополнить по пункту 10. Объединение интерфейсов и их реализаций в одной сборке является анти-шалоном Entourage. Шаблон Stairway говорит о разделении интерфейсов и реализации на отдельные сборки. Детальнее об этом можно прочитать в книге «Adaptive Code via C#: Agile coding with design patterns and SOLID principles».

Хочу дополнить по пункту 10. Объединение интерфейсов и их реализаций в одной сборке является анти-шалоном Entourage. Шаблон Stairway говорит о разделении интерфейсов и реализации на отдельные сборки. Детальнее об этом можно прочитать в книге «Adaptive Code via C#: Agile coding with design patterns and SOLID principles».

как-то даже не слышал об этих паттернах, но было бы странно, если бы их ещё не обозвали :)
А за книжку спасибо, как-то она у меня выпала из поля зрения, даже не натыкался на неё, надо почитать. Хотя название её очень созвучно с книжкой дядюшки Боба :)

Спасибо, Олег! Хорошая статья

Не очень понял первый раздел. Вот есть у меня таблица (или несколько, одна из них основная, остальные связанные), есть класс бизнес-сущности, которая по набору данных плюс-минус соответствует этой таблице и есть механизм двустороннего маппинга таблицы на сущность и обратно (универсальная декларативная ORM типа Hibernate или наколенное императивное поделие, формирующее SQL из сущностей или сущности из SQL). Где тут место DTO?

Изменится таблица(ы) — изменим в одном месте маппинг. Изменится хранилище (c MS SQL перейдём на Postgre) — изменим (в идеале немного) механизм формирования SQL. Сильно изменится хранилище (перейдём на Mongo или вынесем в сервис с HTTP JSON API) — сильно изменим маппинг, вместо ORM будет ODM. Слой бизнес-логики, в котором лежат сущности, у нас связан с хранилищем через механизм ORM, куда и зачем тут вводить слой DTO? Делать примитивный 1:1 маппинг таблицы на DTO, а потом маппинг DTO на сущности? И в случае изменения таблицы менять два маппинга?

Это на тот случай, если, например, Deutsche Bank вдруг решит переехать с Oracle на MSSQL. В случае использования DTO достаточно будет в конфиге connection string заменить. Очевидно же.

Я про этот случай написал

изменится хранилище (c MS SQL перейдём на Postgre) — изменим (в идеале немного) механизм формирования SQL.

В идеале, да, только изменив параметры подключения, а умный Hibernate изменит диалект SQL и т. п. Зачем тут DTO? Какая разница маппить базу на сущности напрямую или сначала их маппить на DTO, а потом DTO на сущности?

В общем-то это сарказм был :)
Просто я эту мантру о простоте переезда между БД благодаря DTO уже лет 15 периодически слышу. Так не бывает. Как и универсальных рецептов, что и где использовать.
Я думаю, что DTO — уместный архитектурный инструмент для гетерогенных сред, где нужно унифицировать и атомизировать единицу данных. Всё остальное — это «любая архитектурная проблема решается дополнительным слоем абстракции». Если без этого можно обойтись — так и нужно поступать.

Если без этого можно обойтись — так и нужно поступать.

только моя практика говорит, что как только в приложении появляется чуть более, чем несколько связей между сущностями — то без DTO обойтись ещё можно, но получается кривовато

Кривовато — это когда ORM отправляет 1К запросов на справочник, когда его нужно сджойнить с коллекцией сущностей :)

в таком случае вытащите справочик в сингтон и там крутите его, это никак не мешает DTO :)

Синглтон? Щаз набегут архитекторы, объяснят... :)

Они просто не умеют его готовить :)
Вообще я и сам довольно настороженно отношусь к синглтону, но часто он очень даже к месту.

Я не то чтобы спец по теме БД, но проблема в чём.

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

С другой стороны, современные мапперы эту проблему, насколько я знаю, давно обходят, и можно маппить чуть ли не на приватные поля напрямую, поэтому проблема не настолько актуальна. Но есть нюанс: даже сейчас подобная настройка маппинга с сохранением инкапсуляции может требовать написания лишнего конфигурационного кода, и, кроме того, конвенции/стратегии объявления сущностей для мапперов могут меняться — соответственно, нужно этот конфигурационный код мейнтейнить.

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

Приватные поля не проблема для современных мапперов обычно. А вот конфигурационный код... Введением слоя DTO между базой и бизнес-ентити будет требовать поддержки двух уровней «конфига»: маппинг базы на DTO и маппинг DTO на бизнес-ентити. Ну и проблемы инкапсуляции бизнес-ентити тоже решать нужно будет, если классически решаем, что они не должны зависеть от слоя хранения.

А вот конфигурационный код... Введением слоя DTO между базой и бизнес-ентити будет требовать поддержки двух уровней «конфига»: маппинг базы на DTO и маппинг DTO на бизнес-ентити.

Так это уже будет не конфигурационный код, а best practice). Концепция же в том, что данные могут храниться в любом виде, и мы не должны в бизнес-энтити зависеть от логики, которая связана именно с хранением и передачей этих данных, т.к. это жёсткая связность как она есть. И второй момент (который сильно зависит от конкретного ORM): в случае с надобностью сохранения инкапсуляции вроде даже легче и быстрее добавить одно новое поле или там проперти в 2 класса, чем писать дополнительный конфиг код.

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

Ну, нкапсуляция бизнес-энтити решается самой первой и так... Т.е., сначала написали нормальный код бизнес-энтити, всё там попрятали как нужно, добавили методы изменения состояния если нужно, а дальше два пути: либо начинаем прикручивать туда ещё и ORM, либо делать отдельно DTO. А в DTO инкапсуляция внутри класса относительно по-барабану уже).

Так с хорошим ORM не зависят entity от базы, они ничего не знают о ней, конфиги где-то сбоку, ну или в незначащих для логики аннотациях. Базу вообще прикрутить к проекту можно когда всё оттестировано будет на объектной модели с репозиторями объектов в памяти.

Да, не всегда ORM достаточно хорош, чтоб совсем не текло, но тут уже смотреть надо, стоит дополнительный слой между базой и бизнес-моделью (именно дополнительный, ORM сам по себе изолирующий слой) неудобств этой протечки или нет. А может глобально его не вводить, а только точечно.

Что до инкапсуляции: так заполнить бизнес-сушность из DTO ничем не проще, чем из других данных. Или сеттерами кишки наружу, или какой рефлекторной или кложурной «магией». Ну, именованные конструкторы createFromDtos, вводящие зависимость бизнес-логики от нового слоя.

Или сеттерами кишки наружу

Ну зачем, Ваш вариант ниже через конструкторы — гораздо лучше.

Ну, именованные конструкторы createFromDtos, вводящие зависимость бизнес-логики от нового слоя.

Через конструкторы слабее связность — всё ОК, если слой прозрачный (а DTO — самый что ни на есть прозрачный слой).

Если слой DTO у нас 1:1 соответствует базе, то получаем зависимость бизнес-логики от базы.

Если слой DTO у нас 1:1 соответствует базе, то получаем зависимость бизнес-логики от базы.

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

Я так понял идею слоя DTO между объектной моделью и базой. Если мы можем спокойно отмаппить любую базу на объектную модель любой сложности без протечки реялционной модели, то зачем нам слой DTO? Я так понял, он нужен, чтобі протечки не уходили дальше него.

Если слой DTO у нас 1:1 соответствует базе, то получаем зависимость бизнес-логики от базы.

Даже если представить 1:1, то почему? Бизнес-энтити не знает, откуда взялись данные — она видит только внешнюю структуру DTO-болванки.

Кстати, возник вопрос. Вы говорите, что можно и таким образом держать оттестированные бизнес-энтити без БД. Уточните, плз, как — я, может, неправильно понял Вас изначально. Например, есть бизнес-энтити с инкапсуляцией, на которую маппятся данные из БД напрямую. Если есть инкапсуляция, значит, назначить энтити состояние просто так не выйдет. Как это сделать?
1. Либо писать мок, наследуясь от интерфейса бизнес-энтити и делать полностью идентичный ей класс, только в этот раз с открытым к изменению состоянием и без БД-зависимого кода.
2. Либо расширять существующий класс бизнес-логики потомком с новым конструктором для приёма данных в виде набора значений для состояния, например (прямо скажем, странное было бы решение — даже не знаю, делал ли так хоть кто-то когда-нибудь).
3. Подскажите способ.

В итоге, намного легче написать DTO (аналогично п.1, но без логики — меньше кода) и убить сразу двух зайцев: тестируя реальные бизнес-объекты с реальной цепочкой их инициализации в виде передачи DTO как источника данных, и также уменьшая связность.

2. Либо расширять существующий класс бизнес-логики потомком с новым конструктором для приёма данных в виде набора значений для состояния, например (прямо скажем, странное было бы решение — даже не знаю, делал ли так хоть кто-то когда-нибудь).

а чем прямо так уж и странное. Очень даже, подсовываем имитацию базы в памяти и тестируем.
Вот, например: github.com/moq/moq4
А вот даже Мартин Фаулер об этом пишет: www.martinfowler.com/...​InMemoryTestDatabase.html

Вот, например: github.com/moq/moq4

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

Ну, это не совсем наш случай, т.к. там объекты без инкапсуляции — создали, назначили всё без лишних движений, и замокали саму работу с базой.

Вопрос в том, если у нас есть бизнес-энтити с инкапсулированным состоянием — как протестировать её. Т.е., если бы в том примере из EF класс Blog был с закрытыми сеттерами, то поступали бы точно так же, как и с моканьем контекста базы? Не знаю, не нравится мне такое решение. Нет, как её... integrity, или системности, что ли). В таком случае писать каждый раз новую строку кода на каждую инкапсулированную проперти объекта для того, чтобы она возвращала что-то определённое — ну, такое себе решение... Может, я и не прав, но не очень люблю моки «кусками» и стараюсь их избегать. Хотя моё «оригинальное» решение из п.2 предлагало именно создавать какой-нибудь TestBlog: Blog, который бы принимал на вход нового конструктора значения для состояния, и было интересно, поступает ли кто-то именно так).

Я иногда применяю этот подход, но крайне не люблю его когда только для тестов приходится менять видимость членов с private на protected

Я иногда применяю этот подход, но крайне не люблю его когда только для тестов приходится менять видимость членов с private на protected

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

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

это неправильное понимание объектно-ориентированного проектирования или умышленное отступление от него, а для популярных фреймворков для юнит тестирования ood обязательное требование

Как тогда правильно спроектировать по феншую, если у меня например есть приватный метод — парсер ответа от стороннего сервиса, но я не хочу выносить никакую зависимость из класса?

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

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

Как тогда правильно спроектировать по феншую, если у меня например есть приватный метод — парсер ответа от стороннего сервиса, но я не хочу выносить никакую зависимость из класса?

вот ты меня сча спросил: «как спроектировать правильно, если хочется проигнорить один из основных ood принципов?» — никак

но блин, оно используется там и только там

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

как по мне в этом случае это плодит ненужную сложность

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

и нарушает в какой-то степени инкапсуляцию, делая видимым в самой сборке этот класс, который нужен только этому классу

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

Я так подумал, в этом моменте ты наверное действительно прав и мои опасения выделять класс даже из такого случая с приватным методом напрасны. Завтра захочется расщепить главный класс на 2 и всё равно придётся выделять. Пусть оно и немного «засоряет» модуль, но чем больше мы детализируем действия класса — тем будет лучше для его поддержки. Плюс ко всему, здесь не только SRP, здесь и OCP есть — завтра внешний ресурс поменяет результат парсинга и нам нужно менять текущий класс, вместо того, чтобы наследовать, а старый парсинг зачем-то ещё может быть нужен (для поддержки старых версий). Вроде это и понимаю, и делал сотни раз для менее «закрытых» кусков, но бывает всё равно на практике куда-то чуть потянет не туда.
А с приватным методом отдаёт процедурным программированием

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

Да, это тоже аспект. Если хотим протестировать уже какое-то предопределённое состояние объекта, а не добиваться этого состояния каким-то процессом (что уже совсем другой тест должен делать)

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

Но некоторые считают, что это неправильные тесты, что должна быть одна и только одна проверка на тест.

ну, я тоже так считаю :) Точнее, что так было бы идеально. Иначе потом непонятно, что проверяет тест, то ли установку объекта до этого состояния, то ли уже само это состояние

Потому что если 1:1, то изменения в базе, сделанные, например, DBA приведут к необходимости изменять DTO, а раз у нас жёсткая зависимость сущности от DTO, то и изменять сущность. По факту нарушается SRP — появляется несколько причин изменять сущность: изменение бизнес-логики, и изменение логики хранения. Тот же ActiveRecord по сути.

Рабочие варианты задания состояния:

1. Использование разных «магических техник», типа рефлексии, прибинженных замыканий, десериализаторов и т. п. Можно те же что использует ORM с целью уменьшения конфигурирования и вообще единообразности.

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

3. Тупое прохождение жизненного цикла сущности до нужного состояния.

Я не против DTO в принципе. Я не понимаю их места между бизнес-слоем и слоем хранения. Единственная ситуация: используемый ORM уже не позволяет без явной грубой протечки реляционной модели (например коллекция ValueObject c идентификаторами) отмаппить базу на объектную модель. Тут или надо смириться с этим, или маппить базу на DTO, а потом маппить их реляционно-обїектнуюю модель на чистую объектную.

Потому что если 1:1, то изменения в базе, сделанные, например, DBA приведут к необходимости изменять DTO, а раз у нас жёсткая зависимость сущности от DTO, то и изменять сущность

Хм, ну почему же — сущность как раз будет оставаться такой, как ей угодно. Зачем нам её менять, если изменились DTO? Зависит от

то изменения в базе

Если добавили новую колонку, ничего не изменится. Если поменяли что-то кардинально или удалили, то в таком случае изменится вообще много чего везде, и DTO там роли не сыграет (да и кто так делает :) ).

Выше, например, один из подходов описывается как «маппим на несколько классов, которые являются отображением БД, и собираем из них доменный объект» — так это ведь самые настоящие DTO, хоть их так никто и не назвал).

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

Ну в конструкторе User поле name инициализировалась как dto.firstName + ’ ’ + dto.lastName (никогда так не делайте :) ), в таблице их в какой-то момент объединили, и надо менять в конструкторе.

Так про этот подход я и говорю: зачем маппить на несколько классов из которых потом собирать доменный объект, если можем сразу замаппить на него?

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

Если у Вас бизнес-модель 1 к 1 совпадает с реляционной моделью БД, то что-то тут не так (скорее всего, неправильно построены классы бизнес-модели). Бизнес-модель строится на основе реального мира, по своей структуре она ближе к клиентской части, нежели к БД, можно так сказать.

плюс-минус это не 1:1 :) Современные универсальные ORM достаточно умны, чтобы реализовывать несколько видов наследования, которого нет в РСУБД (ну или где-то есть но редко используется), преобразование столбцов в объекты, в том числе нескольких столбцов в один объект, ну грубо столбцы country, state, city, street таблицы user смапить на свойство address объекта User типа Address и т. п. А главное позволяют писать свои кастомные маппинги.

Хотя, конечно, не всегда даже возможностей маппера хватает чтоб совсем протечек не было и тут уже без отдельного слоя (псевдо)DTO и двойного маппинга не обойтись. Хотя, зачастую, когда не хватает возможностей маппера — это часто звоночек, что что-то не так с объектной моделью, переусложнена она, обслуживает несколько контекстов.

Умность маппинга — это уже детали.
Что вообще делает реляционная модель в бизнес-логике? Почему какие-нибудь специфические вещи, по которой генерится БД и которые свойственны только данной конкретной БД, должны как-то существовать в бизнес-логике?
Поймите, даже то, что это противоестественная вещь для бизнес-логики, уже говорит о том, что её там быть не должно. Всё может идти гладко до какого-то момента, пока не придётся делать какую-то затычку с пометкой «не обращайте на это внимания, это касается БД». Ну, если с ходу придумать — например, это атрибуты для создания индексов.

Да не делает она там ничего. ORM маппит объектную модель на реляционную и обратно. Сам ORM не часть бизнес-логики, это инфраструктура, слой бизнес-логики ничего не знает о базе и даже о своей персистетности.

Ну, «знает» — это в общем случае == «присутствует в коде». Иначе не совсем понятно, что ещё это может значить). Т.о., если даже минимально только какие-то атрибуты-декораторы есть и подтягиваются зависимости в конкретный класс — это уже «знает».

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

Правильно, инфраструктура должна лежать в инфраструктуре. Классы, описывающие реляционную БД (по которой она потом строится) должны лежать там же :)

ЗЫ. В принципе, у того же Entity Framework есть т.н. Fluent API, позволяющий описать те же индексы в отдельном классе, который мы можем положить в инфраструктуру. А если бы не было такой возможности, а есть возможность только через атрибуты, то как тогда выкручиваться? Делать свойства виртуальными, а потом переопределять, чтобы прицепить атрибут? Тогда спрашивается, почему мы должны устраивать пляски с бубном с классом бизнес-логики, чтобы удовлетворить инфраструктуру? :)

Классы, описывающие реляционную БД (по которой она потом строится) должны лежать там же :)

Класи, які описують реляційну БД взагалі не мусять існувати. Ця креатура не здатна на життя з народження.

Класи, які описують реляційну БД взагалі не мусять існувати. Ця креатура не здатна на життя з народження.

Почему? Entity Framework именно так и работает, и вполне успешно. Вопрос в том, что они не должны вылазить дальше слоя инфраструктуры, о чём я собственно и пишу в статье.

Тому що вони не потрібні. Як клас. Бізнес-логіка оперує певними об’єктами. Як саме зберігається об’єкт в базі нікому не цікаво. В нормальних СУБД можна пхати в них та отримувати з них відповіді прямо в об’єктах бізнес-логіки. Минаючи безглузду подвійну трансформацію.

В нормальних СУБД можна пхати в них та отримувати з них відповіді прямо в об’єктах бізнес-логіки. Минаючи безглузду подвійну трансформацію.

она как бы есть уже, эта трансформация, когда пихаете прямо в объекты бизнес-логики

Її можна й оминати. Якщо що. Не всі СУБД тупі як мускуль.

Нет в моей вселенной классов, описывающих реляционную БД. :) Есть какое-то конфиги, которые описывают маппинг объектов на базу. Про атрибуты, боюсь, не в курсе.

Если у Вас классы бизнес-логики описывают именно бизнес-логику, то это совсем другое дело.

А бывает по другому? :) Ну, кроме ActiveRecord

годная статья, наконец-то по делу :-) я бы еще добавил — избретение «велосипедов» вместо проверенных и работающих решений из стандартных или опенсорсных библиотек

А я бы ещё добавил изобретение велосипедиков вместо тяжёлой опенсорсной библиотеки. :) Да даже не тяжелой, а типа leftpad или что там было в nodejs экосистеме.

Із стандартних- так. Бібліотек з відкритим кодом і з неясним версіонуванням- можна поспорити :)

Я из прочтения статьи так и не понял, о какой конкретно неправильно архитекутре идет речь. Это скорее просто набор рекомендаций, из вашего личного опыта, каких проблем стоит избегать в анемичных сервисах на database centric(скорее всего) архитектуре.

Но ведь можно просто хранить в этом поле null, что будет правильно с логической и удобно с технической точки зрения!

Это нифига неудобно технически и выплывает из плохого дизайна языка(что ref типа по-умолчанию nullable, null не имеет никакой type safety и его юзают при моделировании домена) — правильно и удобно это Option-ы.

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

Якщо тобі на одну зміну треба тисячі разів читати, то покласти це поле в базу/кеш та не вираховувати постійно, може скоротити багато ресурсів та нервів. Не все так просто, як здається.

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

Тут можна піти ще далі. Клієнт взагалі не мусить нічого знати про структури даних, реляційну модель, залежності, тощо. Якщо побудувати DBaaS, назовні виставити примітивний API та на всі запити віддавати JSON/XML, то така система дозволить не тільки гнучко все реалізовувати та змінювати, але й прозоро міняти навіть саму DBMS без жодних змін коду клієнта.

Резюме. Всегда уделяйте внимание правильной обработке ошибок.

Правильна обробка помилок — не допускати їх. Якщо архітектура буде побудована за принципом fail safe, то виключенням не буде місця. Вони просто стануть майже не потрібними.

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

А ще розробники не розділяють архітектуру коду та глобальну архітектуру проекту...

Правильна обробка помилок — не допускати їх

При использовании внешних сервисов и сторонних библиотек от них нужно ожидать любой пакости. В статье, кстати, об этом тоже сказано в разделе про валидацию.

Код мусить бути готовим до будь-яких несподіванок в fail safe системах. Наявність неправильної поведінки є нормою та не є виключною ситуацією.

у вас з автором різне розуміння того, що є виключною ситуацією. Автор підсумував, що помилки не треба ігнорувати і адекватно показувати, що сталося і при цьому не вбивати аппку, якщо помилка не критична. Бо якраз пару тижнів тому дебажив цілий день такий ідеальний і «готовий до будь-якої несподіванки» «fail-safe» код, який просто ігнорував всі помилки і писав хлам до Кафки

Бо якраз пару тижнів тому дебажив цілий день такий ідеальний і «готовий до будь-якої несподіванки» «fail-safe» код, який просто ігнорував всі помилки і писав хлам до Кафки

Значить архітектура решти системи побудована неправильно. Хлам треба писати в іншу чергу кафки. З назвою «khlam»

Код мусить бути готовим до будь-яких несподіванок в fail safe системах. Наявність неправильної поведінки є нормою та не є виключною ситуацією.

я как бы именно это и хотел сказать, призывая обрабатывать эти ситуации

В таких системах обробка виключень — це тихо померти. Як раз прямо протилежне тому, що описується в статті.

Якщо тобі на одну зміну треба тисячі разів читати, то покласти це поле в базу/кеш та не вираховувати постійно, може скоротити багато ресурсів та нервів. Не все так просто, як здається.

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

Тут можна піти ще далі. Клієнт взагалі не мусить нічого знати про структури даних, реляційну модель, залежності, тощо. Якщо побудувати DBaaS, назовні виставити примітивний API та на всі запити віддавати JSON/XML, то така система дозволить не тільки гнучко все реалізовувати та змінювати, але й прозоро міняти навіть саму DBMS без жодних змін коду клієнта.

так и я об этом!

Правильна обробка помилок — не допускати їх. Якщо архітектура буде побудована за принципом fail safe, то виключенням не буде місця. Вони просто стануть майже не потрібними.

Во-первых, так не бывает. Ошибки будут всегда. Что их нужно стараться свести к минимуму — это другое. Мне даже видится то, что разработка ПО ближе к неточным наукам, нежели к точным. Потому что мы не можем гарантировать и доказать, что программа не содержит ошибок.
Касательно же исключений — это вообще элемент стандартного поведения ПО. Недоступен внешний сервис — кидаем эксепшн (или перебрасываем готовый) и ловим его в нужном месте. Внешний сервис вернул совсем не то, что мы ожидали (в другом формате и т.д.) — аналогично. Это всё нужно обрабатывать.

А ще розробники не розділяють архітектуру коду та глобальну архітектуру проекту...

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

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

Не бачу необхідності в цьому. Особливо в киданні ексепшинів кудись там за межі драйвера. Чому? Тому що на будь-яку виключну ситуацію потрібен адекватний вихід з неї. Не «ой, халепа трапилася, вмикаємо режим лапки до гори», а щось на кшталт: якщо немає конекшину, то відправити задачу в кафку, якщо кафка впала, відправити в локальний кеш-сервіс, якщо й він недоступний, то записати в файл, а якщо й це неможливо, то створити аналог мемдиску в пам’яті та засунути туди. Якщо все це неможливе, то ок. Все це гіперболізовано, але для демонстрації перебігу думок підійде. Тобто драйвер на запит мусить завжди повертати контейнер з даними та сервісними обгортками по протоколу. Нікому його ексепшини не потрібні.

а щось на кшталт: якщо немає конекшину, то відправити задачу в кафку, якщо кафка впала, відправити в локальний кеш-сервіс, якщо й він недоступний, то записати в файл, а якщо й це неможливо, то створити аналог мемдиску в пам’яті та засунути туди.

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

Правильно, но делать это должен слой бизнес-логики, а не слой доступа к БД!

Не потрібно бізнес-лозіці знати, що не так з процесом обміну даними. Це транспортний рівень.

Один из способов (на мой взгляд, оптимальный) — кинуть соответствующий эксепнш

У вас ексепшиноманія. Драйвер може під капотом кидати скільки завгодно ексепшинів, тільки от назовні він мусить віддати пакет за протоколом. Не ексепшин. В пакеті буде вже стан обробки запиту. Тоді можна розносити транспорт на різні системи.

кто-то выше его перехватывает

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

Не потрібно бізнес-лозіці знати, що не так з процесом обміну даними. Це транспортний рівень.

Хорошо, внедрите ещё один слой между ними. Но, в любом случае, запрос к внешнему источнику в случае недоступности БД должен делать не слой доступа к БД! Он должен сообщить об этом и всё.

У вас ексепшиноманія. Драйвер може під капотом кидати скільки завгодно ексепшинів, тільки от назовні він мусить віддати пакет за протоколом. Не ексепшин. В пакеті буде вже стан обробки запиту. Тоді можна розносити транспорт на різні системи.

правильно, потому что драйвер самодостаточен.Если проводить аналогию, то слой представления не должен кидать эксепшины юзеру или кто его там использует (если это Web API — то например возвращать соответствующий статус код), под капотом может кидать что угодно. Но наружу он отдаёт, Вашими словами, «пакет за протоколом».

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

И что? Тогда пишите оболочку, которая перехватит этот эксепшн и преобразует её в удобочитаемый вид для этой не риднесенькой сыстемы. Либо второй подход, с уведомлениями, например пишите в общедоступную очередь. Но этот слой доступа к БД ничего не должен знать о специфики этой не риднесенькой системы от слова вообще!

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

Несколько комментариев с точки зрения разработчика БД относительно архитектуры ПО.
С моей точки зрения, ошибки при проектировании описаны верно, но хотелось бы дополнить.

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

Да, поэтому следующий шаг — исключить зависимости слоя DTO от структуры БД. Как вариант решения — вынести логику работы с БД в хранимые процедуры. Таким образом, результат выборки будет возвращаться в виде REF-курсора, с необходимой избыточностью/дублированием где это необходимо. При этом структура БД остается за кадром и за нее отвечают разработчики БД. Слой DTO изолируется от «архитектора из Сербии», который может на вверенном ему участке как угодно менять структуру БД, руководствуясь любыми аргументами.

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

Это не всегда верно. В современных СУБД вычисляемые поля и материализированные представления берут на себя львиную долю работы по сопровождению и изменению поля. В идеале, для слоя DTO механизм расчета значения должен быть скрыт, по бОльшей части это задача СУБД — быстро и правильно рассчитать/выдать значение. Для части случаев имеет смысл реализовать накопление кумулятивных данных в обход этих механизмов, если расчет сложный и данные либо механизмы расчета данных за прошлые/текущие периоды могут изменяться.

То есть клиентская часть передаёт новый объект, и нам нужно обновить текущий объект в БД. Мы не знаем...

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

Если логика приложения зависит от выбранного значения, то, скорее всего, стоит использовать enum

Наверное, наиболее гибким способом использования enum будет его генерация на основе ID сущности из таблицы БД (где она используется в FK/PK и так просто не может быть изменена). При этом каждому ID назначается более удобочитаемый Alias (который и используется далее в коде), в идеале — он берется тоже из таблицы БД. Дополнительные ограничения по поводу изменяемости можно обеспечить как организационными методами, так и методами самой БД. Конечно, если сущность enum никак не представлена в структуре данных БД, то это исключительно служебная информация и Ваши изложения верны.

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

В случае использования хранимых процедур разработчики БД могут производить изолированно какой угодно сложный рефакторинг. Это никак не влияет на слой DTO, набор входных/выходных параметров процедуры при этом не меняется. И это касается не только рефакторинга, но и решения проблем производительности, изменения структуры таблиц/схем БД и прочих активностей. Эти задачи остаются целиком в зоне ответственности разработчиков БД и в идеале никак не должны влиять на слой DTO.

Если правило чуть сложнее примитивной логики (которая контролируется ключами и constraints) — нужно писать триггер

Это не самое лучшее решение. Повторюсь, но как мне кажется, наилучшим решением есть хранимая процедура как единая точка входа данных в БД. Гранты напрямую к таблицам слой DTO иметь не должен, даже на чтение, исключая возможно какие-то служебные таблицы, обеспечивающие работоспособность самого слоя DTO.

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

Мое видение относительно SQLException. Исходя из вышесказанного, слой DTO должен быть изолирован от системных ошибок в БД вида ORA-00001 unique constraint (string.string) violated. Хранимая процедура либо в REF-курсоре, либо в отдельных выходных параметрах должна вернуть причину ошибки в удобочитаемом и понятном виде, либо информационное сообщение с описанием того, как при необходимости разобраться в деталях ошибки. Конечно, это не исключает необходимости обрабатывать SQLException в слое DTO, т.к. могут быть проблемы с самим вызовом процедуры/при фетче курсора/необходимостью реконнекта к БД и т.д. Но перечень таких ошибок, как и возможная реакция на них, намного меньше. Более того — каждая такая ошибка должна рассматриваться как нестандартное поведение, с соответствующим логгированием/реакцией на нее. Высшим пилотажем является внедрение глобального справочника кодов ошибок с их описанием и механизмом обработки/реакции на них.

Это не всегда верно. В современных СУБД вычисляемые поля и материализированные представления берут на себя львиную долю работы по сопровождению и изменению поля.

+1, сразу об этом подумал, когда читал статью — они затем и нужны, чтобы эту проблему решать.

Это не всегда верно. В современных СУБД вычисляемые поля и материализированные представления берут на себя львиную долю работы по сопровождению и изменению поля.

Вы уже третий (если я не сбился со счёта), кто об этом пишет :) Ну я же специально написал, «без необходимости»! Мне же в ответ «это не всегда верно». Так я тоже самое и пишу, что НЕ ВСЕГДА :)

Да, проблема в разном понимании термина «без необходимости» и даже «без особой необходимости», особенно в случае, когда ты как тимлид объясняешь его пришедшему новичку. И он потом, чтобы не нарушать корпоративный стандарт, начинает рассчитывать вычисляемые поля где-то в промежуточных слоях, вовне БД. И ведь потом не придерешься, сам ведь сказал «без необходимости».

Я бы переформулировал Ваш тезис таким образом:

Если есть поля A, B и C и по ним можно вычислить поле D, то ПО ВОЗМОЖНОСТИ, это поле должно быть в информации, получаемой из БД!

БД отвечает за обработку/хранение/трансформацию/выдачу информации, и самое подходящее место для любых вычисляемых значений (если эти значения основаны на данных БД) — как раз слой БД.

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

Это на уровне архитектуры решается за что отвечает БД. Особенно, учитывая, что РСУБД не очень легко масштабируются, а ORM не очень хорошо дружат с вычисляемыми полями, вполне может быть решено, что БД отвечает только за хранение данных, чтобы минимизировать нагрузку на базу и минимизировать код, непосредственно работающий с базой.

Собственно, решение о том, какую функцию в системе будет выполнять СУБД (если она будет) — одно из основных определяющих архитектуру системы. Можно отвести ей роль тупого хранилища, и тогда вычисляемые поля и вообще любая логика в него кладётся только при карйней необходимости. Можно занести в неё всю бизнес-логику и почти всю инфраструктурную, оставив приложению роль маппера запросов от UI на вызовы хранимок. Можно как-то размазать даже не установив чётких границ, что мы делаем в БД, а что в приложении.

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

Я как раз этот случай и пытался описать. Даже наверное не стоило называть это поле «вычисляемым», потому что оно не является вычисляемым в том понимании, в котором можно трактовать относительно БД

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

В данном случае я просто описывал подход с «пробросом» класса, который меппится на БД, на самый верх, без DTO и один из его плюсов, не более :)

Наверное, наиболее гибким способом использования enum будет его генерация на основе ID сущности из таблицы БД (где она используется в FK/PK и так просто не может быть изменена). При этом каждому ID назначается более удобочитаемый Alias (который и используется далее в коде), в идеале — он берется тоже из таблицы БД. Дополнительные ограничения по поводу изменяемости можно обеспечить как организационными методами, так и методами самой БД.

Зачем и где тут гибкость? Что нам даст этот enum? Или Вы имеете ввиду, что логика приложения зависит от выбранного значения?

ЗЫ. А что будет, если кто-то удалит какую-то запись из таблицы?

Согласен, вопрос спорный. Но удаление записи из справочника — задачка еще та, особенно если на ID уже навешаны Foreign Keys. И в 99% случаев именно удаление не требуется, часто решается добавлением аттрибута Active/Inactive и периодом действия.

Согласен, вопрос спорный. Но удаление записи из справочника — задачка еще та, особенно если на ID уже навешаны Foreign Keys.

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

Код не должен зависеть от данных!

И снова вопрос спорный.

Данные в БД хранятся как в таблицах фактов, так и в таблицах измерений. И если по поводу фактов вопросов не возникает (зависимость от этих данных в коде недопустима!), то по поводу измерений вопрос неоднозначный.

Предположим, существует зависимая от данных в таблице измерений логика как в слое DTO (и выше), так и в слое БД. Самое худшее решение в этом случае — независимость такого кода от данных: тогда очевидно, что связи между этими данными в разных слоях нет. А эта связь очень часто требуется. И тогда придется либо приводить к общему знаменателю (генерация справочника в слое DTO на основе справочника в БД), либо на уровне БД производить маппинг значений из разных слоев (по сути, выносить хардкод справочника из слоя DTO в слой БД). Как мне кажется, вариант 1 более правильный.

В принципе, соглашусь, моя категоричность в этом вопросе немного не к месту. Реально бывают ситуации, когда логика зависит от данных и одними энумами не отделаешься. В частности, это пересекается с тем, что я писал здесь: dou.ua/...​tecture-mistakes/#1510130

Но это уже именно частный «гибридный» случай

А касательно более простых случаев, мне кажется, что мой подход (использование энумов только если логика завязана на значениях и использование сущностей в других случаях) очень хорошо ложится на доменную модель. Добавление/изменение записи из БД при использовании сущностей никак не влияет на работу кода и не требует никаких его изменений, удаление записи возможно, если на неё нет никаких вторичных ключей и точно также безболезненно (т.к. она не используется). Удаление значения энума в случае, если энум используется при завязывании на него логики, точно также позволит перекомпилировать проект, если это значение энума не используется и выдаст ошибку компиляции, если используется (что логично).

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

В случае использования хранимых процедур разработчики БД могут производить изолированно какой угодно сложный рефакторинг. Это никак не влияет на слой DTO, набор входных/выходных параметров процедуры при этом не меняется. И это касается не только рефакторинга, но и решения проблем производительности, изменения структуры таблиц/схем БД и прочих активностей. Эти задачи остаются целиком в зоне ответственности разработчиков БД и в идеале никак не должны влиять на слой DTO.

В случае использования хранимых процедур уже нет никакого нарушения SRP, потому что по сути для нашего слоя интерфейс БД — это уже сторед процедуры, а не таблицы. Что там за ними происходит — нас уже не интересует, хоть это вообще пишется в какой-то внешний источник

Это не самое лучшее решение. Повторюсь, но как мне кажется, наилучшим решением есть хранимая процедура как единая точка входа данных в БД. Гранты напрямую к таблицам слой DTO иметь не должен, даже на чтение, исключая возможно какие-то служебные таблицы, обеспечивающие работоспособность самого слоя DTO.

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

Мое видение относительно SQLException. Исходя из вышесказанного, слой DTO должен быть изолирован от системных ошибок в БД вида ORA-00001 unique constraint (string.string) violated. Хранимая процедура либо в REF-курсоре, либо в отдельных выходных параметрах должна вернуть причину ошибки в удобочитаемом и понятном виде, либо информационное сообщение с описанием того, как при необходимости разобраться в деталях ошибки. Конечно, это не исключает необходимости обрабатывать SQLException в слое DTO, т.к. могут быть проблемы с самим вызовом процедуры/при фетче курсора/необходимостью реконнекта к БД и т.д. Но перечень таких ошибок, как и возможная реакция на них, намного меньше. Более того — каждая такая ошибка должна рассматриваться как нестандартное поведение, с соответствующим логгированием/реакцией на нее. Высшим пилотажем является внедрение глобального справочника кодов ошибок с их описанием и механизмом обработки/реакции на них.

Вот здесь я полностью согласен! Приложение ничего не должно знать об эксепшинах, бросаемых БД, даже как минимум потому, что эти экспешины внутренние и для их «проброса» в изначальном виде нужно тянуть сборки доступа к БД выше, что неправильно.
С одной стороны может показаться, что создания тучи собственных исключений по типу SourceNotAvailableException и т.д. — дублирование кода (особенно, если библиотека доступа к БД сама бросает что-то типа SqlConnectException), но на самом деле нет. Потому что этот SourceNotAvailableException будет универсальным, и наше приложение на уровне бизнес-логики и выше будет работать с ним, независимо от того, используем мы MS SQL, Oracle или вообще какое-нить внешнее API.
Аналогично и с кодом ошибок — сначала мы определяем, какие ошибки и как будут обрабатываться бизнес-логикой (и выше), потом его создаём, потом ловим в слое доступа к БД специфические эксепшины для этой библиотеки, которую используем для доступа к БД, либо как-то по-другому перехватываем ситуации, когда нужно кинуть наш эксепшн с кодом и собственно его кидаем

Главное не забыть про оригинальный Sql*Exception, а то получив SourceNotAvailableException без ссылки на него можно очень долго дебажить пытаясь понять, то ли сервер недоступен, то ли пароль не тот, то ли ещё что.

Главное не забыть про оригинальный Sql*Exception, а то получив SourceNotAvailableException без ссылки на него можно очень долго дебажить пытаясь понять, то ли сервер недоступен, то ли пароль не тот, то ли ещё что.

в данном конкретном исключении — да, очень желательно добавить

И я никогда не понимал, зачем класть саму реализацию в ту же сборку, где и интерфейс? В чём тогда вообще смысл интерфейса, если его реализация там же?

Тому, що і той класс, який буде використовувати цей інтерфейс, теж може бути в тому самому проекті. Інтерфейси використовуються не лише для опису між-модульної взаємодії, а і для проектування відношення сутностей всередині одного модуля.

Щодо інтерфейсів, що описують експортовані назовні методи, я згоден з автором — краще мати їх в окремому модулі.

Тому, що і той класс, який буде використовувати цей інтерфейс, теж може бути в тому самому проекті. Інтерфейси використовуються не лише для опису між-модульної взаємодії, а і для проектування відношення сутностей всередині одного модуля.

Я имел ввиду, когда мы используем интерфейсы (абстрактные классы) для DI извне. Вы правы, нужно было об этом упомянуть

projection — проекция, чем плохо?

Сование null в дату последней модификации доставляет неудобств, когда надо ORDER BY
База то все NULL выдаст либо в начале, либо в конце, а на ASC/DESC забьет

А як можна відсортувати НУЛЛ інакше?

никак, потому и можно запихнуть начало эпохи вместо NULL

І отримаєте записи з нульовим таймстампом так само на початку або на кінці виборки. І що ви виграли врешті решт? Так само мусите спеціально обробляти таймстамп на предмет нульовості, як раніше перевіряли нуллевість?

НУЛЛ в БД характерний тим, що для нього зроблено розширення булевої арифметики на тернарний тип даних для всіх логічних операторів — NOT/AND/OR/XOR, включаючи кондово-хардкорну деморганівщину. Оперуючи нуллом можна будувать довільної складності логічні рестрікти. А що я можу зробить з початком епохи?

Вначале или в конце, как я хочу, а не всегда в начале или в конце , как хочет база. Ибо изменение asc на desc на положение этих нулл не повлияет аж никак, в mysql например.

Приходит к вам радостный PO и хочет, чтобы юзер мог кликнуть и посортировать по этой колонке, а у вас дофига строк нуллами забиты в этом поле. Будут вопросы %)

Ну, якщо девелупер, замість пояснити овнеру безглуздість сортування по нуллабл-полю, починає танцювати з бубном, порушуючи реляційний канон в усі можливі способи, то нехай так і буде. Не шкода ні овнера, ні девелупера, ні юзера. Дєдушка Дарвін лагідно посміхається у бороду.

О, какие мы категоричные
Сортировка по полю «последнее чего-то там», причем «никогда» — как валидное значение, — очень распространенная операция в реальном мире.

Дедушка Дарвин усмехается, когда видит развесистую схему 100500 нормальной формы, и когда из нее пытаются выжать перформанс и скалабилити

Стосовно надмірного потягу юних ДБА до нормалізації — абсолютно згідний. Але я першу базу денормалізував десь у 1996 році, це точно не про мене :)

я вообще к тому, что иногда и 0.0001 можно и даже нужно считать как 0, а 100000 — как бесконечность

Это дотнет? Отсортировать в программе не вопрос, но придётся сначала эти данные вытащить

с чего ты взял, что оно сортирует их в самой программе? :)

Вот такой вот запрос оно строит:

SELECT [t0].[id] AS [Id], [t0].[Date], [t0].[Name] FROM [Table_1] AS [t0] ORDER BY (CASE WHEN [t0].[Date] IS NOT NULL THEN 1 ELSE 0 END), [t0].[Date]

А, ORMы такое любят навертеть. Чего только не изобретут, лишь бы не воспользоваться сортировкой по индексированному полю, сводящейся к банальному чтению индекса

Эээ, погоди. Я не скажу, что я гуру в БД и мне действительно интересно и непонятно.
Насколько я понимаю индексы, то некластеризированные индексы не могут быть отсортированы, а кластеризированный индекс и нулл внутри как-то совсем не феншуй...

Почему? Обычный некластерный b-tree индекс содержит упорядоченные ссылки на строки, хотя строки сами по себе могут располагаться иначе. Достаточно, чтобы при ORDER BY по индексированному полю не выполнять явную сортировку. Тут есть некоторые ограничения, но штука рабочая

dev.mysql.com/...​rder-by-optimization.html

может быть, может быть....
А чем тебе запрос не понравился, он что, при индексах другим был бы? Я так понимаю, это уже задача движка SQL, как физически выполнить запрос, перебрав только индексы или сами записи....

когда происходят операции над колонкой, индексы обычно не учитываются. Тут шаг влево, шаг вправо и привет фуллскан

а где в запросе (что я выше показал) учитываются индексы? Разве это не задача движка решить, как искать?

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

да я тебе о другом — как ты понял, что запрос, сгенерённый ОРМ, не будет учитывать индексы? :)

а как он будет учитывать их вот тут?

(CASE WHEN [t0].[Date] IS NOT NULL THEN 1 ELSE 0 END)

емнип выражение типа like ’blah%’ чуть ли не единственное, при котором сохраняется использование индексов, остальное требует вычисления выражения

Есть базы, в которых можно создать индексы по выражению. Правда у них вроде у всех есть NULL LAST или типа того модификаторы сортировки

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

тупость мешает, остается плясать от того что есть
просто ORDER BY column, если column индексирован или первый в составном индексе, то все ок

Я не знаю, но я не пойму, что мешает движку это распарсить, понять что хотят и вытащить с индексов? :)

Тут в другом немножко фокус, насколько я понимаю. order by по полю с b-tree индексом — который упорядочен просто бай дизайн — в лучшем случае (если ты попросил отсортировать в дефолтном для индекса порядке с дефолтным же положением налов) вообще не потребует сортировки как отдельной операции, а в худшем потребуется дешевая операция типа реверса. А в случае сортировки по вычисляемому значению придется таки явно сортировать...

Эээ, а чем нулл вообще может мешать? Что мешает сделать так, что в индексе эти нулл занимали какое-то особое место, откуда их можно бы просто выбрать скопом, а потом по дереву всё остальное?

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

Позиция NULL в индексах и сортировках захардкожена, ну или в конфиги на уровне инстанса сервера. Нет опции NULL FIRST|LAST для индексов и ORDER BY

А не все базы умеют управлять положением null в индексе или сортировке, и подобные хаки единственный способ сделать нестандартную сортировку, хотя часто это будет означать full scan

потому и говорю, что замена NULL на некое значение может иметь смысл. А может и не иметь, конечно)

Спасибо за статью! Годнота =)

Підписатись на коментарі