Как стать автором
Обновить

Почему мы построили монолит на чистой архитектуре. И почему это взбесило системных аналитиков

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров18K
Всего голосов 29: ↑25 и ↓4+23
Комментарии61

Комментарии 61

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

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

Так что да, архитектура должна формализировать, в том числе, и каденции в команде и так далее)

Корнем, все же, было "операционной системы". В том плане, что само понятие "операционная система" проникло и закрепляется в проектирование веб-*(подставить нужное).

С некоторых пор прихожу к выводу что чистая архитектура помогает плавно и бесшовно перейти от обследования (event storming, ...) к проектированию и реализации домена и сервисов.

Да, кода больше, но всё происходит быстро и не задумываясь. И позже интеграция доменов тоже простая.

События - типы данных домена - классы домена с чистыми функциями - репозитории для хранения данных домена (в сложных случаях ещё и для entities) - варианты использования (цепочки вызовов данных репозиториев и методов домена) - API

Всё на автомате и почти без сложных шаблонов

репозитории для хранения данных домена (в сложных случаях ещё и для entities)

Можете пояснить, что может возвращать и принимать репозиторий, кроме entities?

Иногда бывает подход "отделённых объектов хранения" (Persistence Model / Infrastructure Entities) и Domain Model — их разделение означает, что:

  • Order, OrderItem, OrderEvent в домене существуют отдельно.

  • OrderEntity, OrderItemEntity, OrderEventEntity в Infrastructure-слое — отдельно.

Конвертация данных происходит через статичные методы FromDomain() и методы экземпляров ToDomain() для преобразования туда и обратно.

В каких случаях подход оправдан?

Такое разделение (Domain <---> Infrastructure Entity с маппингом) является строгим следованием принципам Clean Architecture, Hexagonal architecture, DDD. Подходит, когда:

  • У вас сложное предметное приложение, которое может развиваться, меняться, мигрировать на другую БД или другие Storages.

  • Вы строго следуете DDD и хотите изолировать домен полностью от инфраструктуры.

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

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

короче, если вы упоротый DDD апологет

using Shop.domain;
using System;
using System.Collections.Generic;
using System.Text.Json;

namespace Shop.infrastructure
{
    public class OrderEntity
    {
        public Guid OrderId { get; set; }
        public string CustomerJson { get; set; }
        public OrderStatus Status { get; set; }
        public DateTime CreatedAt { get; set; }
        public DateTime? UpdatedAt { get; set; }

        public List<OrderItemEntity> OrderItems { get; set; } = new();

        public static OrderEntity FromDomain(Order order)
        {
            return new OrderEntity
            {
                OrderId = order.OrderId,
                CustomerJson = JsonSerializer.Serialize(order.Customer),
                Status = order.Status,
                CreatedAt = DateTime.UtcNow,
                OrderItems = order.Items.ConvertAll(item => OrderItemEntity.FromDomain(order.OrderId, item))
            };
        }

        public Order ToDomain()
        {
            var customer = JsonSerializer.Deserialize<CustomerInfo>(CustomerJson);
            var items = OrderItems.ConvertAll(item => item.ToDomain());

            // Создаем Order через приватный конструктор, чтобы реконструировать существующий заказ
            return new Order(OrderId, customer, items);
        }
    }

    public class OrderItemEntity
    {
        public Guid OrderId { get; set; }
        public Guid ProductId { get; set; }
        public string ProductName { get; set; }
        public decimal UnitPrice { get; set; }
        public int Quantity { get; set; }

        public static OrderItemEntity FromDomain(Guid orderId, OrderItem item)
        {
            return new OrderItemEntity
            {
                OrderId = orderId,
                ProductId = item.ProductId,
                ProductName = item.ProductName,
                UnitPrice = item.UnitPrice,
                Quantity = item.Quantity
            };
        }

        public OrderItem ToDomain()
        {
            return new OrderItem(ProductId, ProductName, UnitPrice, Quantity);
        }
    }

    public class OrderEventEntity
    {
        public Guid Id { get; set; } = Guid.NewGuid();
        public Guid OrderId { get; set; }
        public string EventType { get; set; }
        public string EventData { get; set; }
        public DateTime OccurredAt { get; set; }

