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. Klíčové je komunikovat s doménovými experty, porozumět doménové problematice a poté jí správně podle doporučených postupů naprogramovat.

Slovníček

DDD Domain Driven Design je přístup, který nám pomáhá zvládnout vývoj složité aplikace tím, že nám radí, abychom se primárně zaměřili na pochopení a naprogramování doménové problematiky.
MVC Model View Controller je návrhový vzor, který je v současné době v prostředí webových aplikací velmi používaný.
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

DDD

Domain Driven Design nám říká, že nejdůležitější je porozumět businessové problematice, kolem které se pak vše odvíjí. Obsahuje následující doporučení

  • Porozumět doméně a být v častém kontaktu s doménovými experty
  • Nadefinovat, upřesnit a sjednotit doménovou terminologii pro týmovou komunikaci, programování a dokumentaci
  • Vytvořit si tzv. kontextuální mapu, která nám dá vhled do organizačních a systémových závislostí
  • Doporučené praktiky pro programování

Porozumění doméně

Pokud budeme rozumět problematice, kterou se snažíme naprogramovat, ideálně na všech úrovní vývoje, tak můžeme snáž předejít nedorozuměním v komunikaci a také budeme lépe rozumět tomu co a proč se od nás očekává. K tomu je nutné být v úzkém kontaktu se zákazníkem a doménovými experty.

Doménová terminologie (tzv. ubiquitous language)

Lidé s různou odborností, zkušenostmi nebo v odlišných situacích si mohou pod stejným výrazem představit rozdílné věci. Doporučením je upřesnit si vzájemně terminologii a té se pak držet při komunikaci, programování a psaní dokumentace.

Kontextuální mapa

Jedná se o techniku, kdy si mapujeme okolí naší aplikace. Můžeme si zmapovat technické, systémové nebo organizační vztahy a závislosti. Na základě těchto vztahů a závislostí můžeme lépe navrhnout architekturu aplikace.

Doporučené praktiky pro programování

Jedná se o objekty, pravidla a principy, které nám dávají určitý návod jak uspořádat vztahy uvnitř aplikace. Mezi základní objekty patří

  • 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