Меня раздражает традиционная архитектура бизнес-приложений — об этом я уже говорил. Я критикую — я предлагаю. Сейчас я расскажу, к чему меня привели поиски решений для проблем из предыдущей статьи.  Мне нравится перебирать архитектурные концепции. Всю жизнь я пытаюсь найти в области архитектуры и дизайна ПО что-то работающее и в то же время простое. Не требующее разрыва мозга для понимания и кардинальной смены парадигмы. Идей накопилось порядочно и я решил объединить лучшие из них в своём фреймворке — Reinforced.Tecture. Разработка таких штук даёт гигантское количество пищи для размышлений, я хочу ими поделиться. Тексты про такие технические вещи обычно до ужаса нудные. Я честно постарался не нудить, поэтому мой текст получился слегка агрессивным. Если вам с этим норм и интересно почитать про архитектуры .NET-приложений — заходите. Это продолжение моей предыдущей статьи. Думаю, стоит сказать несколько слов перед тем, как перейти к сути дела. Момент первый: я не считаю что я тут самый умный, не пытаюсь никого научить, оскорбить или что-то продать. Я уважаю многолетний опыт индустрии (и опыт читателей в том числе), но то, что я наблюдаю в кишках каждого проекта всю свою карьеру — мне совсем не нравится. Мне за это стыдно. Я делал Tecture для себя, чтобы сэкономить своё время и успокоить свои нервы. Я презираю DevRel и коммерцию в open source за лицемерие, о чём уже писал в своей старой статье. Мне никто не платит, я не выступаю от лица никакой компании и делаю свои проекты в свободное время на свой страх и риск, без всяких гарантий (о чём сказано в MIT-лицензии). Я делюсь своими наработками на языке программирования, который не высасывает мне мозг и если они будут кому-то полезны — хорошо. Момент второй: я рассказываю про код для бизнеса. Не для игр, не для библиотек, не для фронтенда, а для бизнеса. Про кровавый интерпрайз, другими словами. Все знают что такое кровавый интерпрайз: опердни, автоматизации документооборота, складов, отчётности, делопроизводства, биллинг и прочая нудятина с префиксом "бизнес-" и привкусом офисной затхлости. Обычно такой код пишется двумя способами: В народе первое считается "правильно", а второе вроде как "быстро". А вот чтобы и быстро и правильно — никто вроде не сделал. Поэтому я попробовал сам, со своими представления о прекрасном. Получилось крафтово, оригинально и местами кринжово для мира .NET. Момент третий: я говорю про большие проекты для бизнеса. Не про хэллоуворлды, которых по 100 рублей пучок в базарный день можно купить на UpWork-е и не про стартапные кривожопые MVP. Я говорю про огромные, как слоновий хер проекты для бизнеса, которые делаются командами по 30 и более человек и длятся по 15 лет. Которые уже распластались на несколько баз данных, десятки подпроектов и просто вопят о том, что нужно уменьшить сложность. К таким проектам нет и никогда не было чёткой документации, но они работают в кровавом production-е, ежедневно обслуживая чёртову прорву разных бизнес-процессов. Прототипы списка покупок на node/react меня не интересуют. В них архитектура не нужна, потому что они всё равно сдохнут быстрее, чем их разработчики окончат университет. Мне с исследовательской позиции интересно управление сложностью в long term. У больших проектов остро встаёт вопрос "если переписывать, то как" и тут я попробую подкинуть пару идей. Бизнес-логика пишется в агрессивной среде: с одной стороны база данных со своими заморочками, с другой — очередь сообщений, с третьей ещё какой-нибудь SalesForce. Все эти внешние системы надо как-то прокладывать по периметру абстракциями, чтобы сконцентрироваться непосредственно на логике, а не на том, что там продактам очередного мутного API взбрело в голову. Работа с внешними системами — это краеугольный камень разработки бизнес-приложений. Первое, что надо сделать чтобы отлепить свой код от внешних систем — это понять и принять факт, что сделать это полностью невозможно. Откройте свой рабочий проект, найдите то, что у вас называется "бизнес-логика": это просто код, который сам ничего не делает, а только говорит что делать внешним по отношению к вашей системам. Какие-то действительно сложные вычисления руками в рамках формошлёпства — скорее исключение, чем правило. За примером далеко ходить не надо: скорее всего вы работаете с реляционной базой данных. Такие базы удобны бизнесу, который пользуется вашей системой, но не удобны вам. Поэтому вы используете O/RM, который по сути здоровенный конструктор SQL-я. Но в своё время вокруг O/RM-ов раздули дикий хайп и преподносили их чуть ли не как серебряную пулю от всех бед. А бизнесу тем временем совершенно пофигу — напишете вы INSERT-ы руками, или за вас их слепит библиотека. Я, кстати, не доверяю O/RM-ам уже давно и вот почему: они старательно пытаются внушить мне что "ложки нет". Есть, мол объекты, ты их меняешь. Есть коллекции, ты в них объекты добавляешь и удаляешь. А базы данных не существует. Ну подумаешь — надо вызывать SaveChanges время от времени. И это полная чушь. Как и все подобные абстракции, O/RM безбожно течёт. Он рвётся от натуги, когда пытается полностью заменить собой базу. Регулярно приходится выбирать — сделать "как по ООП" или выразиться в терминах SQL, чтобы работало быстрее. Не я первый натолкнулся на эту проблему, она довольно известна и называется "object-relational impedance mismatch". При попытке понять причины и придумать решение можно легко потонуть в высокопарных рассуждениях о несоответствии контрактов. Поэтому, я предлагаю проще: дело в том, что я не работаю с объектами. Моя конечная цель — изменения в базе данных, пусть и сделанные через объекты. Нельзя долго делать вид, что базы нет, а то она обидится и даст по роже в самый неподходящий момент самым непредсказуемым образом. Но и писать интерпрайз целиком в примитивах базы данных — тоже хреновая затея. Я ж не DBA какой-нибудь и не хочу перевести всю логику на stored-процедуры. Нужен разумный баланс. Я нашёл его в концепции каналов и аспектов. Моё авторское мнение: лучше не спорить с объективной реальностью и признать что у нас есть внешние системы. До них мы прокидываем каналы, с которыми работаем в тех или иных определённые аспектах. Именно такое положение дел перетекло в Tecture дословно. И вот первая абстракция, которую я добавляю. Даже две абстракции, которые ходят парой. Канал олицетворяет любую внешнюю систему, с которой мы будем взаимодействовать. База данных, очередь, кэш или что у вас там. В Tecture канал — это интерфейс, не требующий реализации. Чтобы его задекларировать — достаточно написать: Букву I перед именем канала писать не нужно.  Если бы я хотел поумничать и ляпнуть со сцены условного CodeFest-а что-то солидное от лица компании, то я бы наверняка сказал, что канал — "это типовой разделитель логики, который мы используем для извлечения метаинформации посредством HKT", но нет. Мне за умные слова никто не платит, поэтому я объясню проще. В C# нет ключевого слова type как в F# или TypeScript, а его самый близкий аналог — interface без реализации. Каналы будут использоваться именно как type — подставляться в методы и классы тип-аргументами, предоставляя метаинформацию и шевеля шарповый type inference под нужными мне углами. Плюс, в C# нет HKT, поэтому с реальными системами каналы будут сопоставляться через позднее связывание на reflection-е. Канал есть. Теперь надо привязать к нему аспекты. Аспект определяет как мы работаем с системой. Но он не определяет как система работает на самом деле. У нас есть канал базы данных и в куске кода ниже по тексту мы хотим сказать что мы будем работать с ним через O/RM, а ещё будем пулять в неё голым SQL-ем. И если по-честному, то прятаться за этим каналом может всё, что угодно, и ему не обязательно поддерживающее смапленные на типы множества или SQL нативно. Достаточно исполнять обязательства по аспектам. Это как интерфейс, только обыгранный чуть по-другому. Сначала я называл это "фича", но мне сказали что если переименовать в "аспект" — будет круче звучать. Я не очень хочу начинать со слов, что я сделал аспектно-ориентированный фреймворк. Вся теория вокруг AOP сложна и содержит кучу не очень удачных терминов. В Tecture можно разглядеть и аспекты, и советы, и срезы и точки соединения, но зачем? Я хочу уменьшить сложность, а не увеличить. Ещё канал может подсасывать несколько аспектов сразу. Я снасильничал над компилятором C# и обыграл это через множественный экстенд интерфейсов. Получается приятно и лаконично: Аспекты подтягиваются из отдельных пакетов, в ядре же самого Tecture ничего нет кроме поддержки корневых концепций (сервисы, каналы, команды и запросы). Вся конкретика by design должна лежать отдельно. Это сознательное решение: ядро и аспекты не требуют ничего сверх netstandard2.0. Конкретного кода там довольно мало — считай одни абстракции. А сборки на нетстандарте превосходно подключаются и к .NET Core и к полноразмерному фреймворку. Более того, ядро и аспекты (по задумке) являются необходимыми и достаточными зависимостями для реализации бизнес-логики. А это значит, что target framework для неё так же будет не выше netstandard2.0. В воздухе отчётливо запахло переездом на неткор. Таким образом, Tecture не мешает отвязке от полноразмерного .NET (читай: от windows), а очень даже потворствует. Но на практике всё зависит от того, какие ещё зависимости подтягиваются в логику. Если там есть что-то хитрое, требующее полноразмерный .NET Framework, то чуда не случится.  А ещё умные мужики, придумавшие SOLID, называют это O/CP и Separation of Concerns. И благословляют. Также аспекты — это крутая тема с организационной точки зрения. Башковитый системный архитект может сам нахерачить аспект и связать его с каналом, таким образом закрепив для линейных разрабов формальные правила работы с внешними системами, специфичные для проекта. И эти правила ни один джун не сможет перепрыгнуть в силу строгой типизации — компилятор тупо не даст. Если так строить систему, то в ней будет соблюдаться порядочек, которым архитект рулит формально и может контролировать. И каналов, в общем-то, можно сделать сколько угодно. Тут есть свои бенефиты: можно организовать separated contexts в дань традиции DDD, а можно не мудрствуя лукаво использовать несколько баз данных из одного приложения без разрыва жопы. Вот такие вот они — каналы и аспекты. Выглядит на самом деле слишком оторванно от реальности, надо приложить к чему-то конкретному чтобы заиграло. Поэтому я перейду к более приземлённой штуке: сервисам. Я считаю что сервис-ориентированная архитектура хороша, и реализовал её поддержку в Tecture. Вроде как интуитивно понятно к чему это, но есть своя специфика. Сервисы — это место, где живёт бизнес-логика. Они оптимизированы именно под неё и ни для чего другого не подходят. Вот типичный сервис в Tecture, в котором лежит до ужаса тупая логика: Такие сервисы уделывают обычные, сделанные руками по всем пунктам: Во-первых их не надо регистрировать ни в каком IoC-е. Вызвать один сервис из другого можно написав Let<Products>().СделатьЧтото(...) в любом месте сервиса (кроме конструктора). Одна только эта примочка начисто сносит 90% однотипных записей в регистрации IoC-а (вместе с модулями да) и выпиливает из проекта километровые портянки бессмысленного и тупого интеграционного кода, в котором легко потерять строчку при рефакторинге. И сидеть потом в ожидании runtime exception. Да и вообще, когда те же проблемы решаются за меньшее количества кода — это хорошо. Всё по ТРИЗ, как деды завещали. В Tecture есть свой мини-IoC на типах. Тупой ровно настолько же, насколько и эффективный. Лайфтаймы в нём прописывать не надо — они всё равно во всех проектах одинаковые и прибиты гвоздями к лайфтайму подключений к базе (и остальным внешним системам). Инстансы сервисов Tecture создаёт лениво, поэтому можно не бояться циклических зависимостей. И за потребление памяти тоже можно не бояться. В довершении всего: никакого дискаверинга сервисов при старте не происходит, что позволяет Tecture не добавлять приложению перфоманс-оверхеда без необходимости. Во-вторых интерфейс для такого сервиса не нужон. Обычно сервисы прячутся за интерфейсы чтобы писать моки, но Tecture построен так, что потребность в моках отпадает. Как и почему это происходит я объясню потом. Пока важно вот что: выносить сервисы за интерфейсы не надо и точка. Да, можно выкинуть из кодовой базы кучу бесполезных файлов с ISomethingService. Это тоже хорошо. Меньше типов, меньше абстракций — проще проект. Я терпеть не могу интерфейсы, у которых ровна одна реализация и постоянно заменяю их на классы (расставляя virtual если потребуется). Они не нужны примерно ни за чем, кроме как чтобы добавить мне ещё один клик мышкой при попытке увидеть код метода. В-третьих с такими сервисами, например, можно резать систему по семантическим швам и закатывать в отдельные сборки вместе с используемыми сущностями. Вот есть у нас отдел обработки заказов на фирме — сделаем под него отдельную dll-ку, декларирующую все, используемые отделом заказов сущности. Отведём на обслуживание этой части системы отдельную команду. А наружу будут торчать сервисы для работы с заказами. Сущности для полного счастья можно закрыть на изменение модификатором internal так, чтобы все изменения проходили только через сервисы. Готово, вы великолепны: система гранулирована на мелкие кусочки, всё разложено по полочкам — хоть инкрементальные билды делай. Зависимости между такими сборками будут иерархическими, а иерархия всегда проще для понимания чем линейная структура. Ну и назвать такие сборки можно "domains", типа бизнес-домен. В-четвёртых: тулинги. Я не нашёл подходящего названия для этого механизма, поэтому называю его тулинг. Это вот там, где у сервиса указаны тип-параметры: Тулинги явно описывают что в этом сервисе делается, а чего в нём точно не делается. Степень детализации этой информации зависит от аспекта. Вот про сервис из примера выше точно можно сказать что Order-ы он не удаляет, а OrderLine-ы не меняет (только создаёт). И это мы глянули только на шапку сервиса, а уже сколько информации. Я могу так сделать, потому что строгая типизация в C# решительно даёт прикурить остальным языкам. Если попробовать написать в этом сервисе, скажем To<Db>().Delete(order) — компиляция упадёт с ошибкой, как бы говоря нам: "хэй, чувак, это наш двор и ордеры тут не удаляют". Тулинги гибкие. Они тоже подсасываются из аспекта. Вот интерфейсы Updates<> и Adds<> определены в аспекте ORM до восьми сущностей включительно. Само собой, это автогенерированный код, я не писал это всё руками. Жаль что в шарпе пока нет квазицитирования и HKT — приходится собирать подобные конструкции из говна и палок.  Но с другой стороны — оно и к лучшему. Отсутствие HKT не позволяет писать типы, параметризуемые потенциально бесконечными числом аргументов и вынуждает меня ограничивать как сервисы, так и их тулинги по числу тип-параметров. Это можно использовать чтобы предотвратить появление в системе god object-ов. Я считаю так, что если вы добавляете девятую по счёту обязанность сервису, то ему уже хватит и надо его декомпозировать, а не накидывать. Компилятор просто помогает мне как архитекту доносить эту мысль до линейных разработчиков наиболее эффективно. По задумке — сделать заготовку для TectureService с девятью параметрами будет сложнее, чем потратить 10 минут и разбить сервис на два. Так я ситуативно использую лень разработчика, чтобы направить его по пути декомпозиции. В-пятых: я убрал дебильный суффикс "Service". И так понятно что это — сервис. В жопу суффикс. Чтобы вызвать один сервис из другого есть Let<>. Но как вызвать сервис извне? Подробно я расскажу в следующей статье, но для полноты в двух словах: сам Tecture может регистрироваться в любом IoC-е как интерфейс ITecture (через фэктори метод). Получить инстанс ITecture можно пнув TectureBuilder и забайндив каналы. Именно так, через построитель каналы и аспекты связываются с живыми внешними системами. Штука, которая непосредственно обеспечивает коммуникации и реализует аспекты называется рантайм. И пока что достаточно о них. Так вот, у ITecture есть метод Let<>(), такой же как и внутри сервиса. Через него можно позвать позвать любой понравившийся сервис просто подстановкой типа: tecture.Let<Orders>().CreateOne(...), как только инстанс ITecture окажется у вас в руках. Что ещё доступно внутри сервиса? Не считая всякие службные штуки, можно сказать что в основном там обитают три интересных метода (закрыты модификатором protected): На From<> и To<> стоит остановиться подробнее. Что меня зацепило в архитектуре EntityFramework: запросы к базе данных делаются вот прямо вот на месте. Пишешь LINQ, транслятор его запинывает в SQL, используя метаданные, скармливает базе и выплёвывает коллекцию объектов в момент, когда разворачивается получившийся IQueryable. Но! Если хочешь что-то записать в базу, то всё происходит по-другому. Ты меняешь объекты, зовёшь .Add, .Remove — создаёшь такой… чертёж изменений. Потом хлоп — SaveChanges, всё собирается в SQL батч и летит в базу. Я зацепился за эту мысль и долго крутил её в голове. Ненавижу EF-ный ChangesTracker, который сравнивает начальное и конечное состояние объектов и выводит diff, но вот сам подход "запросы сейчас, а изменения — потом" — звучит дельно. Вообще разделять чтение и запись — это хорошо. Даже с грёбаным файлом мы читаем и пишем по-разному. Я не о том, что читать надо методом Read, а писать методом Write. Я про концептуальную разницу. Вот та же база данных. Какие грабли подстерегают нас при записи? Индексы тормозят из-за перебалансировки б-дерева (особенно кстати на вставке гуидов заметно), транзакции надо разруливать чтобы не перетереть чужие изменения, консистентность данных там блюсти, денормализацию ещё затриггерить. Ну что-то в этом духе. Когда пытаешься читать — всплывает совершенно другое. Типа а как читать по-быстрее, как составить запрос, какие данные клиенту нужны, а какие не очень, что делать если требуют 10 тысяч записей одной пачкой, в какие индексы смотреть чтобы не облажаться, может вообще читать из кэша? Ну и самое очевидное — редко когда удаётся что-то записать не прочитав. Инструментарий для чтения и записи должен быть разным. Я долго думал как это обыграть. Пошёл посмотреть что в интернете предлагают, открыл для себя дивный мир CQRS, вскоре разочаровался в нём, посмотрел на MediatR, изучил тему DDD, пролистал книгу "Entity Framework Core in Action" за авторством какого-то умного чувака и поглядел репозиторий к ней. Выпал в осадок и понял, что так делать точно не надо. Потом пошевелил мозгами и решил делать как Microsoft — тупо, нагло, прямо. Если Microsoft берётся делать фреймворк для MVC, то жди классов Model, View и Controller. Если для web-а, то будет HttpRequest и HttpResponse. Прямо, эффективно, без лишней зауми и оверинжиниринга. Местами даже тупо. Ну будем подражать великим. В Tecture разделение чтения и записи обыгрывается в лоб: чтение выполняется через запросы, а запись — через отложенные команды. Чтобы сделать запрос — надо достать входной конец канала через From<>. Тут наблюдаем мелкобытовой нацизм: в Tecture считается что команды рулят, а запросы — так. Чтобы хранить команды — отведены целые сервисы. Чтобы хранить запросы отведено целое нихрена. Потому что все запросы в Tecture — статические. Они набрасываются экстеншонами к читальному концу канала и его производным. В этом есть глубокий теоретический смысл: запросы в основном не меняют состояние внешней системы, если отбросить concern-ы производительности. SELECT данных в базу не добавляет. В идеальном мире его можно выполнить сколько угодно раз и получить один и тот же ответ. А это уже толстый намёк на идемпотентность чтения, что в случае с базой данных реально так, если юзать транзакции — см. Repeatable Read. Тут любители ФП кричат нам с дивана: функция, которая возвращает результат, не модифицирует глобальный контекст, да ещё и собственные параметры не изменяет, что-то подозрительно похожа на чистую. А чистые функции в C# принято выражать экстеншонами. Если отдельно проработать механизмы перехвата запросов и возможность подстановки fake-ответов, то быстро выясняется что Repository Pattern НЕ НУЖЕН. По этому пути я и пошёл. В итоге запросы в Tecture писать непривычно, но просто: берёшь "читальный конец" канала, заворачиваешь в отдельную абстракцию и просто фигачишь к ней статические методы расширения. Это хорошо тем, что становится пофигу где именно написан метод запроса — компилятор, женерик-констрейнты и маркировочные интерфейсы прицепят их куда надо. А решарпер ещё и подскажет. Вот мой любимый пример: как сделать метод GetSomethingById, выкинув из системы добрую половину репозиториев: Всё, репозитории не нужны: Не то чтобы я тут изобретал что-то совсем новое — аналогично устроен весь LINQ, да и в целом весь fluent-стиль, но чёрт побери, как же это удобно. Для чтения у каналов есть читальный конец — From<>(). Значит для операций записи/изменения есть… я не знаю, ПИСАЛЬНЫЙ КОНЕЦ? В общем та штука, которую возвращает To<>() внутри сервиса. Получить её за пределами сервиса невозможно — так сделано, чтобы не было соблазна разбрасывать изменение данных по всему коду. Хочешь изменений — вступай в сервисы. To<>() возвращает тип Write<...> с кучей женерик-параметров, к нему подтягиваются экстеншоны из аспекта. С учётом тулингов, конечно же. Там вот выше в примере таким способом был вызван .Add. Вот его исходники. Если аспект или тулинг не позволяет подобрать .Add с нужными аргументами — ошибка компиляции. Если позволяет — успех. Дальше. Никакой записи прям вот на месте не происходит. Вместо этого Tecture создаёт инстанс команды Add, кладёт его во внутреннюю очередь и логика продолжает выполняться. По итогу схема такая: бизнес-логика берёт пользовательский ввод, подтягивает недостающие данные из внешних систем и составляет список того, что с этой байдой надо сделать в сухом остатке. Это как программа, только маленькая и сравнительно простая. Тот самый чертёж изменений по аналогии с EntityFramework. Только глобальный. Для всего. Ну и по аналогии с EntityFramework, можно сделать Save у корневого инстанса ITecture. Я называю этот этап сохранение. В чём профит? Больше контроля из одной точки. С такой очередью удобно работать. Можно как угодно издеваться над ней программно. Например, отдельные этапы записи можно обложить логами, можно гибко сделать отлов exception-ов один раз на всё приложение, можно сериализовывать очередь. Можно вообще её не выполнять. Я подумываю над программным способом отката изменений, но пока такое сложновато. Но самое крутое в том, что появляется чёткое разделение ошибок. Все exception-ы, пойманные в ходе выполнения логики можно смело трактовать как логические ошибки приложения. То есть смысловые. Например: не выполняется какое-то бизнес-правило, нарушаются ограничения выданные по ТЗ, недостаточно товара на складе, нет подходящей детали и иже с ними. А вот технические ошибки по причине, скажем, недоступности базы данных, отвалившейся транзакции, упавшего веб-сервиса, неушедшего e-mail-а проявляются только в моменты сохранения (ну и запросов). Становится проще концептуально отделить мух от котлет и понять — это вы лажаете, или сторонняя система гонит. Ну и чинить по обстоятельствам. Последний узкий момент, который надо упомянуть: начать сохранение изнутри сервиса невозможно технически. Но как быть, если надо что-то сделать с только что добавленным заказом? А вот как: можно тоже положить это действие в специальную очередь, которая будет разобрана после того, как Tecture разберёт основную очередь. И это изящно обыграно синтаксически (код приводится на примере ORM-аспекта, ни один Order не пострадал): Это Save-оцентрический await. Ну весело же, ну!  На самом деле это только выглядит красиво на простых примерах, но вообще использовать его надо с осторожностью — конфликтует с асинхронными запросами. В крайнем случае можно откатиться к явной записи через Save.ContinueWith(...).  Это были основные примитивы Tecture и я намеренно старался держать их количество под контролем, чтобы снизить порог вхождения. По той же причине я не пишу про его внутреннее устройство. Там местами свой локальный адок (связанный с обходом отдельных языковых ограничений C#), но в общем ничего криминального. Исходники открыты — вот ссылка на репозиторий. Однако, сухой текст и теоретические рассуждения не позволяют прочувствовать как Tecture ведёт себя на практике. Тот самый Development Experience надо показывать на примерах. Поэтому я приглашаю вас в следующую статью, в которой много картинок, а некоторые из них даже анимированные. В ней я покажу как это добро использовать на благо мироздания на примере небольшого тестового проекта.   Там и встретимся. .NET-разработчик