        public static OrderEventEntity FromDomain(OrderEvent evt)
        {
            return new OrderEventEntity
            {
                OrderId = evt.OrderId,
                EventType = evt.GetType().Name,
                EventData = JsonSerializer.Serialize(evt, evt.GetType()),
                OccurredAt = evt.OccurredAt
            };
        }

        public OrderEvent ToDomain()
        {
            Type eventType = Type.GetType($"Shop.domain.{EventType}, Shop");
            return (OrderEvent)JsonSerializer.Deserialize(EventData, eventType);
        }
    }
}

Коллега, вы очень круто подхватили мысль.

Но я добавлю: т.к у нас было легаси мы пошли через анемичную модель. Это еще интереснее путь)

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

А хорошо ли это?

Тогда чтобы понять что представляет из себя модель надо точно знать кто её породил.

является строгим следованием принципам Clean Architecture, Hexagonal architecture, DDD.
CustomerJson = JsonSerializer.Serialize(order.Customer),
CreatedAt = DateTime.UtcNow,
Guid.NewGuid()

Глобальные переменные. Какая уж тут чистая архитектура.

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

Простите а в каком месте вы здесь увидели глобальные переменные? Здесь только статичные методы. Если очень сильно хочется, то можно конечно их обернуть интерфейсами чтобы в тестах мокать, но это совершенно не обязательное упражнение. Это уже попахивает solid головного мозга

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

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

Статические методы могут обращаться только к статическим полям класса

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

Кроме того,

CustomerJson = JsonSerializer.Serialize(order.Customer)
CreatedAt = DateTime.UtcNow
Guid.NewGuid() 

являются абсолютно стандартными способами в c# так что это уже скорее к вопросу дизайна стандартной библиотеки.

А вот например такой код вас тоже смущает?

int a = Math.Abs(-5);

Более того, c# позволяет сделать extention так чтобы можно было написать

order.Customer.Serialize();

Где Serialize будет фактически вызвать JsonSerializer.Serialize(order.Customer)

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

В данном случае все эти статические методы являются чистыми.

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

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

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

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

А вот например такой код вас тоже смущает?

Немного. Но там хотя бы общеизвестные математические операции, которые зависят только от аргументов, а DateTime.UtcNow и Guid.NewGuid() это ввод-вывод.

order.Customer.Serialize();
Такой код тоже плохой?

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

для хранения данных домена (в сложных случаях ещё и для entities)

Можете пояснить, что может возвращать и принимать репозиторий, кроме entities?

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

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

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

Можете рассказать подробнее?

Вот вы меня сейчас как спросили? Просто и по делу. Был бы я американцем, ваш вопрос я воспринял бы как «Слышь, а чо там было, обоснуй?!»

Мы спрашивали просто и по-деловому:

Hi,

  1. Why you wrote this and that?

  2. Why is added this and that?

  3. Where is this and that?

  4. What have you mind here and there?

  5. Here is wrong. And here will be a bug. And this is completely unaccetable.

А в их языковой культуре это очень грубый наезд. Примеры мягкого обращения: http://begin-english.ru/razgovornik/vezhlivost-po-angliyski/

И еще наш английский часто корявый, и если уже появляется ощущение невежливости от письма, то оно усиливается до уровня «Уауэээ, насяльника-намана, ты мне подыробнасти рассказать, я тебя понимать буду, тестировать твой такие-то требования буду…» То есть, неграмотный и грубый бабуин машет бананом и грубо требует объяснить, почему то и это. О чем с ним говорить?

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

А, понятно, да, есть такое, этому надо научиться, чтобы не грубить, использовать could, should, would + please, и ещё следовать культуре смоллтока перед созвоном). Вы очень точно описали ваши примеры как "насяльника моя твоя не понимать", ну такое отношение и будет, да. Не знаю, сам общался с клиентами из Штатов много, и никогда следование парадигме вежливости проблем не вызывало, в обратную сторону это тоже работало. Но у меня свободный английский и мне это было делать достаточно просто. Хотя не знаю, чего там сложного. Сложнее было с разным деловым речекряком) там уже надо поднатореть.

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

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

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

Нет, разбегались все без дотрепывания. Разница в длительности передтрëпа и в детализации.

