Moja implementacja DDD w C#

3

Chciałbym zaprezentować efekt mojej miesięcznej pracy, a mianowicie aplikację systemu głosowania. Jej tematyka może i nie jest zbyt pomysłowa. Jednak zamysł na aplikację wziął się stąd, że chciałem w końcu zaimplementować to słynne DDD "tak jak się należy". Tak, aby mieć szablon na jakieś przyszłe projekty. Po przejrzeniu setek repozytoriów zabrałem się za pracę. Po napisaniu dużej części kodu zorientowałem się, że duża jego część (która jest potrzebna) nie wygląda zbyt ciekawie - różnego rodzaju interfejsy, obsługa CQRS, rejestracje w Autofacu i tym podobne rzeczy. Cały ten kod wyniosłem do osobnych bibliotek, które później nazwałem SharpDomain. Chciałbym usłyszeć opinię o moim projekcie oraz czy takie rozwiązanie ma w ogóle sens (wydzielenie biblioteki DDD pod przyszłe projekty). Czy wszystko zrobiłem zgodnie ze sztuką?

W projekcie wykorzystałem:

  • do IoC: Autofac,
  • do CQRS: MediatR,
  • do walidacji: FluentValidation,
  • do mapowania: Automapper,
  • do persystencji: własne rozwiązanie "in memory" (czyli właściwie jest tylko symulacja persystencji)
    Aplikacja posiada uwierzytelnianie i autoryzację użytkowników (bez hasła, ale to celowo).

SharpDomain - znajduje się tu kod który mi nie pasował w aplikacji docelowej i go wyniosłem do osobnych bibliotek.

SharpDomain.Example - repozytorium zawierające system głosowania. Są tam dwie aplikacje: konsolowa oraz WebApi. Nie mają one ze sobą żadnej interakcji. Są dwie dlatego, że chciałem aplikację odpalić też w postaci konsolowej, a nie tylko w ASP.NET Core. Aplikacja API nie posiada frontendu - tylko Swagger (być może ktoś jest chętny na dodanie?). Aplikacja konsolowa posiada symulowanych głosujących, dlatego liczba głosów może się samoistnie zmieniać.

Kod nie posiada testów - jestem tego w pełni świadomy. Wynika to z tego, że nie poruszam się jeszcze w tym temacie swobodnie. Kod zmieniał się intensywnie i często - nie chciałem tracić czasu na dostosowywanie testów. Z dokumentacją sytuacja wygląda podobnie - nie chciałem ciągle gonić za często zmieniającym się kodem.

Jako ciekawostkę podam, że wrzuciłem te biblioteki na Nugeta i w ciągu miesiąca mam ponad 3000 pobrań. Nie wiem z czego to wynika, wątpię by ktoś ich używał bez jakiejkolwiek dokumentacji. Może jakieś roboty?

Nie wiem czy struktura katalogów jaką wybrałem jest odpowiednia. Mam tu na myśli to, że katalogi są ponazywane na przykład: Commands, Queries, Models. Czy może lepiej pogrupować je według "domeny", na przykład Vote, Question i dopiero tam wszystko co związane z danym modelem?

Czekam na waszą opinię. Jak ktoś stwierdzi, że nie takie słabe to ucieszę się z gwiazdki na Githubie.

1

Mam pytanie, z jakiegoś konkretnego powodu zdecydowałeś się na Autofac, zamiast skorzystać z wbudowanego DI w .NET Core?

Nie wiem czy struktura katalogów jaką wybrałem jest odpowiednia. Mam tu na myśli to, że katalogi są ponazywane na przykład: Commands, Queries, Models. Czy może lepiej pogrupować je według "domeny", na przykład Vote, Question i dopiero tam wszystko co związane z danym modelem?

Ja grupuję pierw agregaty, a następnie dzielił je na dane usecase w jego obrębie i wydzielał Commands/Queries, jako że w DDD wszystko opiera się o domenę, to w większości przypadków też z takim podziałem się najczęściej w warstwie aplikacyjnej spotykałem w przeróżnych artykułach i przykładach.

