Onion Napisać aplikację to jedno. Wdrożyć to drugie. Tylko jak później tę aplikację utrzymać i ciągle zmieniać?  Dodawać nowe funkcje, poprawiać błędy, zmieniać istniejącą logikę biznesową.

Można by powiedzieć, że to jest największa zmora programowania. W końcu każdemu zdarzyło się chociaż raz powiedzieć lub pomyśleć

"Ta aplikacja to takie gówno, że szybciej bym napisał lepszą od zera".

Czasami jak czas i pieniądze pozwolą, tak się dzieje. Piszemy aplikację drugi raz, bo poprzednia wersja jest Behemotem.

Jak więc napisać oprogramowanie, które jest otwarte na rozwój. 

Pomyśli. Taka aplikacja mogłaby ulegać zmianie bez obawy, że całkowicie ją rozwalisz, gdy dodasz nową linijkę kodu. Aplikacja ta miałaby opisaną logikę domenową na tyle jasno, aby do nie później każdy inny programista mógł dodać coś nowego. 

Modyfikacja warstwy widokowej nie powinna niszczyć logiki domenowej. Modyfikacja bazy danych i jej modelu nie powinna wpływać na reguły biznesowe.

Sama logika biznesowa/domenowa powinna być testowalna. Pisanie testów jednostkowych powinno być przyjemnością, ponieważ kod jest już podzielony na warstwy.

Clean Architecture ? Dlaczego ?

Ogólnie z tym pojęciem spotkałem się pierwszy raz w ubiegłym roku. Co mnie zdziwiło, bo całą poprzednie 2 dekadę z mojej perspektywy w programowaniu mówiło się o architekturze aplikacji jako o warstwach. 

Ba nawet w 2012-2013 czytałem książkę, która opisywała wzorce projektowe, które można zastosować w poszczególnych warstwach aplikacji. 

Book Asp.net desing Patterns

W swojej karierze zawodowej też widziałem napisane aplikację z podziałem na 3 lub 5 warstw.

Przykład aplikacji wielowarstwowej

Czy jest coś nie tak z taką filozofią? Bo gdyby pierwszy raz czytałem o Clean Architecture to brzmiało to jako lepsza alternatywa dla tych warstw lazani. 

Co jest takiego złego z tymi warstwami? Wiesz mi magicznie, te warstwy nie znikną. Problem jednak oryginalnego założenia takie architektury był taki, że każdy taki klocek w tej warstwie był przeznaczony tylko do jeden dużej aplikacji monolit.

Ile warstw i jaki jest problem

W czym jest problem:

  • Mimo iż podzieliśmy aplikację na warstwy, to wciąż te warstwy są zależne od siebie (zazwyczaj tego nie pilnujemy)
  • Zachowuje się to wszystko jak jedna duża aplikacja monolit
  • Co, jeśli poszczególne warstwy są nie tylko do tej aplikacji

Wielcy architekt zwrócili uwagę, że często te klocki są używane ponownie. Fajnie by było, gdyby ta sama biblioteka z logiką biznesową mogłaby być użyta w aplikacji mobilnej, jak i w aplikacji webowej.

Przepływa aplikacji, też nie jest prosty. To nie tak, że kod zawsze schodzi w dół w tych samych miejscach.

Wtedy nadeszła rewolucja. Bo ktoś mądry zobaczył, że pod projekty, które są rozsypane między sobą i sklejone można otoczyć kółkami.

Projekty

Otaczamy:

Projekty otoczone w koło

A może otoczmy to nie w koło tylko w sześcian

Projekty otoczone w sześcian

Mam dwa style Clean Architecture:

  • Onion, czyli cebula
  • Heksagonalna, czyli Port i Adaptery

Jeden architekt powiem, że to jedno i to samo, a drugi nie. Całe to zagadnienie Portów i Adapterów podobno jest alternatywą do tworzenia warstw, ale moim zdaniem nieważne jak to się nazwie nadal, to są warstwy.

