Domain driven design v MVC aplikaci

30.11.2020

Dnešní článek bude o implementaci myšlenek domain driven designu (DDD) v MVC aplikaci (ASP.NET Core). Na jednoduchém příkladě si jako Han Solo vyzkoušíme objednávku střelných zbraní z galaktického army shopu :-)

Domain driven design byl prvně popsaný ve stejnojmenné knížce DDD: Tackling Complexity in the Heart of Software a obsahuje koncepty a postupy jak se vypořádat se složitými aplikacemi. Klade důraz na správné porozumnění problematiky, její namodelování pomocí OOP a komunikaci s doménovými experty.

Slovníček

DDD Domain Driven Design je přístup, který nám pomáhá modelovat doménovou problematiku.
MVC Model View Controller je návrhový vzor, který je v současné době v prostředí webových aplikací jeden z nejpoužívanějších.
OOP Objektově orientované programování, alternativou je procedurální nebo funkcionální přístup.
CQRS Command Query Responsibility Segregation, je návrhový vzor, který nám model rozdělí na dva, pro čtení a zápis.

Obsah

MVC

MVC je návrhový vzor ze 70. a 80. let, určený původně pro desktopové aplikace. Na popularitě získal společně s rozvojem webových aplikací. Hlavní myšlenkou je rozdělení aplikace do tří částí

  • Model - Obsahuje data a business logiku
  • View - Uživatelský interface (UI)
  • Controller - Řídí komunikaci mezi Modelem a View
MVC funguje trochu jinak v desktopové aplikaci a trochu jinak ve webové aplikaci, kde můžeme použít i označení MVP (Model-View-Presenter). Dovolím si načrtnout diagram, jak zhruba MVC funguje v kontextu standardní webové aplikace (server side rendering).

DDD

Hlavní myšlenkou je zaměření se primárně na businessovou problematiku, kolem které se vše odvíjí. Jedná se o iterativní proces, který je v souladu s agilním přístupem. Základní koncepty jsou

  • Pochopit co a proč vyvíjíme
  • Používat společný jazyk mezi businessem a programátory
  • Kontextuální mapa
  • Sada technik a abstrakcí pro naprogramování modelu

Pochopit co a proč vyvíjíme

Určitě to spousta juniorních vývojářů zná, dostanou zadání a začnou se soustředit primárně na technickou část. Důvěřují, že někdo jiný již správně provedl business analýzu. Nicméně, pokud problematiku kterou programujeme máme popsanou pomocí příběhů, mnohem lépe si uvědomíme co a proč řešíme a jsme schopni lépe odchytit chyby a nekonzistence mnohem dříve. To pak vede k menšímu množství zpětných oprav, které můžou být velmi drahé a demotivovat tým.

Společný jazyk mezi businessem a programátory

Jazyk přirozeně umíme a používáme od malička, nicméně i přesto může být problém se domluvit, pokud se setkají například lidé s různou odborností, zkušeností nebo jednoduše lidé, kteří si z jakéhokoliv důvodu pod stejnými výrazy představují odlišné věci. Někdy také může více významů znamenat, nebo zdánlivě znamenat, totéž. Doporučením je sepsat slovníček, na kterém se business a vývojový tým shodnou, že budou používat a který se také začne promítat do kódu.

Kontextuální mapa

Jedná se o techniku, kdy si mapujeme okolí naší aplikace. Mapujeme si nejen technické závislosti, ale také závislosti organizační a personální. Pokud jsme například závislí na jiném systému, ke kterému nemáme důvěru, můžeme zvolit defenzivnější přístup k přijatým datům nebo si zjistit plánovanou životnost takového systému. Pokud jsme závislí na jiném týmu, můžeme si zase vyžádat smluvní součinnost nebo SLA. Pokud se pouštíme do projektu, měly bychom také vědět organizační strukturu ve které se nacházíme a role a zodpovědnosti jednotlivých lidí. Můžeme do úvahy vzít také historickou zkušenost.

Sada technik a abstrakcí pro naprogramování modelu

Jedná se o stavební bloky, pravidla a principy, které můžeme použít při modelování businessové problematiky. Mezi ty základní patří následující

  • Entita
  • Agregát
  • Value objekt
  • Doménová služba
  • Aplikační služba
  • Repository
  • Modul

Entita