PS. Postaram się w późniejszym czasie przejrzeć kod i do niego odnieść.

1
XardasLord napisał(a):

Mam pytanie, z jakiegoś konkretnego powodu zdecydowałeś się na Autofac, zamiast skorzystać z wbudowanego DI w .NET Core?

Nie potrafię w jakiś logiczny sposób odpowiedzieć na to pytanie. Być może dlatego, że lepiej znam Autofaca, bardziej podoba mi się jego API? Posiada dość prostą integrację z DI .Net Core'a.

Ja grupuję pierw agregaty, a następnie dzielił je na dane usecase w jego obrębie i wydzielał Commands/Queries, jako że w DDD wszystko opiera się o domenę, to w większości przypadków też z takim podziałem się najczęściej w warstwie aplikacyjnej spotykałem w przeróżnych artykułach i przykładach.

Też tak myślę. Przy mało skomplikowanej domenie moje dotychczasowe rozwiązanie może i ma swoje plusy, ale na dłuższą metę wydaje mi się, że się nie sprawdzi. Zmienię to w wolnej chwili.

PS. Postaram się w późniejszym czasie przejrzeć kod i do niego odnieść.

Czekam, liczę na każdą opinię. Próbuję znaleźć najlepsze praktyki. Widzę, że sam na swoim GH też masz taki szablon. Chętnie przejrzę, obcego kodu nigdy za wiele :) .

0

Aktualizacja zmian:

  • Dodałem kilka testów integracyjnych (czy to są testy integracyjne? niech ktoś mnie poprawi jeśli nie).

  • Przerobiłem strukturę katalogów na zgodną z domeną, czyli to o czym pisaliśmy wcześniej w tym wątku. Miałem jeden problem, z którego rozwiązania nie jestem zbyt zadowolony. Podczas tworzenia nowych przestrzeni nazw dla tych katalogów, na przykład: VotingSystem.Application.Question zrobił mi się konflikt nazw z klasą modelu Question. Zmieniłem nazwy wszystkich modeli na ...Model, na przykład QuestionModel. Może ktoś ma jakąś podpowiedź jak to zrobić lepiej, bo jednak wolałbym uniknąć tego ...Model

1
maszrum napisał(a):

Jak dla mnie, to nie, bo nie uruchamiasz ich na żywej aplikacji, tylko uderzasz w MediatR bezpośrednio, na dodatek w testach składasz IoC na nowo.
Czyli testy nie wykryją Ci problemu z błędną konfiguracją IoC w produkcyjnym kodzie, ani problemów na poziomie weba/HTTP.

Może ktoś ma jakąś podpowiedź jak to zrobić lepiej, bo jednak wolałbym uniknąć tego ...Model

Użyć liczby mnogiej w namespacach?

0
somekind napisał(a):

Użyć liczby mnogiej w namespacach?

Wstyd przyznać, ale... nie wpadłem na to, dzięki.

Jak dla mnie, to nie, bo nie uruchamiasz ich na żywej aplikacji, tylko uderzasz w MediatR bezpośrednio, na dodatek w testach składasz IoC na nowo.
Czyli testy nie wykryją Ci problemu z błędną konfiguracją IoC w produkcyjnym kodzie, ani problemów na poziomie weba/HTTP.

Czyli rozumiem, że testy integracyjne powinno robić się w postaci zapytań HTTP i weryfikacji odpowiedzi? Coś na zasadzie "czarnej skrzynki", ale czy wtedy nie są to testy end-to-end? Muszę się doszkolić z tego tematu. Postanowiłem, że skoro napisałem te moje nieszczęsne testy pseudo-integracyjne to nie będę ich już usuwał. Zastanawiam się tylko, czy nie nazwać ich inaczej.

na dodatek w testach składasz IoC na nowo.

Rozwiązałem ten problem wydzielając budowanie IoC do osobnego projektu i teraz korzystają z niego: aplikacja konsolowa, web API i testy.