С американцами достаточно просигнализировать i'm great, me too, the wheather is great, we did a great job, let's do a new release even greater — это показатель того, что дела в норме. Если сказать всего лишь i'm fine или i'm ok — вероятно, что-то идет не так.

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

С латиноамериканцами в принципе то же самое, но я действительно узнал излишне много про их бабушек и детей и друзей У них сигнальная система такая…

Это село против города. Вашей прабабушке было бы крайне интересно с ними беседовать.

В общем, там 2 координаты: трепаться до-после, и город-деревня.

"а потом я проснулся"

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

Unit of work — это высокоуровневый API для доступа к хранилищу

разве?

Unit of work связан непосредственно с Use-кейсом и обеспечивает его необходимым набором репозиториев и мапперов

кого и как и зачем unit of work обеспечивает? это трекалка состояния, как она у вас такой всемогущей получилась?

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

разве?

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

чью идею?

не знаю что вы там построили, но учитывая путаницу а терминах звучит страшно

Поддерживаю предыдущего оратора. Особенно порадовал пассаж:

«Теперь наше основное хранилище соответствовало принципу ACID, и мы могли спокойно роллбекать и коммитить изменения.

Паттерн Unit of work позволял использовать любое постоянное хранилище и удобно тестировать код.

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

Вообще-то реализация ACID - достаточно не тривиальная задача, поэтому БД делают люди с пониманием. Наврядли систему которая заточена на ACID можно просто переделать на KV.

Хотелось бы побольше конкретных примеров а не общей воды.

Тут речь не про изобретение своей RDBSM, а про органичное использование возможностей. Например: что делать, если вам нужно отправить уведомление пользователю, но при этом транзакция в конце откатывается? Это явно не RDBSM решать.

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

Мы пошли простым путем: uow был обозначением бизнес транзакции - т.е полноценного бизнес значимого действия. По сути post-commit - это место, где можно отправить уведомление.

За скобки вынесем приемы DDD - это не тема статьи)

Я тоже поддержу, звучит текст красиво, но непонятно, что там вообще за некоторыми фразами.

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

Да кстати, Django это же фреймворк питона...

Спасибо за комментарий! Аргументы разумные.

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

Не стоит упарываться строго по терминам. "Трекалка состояния" эволюционировала естественным образом спустя пол года: мы просто поняли, что это отличное место для ослабления связности и мониторинга. При этом мы не нарушили слоистость - просто дали больше возможностей компоненту, который нам удобен. Более того: мы отказались, в последствии, от трекания состояния в kv-storage и перешли к commit-rollback RDBSM.

Что нам это дало? Далее мы смогли в сервисы уносить просто куски кода и переписывать маленькую часть - маппер.

Что касается репозитория - это не только мое мнение, но и https://martinfowler.com/eaaCatalog/repository.html

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

Краткое содержание: Был бардак. Навели порядок (вернее все переписали), повысили прозрачность кода, повысили вовлеченность сотрудников - стало лучше.

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

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

Опять же, бардак образуется естественным путем при развитии системы. Удастся ли через 5 лет активного развития текущую итерацию назвать чистой архитектурой? Или придется снова все переписывать?

P.S. и да, много общего описания без практической конкретики.

Хорошо сказано

Поддержу твой поинт)

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

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

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

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

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

Ох, если бы кто-то глянул на мой код на джанге от 2007-го года... Когда джанга была в альфе-бетте, когда еще не было механизмов наследования моделей, да и вообще 95% современных фич и модулей. Поэтому, да, историческую ретроспективу надо обязательно учитывать, прежде чем критиковать чужой код.

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

MVP - еще ладно. Небольшой проект/сервис, который реально за разумные сроки переписать - допустимо.

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

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

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

А почему в хабе "PostgreSQL"?

СУБД упоминается всего 1(один) раз .

Давным давно , когда я писал статьи в корпоративном блоге на Хабре , мне PRщица говорила - нужно побольше разных хабов , не обязательно про СУБД.

Вы тоже так делаете ? Для закрытия KPI ?

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

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

Разумеется, это только предположение. Хороший системный аналитик - это подарок и им надо дорожить :)

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

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