Entita je objekt, který rozpoznáváme podle jeho identity, nikoliv podle jeho hodnot. V průběhu času se hodnoty mohou měnit, ale identifikátor objektu zůstává neměnný. Jako identifikátor můžeme použít buď databázovou sekvenci nebo GUID (UUID). GUID má výhodu takovou, že si ho může vygenerovat přímo klient například v offline režimu a s databází se pak pouze sesynchronizovat (máme jistotu, že GUID nemá přiřazený žádný jiný objekt). Nevýhodou je, že GUID neobsahuje časovou seřaditelnost, takže pro případné řazení musíme použít sloupec s časovou známkou. Entita musí být v jakémkoliv stavu validní (nesmí se dostat do stavu, kdy by objekt porušoval nějaké businessové pravidlo), proto má všechny své parametry nastavené jako private set a je možné je měnit pouze v konstruktoru nebo zavoláním public metody. Technika, kdy si kontrolujeme platnost objektu pomocnou metodou, například IsValid(), se nedoporučuje, protože můžeme metodu zapomenout zavolat, ale také kvůli tomu, že při volání musíme vždy spustit veškeré kontroly, což je nadbytečné.

Agregát

Agregát je entita, která obsahuje (zodpovídá, spravuje) jinou entitu nebo kolekci jiných entit. Hlavním cílem agregátu je vynutit si validaci nad položkami, za které zodpovídá, jelikož agregát také musí být vždy ve validním stavu. Příkladem může být objednávka, která obsahuje položky. Objednávka může obsahovat pravidlo, které se týká seznamu položek, kdy je například určená celková maximální a minimální hodnota objednávky a takovou kontrolu může provést pouze agregát. DDD preferuje jednosměrný vztah (unidirectional relationship), kdy objednávka obsahuje kolekci položek, ale položka samotná obsahuje již pouze identifikátor agregátu. Pokud má model sloužit i pro čtení, můžeme z pravidla ustoupit a model obohatit o navigační proměnné ORM frameworku (položka objednávky pak může obsahovat objekt objednávky), což nám výrazně usnadní získávání dat. Při modelování je pak ale potřeba na takové navigační proměnné dávat pozor a nepoužívat je. Pokud by se nám takový přístup nelíbil, můžeme model rozdělit na modely dva, jeden pro zápis a druhý pro čtení, takové technice se pak říká CQRS (Command Query Responsibility Segregation). Agregát nikdy neobsahuje jiný agregát, ale může obsahovat jeho identifikátor. Pokud je to možné, snažíme se agregát udržovat co nejmenší. Agregát by také neměl používat repository službu.

Value objekt

Objekt, který nemá identifikátor, ale jehož identifikátorem je jeho hodnota nebo hodnoty. Pokud dva value objekty mají stejné hodnoty, jedná se o tentýž objekt. Příkladem může být číslo, text, čas s datumem, měna, telefonní číslo, poštovní směrovací číslo apod.

Repository

Repositář je služba, která za nás ukládá data a ve stejném stavu nám je zase vrací zpátky. Vytváří se společně s interfacem, který vytváří iluzi in-memory kolekce. Teorie je taková, že repositář by se měl vytvářet pouze pro konkrétní agregát, v praxi se ale často místo repositáře používá ORM framework, který funguje jako repositář i pro každou entitu.

Doménová služba

Pokud chceme provést nějakou operaci, ale není zodpovědností žádné entity, value objektu nebo agregátu, musíme pro ní vytvořit doménovou službu. Často se jedná o operaci, která vyžaduje práci s více objekty a nebo se jedná o komplexní operaci, která si zaslouží být samostatně. K třídě vždy vytváříme i interface a použít jí můžeme technikou double dispatching, kdy interface služby vložíme jako parametr do metody entity a entita pak vloží sama sebe do metody doménové služby. Tím máme zajištěno, že služba nemění stav žádného objektu, ale objekt změní sám sebe na základě získaných informací ze služby.

Aplikační služba

Je služba, která je klientem modelu. Služba používá model ke zpracování businessového požadavku. Data si čte a zapisuje pomocí repository služby a je také zodpovědná za správu transakcí a provádění autorizace. V našem případě jí implementujeme pomocí command patternu s mediátorem. Díky tomu budeme přehledně vidět, které operace aplikace podporuje a jaké informace k tomu potřebuje. Zároveň klientovi aplikační služby bude stačit pouze reference na mediátor a ten už pak sám najde správný command handler.

Modul

DDD upřednostňuje strukturu projektu podle principu folder by feature, Zatímco výchozí struktura MVC projektu je folder by type. U větších projektů bych šel doporučenou cestou, u menších bych klidně zůstal u výchozího nastavení.