@somekind: A ogólnie jak ocenisz kod? Generalnie to wydaje mi się, że to jest chyba najbardziej przeinżynierowana aplikacja jaką kiedykolwiek widziałem. Choć z drugiej strony taki był zamysł, bo ma to być szablon pod jakieś przyszłe projekty oraz moja edukacja.

1
maszrum napisał(a):

Wstyd przyznać, ale... nie wpadłem na to, dzięki.

Oj tam, jakbyś frontend robił, to byś się mógł wstydzić. ;)

Czyli rozumiem, że testy integracyjne powinno robić się w postaci zapytań HTTP i weryfikacji odpowiedzi? Coś na zasadzie "czarnej skrzynki", ale czy wtedy nie są to testy end-to-end? Muszę się doszkolić z tego tematu. Postanowiłem, że skoro napisałem te moje nieszczęsne testy pseudo-integracyjne to nie będę ich już usuwał. Zastanawiam się tylko, czy nie nazwać ich inaczej.

No ja testami integracyjnymi w przypadku API nazywam testy end-to-end, i chyba nie jestem w tym odosobniony.

Rozwiązałem ten problem wydzielając budowanie IoC do osobnego projektu i teraz korzystają z niego: aplikacja konsolowa, web API i testy.

No to ma sens.

@somekind: A ogólnie jak ocenisz kod? Generalnie to wydaje mi się, że to jest chyba najbardziej przeinżynierowana aplikacja jaką kiedykolwiek widziałem. Choć z drugiej strony taki był zamysł, bo ma to być szablon pod jakieś przyszłe projekty oraz moja edukacja.

Ogólnie, to ja nie chcę oceniać, bo ja nie do końca rozumiem ideę. Wygląda w sumie jak framework bardziej do CQS i opakowanie na MediatR niż DDD, albo ja po prostu nie widzę granicy między jednym a drugim w tym projekcie, albo czegoś jest za mało.
Ale na pewno warto pisać sobie framework, bo można się przy tym wiele nauczyć, np. jeśli w pewnym momencie postanowimy nie przywiązywać się do jednego kontenera IoC, lecz umożliwić integrację z różnymi.

0
somekind napisał(a):

Ogólnie, to ja nie chcę oceniać, bo ja nie do końca rozumiem ideę. Wygląda w sumie jak framework bardziej do CQS i opakowanie na MediatR niż DDD, albo ja po prostu nie widzę granicy między jednym a drugim w tym projekcie, albo czegoś jest za mało.

Tak właściwie to ja sam nie wiem co to ma być. Biblioteka, framework, czy jakiś zbiór najlepszych zasad zgromadzonych przeze mnie w jednym miejscu. Idea była taka, że chciałem napisać jakąś prostą aplikację w DDD, ale tak idealnie, książkowo. W trakcie pracy zauważyłem dużo "brudnego" kodu, który zaciemniał całą logikę, a który według mnie jest uniwersalny i równie dobrze sprawowałby się w innych projektach (a przynajmniej mi się tak wydaje). I wtedy wydzieliłem to wszystko do osobnego repozytorium. W żadnym razie nie było to od początku planowane jako jakiś framework.

bardziej do CQS

Właśnie nie bardzo rozumiem różnicę pomiędzy CQS i CQRS. Oczywiście mogę poczytać regułki z książek, ale jakby nie mogę znaleźć różnicy w kodzie. Czy CQS to jakby "idea", a CQRS to jej implementacja? Coś jak Scrum i Agile?

Ale na pewno warto pisać sobie framework, bo można się przy tym wiele nauczyć, np. jeśli w pewnym momencie postanowimy nie przywiązywać się do jednego kontenera IoC, lecz umożliwić integrację z różnymi.

To chyba trzeba by zrobić jakąś warstwę abstrakcji nad IoC, jego builderem i tak dalej? Ja się tego nie podejmę na pewno. Podoba mi się mój Autofac :P.

Aktualizacja: Dapper + PostgreSQL