Если модель анемичная, зачем городить весь этот огород? Задача проектировщика - упрощать сложное. "Репозиторий" звучит красиво, но за ширмой - дореляционный подход из 1950-70-х "на каждый запрос пишем программу". "Абстрагирование" от СУБД до уровня "умной файловой системы" в интенсивно использующих долгохранимые данные приложениях - путь к нерешаемым проблемам производительности именно по архитектурным причинам.

Чистые архитектуры и вакуум

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

Как минимум нужна структурная схема используемой архитектуры. Иначе сложно понять как этот монолит работает.

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

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

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

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

Давайте попробуем, при условии что вы не будете использовать отражения, а в IData будете передавать реальный uow.

public sealed class Client : BaseEntity
{
    public interface IRepository : IBaseRepository<Client>
    {
        protected static Client Restore(Guid guid, string name, string address, Conditions condition)
            => new(guid, name, address, condition);
    }


    public string Name { get; private set; }
    public string Address { get; private set; }
    public Conditions Condition { get; private set; }

    public enum Conditions
    {
        Work = 1,
        Archive = 2
    }

    private Client(Guid guid, string name, string address, Conditions condition)
    {
        Guid = guid;
        Name = name;
        Address = address;
        Condition = condition;
    }


    public record CreateArg(string Name, string Address);
    public static List<Client> CreateRange(List<CreateArg> args, IData data)
    {
        var names = args.Select(x => x.Name).ToList();
        if (data.Client.List.Any(x => names.Contains(x.Name)))
            throw new DomainException("В системе уже зарегистрирован клиент с таким наименованием");

        List<Client> clients = new List<Client>();

        foreach (var arg in args)
        {
            var client = new Client(Guid.CreateVersion7(), arg.Name, arg.Address, Conditions.Work);
            client.Append(data.Client);
            clients.Add(client);
        }

        return clients;
    }

    public record UpdateArg(Client Client)
    {
        public string Name = Client.Name;
        public string Address = Client.Address;
    };
    public static void UpdateRange(List<UpdateArg> args, IData data)
    {
        foreach (var arg in args)
        {
            arg.Client.Name = arg.Name;
            arg.Client.Address = arg.Address;
            arg.Client.Update();
        }

        foreach (var arg in args)
        {
            if (data.Client.List.Any(x => x.Name == arg.Name && x.Guid != arg.Client.Guid))
                throw new DomainException("В системе уже зарегистрирован клиент с таким наименованием");
        }
    }

    public static void DeleteRange(List<Client> clients, IData data)
    {
        var clientGuids = clients.Select(x => x.Guid).ToList();

        var shipments = data.Shipment.List.Where(x => clientGuids.Contains(x.ClientGuid));
        if (shipments.Any())
            throw new DomainException("Невозможно удалить клиента, т.к. в системе существует отгрузка, использующая его");

        foreach (var client in clients)
            client.Remove();
    }

    public void ToArchive()
    {
        if (Condition == Conditions.Work)
        {
            Condition = Conditions.Archive;
            Update();
        }
        else
            throw new DomainException("Невозможно перевести в архив, т.к. клиент уже находится в архиве");
    }

    public void ToWork()
    {
        if (Condition == Conditions.Archive)
        {
            Condition = Conditions.Work;
            Update();
        }
        else
            throw new DomainException("Невозможно перевести в работу, т.к. клиент уже находится в работе");
    }
}

Допустим, у нас есть код с логикой в сервисе.
Вы говорите "Что если мы напишем новый код, который будет менять состояние сущности произвольным образом без наших проверок?".

class ClientService {
    public ClientService(IData data) {
        this.data = data;
    }

    public void DeleteRange(List<Client> clients) {
        var shipments = this.data.Shipment.List.Where(...);
        if (shipments.Any())
            throw new DomainException("...");
        
        foreach (var client in clients)
            client.Remove();
    }

+   public void DeleteRangeWithoutCheck(List<Client> clients) {
+       foreach (var client in clients)
+           client.Remove();
+   }
}

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

Теперь я беру сущность и пишу в ней следующий код.