Architekturu můžeme navrhnout následovně

Implementace

Protože implementace obsahuje hodně kódu, nahrál jsem celou aplikaci na GitHub, aby si jí mohl případný zájemce projít a vyzkoušet. Aplikace obsahuje pět projektů. Databázový projekt, pomocí kterého spravujeme SQL Server databázi. Doménový projekt, ve kterém je uložená veškerá business logika a businessová pravidla. Mvc projekt je naše webová stránka a nakonec projekt pro jednotkové testy a projekt pro integrační testy. Jestli budou jednotlivé vrstvy aplikace ve složkách jednoho projektu nebo budou oddělené v samostatných projektech je vždy na zvážení podle konkrétních potřeb. Implementaci popíšu pouze na základě tří souborů, které mi přijdou nejvíce stěžejní.

Model

Model žije v našem doménovém projektu a obsahuje objekty, které definují veškerá businessová pravidla a operace, které je možné v aplikaci volat. V našem galaktickém obchodě může Han Solo objednávat zbraně, proto jsem vytvořil třídu objednávka (agregát), která obsahuje seznam objednávkových položek (kolekce entit). Objednávka má veškeré vlastnosti nastavené jako private set, což jí chrání před nevyžádanýma změnama jiných objektů, které by mohli objednávku dostat do nevalidního stavu. Pouze ona je zodpovědná za svoje hodnoty (svůj stav). Žádný jiný objekt nemá možnost objednávce tyto hodnoty změnit napřímo a má možnost volat pouze veřejné metody objednávky. Jedná se o základní princip OOP (zapouzdření/enkapsulace).

Order.cs

namespace Domain.Models
{
    public partial class Order : IAggregate
    {
        // Identity can be of a Guid type instead.
        public int? Id { get; private set; }
        public int CustomerId { get; private set; }

        // This will increase the encapsulation and prevent manipulation of the public collection. 
        // But it will not prevent the direct calling of the method of 
        // a specific object in the collection.
        // --------------------------------------------------------------------------------
        private List<OrderItem> _orderItems = new List<OrderItem>();
        public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
        // --------------------------------------------------------------------------------
        //public List<OrderItem> OrderItems { get; private set; }

        public int VoucherPercentageDiscount { get; private set; }
        public DateTime CreatedOn { get; private set; }

        // EF navigation property.
        // If we dont like to put navigation properties into the domain model. 
        // We can use the CQRS technique and split the model.
        public Customer Customer { get; private set; }

        // EF private constructor.
        private Order() { } 

        public Order(int customerId, List<OrderItem> orderItems)
        {
            if (orderItems == null || orderItems.Count == 0)
            {
                throw new DomainException("The order must contain at least one item.");
            }
            CustomerId = customerId;
            _orderItems = orderItems;
            CreatedOn = DateTime.Now;
        }

        public void SetDiscount(int voucherPercentageDiscount, IDiscountValidator validator)
        {
            // double dispatch technique
            validator.ValidateDiscountAmount(this, voucherPercentageDiscount);
            
            VoucherPercentageDiscount = voucherPercentageDiscount;
        }
    }

Aplikační služba

Služba je zodpovědná za samotné zpracování požadavku. Můžeme jí implementovat pomocí command patternu, kdy každý požadavek má přiřazenou vlastní službu (command handler) a zároveň máme definovaný objekt, který obsahuje parametry, které služba potřebuje pro zpracování (command). To má velkou výhodu v tom, že v projektu velmi rychle poznáme, jaké operace aplikace podporuje a jaké informace pro každý požadavek potřebuje.

CreateOrderCommand.cs

using MediatR;
using System.Collections.Generic;

namespace Mvc.Application.Commands.CreateOrder
{
    public class CreateOrderCommand : IRequest<int>
    {
        public int CustomerId { get; set; }

        public int? VoucherPercentageDiscount { get; set; }

        public List<OrderItem> OrderItems { get; set; }