Dodałem obsługę bazy danych PostgreSQL przez Dappera. Napotkałem przy tym kilka trudności, ale na szczęście udało mi się je pokonać (lub mam plan je pokonać :P):

  • Problem z istniejącymi testami

Kiedy pisałem testy integracyjne miałem "bazę danych", czyli po prostu atrapę in-memory. Wszystkie przechodziły na zielono, do czasu aż skończyłem prawdziwą persystencję. Wtedy okazało się, że testy nie przechodzą, bo były dostosowane do ulotnej pamięci, to znaczy: każdy test odpalał aplikację na nowo, przez co pracował na "świeżej" instancji.

Rozwiązałem to dodając mechanizm inicjalizacji aplikacji. Do wyboru są dwa warianty: IfNeed - tworzy tabele jeśli aplikacja wykryje, że jakiś brakuje i Forcefully - kasuje wszystkie tabele i tworzy je na nowo. W normalnym użyciu wykorzystuje pierwszą opcję, w testach drugą. Trudności były z zachowaniem OCP, przy wdrażaniu tego, ale wydaje mi się, że poszło ok.

  • Konfiguracja i przechowywanie ConnectionString

Tego jeszcze nie zrobiłem, ale mniej więcej mam plan jak to zrobić. Na razie trzymam konfigurację bazy danych "zahardkodowaną". Planuję po prostu wykorzystać konfigurację od ASP.NET Core, wpleść ją jakoś do mojego systemu i ConnectionString trzymać w pliku konfiguracyjnym. Nie wiem, czy to dobre rozwiązanie?

  • Zmiany w warstwie aplikacji

Nie było żadnych zmian. Udało mi się doprowadzić do takiego rozwiązania, że wystarczy zakomentować jedną linię #define i odkomentować inną w tym pliku. Nie musiałem zmieniać ani jednej linijki w warstwie aplikacji, żeby przejść z jednego sposobu persystencji na drugi. Miałem wydzielony interfejsy read repozytoriów, więc poszło gładko. A write repozytoria komunikują się z warstwą aplikacji przez zdarzenia.

Dygresja

Może to jest banalne, ale ja spędziłem nad tym kawałek czasu, zanim ogarnąłem o co chodzi.
Kiedy pisałem jakiś fragment kodu (mniejsza już jaki), chciałem wywołać metodę obiektu z jakieś Nugetowej biblioteki. Wiedziałem, że na pewno ta metoda istnieje, ale w żadnej sposób IntelliSense nie chciał mi jej podpowiedzieć, a kompilator wywalał błędy. Wtedy po przeszukaniu Stackoverflow odkryłem, że można implementować metody interfejsów w klasie sposobem implicit. Na prawdę nie wiedziałem, że można ukryć publiczną metodę interfejsu w klasie go implementującej. Czy to nie łamie jakiś zasad OOP? Dla mnie to było odkrycie roku.

Co chcę dodać w przyszłości:

  • Wspomnianą konfigurację, czyli obsługę plików konfiguracyjnych, itd.
  • Obsługę SignalR do rozgłaszania zdarzeń pojawiających się w systemie.
  • Obsługę EF Core, tylko nie wiem z jaką bazą.
  • Przerobić to na jakiś ambitniejszy "biznes"? Nie wiem, jakiś system aukcyjny?
2
maszrum napisał(a):

Tak właściwie to ja sam nie wiem co to ma być.

Brzmi jak coś w JS.

Właśnie nie bardzo rozumiem różnicę pomiędzy CQS i CQRS. Oczywiście mogę poczytać regułki z książek, ale jakby nie mogę znaleźć różnicy w kodzie. Czy CQS to jakby "idea", a CQRS to jej implementacja? Coś jak Scrum i Agile?

CQRS jest bardziej na poziomie architektury, a CQS na poziomie kodu. Chociaż pewnie masa fantastyków jednej czy drugiej religii się z tym nie zgodzi.

To chyba trzeba by zrobić jakąś warstwę abstrakcji nad IoC, jego builderem i tak dalej? Ja się tego nie podejmę na pewno. Podoba mi się mój Autofac :P.