Ja skoncentruje się na cebuli przez resztę tego wpisu? Z tego, co widzę w Polskiej blogo sferze i na konferencjach IT, Onion jest bardziej popularny niż Heksagon.

Każdy ma swój diagram Onion. Mój wygląda tak 

Clean Architecture Onion

Czym jest ta cebula ?

Jak jest złota zasada naszej cebuli? 

Czym jest ta cebula w Clean Architecture

Nic wewnętrznych kółek nie może wiedzieć i być zależne od kółek zewnętrznych. Mówimy tutaj o wszystkich klasach, funkcjach, zmiennych i wszystkich innych stworach programistycznych.

projekt w Visual Studio

Jak utrzymać te luźne powiązania?

Oczywiście na pomoc przychodzą interfejsy, czyli kontrakt.

W zielonym kółku Application Core tworzysz tylko interfejs, który określa jak dostęp do danych powinien wyglądać. Jego implementacja będzie zewnętrznym różowym kole Infrastructure. 

Repozytoria w Visual Studio Cebula jak wygląda

Jakie to ma zalety? Jeżeli chcesz testować logikę domenową, to testujesz tylko to. Interfejsy możesz uzupełnić "Mokiem".

Jak jeszcze można zrobić luźne powiązania? Skorzystać CQRS i wzorca projektowe Mediator. Może pomóc Ci w tym paczka MediatR. W zielonym kółku Application Core definiujesz swoje polecenia Command i Query i jak mają być one obsługiwane.

One też korzystają z interfejsów. Command i Query siedzi w warstwie Application Core i tutaj nie powinno być implementacji. Co najwyżej logika biznesowa i walidacyjną. 

Przykładowo akceptacje programisty/developera, jeżeli wcześnie nie został zaakceptowany, czyli jest nowy. 

public class AcceptDeveloperCommandHandler
:
IRequestHandler<AcceptDeveloperCommand, AcceptDeveloperCommandResponse>
{
        private readonly IDeveloperRepository _callRepository;
        private readonly IMapper _mapper;

        public AcceptDeveloperCommandHandler(IDeveloperRepository callRepository,
            IMapper mapper)
        {
            _callRepository = callRepository;
            _mapper = mapper;
        }

        public async Task<AcceptDeveloperCommandResponse> Handle
            (AcceptDeveloperCommand request, CancellationToken cancellationToken)
        {
            var developerUniqueId = _mapper.Map<DeveloperUniqueId>
                (request.DeveloperUniqueId);


            var developer = await _callRepository.GetByIdAsync(developerUniqueId);

            if (developer.Status == DeveloperStatus.New)
                await _callRepository.SaveAcceptenceAsync
                    (developerUniqueId);
            else
            {
                return new AcceptDeveloperCommandResponse("Can't Accept Developer " +
                    "that is alread " + developer.Status.ToString(), false);
            }

            return new AcceptDeveloperCommandResponse();
        }
}

W luźnych powiązania także może Ci pomóc mapowanie obiektów domenowych. W warstwie User Interfejs nie korzystasz klasy domenowej tylko z klas Data Transfer Object albo ViewModel. Tak, aby nagła potrzeba zmiany w warstwie User Interface nie rozwaliła Ci czegoś w wewnętrznym kółku.

Teraz mogę nawet powiedzieć coś kontrowersyjnego. 

Twoje tabelki w bazach danych nie powinny wymuszać wyglądu klasy domenowej.  Czyli w warstwie Persistence masz kolejne klasy, które powinny być mapowane z klasy domenowej na taką klasę Tymczasową.  Akurat ten problem najbardziej wyszedł mi, gdy pisałem aplikację z Dapper i przy pisaniu gołych Selektów doszedłem do wniosku, że fajnie by było mieć klasę tymczasową, która dokładnie takie same nazwy jak kolumny w tabelce.

Oto przykład klasy domenowej, która reprezentuje zgłoszenie mowy na konferencje:

public class CallForSpeech : Entity<CallForSpeechId, CallForSpeechUniqueId>
{
    public Speaker Speaker { get; set; }
    public Speech Speech { get; set; }
    public Registration Registration { get; set; }
    public CallForSpeechNumber Number { get; set; }

    public Category Category { get; private set; }
    public CallForSpeechStatus Status { get; private set; }

    public CallForSpeechScoringResult Score { get; private set; }

    public Decision PreliminaryDecision { get; private set; }
    public Decision FinalDecision { get; private set; }


    public CallForSpeech(CallForSpeechNumber number, Speech speech,
           Speaker speaker, Category cat)
        : this(number, CallForSpeechStatus.New, speaker, speech, cat,
    null, new Registration(AppTime.Now()), null, null,
    new CallForSpeechId(0))
    {

    }

    public CallForSpeech(
        CallForSpeechNumber number,
        CallForSpeechStatus status,
        Speaker speaker,
        Speech speech,
        Category category,
        CallForSpeechScoringResult score,
        Registration registration,
        Decision preliminaryDecision,
        Decision finalDecision,
        CallForSpeechId callForSpeechId)
    {
        if (category == null)
            throw new ArgumentException("Category cannot be null");
        if (number == null)
            throw new ArgumentException("Number cannot be null");
        if (speech == null)
            throw new ArgumentException("speech cannot be null");
        if (speaker == null)
            throw new ArgumentException("speaker cannot be null");
        if (registration == null)
            throw new ArgumentException("Registration cannot be null");

        Id = callForSpeechId;
        Number = number;
        Status = status;
        Score = score;
        Speech = speech;
        Speaker = speaker;
        Registration = registration;
        PreliminaryDecision = preliminaryDecision;
        FinalDecision = finalDecision;
        Category = category;
        Version = 1;
        UniqueId = CallForSpeechUniqueId.NewUniqueId();
    }

    // To satisfy EF Core
    protected CallForSpeech()
    {
        UniqueId = CallForSpeechUniqueId.NewUniqueId();
        Version = 1;
    }

    public void Evaluate(ScoringRules rules)
    {
        if (Status != CallForSpeechStatus.New)
        {
            throw new ApplicationException("Cannot accept application that isn't new");
        }

        Score = rules.Evaluate(this);
        if (!Score.IsRed())
        {
            Status = CallForSpeechStatus.EvaluatedByMachine;
        }
        else
        {
            Status = CallForSpeechStatus.Rejected;
        }
    }

    public ExecutionStatus TryEvaluate(ScoringRules rules)
    {
        if (Status != CallForSpeechStatus.New)
        {
            return ExecutionStatus.LogicError("Cannot accept application that isn't new");
        }

        Score = rules.Evaluate(this);
        if (!Score.IsRed())
        {
            Status = CallForSpeechStatus.EvaluatedByMachine;
        }
        else
        {
            Status = CallForSpeechStatus.Rejected;
        }
        return ExecutionStatus.LogicOk();
    }

    public void PreliminaryAccept(Judge decisionBy)
    {
        if (Status == CallForSpeechStatus.PreliminaryAcceptedByJudge)
        {
            throw new ApplicationException("You already PreliminaryAcceptedByJudge this CallForSpeech");
        }

        if (Status != CallForSpeechStatus.EvaluatedByMachine)
        {
            throw new ApplicationException("Cannot accept application that WASNT'T in EvaluatedByMachine");
        }

        if (Score == null)
        {
            throw new ApplicationException("Cannot accept application before scoring");
        }

        if (!decisionBy.CanAccept(this.Category.Id))
        {
            throw new ApplicationException("Judge is from diffrent category. Can't Accept");
        }

        Status = CallForSpeechStatus.PreliminaryAcceptedByJudge;
        PreliminaryDecision = new Decision(AppTime.Now(), decisionBy);
    }