        public class OrderItem
        {
            public int ProductId { get; set; }
            public int Quantity { get; set; }
        }
    }
}

CreateOrderCommandHandler.cs

using Domain.Models;
using Domain.Services;
using MediatR;
using Mvc.Infrastructure.Data;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Mvc.Application.Commands.CreateOrder
{
    public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
    {
        // We might use repository pattern, if we need to decouple EF.
        private readonly AppDbContext _context;
        private readonly IDiscountValidator _discountValidator;
        public CreateOrderCommandHandler(AppDbContext context, IDiscountValidator discountValidator)
        {
            _context = context;
            _discountValidator = discountValidator;
        }
        public Task<int> Handle(CreateOrderCommand command, CancellationToken cancellationToken)
        {
            var orderItems = new List<OrderItem>();

            foreach (CreateOrderCommand.OrderItem orderItem in command.OrderItems)
            {
                orderItems.Add(
                    new OrderItem(
                        orderId: null,
                        productId: orderItem.ProductId,
                        quantity: orderItem.Quantity));
            }

            var order = new Order(command.CustomerId, orderItems);

            // The SetDiscount operation can also be part of Order constructor,
            // this is just an example of calling some operation.
            if (command.VoucherPercentageDiscount != null)
            {
                order.SetDiscount((int)command.VoucherPercentageDiscount, _discountValidator);
            }

            _context.Orders.Add(order);

            _context.SaveChanges();

            return Task.FromResult((int)order.Id);
        }
    }
}

Controller

Kontroler ví co dělat s požadavkem, který poslal webový prohlížeč (frontend) a ví jakou odpověď poslat zpátky. V našem případě požadavek předává dál aplikační službě a pokud nedojde k chybě tak klienta přesměruje na stránku pro založení další objednávky :-) Pokud k chybě dojde, tak se klientovi vrátí objednávka s vyplněnými údaji a chybovou hláškou. Post/Redirect/Get (PRG) pattern nás chrání proti dvojitému odeslání formuláře pomocí refreshnutí prohlížeče.

OrderController.cs
using Domain.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Mvc.Infrastructure.Data;
using Mvc.ViewModels.Order;
using System;
using System.Linq;

namespace Mvc.Controllers
{
    public class OrderController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly IMediator _mediator;
        private readonly AppDbContext _context;
        public OrderController(ILogger<HomeController> logger, IMediator mediator, AppDbContext context)
        {
            _logger = logger;
            _mediator = mediator;
            _context = context;
        }

        [HttpGet]
        public IActionResult Create()
        {
            var model = new CreateOrderViewModel();
            PopulateCreateViewModel(model);
            return View(model);
        }

        // Server side rendering.
        // In case of client side, we can use 
        // "return BadRequest(ModelState)" and "return Ok()" statements.
        [HttpPost]
        public IActionResult Create(CreateOrderViewModel model)
        {
            if (!ModelState.IsValid)
            {
                PopulateCreateViewModel(model);
                return View(model);
            }
            try
            {
                model.Command.VoucherPercentageDiscount = 10;

                var response = _mediator.Send(model.Command).Result;

                _logger.LogInformation($"Order {response} created.");

                TempData["success"] = "true";

                return RedirectToAction("Create"); // PRG (post/redirect/get) pattern
            }
            // Domain errors are displayed to the user, but other errors are not.
            catch (Exception e) when (e is DomainException || e is AggregateException && e.InnerException is DomainException)
            {
                ModelState.AddModelError("", e.Message);
                PopulateCreateViewModel(model);
                return View(model);
            }
        }

        private void PopulateCreateViewModel(CreateOrderViewModel model)
        {
            model.Customer = _context.Customers.Find(1); // Han Solo Sample Customer
            model.Products = _context.Products.ToList();
        }
    }
}

Výsledek

Máme k dispozici webovou aplikaci, která je dostatečně robustní na to, aby do velké míry zvládla vydržet nové a nové požadavky na funkcionalitu nebo změny tak, aby nevznikl špagetový kód (Big Ball of Mud) a zároveň se dá velmi dobře testovat, takže by neměl být problém aplikaci průběžně upravovat nebo provádět refactoring. Struktura projektu také zůstala celkem přehledná. Pokud nás tedy čeká projekt s vyšší mírou komplexity, výše popsanou architekturu můžeme zvážit.

Nevýhodou tohoto přístupu je větší investice do přípravy a také potřeba neustálé součinnosti doménového experta (business). Pokud bychom vzali za příklad naší aplikaci, tak jsme napsali absurdní množství kódu pro formulář, který obsahuje pouze dvě pole. Pro jednoduché aplikace jako jsou blogy, prezentační stránky, jednorázové marketingové weby apod. se tedy příliš nehodí.

Čím složitější aplikace je, tím náročnější (dražší) jsou její úpravy. Následující graf znázorňuje boj s komplexitou v průběhu času :-)

Odkazy