Poprzednim razem skończyłem na szkielecie zdolnym do transportu komend i kwerend. Teraz, gdy aspekty techniczne są załatwione mogę w końcu przejść do meritum problemu. Celem całej serii było zapoznanie ze wzorcem oraz dowód, że małym kosztem można to zrobić wykorzystując jedną bazę danych z zachowaniem spójności modelu odczytowego i zapisowego. Prosta aplikacja, którą przygotowałem, posłuży jako przykład implementacji CQRS na wspomnianym szkielecie. Jako narzędzia pomocnicze skorzystam z EF Core’a i Dappera.

Część zapisowa przykładowej aplikacji

Podobnie jak wcześniej, zapis jest zdecydowanie prostszy, dlatego stanowił będzie dobry punkt wejścia w temat. Aby nie komplikować domeny, postanowiłem wykorzystać tę, którą wykorzystałem jako przykład w poprzednim poście. Przypomnę, że cały proces wyglądał następująco:

  • Zapis do newslettera
  • Złożenie zamówienia

Zacznijmy od początku. Komenda deleguje zapis do newslettera przekazując Email subskrybenta oraz identyfikator Id, który ma zostać przyporządkowany do tego rekordu.

public class SubscribeToTheNewsletter : ICommand
{
    public Guid Id { get; set; }
    public string Email { get; set; }
}

Aplikacja po otrzymaniu takiej komendy uruchamia odpowiedni Handler, który zapisuje subskrybenta do newslettera i w ramach podziękowania wysyła mu maila z kodem rabatowym na zakupy. Komunikacja z bazą danych schowana jest za interfejsem repozytorium, którego implementacja znajduje się w oddzielnej warstwie aplikacji. Serwis do wysyłki maili został na tym etapie zamockowany, ponieważ nie jest to szczególnie ważny element całego procesu a jego implementacja mogłaby wprowadzić niepotrzebne zamieszanie.

public class SubscribeToTheNewsletterHandler : ICommandHandler<SubscribeToTheNewsletter>
{
    private readonly IEmailService _emailService;
    private readonly IIdentityProvider _identityProvider;
    private readonly INewsletterRepository _newsletterRepository;
    private readonly IDiscountRepository _discountRepository;
    private readonly IDiscountCodeGenerator _discountCodeGenerator;

    public SubscribeToTheNewsletterHandler(
        IEmailService emailService,
        IIdentityProvider identityProvider,
        INewsletterRepository newsletterRepository,
        IDiscountRepository discountRepository,
        IDiscountCodeGenerator discountCodeGenerator)
    {
        _emailService = emailService;
        _identityProvider = identityProvider;
        _newsletterRepository = newsletterRepository;
        _discountRepository = discountRepository;
        _discountCodeGenerator = discountCodeGenerator;
    }

    public async Task HandleAsync(SubscribeToTheNewsletter command, CancellationToken cancellationToken = default)
    {
        var subscriber = Subscriber.Subscribe(command.Id, command.Email);
        var discount = _discountCodeGenerator.GenerateCodeForSubscriber(_identityProvider.Next(), subscriber.Id);
        await _newsletterRepository.Add(subscriber, cancellationToken);
        await _discountRepository.Add(discount, cancellationToken);
        await _emailService.SendEmailWithDiscountCode(discount.GetCode(), cancellationToken);
    }
}

Kolejnym punktem procesu jest złożenie zamówienia w serwisie. Zgodnie z podejściem Task Based przygotowałem dedykowaną komendę. Warto zaznaczyć, że jest ona maksymalnie uproszczona i na pierwszy rzut oka widać, że brakuje tu wielu elementów, które można by znaleźć w prawdziwym zamówieniu. Dla tego wpisu nie ma to jednak żadnego znaczenia, a dodatkowe pola wprowadziłyby nowe komplikacje. Z powodzeniem można przyjąć, że do złożenia zamówienia w tym serwisie wystarczy jego wartość, kod rabatowy (jeżeli użytkownik taki posiada) i identyfikator.

public class PlaceOrder : ICommand
{
    public Guid Id { get; set; }
    public decimal Value { get; set; }
    public string DiscountCode { get; set; }
}

Handler na podstawie otrzymanej komendy potrafi określić czy rabat należy naliczyć na podstawie kodu rabatowego, czy może zwykłych zasad. Kolejno utworzone zamówienie jest zapisywane w repozytorium.