Takie rzeczy to robi Microsoft. Normalnie to należy po prostu zapewnić, aby biblioteka/framework miała swoje fabryki/buildery, które na podstawie prostych informacji potrafią zbudować całą potrzebną hierarchię obiektów (używając ich konstruktorów, bez zależności od jakiegoś konkretnego kontenera). Mając takie fabryki, następnie można napisać małą klasę integrującą Twój framework z dowolnym kontenerem (oczywiście każdy kontener musi mieć swoją klasę do integracji).

Tego jeszcze nie zrobiłem, ale mniej więcej mam plan jak to zrobić. Na razie trzymam konfigurację bazy danych "zahardkodowaną". Planuję po prostu wykorzystać konfigurację od ASP.NET Core, wpleść ją jakoś do mojego systemu i ConnectionString trzymać w pliku konfiguracyjnym. Nie wiem, czy to dobre rozwiązanie?

Niespecjalnie widzę alternatywę. Tylko aplikacja asp.net core musi jakoś sensownie ten connection string do Twojego frameworka przekazać.

Nie było żadnych zmian. Udało mi się doprowadzić do takiego rozwiązania, że wystarczy zakomentować jedną linię #define i odkomentować inną w tym pliku. Nie musiałem zmieniać ani jednej linijki w warstwie aplikacji, żeby przejść z jednego sposobu persystencji na drugi. Miałem wydzielony interfejsy read repozytoriów, więc poszło gładko. A write repozytoria komunikują się z warstwą aplikacji przez zdarzenia.

No trochę to dzikie, ale w przykładowym projekcie może przejdzie.

Może to jest banalne, ale ja spędziłem nad tym kawałek czasu, zanim ogarnąłem o co chodzi.
Kiedy pisałem jakiś fragment kodu (mniejsza już jaki), chciałem wywołać metodę obiektu z jakieś Nugetowej biblioteki. Wiedziałem, że na pewno ta metoda istnieje, ale w żadnej sposób IntelliSense nie chciał mi jej podpowiedzieć, a kompilator wywalał błędy. Wtedy po przeszukaniu Stackoverflow odkryłem, że można implementować metody interfejsów w klasie sposobem implicit. Na prawdę nie wiedziałem, że można ukryć publiczną metodę interfejsu w klasie go implementującej. Czy to nie łamie jakiś zasad OOP? Dla mnie to było odkrycie roku.

No, czasem po prostu warto poczytać dobrą książkę do języka. Albo jakąkolwiek, bo w każdej o tym mówią. Tylko to ma sens generalnie, gdy metoda implementowana z interfejsu ma taką samą sygnaturę jak metoda, która już jest w klasie.

0
somekind napisał(a):

Takie rzeczy to robi Microsoft. Normalnie to należy po prostu zapewnić, aby biblioteka/framework miała swoje fabryki/buildery, które na podstawie prostych informacji potrafią zbudować całą potrzebną hierarchię obiektów (używając ich konstruktorów, bez zależności od jakiegoś konkretnego kontenera). Mając takie fabryki, następnie można napisać małą klasę integrującą Twój framework z dowolnym kontenerem (oczywiście każdy kontener musi mieć swoją klasę do integracji).

Mniej więcej już wiem o co chodzi. Ale mimo wszystko odpuszczę ten temat na razie, chyba by mnie to przerosło.

[...] Planuję po prostu wykorzystać konfigurację od ASP.NET Core, wpleść ją jakoś do mojego systemu i ConnectionString trzymać w pliku konfiguracyjnym. [...]

Niespecjalnie widzę alternatywę. Tylko aplikacja asp.net core musi jakoś sensownie ten connection string do Twojego frameworka przekazać.

W takim razie tak to zrobię. Jak natrafię na jakieś problemy w trakcie pracy to będę dopytywał o konkrety.