public sealed class Client : BaseEntity
{
    public static void DeleteRange(List<Client> clients, IData data)
    {
        var shipments = data.Shipment.List.Where(...);
        if (shipments.Any())
            throw new DomainException("...");
        
        foreach (var client in clients)
            client.Remove();
    }

+   public static void DeleteRangeWithoutCheck(List<Client> clients, IData data) {
+       foreach (var client in clients)
+           client.Remove();
+   }
}

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

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

В моем примере вообще никаких сервисов домена нет, логика написана в самой сущности, более того client.Remove() наследуется от BaseEntity, там он помечен как protected, т.е. на самом деле вы не сможете вызвать его где то еще и только сама сущность будет определять свое поведение.

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

Из репозитория вы можете достать сущность и поменять свойства как хотите и сохранить сущность. Для этого не нужно менять код сервиса или самой сущности.

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

В моем примере вообще никаких сервисов домена нет

Я и не говорил, что в вашем примере они есть. Первый пример кода показывает мой подход, второй ваш.

т.е. на самом деле вы не сможете вызвать его где то еще

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

Ну и как это не смогу:

public sealed class Client : BaseEntity {
    public void PublicRemove() {
      this.Remove();
    }
}

(new Client(1)).PublicRemove();

Или вообще вот так, другой класс сущности для той же таблицы:

public sealed class Client2 : BaseEntity {
    public static void DeleteRange(List<Client> clients, IData data)
    {
        // нет никаких проверок
        foreach (var client in clients)
            client.Remove();
    }
}

И заметить это можно будет только на код-ревью, так же как и с сервисом.

и только сама сущность будет определять свое поведение

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

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

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

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

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

Вот в том и дело, что нет. Это основное заблуждение. С логикой в сервисах логика пишется только в сервисах, и сервис для сущности обычно один.
В вашем подходе тоже могут быть сущности Client2, Client3, Client4, разбросанные по всей программе в неймспейсах контроллеров, представлений и DTO, вы же не считаете это недостатком вашего подхода.

не меняя код домена

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

Проще говоря вы можете изменить сущность на уровне приложения обойдя все бизнес правила

Я вам привел пример, как изменить сущность в вашем подходе, обойдя все бизнес-правила - просто сделать дублирующую сущность. А еще можно менять данные сущности сырыми SQL-запросами из контроллера. Это тоже запишем в недостатки вашего подхода?

С логикой в сервисах граница домена это сервис, а не сущность. И любой код изменения сущности вне сервиса на ревью выглядит так же, как запрос UPDATE из контроллера, и вызывает вопрос "Что это за фигня?".

в вашем случае обеспечить инварианты не получится

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

И сущность будет обеспечивать инварианты

Ну как это будет, если я вам привел 3 примера, когда не обеспечивает?) Берем и удаляем клиентов без проверки shipments. Вы можете сказать "Раз программист написал такой код в сущности, значит ему так надо", ну так и с сервисами точно так же.

В моем подходе этого сделать не получится, придется изменить сам домен.

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

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

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

Конечно можно наплодить Client2 и Client3, обратиться к бд из контроллера, но за такое скорее всего если и не уволят, то явно притормозят карьерный рост. А вот за то что ошибся и забыл бизнес-правило явно так никто ругать не будет, а забыть то не трудно, если предметная область сложная.

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

когда программисты напишут вам прямо в контроллере доменную логику?

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

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

Такой код там может появиться только если есть бизнес-требование менять первую сущность. А бизнес свои правила знает сам. Какие проверки он сказал, такие и делаем. Не сказал, не делаем. Хотя можем уточнить.

А вот за то что ошибся и забыл бизнес-правило

Что значит "забыл бизнес-правило"? Правила идут от бизнеса и указаны в задаче на добавление нового функционала. Как он сказал так и делаем, неважно, какие правила были для другого функционала. Бизнес со своими правилами сам разберется.
Я же говорю, в сервисах новый код для изменения сущности пишется ровно в тех же случаях, когда с логикой в сущностях надо добавлять в сущность новый метод. И нужные проверки можно пропустить в обоих случаях.

для чего тогда вообще на уровне сценария получать сущности из репозитория

