Przechowywanie złożonego typu danych w bazie na przykładzie Entity Framework

Po moim ostatnim poście dostałem maila z dość ciekawym pytaniem dotyczącym zapisywania klasy Money w bazie danych. Pytanie brzmi następująco:

[…]mam pytanie, którego nie było w temacie, co prawda nie tego dotyczyło, ale może mogę uzyskać wskazówkę, pomoc co w przypadku zapisania takiej struktury do bazy, np wykorzystując EF ? EF domyślnie nie wspiera struct, dlatego mam pytanie, jak to dobrze i prawidłowo zapisywać do bazy ? Jakas podpowiedz ?

Jeśli chodzi o rozwój MoneyStorm to w zasadzie nie miałem w planach dotykania bazy danych przez najbliższe kilka tygodni ale uważam, że pytanie jest na tyle ciekawe, że warto poświęcić dłuższą chwilę na zgłębienie problemu w postaci posta. Być może innym osobom się to również przyda? A może ktoś będzie miał jakiś inny patent na rozwiązanie tego problemu? W poprzednim poście nie pokazałem również jak typu Money można używać co postaram się nadrobić w niniejszym wpisie.

Przedstawiony wcześniej typ Money jest w swoim założeniu złożonym typem danych, który ma ukrywać szczegóły wykonywania operacji na kwotach pieniężnych i je ułatwiać i jako taki nie ma większego sensu istnienia bez swojego kontekstu. Oczywiście można po prostu utworzyć instancję tego obiektu nie powiązaną z żadnym innym bytem ale z biznesowego punktu widzenia będzie ona miała małą wartość. Dopiero połączona z kontekstem, którym może być na przykład wartość transakcji czy stan konta okazuje się być faktycznie przydatna. Istotne jest aby nie traktować typu Money jako encji, która będzie zapisywana w bazie danych bezpośrednio gdyż on po prostu taką encją nie jest – na pewno kiepskim i niepraktycznym pomysłem byłoby tworzenie w bazie danych tabeli tylko z kolumnami Amount i Currency i tworzenie do niej kluczy obcych wszędzie gdzie potrzebujemy informacji o kwocie.

Jak w takim razie zapisywać tę informację w bazie? Moim zdaniem najlepiej zapisywać ją razem z kontekstem natomiast w samym kodzie warto rozdzielić model encji od modelu z domeny biznesowej. Warto zastosować tutaj podejście DDD (Domain-Driven Design) i tworzyć model domeny jak najprostszy a jednocześnie jak najbardziej zbliżony do rzeczywistości mając na uwadze bounding context, w którym się poruszamy. Dodanie szumu wynikającego z zastosowania Entity Framework (czy innego ORM) zdecydowanie modelu nie upraszcza.

Całą ideę spróbuję pokazać na przykładzie. Załóżmy, że mamy tabelę przechowującą transakcje, do której dodajemy osobne kolumny na wartość oraz walutę:

CREATE TABLE [dbo].[Transaction] (
    [Id]          INT      NOT NULL,
    [Value]       MONEY    NOT NULL,
    [Currency]    TEXT     NOT NULL,
    [Date]        DATETIME NOT NULL,
    [Description] TEXT     NOT NULL,
    PRIMARY KEY CLUSTERED ([Id] ASC)
);

Dla takiej tabeli Entity Framework wygeneruje nam klasę encji:

namespace MoneyStorm.DataAccess
{
    [Table("Transaction")]
    public partial class Transaction
    {
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        public int Id { get; set; }

        [Column(TypeName = "money")]
        public decimal Value { get; set; }

        [Column(TypeName = "text")]
        [Required]
        public string Currency { get; set; }

        public DateTime Date { get; set; }

        [Column(TypeName = "text")]
        [Required]
        public string Description { get; set; }
    }
}

Natomiast w naszej domenie biznesowej fajnie byłoby mieć dla transakcji klasę, która będzie operowała na kwocie transakcji jak na jednej wartości z wykorzystaniem typu Money (żeby nie gmatwać za bardzo w moim przykładzie zamierzam tylko i wyłącznie odczytywać dane dlatego klasa ta nie ma żadnego Id – gdybym chciał również zapisywać to takie pole musiałoby być dodane):

namespace MoneyStorm.Core
{
    public class Transaction
    {
        public Money Value { get; }
        public DateTime Date { get; }
        public string Description { get; }

        public Transaction(Money value, DateTime date, string description)
        {
            Value = value;
            Date = date;
            Description = description;
        }
    }
}

To co musimy teraz zrobić to przygotować klasę, która będzie potrafiła mapować obiekty encji na obiekty z domeny biznesowej:

namespace MoneyStorm.CoreDataMapper
{
    public class TransactionMapper
    {
        public IEnumerable<Core.Transaction> GetAll()
        {
            TransactionContext model = new TransactionContext();

            model.Transaction.Load();

            return
                model.Transaction.Local.Select(
                    transaction =>
                        new Core.Transaction(
                            new Money(transaction.Value,
                            new Currency(transaction.Currency)),
                            transaction.Date,
                            transaction.Description)).ToList();
        }
    }
}

I na koniec jak można to wykorzystać po stronie klienta:

TransactionMapper mapper = new TransactionMapper();