Nie było żadnych zmian. Udało mi się doprowadzić do takiego rozwiązania, że wystarczy zakomentować jedną linię #define i odkomentować inną w tym pliku. Nie musiałem zmieniać ani jednej linijki w warstwie aplikacji, żeby przejść z jednego sposobu persystencji na drugi. Miałem wydzielony interfejsy read repozytoriów, więc poszło gładko. A write repozytoria komunikują się z warstwą aplikacji przez zdarzenia.

No trochę to dzikie, ale w przykładowym projekcie może przejdzie.

Dlaczego dzikie? Do której części tej wypowiedzi to się odnosi? To, że przekazuje zdarzenia do warstwy infrastruktury, czy to, że w ogóle nie musiałem nic zmieniać w warstwie aplikacji?

ale w przykładowym projekcie może przejdzie.

Zdaję sobie sprawę, że wszystko działa jak chcę ze względu na banalny projekt i kiedy zrobię bardziej skomplikowany system to moje wyidealizowane wyobrażenie o tym frameworku runie. Dlatego chcę spróbować zrobić coś ambitniejszego. Myślałem nad aukcjami charytatywnymi, czyli coś co często dzieje się na grupkach facebookowych. Myślę, że tam będzie pole do popisu jeśli chodzi o modelowanie i ogólnie DDD. Ale to melodia przyszłości, najpierw chcę posprzątać to co mam.

Brzmi jak coś w JS.

Ja i mój projekt, który dopieszczałem długimi wieczorami z nadzieją, że ktoś doceni.

1
maszrum napisał(a):

Mniej więcej już wiem o co chodzi. Ale mimo wszystko odpuszczę ten temat na razie, chyba by mnie to przerosło.

Tworzenie obiektów przez new?

Dlaczego dzikie? Do której części tej wypowiedzi to się odnosi? To, że przekazuje zdarzenia do warstwy infrastruktury, czy to, że w ogóle nie musiałem nic zmieniać w warstwie aplikacji?

Do dyrektywy kompilatora.

Zdaję sobie sprawę, że wszystko działa jak chcę ze względu na banalny projekt i kiedy zrobię bardziej skomplikowany system to moje wyidealizowane wyobrażenie o tym frameworku runie. Dlatego chcę spróbować zrobić coś ambitniejszego. Myślałem nad aukcjami charytatywnymi, czyli coś co często dzieje się na grupkach facebookowych. Myślę, że tam będzie pole do popisu jeśli chodzi o modelowanie i ogólnie DDD. Ale to melodia przyszłości, najpierw chcę posprzątać to co mam.

Ja bym raczej najpierw zrobił biznes, a potem wydzielił to, co ma sens, bo odwrotnie jest jakieś 90% szans, że nie uda się przewidzieć tego, co rzeczywiście będzie potrzebne.

0
somekind napisał(a):
maszrum napisał(a):

Mniej więcej już wiem o co chodzi. Ale mimo wszystko odpuszczę ten temat na razie, chyba by mnie to przerosło.

Tworzenie obiektów przez new?

Więc nie jednak nie wiem o co chodzi. Postaram się później jeszcze dokładniej przeanalizować to, co napisałeś wcześniej.

Dlaczego dzikie? Do której części tej wypowiedzi to się odnosi?

Do dyrektywy kompilatora.

Chodziło tylko o to aby w jakiś prosty sposób przełączać pomiędzy tymi linijkami w zależności od tego jaki typ persystencji ma zostać użyty:


            ContainerBuilder
#if IN_MEMORY_PERSISTENCE
                .RegisterInMemoryPersistence()
                .RegisterAutoTransaction(inMemoryPersistenceAssembly);
#elif DAPPER_PERSISTENCE
                .RegisterDapperPersistence(_ => new DatabaseConfiguration())
                .RegisterAutoTransaction(dapperPersistenceAssembly);
#endif

Może i te dyrektywy nie są zbyt eleganckie (nawet forumowy parser sobie z nimi nie radzi), zaburzają indentacje, ale nie wymyśliłem nic lepszego.

1 użytkowników online, w tym zalogowanych: 0, gości: 1