    public ExecutionStatus TryPreliminaryAccept(Judge decisionBy)
    {
        if (Status == CallForSpeechStatus.PreliminaryAcceptedByJudge)
        {
            return ExecutionStatus.
                LogicError("You already PreliminaryAcceptedByJudge this CallForSpeech");
        }

        if (Status != CallForSpeechStatus.EvaluatedByMachine)
        {
            return ExecutionStatus.LogicError("Cannot accept application that WASNT'T in EvaluatedByMachine");
        }

        if (Score == null)
        {
            return ExecutionStatus.LogicError("Cannot accept application before scoring");
        }

        if (!decisionBy.CanAccept(this.Category.Id))
        {
            return ExecutionStatus.
                LogicError("Judge is from diffrent category. Can't Accept");
        }

        Status = CallForSpeechStatus.PreliminaryAcceptedByJudge;
        PreliminaryDecision = new Decision(AppTime.Now(), decisionBy);
        return ExecutionStatus.LogicOk();
    }



    public void Accept(Judge decisionBy)
    {
        if (Status == CallForSpeechStatus.AcceptedByJudge)
        {
            throw new ApplicationException("You already Accepted this CallForSpeech");
        }

        if (Status == CallForSpeechStatus.Rejected)
        {
            throw new ApplicationException("Cannot accept application that is already rejected");
        }

        if (Status != CallForSpeechStatus.PreliminaryAcceptedByJudge)
        {
            throw new ApplicationException("Cannot accept application that wasn't PreliminaryAccepted FIRST");
        }

        if (Score == null)
        {
            throw new ApplicationException("Cannot accept application before scoring");
        }

        if (!decisionBy.CanAccept(this.Category.Id))
        {
            throw new ApplicationException("Judge is from diffrent category. Can't Accept");
        }

        Status = CallForSpeechStatus.AcceptedByJudge;
        FinalDecision = new Decision(AppTime.Now(), decisionBy);
    }

    public ExecutionStatus TryAccept(Judge decisionBy)
    {
        if (Status == CallForSpeechStatus.AcceptedByJudge)
        {
            return ExecutionStatus.
                LogicError("You already Accepted this CallForSpeech");
        }

        if (Status == CallForSpeechStatus.Rejected)
        {
            return ExecutionStatus.
                LogicError("Cannot accept application that is already rejected");
        }

        if (Status != CallForSpeechStatus.PreliminaryAcceptedByJudge)
        {
            return ExecutionStatus.
                LogicError("Cannot accept application that wasn't PreliminaryAccepted FIRST");
        }

        if (Score == null)
        {
            return ExecutionStatus.
                LogicError("Cannot accept application before scoring");
        }

        if (!decisionBy.CanAccept(this.Category.Id))
        {
            return ExecutionStatus.
                LogicError("Judge is from diffrent category. Can't Accept");
        }

        Status = CallForSpeechStatus.AcceptedByJudge;
        FinalDecision = new Decision(AppTime.Now(), decisionBy);
        return ExecutionStatus.LogicOk();
    }

    public void Reject(Judge decisionBy)
    {
        if (Status == CallForSpeechStatus.Rejected ||
            Status == CallForSpeechStatus.AcceptedByJudge)
        {
            throw new ApplicationException("Cannot reject application that is already accepted or rejected");
        }

        if (!decisionBy.CanAccept(this.Category.Id))
        {
            throw new ApplicationException("Judge is from diffrent category. Can't Accept");
        }

        Status = CallForSpeechStatus.Rejected;
        FinalDecision = new Decision(AppTime.Now(), decisionBy);
    }

    public ExecutionStatus TryReject(Judge decisionBy)
    {
        if (Status == CallForSpeechStatus.Rejected ||
            Status == CallForSpeechStatus.AcceptedByJudge)
        {
            return ExecutionStatus.
                LogicError("Cannot reject application that is already accepted or rejected");
        }

        if (!decisionBy.CanAccept(this.Category.Id))
        {
            return ExecutionStatus.
                LogicError("Judge is from diffrent category. Can't Accept");
        }

        Status = CallForSpeechStatus.Rejected;
        FinalDecision = new Decision(AppTime.Now(), decisionBy);

        return ExecutionStatus.LogicOk();
    }