IEnumerable<Core.Transaction> transactions = mapper.GetAll();

foreach (Core.Transaction transaction in transactions)
{
	Console.WriteLine("Transaction '{0}' for {1} made on {2}.",
                transaction.Description,
                transaction.Value,
		transaction.Date);
}

Console.WriteLine("Sum of the transactions is: {0}",
	transactions
        .Select(t => t.Value)
        .Aggregate((working, next) => working + next));

MoneyStorm.client_console

Istotne tutaj jest odpowiednie rozdzielenie poszczególnych klas pomiędzy projektami i trzymanie porządku w referencjach między nimi. Klasa z modelem biznesowym trafia więc do projektu modelującego dziedzinę. Projekt ten modeluje reguły biznesowe rządzące wycinkiem świata rzeczywistego ograniczonego przez bounding context i powinien mieć jak najmniej zależności – z jego punktu widzenia nieistotne jest czy dane zapisujemy w bazie SQLServer, w chmurze, czy może w pliku na dysku i czy wykorzystujemy do tego Entity Framework, czy jakąś inną magię. Klasa encji trafia do projektu DataAccess, który korzysta z Entity Framework do wczytywania danych więc musi posiadać referencję na tę bibliotekę. Klasa mappera również mogłaby trafić do projektu DataAccess i wówczas potrzebna byłaby referencja do projektu z modelem biznesowym. Jednak ja uważam, że dobrze jest wydzielić w tym miejscu dodatkową warstwę w postaci projektu odpowiedzialnego za mapowanie obiektów do konkretnej dziedziny biznesowej. W niewielkich aplikacjach może to być zbędne jednak jeśli myślimy o czymś większym, gdzie będzie modelowana więcej niż jedna dziedzina wówczas warto rozważyć takie podejście. Daje to większą elastyczność w modelowaniu obiektów i pozwala na łatwe zastosowanie różnych mapperów w zależności od dziedziny, np. możemy mieć encję pracownika w dziedzinie zarządzania podstawowymi danymi pracownika gdzie będziemy chcieli mieć pełen zestaw danych zmapowany, natomiast w dziedzinie zarządzania urlopami wystarczy nam pewnie imię, nazwisko i jakiś numer identyfikacyjny. Poniższy diagram pokazuje jak wyglądają zależności pomiędzy poszczególnymi projektami przy takim podejściu:

ExperimentGraph

W przypadku małych aplikacji z krótkim czasem życia takie podejście będzie pewnie jak z strzelanie z armaty do muchy jednak dla większych aplikacji warto je rozważyć. Dzięki niemu możemy uzyskać jasny podział odpowiedzialności pomiędzy poszczególnymi warstwami aplikacji oraz łatwość testowania poszczególnych części systemu w izolacji od całej reszty. Jeśli ktoś posiada dostęp do Pluralsight to polecam kurs Improving Testability Through Design, który w ostatnim module w szczegółach omawia opisane przeze mnie podejście.

  • Pingback: dotnetomaniak.pl()

  • A można zamiast EF użyć nHibernate, który wspiera złożone typy danych od zawsze out-of-the-box. Można nawet mapować złożony typ jako klucz encji (przydatne dla kluczy wielokolumnowych)

    • Damian Jarosch

      Nie zawsze można.
      Nie zawsze się chce.
      Powód też jakiś trochę wątpliwy.

      • Nie zawsze można. Ale można wiedzieć, że ma się wybór. A chcenie się to nie wiem czy powinno być argumentem 😉

        • Wiadomo, że lepiej używać narzędzi dla profesjonalistów zamiast zabawek od MS, no ale nawet EF już od paru lat wspiera złożone typy danych.

          • Tak, ale structów nie umie dalej prawda?

          • Nie wiem. Nigdy zresztą nie miałem potrzeby zapisywania typu strukturalnego do bazy, i nie bardzo umiem sobie wyobrazić przypadku, dla którego mogłoby to mieć sens.

  • Polecałbym używać decimal z ustaloną precyzja zamiast money – ciekawy temat do zgłębienia 🙂

  • Mam nadzieję, że nie zostanie to odebrane jako czepialstwo – mam po prostu kilka uwag:

    1) Dobra architektura jest równie ważna jak wydajność. Wczytywanie wszystkich Transakcji z tabeli na raz do pamięci, a potem ich konwertowanie w pamięci na inne obiekty jest niewydajne.

    2) Piszesz o DDD, a jednocześnie słowa encja używasz na określenie modelu składowania danych w bazie. A przecież encja to właśnie podstawowy element definiowania logiki biznesowej w DDD!

    3) TransactionMapper łamie SRP – nie mapuje, lecz wczytuje, a później mapuje.

    4) Jeśli myślimy w kategoriach DDD, to i dostęp do danych należałoby realizować w sposób zgodny z tym podejściem, czyli przez repozytoria operujące na aggregate rootach i ukrywające pod sobą ORMa. DataMappery w tej postaci do tego nie pasują.

    5) A czemu po prostu nie zmapować encji (czyli klas z modelu domeny) za pomocą code first i FluentAPI? Odpadnie wtedy dodatkowa warstwa klas modelujących warstwę składowania danych, a przy tym zachowamy persistence ignorance – czyli model domeny nie będzie nic wiedział o faktycznym źródle danych.