public class PlaceOrderHandler : ICommandHandler<PlaceOrder>
{
    private readonly IDiscountRepository _discountRepository;
    private readonly IOrderRepository _orderRepository;
    private readonly IValueCalculator _valueCalculator;

    public PlaceOrderHandler(IDiscountRepository discountRepository, IOrderRepository orderRepository, IValueCalculator valueCalculator)
    {
        _discountRepository = discountRepository;
        _orderRepository = orderRepository;
        _valueCalculator = valueCalculator;
    }

    public async Task HandleAsync(PlaceOrder command, CancellationToken cancellationToken = default)
    {
        if (!string.IsNullOrWhiteSpace(command.DiscountCode))
            await HandleOrderWithDiscountCode(command, cancellationToken);
        else
            await HandleOrder(command, cancellationToken);
    }

    private async Task HandleOrderWithDiscountCode(PlaceOrder command, CancellationToken cancellationToken)
    {
        var discount = await _discountRepository.FindByCode(command.DiscountCode, cancellationToken);
        if (discount == null)
            throw new InvalidOperationException($"Couldn't find discount of following code: {command.DiscountCode}");
        var order = Order.PlaceDiscountedOrder(command.Id, command.Value, discount, _valueCalculator);
        await _orderRepository.Add(order, cancellationToken);
    }

    private async Task HandleOrder(PlaceOrder command, CancellationToken cancellationToken)
    {
        var order = Order.PlaceOrder(command.Id, command.Value);
        await _orderRepository.Add(order, cancellationToken);
    }
}

Część odczytowa aplikacji

Ostatnim wymaganiem przykładowej aplikacji było udostępnienie widoków, kliku prostych projekcji, które powstały z kombinacji zamówień, kodów rabatowych i subskrybentów. Dwie wyżej przedstawione powyżej komendy w zupełności wystarczą do ich utworzenia. Zgodnie z zasadą tworzenia projekcji szytych na miarę, każdy widok otrzymał własny model oraz dedykowaną kwerendę. Przypomnę raz jeszcze wymagania co do widoków dostępnych w aplikacji:

  • Podgląd kodów rabatowych wygenerowanych dla subskrybentów
  • Podgląd zamówienia o podanym Id
  • Podgląd zamówień, które zostały przecenione przez kod rabatowy

Przekłada się to na następujące klasy:

public class SubscriberDiscountProjection
{
    public string Email { get; set; }
    public string DiscountCode { get; set; }
}

public class OrderProjection
{
    public Guid OrderId { get; set; }
    public decimal Value { get; set; }
}

public class OrderFromNewsletterProjection
{
    public Guid OrderId { get; set; }
    public decimal Value { get; set; }
    public decimal DiscountValue { get; set; }
    public decimal TotalValue { get; set; }
    public string SubscriberEmail { get; set; }
}

Przygotowane modele zwracane będą przez kwerendy. Podążając za przedstawionymi wymaganiami, łatwo określić parametry, które kwerendy powinny przyjmować do pobrania odpowiednich danych. Prezentują się one następująco:

public class RetrieveSubscriberWithDiscountCode : IQuery<SubscriberDiscountProjection>
{
    public Guid SubscriberId { get; set; }
}

public class RetrieveOrder : IQuery<OrderProjection>
{
    public Guid Id { get; set; }
}

public class RetrieveOrdersFromNewsletter : IQuery<ICollection<OrderFromNewsletterProjection>>
{
}

Choć też proste, to od samych Queriesów zdecydowanie ciekawsze są ich Handlery.

public class RetrieveSubscriberWithDiscountCodeHandler : IQueryHandler<RetrieveSubscriberWithDiscountCode, SubscriberDiscountProjection>
{
    private readonly ISubscriberDiscountProjectionRepository _subscriberDiscountProjectionRepository;

    public RetrieveSubscriberWithDiscountCodeHandler(ISubscriberDiscountProjectionRepository subscriberDiscountProjectionRepository)
    {
        _subscriberDiscountProjectionRepository = subscriberDiscountProjectionRepository;
    }

    public Task<SubscriberDiscountProjection> HandleAsync(RetrieveSubscriberWithDiscountCode query, CancellationToken cancellationToken = default)
    {
        return _subscriberDiscountProjectionRepository.Find(query.SubscriberId, cancellationToken);
    }
}

public class RetrieveOrderHandler : IQueryHandler<RetrieveOrder, OrderProjection>
{
    private readonly IOrderProjectionRepository _orderProjectionRepository;