    public CallForSpeechIds Ids()
    {
        if (this.Id != null && this.Id.Value != default)
            return new CallForSpeechIds()
            {
                UniqueId = this.UniqueId,
                CreatedId = this.Id
            };
        else
            return new CallForSpeechIds()
            {
                UniqueId = this.UniqueId,
                CreatedId = this.Id,
                Status = IdsStatus.DudeYouCantReturnCreatedIdWhenYouAreEventSourcing

            };
    }
}

A tak wygląda moja klasa tymczasowa:

public class CallForSpeechTemp
{
    public int Id { get; set; }
    public string UniqueId { get; set; }
    public int Version { get; set; }

    public string Number { get; set; }
    public int Status { get; set; }

    public string PreliminaryDecision_Date { get; set; }
    public int? PreliminaryDecision_DecisionBy { get; set; }

    public string FinalDecision_Date { get; set; }
    public int? FinalDecision_DecisionBy { get; set; }

    public string Speaker_Name_First { get; set; }
    public string Speaker_Name_Last { get; set; }
    public string Speaker_Adress_Country { get; set; }
    public string Speaker_Adress_ZipCode { get; set; }
    public string Speaker_Adress_City { get; set; }
    public string Speaker_Adress_Street { get; set; }
    public string Speaker_Websites_Facebook { get; set; }
    public string Speaker_Websites_Twitter { get; set; }
    public string Speaker_Websites_Instagram { get; set; }
    public string Speaker_Websites_LinkedIn { get; set; }
    public string Speaker_Websites_TikTok { get; set; }
    public string Speaker_Websites_Youtube { get; set; }
    public string Speaker_Websites_FanPageOnFacebook { get; set; }
    public string Speaker_Websites_GitHub { get; set; }
    public string Speaker_Websites_Blog { get; set; }
    public string Speaker_BIO { get; set; }
    public string Speaker_Contact_Phone { get; set; }
    public string Speaker_Contact_Email { get; set; }
    public string Speaker_Birthdate { get; set; }

    public string Speech_Title { get; set; }
    public string Speech_Description { get; set; }
    public string Speech_Tags { get; set; }
    public int Speech_ForWhichAudience { get; set; }
    public int Speech_TechnologyOrBussinessStory { get; set; }
    public string Registration_RegistrationDate { get; set; }
    public int CategoryId { get; set; }
    public int Score_Score { get; set; }

    public string Score_RejectExplanation { get; set; }
    public string Score_WarringExplanation { get; set; }

    public string Category_DisplayName { get; set; }
    public string Category_WhatWeAreLookingFor { get; set; }
    public string Category_Name { get; set; }

}

Korzystając z Entity Frameworka czy NHibernate ten problem można ominąć. W końcu oni to mapowanie ogarnia za Ciebie.

Skoro jesteśmy przy Core i mówiłem coś wcześniej o obiektach Domenowych, to trzeba coś powiedzieć o DDD.

DDD, czyli Encje oraz ValueObject

Domain Driven Desing to idea polegająca na tym, że programiści, jaki i eksperci od logiki biznesowej powinni używać tych samych nazw w obrębie danego kontekstu.

DDD, czyli Encje oraz ValueObject

 

Clean Architecture zakłada, że zaczynasz tworzyć swój projekt od Domain. Nie od ASP.NET CORE, czyli widoku czy REST API.

Nie zaczynasz też swojego projektu od modelowania bazy danych. W Scrumie pracujesz z różnymi zespołami i być może to normalne, że najpierw masz bazę danych.

Clean Architecture natomiast zakłada, że najpierw piszemy klasy domenowe. A baza danych to szczegół implementacyjny, który Ciebie nie interesuje.

Z drugiej strony ciężko jest rozpocząć projekt zaczynając od warstwy dostępu do danych. 

