data.mdx.frontmatter.hero_image

Trzy sposoby modelowania encji

2019-01-31 | .NET | bd90

Przez ostatnie kilka miesięcy moją uwagę, w wolnym czasie, zabierał mi Domain Driven Design. Wieczory umilała mi lektura Evansa, Vernon-a, Folwer-a. Pogłębianie wiedzy dało mi do myślenia. Zacząłem się zastanawiać jak wygląda domena mojego projektu i w jaki sposób mogę ją zamodelować przy pomocy kodu. W tym artykule chciałby Ci, mój drogi czytelniku, przedstawić trzy sposoby modelowania encji jakie towarzyszyły mi podczas mojej kariery programisty. Na wstępie zaznaczę, że nie jest to kolejny wstęp do DDD, chociaż pojawią się tutaj pewne jego elementy. Także jest to dość subiektywny artykuł bazujący na moim doświadczeniu i w pewnym stopniu na zdobytej w ostatnim czasie wiedzy.

Metoda na ORM

Zaczynając swoją przygodę z programowaniem, wtedy jeszcze w języku PHP (tak wiem co wielu z was myśli o tym języku :P ), używałem, do komunikacji z bazą danych, bibliotek typu ORM (Object Relational Mapper). Nie zależnie czy to framework Kohana, Laravel czy jakikolwiek inny - modele zawsze wyglądały podobnie. Nawet teraz, używając Entity Framework Core, nie znajduje wielu różnic. Tak uczciwie, jedną z bardziej widocznych w .NET-cie jest konieczność zdefiniowania pola w klasie, które ma być mapowane z kolumnami tabeli.

Przyjrzyjmy się jak wyglądał proces modelowania encji w tym przypadku. Potrzebujemy najpierw stworzyć domenę. Uznajmy, że tworzymy ją na potrzeby oprogramowania dla działu HR. Na pewno będziemy potrzebowali encji pracownika. Niech zawiera takie informacje jak imię, nazwisko, płaca i stanowisko, na jakim jest zatrudniony. Najprostsza implementacja wyglądałaby następująco:

public class Employee 
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Salary { get; set; }
    public Position Position { get; set; }
}

Przychodzą wymagania biznesowe

Samo napisanie klasy nie powinno nikomu sprawić większych problemów. Gorzej zaczyna się robić w momencie, gdy przychodzi biznes i zaczyna opowiadać jakie funkcjonalności chcą mieć w systemie. Zakładając, że piszemy system HRM, bardzo realnym wymaganiem biznesowym byłaby funkcjonalność podwyżki.

Jako, że wszystkie propertisy klasy Employee mamy dostępne publicznie, to istnieje pokusa wyniesienia jej do oddzielnej klasy. My jednak pójdźmy w stronę serwisu. Wylądujemy ostatecznie z czymś takim:

public class EmployeeService
{
    public void RaiseBy(Employee employee, int amount)
    {
         employee.Salary += amount;
    }
}

Tworzenie wymagania jest jak... myśleliście, że powiem kakałko? Myślałem nad tym, ale słabo tu z porównaniem, To raczej odcinek "Doktora Hausa", gdzie po jednym objawię przybywają następne. U nas zjawiają się coraz to nowe wymagania. Tym razem biznes chce wprowadzić, w naszym systemie, widełki na stanowiskach. Musimy zaimplementować zabezpieczenie, aby pracownik nie mógł zarabiać więcej niż definiuje jego stanowisko. Taki if wyląduje oczywiście w metodzie RaiseBy

public void RaiseBy(Employee employee, int amount)
{
     var newSalary = employee.Salary + amount;
     if (newSalary <= employee.Position.MaxSalary) 
     {
         employee.Salary += amount;
     }
}

Takie zachowanie możemy nazwać niezmiennikiem. Czy domyślasz się, jakie problemy mogą być z taką implementacją? Wystarczy, że ktoś zamiast wywołać metodę RaiseBy z EmployeeService będzie działał na obiekcie Employee. Wielu z was pewnie pomyśli: głupi przecież można to obskoczyć za pomocą DataAnnotations. No niestety walidację takich atrybutów trzeba wywołać ręcznie (oprócz ModelBinding w kontrolerze). Tak więc za dużo by to nie zmieniło. Czy są do tego lepsze narzędzia? Jasne! Przejdźmy do drugiego sposobu modelowania encji.