    public RetrieveOrderHandler(IOrderProjectionRepository orderProjectionRepository)
    {
        _orderProjectionRepository = orderProjectionRepository;
    }

    public Task<OrderProjection> HandleAsync(RetrieveOrder query, CancellationToken cancellationToken = default)
    {
        return _orderProjectionRepository.Find(query.Id, cancellationToken);
    }
}

public class RetrieveDiscountedOrdersHandler : IQueryHandler<RetrieveOrdersFromNewsletter, ICollection<OrderFromNewsletterProjection>>
{
    private readonly IDiscountedOrderProjectionRepository _discountedOrderProjectionRepository;

    public RetrieveDiscountedOrdersHandler(IDiscountedOrderProjectionRepository discountedOrderProjectionRepository)
    {
        _discountedOrderProjectionRepository = discountedOrderProjectionRepository;
    }

    public Task<ICollection<OrderFromNewsletterProjection>> HandleAsync(RetrieveOrdersFromNewsletter query, CancellationToken cancellationToken = default)
    {
        return _discountedOrderProjectionRepository.List(cancellationToken);
    }
}

Choć sama logika Handlerów nie jest rozbudowana, ot kolejna warstwa proxy do bazy danych, to dzieje się tu naprawdę dużo pod spodem. Uwagę powinny przykuć repozytoria, ponieważ każdy handler dysponuje swoim własnym. Nie o to jeszcze chodzi. Istotne jest, że są to całkowicie inne repozytoria niż te wykorzystane przy zapisie. Obserwując *Command Handlery i Query Handlery widać, że nastąpiła tu separacja modelu odczytowego i zapisowego, a przynajmniej repozytoriów.

Sama implementacja nie narzuca tego w żaden sposób. Równie dobrym podejściem jest odpytanie źródła danych w tym miejscu, bez dodatkowej warstwy abstrakcji. Wprowadziłem tę warstwę jednak, żeby widać było analogię do *Command Handlerów oraz aby łatwo było wychwycić różnicę gdzie korzystamy z jakiego źródła danych.

Kontekst zapisowy - EF Core

Dużo zostało już powiedziane o enigmatycznych repozytoriach, ale wciąż nie pokazałem co się za nimi kryje. Przejdę do tego już za moment, został jednak jeszcze jeden ważny element układanki - sposób dostępu do danych. Z uwagi na popularność Entity Frameworka w nowych projektach dotnetowych, uznałem, że będzie on dobrym narzędziem do wykorzystania i pokazania, że nawet gdy używamy ORMa można wprowadzić CQRS do swojego projektu lub jego części. Zacząłem od przygotowania DbContextu zapisowego dla aplikacji i zmapowania encji.

public class WriteDbContext : DbContext
{
    public DbSet<Discount> Discounts { get; set; }
    public DbSet<Subscriber> Subscribers { get; set; }
    public DbSet<Order> Orders { get; set; }

    private readonly ILoggerFactory _loggerFactory;

    public WriteDbContext(DbContextOptions<WriteDbContext> options, ILoggerFactory loggerFactory) : base(options)
    {
        _loggerFactory = loggerFactory;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new DiscountMappings());
        modelBuilder.ApplyConfiguration(new SubscriberMappings());
        modelBuilder.ApplyConfiguration(new OrderMappings());
    }
}

Aby nie zaśmiecać domeny aplikacji infrastrukturalnymi rzeczami, zrezygnowałem z mapowania automatycznego za pomocą atrybutów i wybrałem mapowanie ręczne, które pozwala na enkapsulację pól i schowanie szczegółów implementacyjnych. Warto zaznaczyć, że ręczne mapowanie ma ogromne możliwości co pozwala na użycie Entity Frameworka gdy chcemy zachować czystą domenę w podejściu DDD. Pokażę jedną przykładową encję oraz jej mapę.

public class Order
{
    private Guid? _discountId;
    private readonly decimal _discountValue;
    private readonly decimal _totalValue;
    private readonly decimal _value;

    public Guid Id { get; }
    public Summary Summary => new Summary(_value, _discountValue, _totalValue);

    private Order()
    {
    }

    private Order(Guid id, Guid discountId, decimal value, decimal discountValue, decimal totalValue)
    {
        Id = id;
        _discountId = discountId;
        _value = value;
        _discountValue = discountValue;
        _totalValue = totalValue;
    }

    private Order(Guid id, decimal value)
    {
        Id = id;
        _value = value;
        _discountValue = 0;
        _totalValue = value;
    }

    public static Order PlaceDiscountedOrder(Guid id, decimal value, Discount discount, IValueCalculator valueCalculator)
    {
        if (id == default)
            throw new ArgumentException("Value cannot be equal to default");

        if (discount == null)
            throw new ArgumentNullException(nameof(discount));

        if (value < 0)
            throw new InvalidOperationException("Value cannot be below 0");

        var discountValue = valueCalculator.CalculateDiscount(value, discount);

        return new Order(id, discount.Id, value, discountValue, value - discountValue);
    }

    public static Order PlaceOrder(Guid id, decimal value)
    {
        if (id == default)
            throw new ArgumentException("Value cannot be equal to default");

        if (value < 0)
            throw new InvalidOperationException("Value cannot be below 0");

        return new Order(id, value);
    }
}

Choć samo zachowanie nie jest szczególnie rozbudowane, encja enkapsuluje szczegóły implementacyjne oraz chroni swój stan, zabezpieczając przed przypadkową, niepoprawną modyfikacją. W skrócie - nie ma wystawionych setterów, które mogą wszystko popsuć. Podobnie wygląda sprawa z getterami oraz nawet konstruktorami. Dostępne jest tylko to co jest istotne oraz na zasadach określonych przez encję. Mimo tego wszystkiego, można tak skonfigurować Entity Framework, żeby dał sobię z tym radę. Jednym narzutem narzędzia jest pozostawienie pustego, prywatnego konstruktora. Jest on jednak prywatny, więc nie powinno przynieść to specjalnych szkód w aplikacji. Warto jednak wspomnieć o tym ograniczeniu.

internal class OrderMappings : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");

        builder.HasKey(x => x.Id);

        builder.Property<Guid?>("_discountId").HasColumnName("DiscountId").IsRequired(false);
        builder.Property<decimal>("_value").HasColumnName("Value").HasColumnType("decimal(14,4)").IsRequired();
        builder.Property<decimal>("_discountValue").HasColumnName("DiscountValue").HasColumnType("decimal(14,4)").IsRequired();
        builder.Property<decimal>("_totalValue").HasColumnName("TotalValue").HasColumnType("decimal(14,4)").IsRequired();
    }
}

Nowe wersje Entity Frameworka[1] są bardzo zaawansowane i pozwalają na mapowanie backing fieldów, pól prywatnych, prywatnych konstruktorów oraz oferują szerokie spektrum customizacji. Zachęcam do doczytania tematu we własnym zakresie, gdyż podkreślają jak ważne jest dla nich postępowanie w duchu DDD. Uważam, że to naprawdę dobra zmiana postawy, bo wcześniejsze wersje wręcz zachęcały do ustawiania wszystkiego jako publiczne.

Implementacja repozytorium

