Do czego w DDD wykorzystać projekty i namespace’y z C#?
Projekty i namespace’y to niedoceniane elementy, które mogą znacząco poprawić czytelność naszego kodu. Warto stosować je w sposób przemyślany, a nie „jakkolwiek, bo to przecież nieistotne”. Kod czyta się co najmniej 10 razy częściej, niż się go modyfikuje. Warto więc zainwestować w jego czytelność i wymusić na poziomie kompilacji tak wiele sprawdzeń jak to tylko możliwe.
Marcin Markowski. Deweloper, lider techniczny, zwolennik podejścia Software Craftsmanship i ścisłej współpracy z biznesem. Specjalizuje się w modelowaniu, implementacji Domain Driven Design i projektowaniu architektury systemów. Zaczynał od consultingu biznesowego, później przeszedł do IT. Pracował zarówno nad systemami „enterprise”, jak i tworzył od podstaw rozwiązania dla małych firm. Próbował wejść w świat startupów z własnym produktem, ostatecznie został jednak w IT, gdzie działa jako konsultant i trener. Organizuje meetup DDD-WAW.
Screaming architecture
So what does the architecture of your application scream? When you look at the top level directory structure, and the source files in the highest level package; do they scream: Health Care System, or Accounting System, or Inventory Management System? Or do they scream: Rails, or Spring/Hibernate, or ASP ? – Uncle Bob
No właśnie czego dowiadujemy się po pierwszym spojrzeniu na Solution Explorer? Pewnie tego, że projektów jest dużo. Pewnie również tego, że nazwy projektów są na tyle długie, że ich najciekawsza część nie mieści się na ekranie przy standardowej szerokości tego fragmentu IDE. Możemy się też często dowiedzieć, że mamy niejeden Service, że mamy podział na Frontend i Backend, Business Logic i Data Access Logic oraz jeszcze wielu tego typu cennych informacji. Co one wnoszą do naszej wiedzy o systemie? Niestety nic! Są równie oczywiste co nieistotne. Do czego więc wykorzystać podział na projekty?
Spis treści
Poprawienie czytelności
Po pierwsze nazwy muszą być na tyle krótkie, żeby dało się je czytać. W związku z tym możemy śmiało usunąć z nich zbędne informacje. Nazwa firmy i/lub produktu może być przeniesiona na poziom nazwy całego Solution i nie ma potrzeby powielania jej w nazwie każdego projektu. Dodatki w stylu Service na końcu, lub Model w środku zazwyczaj też możemy sobie darować. Na poziomie Assembly name zostawiamy oczywiście przedrostek z nazwą firmy i/lub produktu, aby zapewnić unikalność nazw plików .dll. Przykładowo projekt Sales z Solution MyCompany.CRM
po kompilacji przyjmie postać MyCompany.CRM.Sales.dll
.
Odzwierciedlenie podziału na Bounded Contexty
Po drugie nazwa projektu powinna mówić o fragmencie biznesu jaki jest przez niego odzwierciedlany. Takie nazwy nigdy nie będą zbyt długie, bo biznes nie byłby w stanie ich wymawiać. Jeżeli nazwy projektów mają 3 czy nawet 5 członów (oprócz nazwy firmy i/lub produktu) to najprawdopodobniej pochodzą z IT i są jedynie szumem informacyjnym.
Optymalnym podziałem dla projektów jest ten pokrywający się z podziałem na Bounded Contexty z DDD, czyli na całe obszary działalności przedsiębiorstwa, które mogą mieć własne specyficzne nazewnictwo. Mogą to być np. Sprzedaż, Magazynowanie, Planowanie dostaw. Jest to najbardziej gruboziarnisty podział biznesu jaki interesuje nas przy projektowaniu systemu. Mamy wtedy zagwarantowane biznesowe nazewnictwo i właściwą wielkość projektów.
Odzwierciedlenie podziału na warstwy
Po trzecie projekty powinny pilnować prawidłowego przebiegu zależności, zgodnego z przyjętą architekturą. W prostych, CRUDowych częściach systemu często wystarczy jedna (sic!) warstwa, a więc jeden projekt. Tam gdzie mamy głęboki model najprawdopodobniej zastosujemy Hexagonal / Clean Architecture. W takim przypadku jeden Bounded Context będzie implementowany przez kilka projektów.
Po jednym projekcie powinno przypadać na warstwę Domeny (Sales.Domain
) i Aplikacji (Sales.App
). Warstwa Adapterów powinna być dodatkowo podzielona w zależności od tego, z czym na zewnątrz naszego systemu ma ona integrować. Oddzielny projekt powinien być dedykowany do komunikacji z bazą SQL (Sales.Adapters.Sql
), a oddzielny do wystawienia REST API (Sales.Adapters.RestApi
).
Referencje między projektami powinny przebiegać jedynie w sposób jaki dopuszcza zastosowana architektura. Sales.Adapters.RestApi
wie o Sales.App
, która z kolei wie o Sales.Domain
, ale odwrotne zależności już nie zachodzą. Sales.Adapters.Sql
wie o Sales.App
i Sales.Domain
, ale o innych Adapterach (np. Sales.Adapters.RestApi
) już nie.
Dodatkowo wszystko co nie stanowi publicznego API danej warstwy powinno mieć poziom widoczności internal, żeby jeszcze bardziej zminimalizować możliwość wykorzystania czegoś niezgodnie z przyjętą architekturą.
Zapewnienie optymalnej wielkości Solution
Co zrobić gdy projektów jest bardzo dużo? Po pierwsze trzeba się zastanowić dlaczego tak się stało. Przy zaproponowanym tu podziale raczej nie powinno do tego dojść. Jednym Solution powinien opiekować się jeden zespół. Mniejsze zmiany może wprowadzać w nim jeszcze maksymalnie kilka zespołów, po review zrobionym przez zespół-opiekuna. Przy takim podziale pracy nie da się efektywnie utrzymywać i rozwijać 100 lub więcej projektów w jednym Solution.
A co jeżeli mamy jedną monolityczną aplikację rozwijaną w jednym repozytorium? Jeżeli trzymamy się przedstawionych wyżej podziałów, to będzie to modularny monolit, czyli posiadający wewnętrzną strukturę zgodną ze strukturą biznesu. Nie ma wtedy problemu z używaniem wielu Solution zawierających rozłączne podzbiory projektów tworzących ten system.
Jeżeli jednak ilość projektów nadal jest zbyt duża do wygodnej nawigacji, to mamy jeszcze do dyspozycji Solution folders. Grupujemy wtedy wszystkie projekty dla każdego Bounded Contextu w osobnych folderach. Nadal mamy zachowane biznesowe nazewnictwo, a nawigacja nawet w naprawdę dużym Solution będzie efektywna.
A co gdy mamy tylko jeden Bounded Context w Solution? Wszystkie te zasady są nadal równie użyteczne. Jedyną różnicą jest to, że mamy bardzo małe Solution i nie dowiemy się z niego o tym jakie inne Bounded Contexty istnieją w naszym systemie. Jest to częsta sytuacja przy wykorzystaniu mikroserwisów rozwijanych przez dedykowane zespoły.
Ubiquitous Language
Nasz kod powinien przede wszystkim nieść opowieść o biznesie, który odzwierciedla. W Domain Driven Design jedną z podstawowych koncepcji, która w tym pomaga, jest tzw. Ubiquitous Language, czyli wszechobecny język. Jest to język wypracowany wspólnie przez biznes i IT, obecny we wszystkich aktywnościach i artefaktach, od spotkań z biznesem, przez modelowanie do kodu i dokumentacji. Nazwy w kodzie powinny więc być takie jak nazwy w biznesie! Podejście takie znacznie ułatwia nam komunikację, zmusza do precyzji i w ogromnym stopniu ułatwia utrzymanie systemu.
Większość nazw z Ubiquitous Language stanie się w kodzie nazwami typów i ich metod. Co jednak z nazwami dotyczącymi nie pojedynczych „bytów” jak klient, czy oferta, tylko wielu powiązanych ze ze sobą „bytów” jak Sprzedaż, Planowanie dostaw? Tu właśnie możemy wykorzystać namespace’y!
Nazwy Bounded Contextów i Modułów
Namespace’y mają hierarchiczną strukturę co świetnie współgra z podziałami biznesowymi od najbardziej ogólnych do najbardziej szczegółowych. Na pierwszym poziomie możemy umieścić nazwę firmy i/lub produktu. Na kolejnym możemy umieścić najbardziej ogólne podziały biznesowe, na całe obszary działalności posiadające spójne nazewnictwo, czyli Bounded Contexty. Ta część namespace’a jest jednocześnie nazwą projektu jak widzieliśmy to wyżej.
Pojedynczy Bounded Context jest najczęściej na tyle duży, że sam wymaga podziału na mniejsze części, żaby dało się go łatwiej zrozumieć. Te części w DDD to Moduły. Przykładowo dla Sprzedaży mogą to być: Zamówienia i Wycena, a w niej Rabaty (MyCompany.CRM.Sales.Orders
, MyCompany.CRM.Sales.Pricing.Discounts
). Oczywiście struktura katalogów powinna 1 – 1 odzwierciedlać strukturą namespace’ów, co znacznie ułatwia nawigację.
To nie jest jedynie techniczne porządkowanie kodu. Nazwy Bounded Contextów i Modułów zawarte w namespace’ach to bardzo istotne źródło wiedzy o koncepcjach występujących w domenie. Warto nie utracić tej informacji!
Namespace’y nie muszą odzwierciedlać podziału na warstwy
A co z tą częścią nazwy projektu, która odzwierciedla podziały na warstwy np: .Domain
, .Adapters.Sql
? Osobiście wolę nie dodawać ich do namespace’ów. Namespace’y niech niosą jedynie informację o podziałach biznesowych. To jaki jest podział na warstwy odzwierciedla już podział na projekty. Oczywiście typy ze wszystkich warstw dotyczące jednej części biznesu trafią wtedy do jednego namespace’a. Nie jest to jednak nic złego. Skoro jest to jeden fragment biznesu, to powinien być on wszędzie nazwany tak samo, niezależnie od podziału na warstwy, który zależy od innych czynników.
Unikaj podziałów technicznych!
Co z podziałami technicznymi jak np. Controllers, Repositories, Entities? Tego typu informacje w namespace’ach nie mają żadnego sensu. Nie wprowadzają żadnej istotnej informacji, za to generują sporo szumu. Naprawdę nie warto tego robić. Namespace’y takie mają tendencję do niekontrolowanego rozrastania się, gdyż wszystkie kontrolery, repozytoria, czy encje z całego projektu trzeba zmieścić w jednym miejscu. Do tego, żeby przeanalizować kod dotyczący jednego fragmentu biznesu trzeba nawigować po wielu katalogach, których struktura sama w sobie nie niesie żadnej biznesowej informacji.
Low coupling – High cohesion
Low coupling – High cohesion – nie ma lepszego podsumowania tych rozważań. Powinniśmy dbać o realizację tej zasady na każdym poziomie od typów do systemów. Jednym z poziomów jest właśnie poziom Bounded Contextów i Modułów. Powinny one mieć ściśle określoną, niezbyt szeroką odpowiedzialność i być na tyle niezależna na ile to możliwe. Odpowiedzialności i dopuszczalny stopień powiązań należy jednak zawsze ustalać na podstawie głębokiego zrozumienia biznesu, a nie kryteriów czysto technicznych!
Podsumowanie
Dzięki takiemu podejściu już sam rzut oka na Solution Explorer daje nam sporo informacji o systemie i biznesie, któremu ma on służyć. Nazwy wszystkich komponentów są na tyle krótkie na ile to możliwe, co znacznie ułatwia nawigację. Ponadto każdy techniczny element kodu ma jasną odpowiedzialność i wiadomo jakiego rodzaju informacji można od niego oczekiwać. System jest podzielony hierarchicznie zgodnie z podziałami w biznesie. Zachowana zostaje zasada Low coupling – High cohesion na każdym poziomie tak rozumianej hierarchii.
Podsumowując:
1. Wszystkie nazwy powinny być tak krótkie jak to możliwe i pozbawione szumu informacyjnego.
2. Nazwa firmy / produktu powinna być obecna w: Solution name (MyCompany.Crm
), Assembly name (MyCompany.CRM.Sales.Domain.dll
), Root namespace (MyCompany.CRM.Sales
), ale lepiej nie dodawać jej do nazwy projektu (Sales).
3. Nazwa projektu powinna zaczyna się od nazwy Bounded Contextu (Sales
lub Sales.Adapters.RestApi
).
4. Projekty powinny odzwierciedlać podziały architektoniczne (np. projekt per warstwa). Zależności między projektami powinny przebiegać zgodnie z przyjętym stylem architektonicznym.
5. Projekty powinny udostępniać innym tylko to co faktycznie potrzebne. Domyślnie powinna być stosowana widoczność internal, a nie public.
6. Namespace’y powinny nieść opowieść biznesową, a nie techniczną. Po nazwie firmy / produktu i Bounded Contextu powinny odzwierciedlać podział na Moduły (MyCompany.CRM.Sales.Orders
).
7. Namespace’y nie powinny zawierać nazw odzwierciedlających podziały architektoniczne, które są już obecne w nazwach projektów (.Domain
, .Adapters.Sql
).
Artykuł został pierwotnie opublikowany na itlibrium.com. Zdjęcie główne artykułu pochodzi z unsplash.com.