Not Always Valid

Jeżeli dopuszczamy, w naszym systemie, możliwość wystąpienia encji w niepoprawnym stanie, potrzebujemy mechanizmu sprawdzania niezmienników. Tak, aby zły stan encji nie został utrwalony np. w bazie danych. Dodatkowo możemy przenieść zachowania do klasy Employee, dzięki czemu zniknie konieczność używania EmployeeService, a samo wywołanie metody odpowiedzialnej za podwyżkę lepiej odzwierciedla język domenowy. Dodając metodę IsValid służącą do pilnowania naszego niezmiennika, klasa Employee będzie prezentowała się następująco:

public class Employee 
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Salary { get; set; }
    public Position Position { get; set; }

    public void RaiseBy(int amount)
    {
        Salary += amount;
    }

    public bool IsValid() 
    {
        return Salary <= Position.MaxSalary;
    }
}

Minusem tej implementacji jest potrzeba sprawdzania, czy encja jest w poprawnym stanie przed utrwaleniem jej w bazie danych.

employee.RaiseBy(2000);

if (employee.IsValid()) 
{
    await unitOfWork.SaveAsync();
}

Nie zmienia to jednak faktu, że ten sposób ma wiele pozytywów. Po pierwsze trzymamy logikę biznesową blisko encji. Ułatwia nam to pisanie testów jednostkowych. Moim zdaniem to nadal nie idealne podejście do modelowania encji. Przejdźmy do kolejnego sposobu.

Always Valid

Ostatni sposób polega na napisaniu encji w taki sposób, abyśmy nie musieli się tym przejmować w innych warstwach aplikacji. Po pierwsze wywalmy publiczne setter-y. W idealnym przypadku najlepiej jakby były prywatne, jednak niektóre ORM-y (takie jak Entity Framework, NHibernate) sobie z tym nie poradzą. Stąd konieczne jest by je ustawić na protected. Jeżeli chcemy korzystać z ORM-ów należy posiadać bezparametrowy konstruktor. Na szczęście nie musi być on publiczny, wystarczy że będzie protected, powinno to wystarczająco zabezpieczyć przed stworzeniem obiektu z default-owymi wartościami.

Następnie tworzymy konstruktor inicjalizujący stan encji. Przechodzimy do implementacji metody RaiseBy. Zgodnie z zasadą Fail Fast, w momencie przekroczenia maksymalnej płacy, rzucamy wyjątkiem. Oczywiście przydałby się tutaj jakiś bardziej customowy wyjątek, aczkolwiek na potrzeby tego artykułu, myślę, że InvalidOperationException całkowicie wystarczy.

Cała implementacja takiej encji wygląda następująco:

public class Employee 
{
    public string FirstName { get; protected set; }
    public string LastName { get; protected set; }
    public int Salary { get; protected set; }
    public Position Position { get; protected set; }

    protected Employee() {}

    public Employee(string firstName, string lastName, int Salary, Position position)
    {
        // Validate input and initialize fields
    }

    public void RaiseBy(int amount) 
    {
        var newSalary = Salary + amount;
        if (newSalary > Position.MaxSalary)
        {
            throw new InvalidOperationException();
        }
        Salary = newSalary;
    }
}

W ten sposób nie ma możliwości, aby Encja była w nie poprawnym stanie. Co więcej wszystkie zachowania są ładnie enkapsulowane przez klasę.

Podsumowanie

W obecnym punkcie mojej drogi programistycznej staram się jak najczęściej tworzyć encję typu: Always Valid. Oczywiście, nie we wszystkich systemach da radę tak zrobić, ale zachęcam was do wypróbowania na własnej skórze. Pamiętajcie tylko o obsłudze wyjątków ;)

Mam nadzieje, że artykuł się podobał. Jak zwykle do następnego!

Cześć

By Bd90 | 31-01-2019 | .NET