Zostawmy jednak EFa i wróćmy do implementacji repozytorium.

 public class WriteDbContextBasedRepository :
        IDiscountRepository, INewsletterRepository, IOrderRepository
{
    private readonly WriteDbContext _dbContext;

    public WriteDbContextBasedRepository(WriteDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task Add(Discount discount, CancellationToken cancellationToken =default)
    {
        await _dbContext.Discounts.AddAsync(discount, cancellationToken);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    public async Task<Discount> FindByCode(string discountCode, CancellationToken cancellationToken = default) =>
        await _dbContext.Discounts.SingleOrDefaultAsync(discount => discount.Code.Equal(discountCode) , cancellationToken);

    public async Task Add(Subscriber subscriber, CancellationToken cancellationToken =default)
    {
        await _dbContext.Subscribers.AddAsync(subscriber, cancellationToken);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    public async Task Add(Order order, CancellationToken cancellationToken = default)
    {
        await _dbContext.Orders.AddAsync(order, cancellationToken);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

Ktoś powie, że tyle zamieszania, a tu prawie nic się nie dzieje. Zwykły prosty zapis wystawiony przez kontekst EFa. Trzeba jednak pamiętać, że jest to tak proste, bo chwilę wcześniej kontekst ten został starannie przygotowany.

Część odczytowa repozytoriów

Zapis jak zapis, każdy z nas widział to wielokrotnie. Ciekawszy jest tu odczyt. Mówię ciekawszy, bo w klasycznym podejściu zwrócone zostały by dane z tych samych obiektów, które dodane zostały do kontekstu zapisowego. Na nic wtedy zdałyby się te wszystkie separacje oraz szkielet komend i kwerend. Aby wycisnąć z CQRS jak najwięcej (a tak naprawdę użyć go w sposób prawidłowy) trzeba wprowadzić odczyt z modeli dedykowanych. Zaproponuję tu wykorzystanie dwóch narzędzi. Pierwszym będzie analogicznie jak przy zapisie EF.

Odseparowany, odczytowy kontekst bazodanowy

Żeby nie czytać z encji zapisowych (co i tak jest niemożliwe przy takiej hermetyzacji encji domenowych) stworzę nowy kontekst:

public class ReadDbContext : DbContext
{
    public DbSet<OrderProjection> Orders { get; set; }
    public DbSet<OrderFromNewsletterProjection> DiscountedOrders { get; set; }

    private readonly ILoggerFactory _loggerFactory;

    public ReadDbContext(DbContextOptions<ReadDbContext> options, ILoggerFactory loggerFactory) : base(options)
    {
        _loggerFactory = loggerFactory;
    }
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
    }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new OrderProjectionMappings());
        modelBuilder.ApplyConfiguration(new OrderFromNewsletterProjectionMappings());
    }
}

Kontekst ten zawiera własne mapy dla utworzonych projekcji, które wykorzystałem już wcześniej przy kwerendach.

  • Pierwsza mapa oparta jest na istniejącej tabeli, ale zmapowane zostały tylko pola wykorzystywane w projekcji.
  • Druga mapa wykorzystuje widok bazodanowy.
internal class OrderProjectionMappings : IEntityTypeConfiguration<OrderProjection>
{
    public void Configure(EntityTypeBuilder<OrderProjection> builder)
    {
        builder.ToTable("Orders");

        builder.HasKey(x => x.OrderId).HasName("Id");

        builder.Property(x => x.Value).HasColumnName("Value").HasColumnType("decimal(144)");
    }
}

internal class OrderFromNewsletterProjectionMappings : IEntityTypeConfiguration<OrderFromNewsletterProjection>
{
    public void Configure(EntityTypeBuilder<OrderFromNewsletterProjection> builder)
    {
        builder.ToTable("OrderFromNewsletterView");
        builder.HasNoKey();

        builder.Property(x => x.OrderId);
        builder.Property(x => x.Value).HasColumnType("decimal(14,4)");;
        builder.Property(x => x.DiscountValue).HasColumnType("decimal(14,4)");;
        builder.Property(x => x.TotalValue).HasColumnType("decimal(14,4)");;
        builder.Property(x => x.SubscriberEmail);
    }
}

Następnie kontekst odczytowy wykorzystuję w repozytorium, analogicznie jak wykorzystywałem kontekst zapisowy w komendach:

public class ReadDbContextBasedRepository : IOrderProjectionRepository,IDiscountedOrderProjectionRepository
{
    private readonly ReadDbContext _dbContext;

    public ReadDbContextBasedRepository(ReadDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task<OrderProjection> Find(Guid id, CancellationToken cancellationToken =default) =>
        _dbContext.Orders.SingleOrDefaultAsync(discount => discount.OrderId == id,cancellationToken);

    public async Task<ICollection<OrderFromNewsletterProjection>> List(CancellationToken cancellationToken = default)
        => await _dbContext.DiscountedOrders.ToListAsync(cancellationToken);
}

Czyste zapytania, bez ORM

Moim zdaniem, do odczytu ORM się zupełnie nie przydaje. Zysk z użycia ORMa jest taki, że pilnuje on zmian na encjach. Skoro nie ma zmian przy odczycie, nie ma zysku ze śledzenia zmian. Wobec tego zdecydowanie prościej jest użyć jakiegoś cienkiego wrappera jak Dapper i pisać zapytania w SQL, do czego gorąco zachęcam.

Implementacja takiego repozytorium jest bardzo prosta i wymaga tylko znajomości SQLa

public class DapperBasedSubscriberDiscountProjectionRepository : ISubscriberDiscountProjectionRepository
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;

    public DapperBasedSubscriberDiscountProjectionRepository(ISqlConnectionFactory sqlConnectionFactory)
    {
        _sqlConnectionFactory = sqlConnectionFactory;
    }

    public async Task<SubscriberDiscountProjection> Find(Guid id,CancellationToken cancellationToken = default)
    {
        var connection = _sqlConnectionFactory.GetConnection();
        var sql = new []
        {
            "SELECT DISTINCT",
            "discount.Code as DiscountCode,",
            "subscriber.Email",
            "FROM",
            "Discounts discount",
            "JOIN",
            "Subscribers subscriber",
            "ON",
            "discount.SubscriberId = subscriber.Id",
            "WHERE",
            "subscriber.Id = @SubscriberId"
        };
        var subscriberDiscountProjections = await connectionQueryAsync<SubscriberDiscountProjection>(string.Join(" ", sql),new {SubscriberId = id});
        return subscriberDiscountProjections.SingleOrDefault();
    }
}

Zdecydowanie prościej bez EFa, nieprawdaż?

Podsumowując, model odczytowy w jednej bazie danych możemy zrobić w następujący sposób:

  • Zmapowanie tabel na dedykowane projekcje
  • Utworzenie widoków bazodanowych i zmapowanie ich na projekcje
  • Wywoływanie czystych zapytań bazodanowych bez warstwy ORM (np. Dapper)

Same projekcje są bardzo proste, ponieważ domenę wymyśliłem na poczekaniu. Warto jednak zapamiętać, że przy niewielkim wysiłku można przygotować dane, których dokładnie potrzebujemy. Co dzięki temu zyskujemy? Dużo prościej jest napisać widok bazodanowy czy zwykłe zapytanie niż leczyć “popsute” dane w skutek niezabezpieczenia domeny. W moim odczuciu, enkapsulacja i hermetyzacja domeny jest bardzo istotna i wykorzystanie CQRS to umożliwia. Ponadto, zyskujemy w ten sposób kontrolę nad zapytaniem, które wywołujemy przez co możemy je optymalizować.

Nie jest to jednak wszystko. Wprowadzenie CQRS daje potencjalne możliwości na dalsze optymalizacje. Gdy komendy i kwerendy korzystają z innych repozytoriów w szybki sposób można wprowadzić inne źródło danych (rozdzielić bazę odczytową i zapisową) co pozwala na niezależne skalowanie zapisu i odczytu. Trzeba tylko podkreślić, że rozdzielenie baz danych wprowadza nowe problemy - system staje się rozproszony. Może wystarczą widoki zmaterializowane?

Warto też dodać, że CQRS daje dużą elastyczność i można go używać tylko w miejscach, które tego potrzebują, a pozostałe zostawić tak jak są obecnie. Nie jest to prawda, że cały projekt musi być w ten sposób napisany. Z powodzeniem w jednym kontekście może istnieć i klasyczne podejście i CQRS, dlatego każdy moment jest odpowiedni na prowadzenie go do swojego projektu gdy tylko zajdzie taka potrzeba.

Czy są jakieś wady?

Moim zdaniem to podejście ma jedną wadę - komplikacja rozwiązania. Choć sam wzorzec nie jest trudny, trzeba było napisać klika klas i wszystko ze sobą połączyć. Może to być problem dla nowych osób dołączających do projektu, które nigdy wcześniej nie widziały takiej implementacji w swoim projekcie. Jest to jednak koszt, który trzeba przekalkulować i wydaje mi się, że przy większych projektach jest tego warty, bo łatwiej nauczyć kogoś jak tego używać albo nawet napisać krótką dokumentację z przykładem użycia, niż leczyć bugi na produkcji. Ktoś powie, że CQRS nie wprowadzi domeny wolnej od błędów - racja. Ale utrudnienie “popsucia” modelu zapisowego w postaci hermetyzacji zmniejsza ryzyko niepoprawnego użycia pól i metod.

Opinie osób postronnych

Przed publikacją posta skonsultowałem temat z innym deweloperem aby poznać zdanie kogoś z kim na co dzień nie pracuję. Jego zdaniem wykorzystywanie dwóch ORMów w jednym projekcie jest zbyt dużym narzutem i korzyści, które w parze prezentują są mniejsze niż koszt ich utrzymywania obok siebie. W tym przypadku zrezygnowałby z Dappera i obsługiwał zarówno stronę odczytową jak i zapisową za pomocą EFa i aby zwiększyć wydajność wykorzystać opcję AsNoTracking. A jakie jest wasze zdanie na ten temat?

Kod źródłowy

Zachęcam do przejrzenia kodu na githubie:

[1] Backing Field and Owned Entity Changes in EF Core 3.0, [artykuł]. Źródło: https://docs.microsoft.com/en-us/archive/msdn-magazine/2019/november/data-points-backing-field-and-owned-entity-changes-in-ef-core-3-0 \

Zostaw komentarz