Warstwa w końcu jest zależna od warstwy domenowej, a ty jej nie masz.

Warstwa też powinna być zależna od Application Core (zielone kółko), bo tam są interfejsy do twoich repozytoriów.  Skąd masz wiedzieć jakie metody powinny mieć twoje implementacje, gdy nie masz interfejsów.

Nagle piszesz aplikację jakby od tyłkowej strony.

Oto przykład projektu Domain:

Oto przykład projektu Domain: w visual Studio

Jakie są pułapki związane z tworzeniem tej warstwy.

Często mówi się o problemie Anemic Domain Models. Ten problem nie występuje, gdy twoje obiekty domenowe w sumie nie mają żadnej logiki, tylko przetrzymują dane.

Wtedy nie musisz się oto martwić.

Inna sprawa, gdy masz w projekcie już logikę biznesową, która tyczy się np. zasad tego, kiedy mowa powinna być odrzucona, a kiedy nie. Czy dany sędzia może oceniać daną mowę? 

Zakładamy np., że kategoria sędziego i mowy musi być taka sama.

Taka logika nie powinna być w klasach pomocniczych czy jakiś serwisach. Ta logika powinna być w obiekcie domenowym.

Oto przykład sędziego

public class Judge : Entity<JudgeId, JudgeUniqueId>
{
    public Login Login { get; init; }
    public Password Password { get; init; }
    public Name Name { get; init; }
    
    public Category Category { get; init; }
    
    public bool CanAccept(CategoryId categoryId)
    {
        if (Category != null)
            return categoryId == Category.Id;
        return false;
    }

Dodatkowo w swoim projekcie domenowy warto wydzielić encję i value object (obiekty wartościowe) na podstawie tego, co stwierdzasz co jest czym

Jak to sprawdzić ?

  • Jak będzie długość życia obiektu ? Jeżeli zerowa to jest to Value Object ?
  • Value Object powinno być niezmienne. Jeśli tak nie jest, to dana nie jest Value Object?
  • Przy Value Object warto zadać sobie pytanie, czy powtarzalność i brak unikatowości do dla Ciebie problem
    • Może to nie jest problem, jak fakt, że w wielu miejscach w systemie masz liczbę 7.

Co jest encją ?

  • Opisuje twój problem domenowy
  • Ma unikatową tożsamość

Na podstawie tych informacji możesz stwierdzić, że takie rzeczy jak: Adres, Imiona i Nazwiska, Login, Hasło, Data rejestracji, Telefon są Value Object.

Tymczasem w obrębie tych przykładów z tego wpisu to: Zgłoszenie mowy, sędzia i kategoria jest już encją.

Application Layer w Application Core

Jest to nasza druga warstwa. Już trochę ją omówiłem wcześniej, ale co w niej się powinno znajdować?

W tej warstwie przygotowujesz środowiska dla swoich obiektów domenowych tak, aby można było na nich wykonywać logikę biznesową.

Tutaj także dodajemy zasady aplikacyjne, które mają się wykonywać w trakcie działania aplikacji. 

Application Core W VISUAL STUDIO

Masz tutaj przykład fabryki zasad oceniania zgłoszeń na mowy:

public class ScoringRulesFactory : IScoringRulesFactory
{
    public ScoringRulesFactory()
    {

    }

    public ScoringRules DefaultSet => new ScoringRules(
    new List<IScoringRejectRule>
    {
        new SpeakerAgeMusteBeAbove17(),
        new SpeakerAgeMusteBeBelow70(),
        new SpeakerMustHaveAtLeastOneSocialMedia(),
        new SpeakerMustHaveBlogOrGitHub()
    },
    new List<IScoringWarringRule>()
    {


    });


}

public class SpeakerAgeMusteBeAbove17 : IScoringRejectRule
{
    public bool IsSatisfiedBy(CallForSpeech cfs)
    {
        return cfs.Speaker.AgeInYearsAt(AppTime.Now()) > 17.Years();
    }

    public string Message => "Speaker age must be above 17.";
}

public class SpeakerAgeMusteBeBelow70 : IScoringRejectRule
{
    public bool IsSatisfiedBy(CallForSpeech cfs)
    {

        return cfs.Speaker.AgeInYearsAt(AppTime.Now()) < 70.Years();
    }

    public string Message => "Speaker age must be below 70.";
}

public class SpeakerMustHaveAtLeastOneSocialMedia : IScoringRejectRule
{
    public bool IsSatisfiedBy(CallForSpeech cfs)
    {
        return cfs.Speaker.SpeakerWebsites.HaveSocialMedia();
    }

    public string Message => "Speaker must have at least one social media";
}

public class SpeakerMustHaveBlogOrGitHub : IScoringRejectRule
{
    public bool IsSatisfiedBy(CallForSpeech cfs)
    {
        return cfs.Speaker.SpeakerWebsites.HaveGitHub()
            || cfs.Speaker.SpeakerWebsites.HaveBlog();
    }

    public string Message => "Speaker must have at blog or github";
}


public interface IScoringRulesFactory
{
    public ScoringRules DefaultSet { get; }
}

Jak widzisz zasady aplikacyjne łatwo zmienić lub stworzyć kolejne.

Warstwa aplikacji w sama w sobie nie robi żadnych operacji IO.

Takie rzeczy idą do warstwy Infrastructure. Warstwa aplikacji może co najwyżej wywołać metodę z  interfejsu, którego się spodziewamy użyć.

Te interfejsy są kontraktami do operacji IO.

Oto więc podsumowanie co idzie gdzie:

Application Core

Co idzie do Core :

aaaaaaaaaaaaaaaaaaaaaaaaaaaa.PNG

ViewModele i Data Transfer Object

W tej warstwie także umieszczamy nasze ViewModele i Data Transfer Object. W moim przypadku znajdują się one w projekcie CQRS. Gdyż wiem, że to będzie przesyłane do warstwy User Interface.

Data Transfer Object gdzie się znajdują

W mapowaniu pomaga mi paczka AutoMapper. Opisałem go tutaj. 

ViewModele w moim przypadku są powiązane mocno z konkretnymi odpowiedziami do  Command i Query.

ViewModele do Command i Query.

Możesz zobaczyć, że ViewModel nie wystawia na świat wszystkich pół z oryginalnej klasy Domenowej, która jest gigantyczna.

public class CallForSpeechInListViewModel
{
    public int Id { get; set; }
    public SpeechDto Speech { get; set; }
    public CategoryDto Category { get; set; }
    public string Status { get; set; }
    public int Version { get; set; }

    public Guid UniqueId { get; set; }
}

Warto zaznaczyć, że ViewModel jaki Data Transfer Object nie zawiera w sobie żadnych metod. Ich rola polega na przesłaniu danych dalej.

Warstwa Infrastruktury

To ona jest odpowiedzialna za wszystkie operacje IO, które są wymagane przez naszą aplikację.

Ta warstwa ma prawo wiedzieć wszystko o Application Core i jej obiektach domenowych.

Warstwa Infrastruktury w onion

W tej warstwie nie powinieneś mieć żadnej logiki biznesowej. Co najwyżej możesz walidować dane przed zapisem danych do bazy.

Sprawdzać dane warto w każdej warstwie.

Nie ma tutaj czegoś innego, co mogłoby definiować przepływ aplikacji.

Tutaj mamy: dostęp do danych, logowanie, autoryzację, inne api klienckie niezależne od naszego, dostęp do plików, wysyłkę e-mail, wysyłkę SMS-ów.

Co idzie do Infrastructure

Tutaj tworzy definicję swoich repozytoriów.

Przechodzimy do warstwy UI lub REST API

W przypadku każdej technologi  tutaj znajduje się punkt, w którym wszystkie składniki z tych warstw łączysz i instalujesz.

W przypadku języka GO wyglądałoby to tak:

package main

import (
    "some/external/dependency/dbclient"
    "myproject/application/services"
    "myproject/infrastructure/repositories"
    "myproject/infrastructure/views/api"
)

func main() {
    client := dbclient.New()
    repo := repositories.NewAccountRepository(client)
    svc := services.NewBankingService(repo)

    api := api.NewAPI(svc)
    api.Start()
}

jak wygląda projekt API

Dla ASP.NET Core w klasie startup odpalamy nasze instalatory z różnych warstwy. Jak to zrobić? Najpierw takie instalatory trzeba napisać. Mam na ten temat oddzielny wpis iservicecollection-wzorzec-w-aspnet-core

public class Startup
{
    public IConfiguration Configuration { get; }

    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public void ConfigureServices(IServiceCollection services)
    {

    services.AddGeekLemonConferenceCQRS(Configuration);
    services.AddGeekLemonPersistenceDapperSQLiteServices(Configuration);

Teraz twoja aplikacja połączyła te klocki LEGO.

Co znajduje się w REST API? Tylko logika, która ma wpływ na statusy HTTP. Pamiętaj logika biznesowa i walidacja powinna być w Application Core.

Oto jak wygląda mój Controller.

[Route("api/[controller]")]
[ApiController]
public class CategoryController : BaseGeekLemonController
{
    private readonly IMediator _mediator;

    public CategoryController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet("all", Name = "getallcategories")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    public async Task<ActionResult<List<CategoryInListViewModel>>> GetAllCategories()
    {
        var result = await _mediator.Send(new GetCategoriesListQuery());

        return Ok(result.List);
    }

    [HttpGet("byId/{id}", Name = "GetCategoryById")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesDefaultResponseType]
    public async Task<ActionResult<CategoryDto>> GetCategoryById(int id)
    {
        var result = await _mediator.Send
            (new GetCategoryQuery()
            { CategoryId = new CategoryId(id) });

        return Ok(result.Category);
    }

W ASP.NET Core tutaj co najwyżej możesz utworzyć swoje Middleware.

Dalej możesz mieć aplikację UI napisaną w Angularze, React czy Blazor, która te API obsłuży.

Co idzie do UI REST API

Dlaczego zalecam odseparować widok na UI i na REST API? Akurat to nie ma dużo wspólnego z Clean Architecture.

Idea jest taka, że UI ciągle się zmienia. Nigdy nie wiesz, kiedy Angular czy React okażą się starą technologią. Nie masz też pewności czy Blazor się przyjmie. Dlaczego więc nie wydzielić tego, a potem zamienić UI, gdy czas taki nadejdzie.

Oddzielenie REST API od Klienta

Zalety Onion Clean Architecture

Taką aplikacją na pewno łatwo się zarządza, a każdy element może zostać podmieniony.

Możesz zmienić bazę danych i kod jej dostępu bez wysadzania wszystkiego innego w powietrze. 

Łatwo tak też znajduje się reguły biznesowe oraz cel naszej aplikacji.

Kod taki jest łatwy do testowania. Możesz testować reguły biznesowe bez gotowego UI czy warstwy dostępu do danych.

Test w Visual Studio

O ile pokazałem Ci tutaj przykład w .NET. To te same podejście może zostać użyte ponownie w innym frameworku czy języku.

Wady Clean Architecture

Może wyglądać jak przerost formy nad treścią, gdy wiesz, że masz aplikację bez reguł biznesowych.

Chociaż na moim pierwszym webinarze miałem taką aplikację i Clean Architecture z CQRS się spisał.

Jeżeli pisałeś aplikację, zaczynając od baz danych, to możesz czuć się dziwnie, korzystając z tej techniki.

Na co trzeba uważać:

Anemic Domain Model

Rozpoczynanie pracy Clean Architecture od bazy danych

Być może w przyszłości spojrzę na Heksagonalną Architekturę, czyli porty i adaptery i zrobimy porównanie. Na razie tyle.