Я не очень понял вопрос, я обычно получаю сущность в контроллере, потому что для нее надо проверить 404 и 403, а возврат этих статусов ответственность контроллера. Можно делать application service и загружать сущность по id там, видимо вы его имеете в виду, но тогда он будет имитировать контроллер, и надо будет использовать исключения. Мне это кажется ненужным усложнением.

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

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

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

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

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

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

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

Ага, в этом и заключается распространенное заблуждение. Бизнес-логика это не поведение сущности, не действия над полями сущности, и не детали ее реализации. По той простой причине, что в одном бизнес-процессе может участвовать несколько сущностей. Поэтому она должна быть не в объекте сущности, а где-то снаружи нее в другом объекте. Объект участвует в некотором бизнес-процессе, а не задает его. Распространенный пример - перевод с аккаунта на аккаунт, его даже DDD рекомендует делать сервисом. И принцип не меняется, если в некотором процессе участвует одна сущность, а не две. Сегодня одна, завтра бизнес захочет три. С логикой в сущностях придется всё переписывать.

Другой пример: "После сохранения заказа в системе нужно отправить email пользователю с информацией о заказе". Тут нет действий над полями сущности, но реализация этих бизнес-требований это бизнес-логика.

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

Куча статических методов это ужасная архитектура. Это само по себе говорит, что вы поместили их не в тот объект, к которому они принадлежат. Об этом же говорит и копипаста IData в каждом методе.
Вы же говорите, что действия должны быть ассоциированы с данными, а статические методы с нестатическими полями объекта не могут работать, значит не ассоциированы с ними, они могут обращаться только к набору статических полей. Вот этот набор статических полей и методов можно выделить в отдельный объект. Это и будет сервис.

проверить изменения домена на ревью сильно проще

Ну я же вам объясняю, вот проверили вы изменения домена на ревью, а контроллер не проверили, а программист там написал SQL-запросы с UPDATE. Проверять в любом случае надо весь код.

Да и изменение существующего бизнес правила будет проще.

Я приводил пример DeleteRange в сервисе. Почему оно проще, если код абсолютно одинаковый?

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

Нету смысла. Поэтому в таких случаях делают один сервис, зачем их два-то делать?) Вы же не делаете две сущности.

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

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

class Product {
  public static void cancelReceipt(List<ProductRecord> receiptProducts) {
    foreach (var productRecord in receiptProducts) {
      var product = this.repository.findOne(productRecord.id);
      
      product.quantity -= productRecord.quantity;
    }
  }
}

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

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

class StorageService {
  public void receipt(ReceiptParamsDTO receiptParams) {
    ... addProduct();
  }

  public void shipment(ShipmentParamsDTO receiptParams) {
    ... removeProduct();
  }

  public void cancelReceipt(Document receiptDocument) {
    ... removeProduct();
  }

  public void cancelShipment(Document shipmentDocument) {
    ... addProduct();
  }
  
  private void addProduct(...) {
    ...
  }
  
  private void removeProduct(...) {
    var resultQuantity = ...;
    if (resultQuantity < 0) throw new RuntimeException(...);
    ...
  }
}

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

Термины часто используются не к месту, что намекает на поверхностное понимание темы. Слово паттерн используется для всего, для чего автор не нашёл иную абстракцию. Звезды удачно сошлись и команда была достаточно заряжена, чтобы нанять несколько аналитиков и следовать best practices последних 10 лет. Молодцы.

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

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

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

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

Ну и последнее, автор пишет про какие-то Приятные результаты:

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

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

  • Мне было проще понять, какой Use-кейс можно переиспользовать в другой части системы, потому что я стал лучше ее понимать. А понимал я ее лучше, потому что у меня не было необходимости погружаться глубоко в код. А разработчикам было проще, потому что они теперь ориентировались на Use-кейсы. Фактически у нас появилось коллективное владение кодом.

1. Как тут помогла чистая архитектура?
2. Тот же вопрос.
3. А в чем проблема разделить на интерфейсы и модули код если сложно было его понимать?


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

А не сильно ли бъет по производительности множество слоев и обмен информацией между этими многочисленными слоями через JSON (с издержками на сериализацию/десериализацию и предачу/обработку/проверку данных в каждом слое)?

Так монолит же - зачем JSON-ки гонять? Хватит и простых DTO )

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий