Domain-Driven Design v MVC aplikaci
30.11.2020Dnešní článek bude o implementaci 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 obchodu se zbraněmi :-) Domain-Driven Design byl prvně popsaný v knížce DDD: Tackling Complexity in the Heart of Software a obsahuje koncepty a postupy, jak se vypořádat se složitými aplikacemi.
Slovníček
DDD | Domain-Driven Design: přístup k vývoji softwaru, který se primárně zaměřuje na porozumění a vytvoření modelu doménové problematiky. |
MVC | Model View Controller: návrhový vzor, který odděluje různé aspekty aplikace, pro zjednodušní vývoje a údržby. |
OOP | Objektově orientované programování: organizace kódu kolem objektů, pro lepší znovupoužitelnost a udržitelnost. |
CQRS | Command Query Responsibility Segregation: návrhový vzor, který rozděluje operace a model na dvě části: jednu pro čtení a druhou pro 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 považuje znalost doménové problematiky za klíčový faktor úspěchu. Tento přístup zahrnuje následující doporučení:
- Zajistit porozumění doménové problematiky a být pravidelně v kontaktu s doménovými experty.
- Definovat, upřesnit a sjednotit společný jazyk pro efektivní týmovou komunikaci.
- Vytvořit kontextuální mapu, která poskytne přehled o organizačních a systémových závislostech.
- Aplikovat osvědčené programovací praktiky a principy.
Porozumění doméně
Pokud budeme rozumět problematice, kterou programujeme, můžeme snadněji předejít nedorozuměním v komunikaci. Zároveň lépe pochopíme, 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, ale také být otevření studiu dané problematiky.
Společný jazyk
Pro zjednodušení komunikace a snížení množství nedorozumění se doporučuje zavést společný jazyk (tzv. ubiquitous language). V podstatě se jedná o společnou definici klíčových pojmů, kterou budou používat jak vývojáři, experti na danou problematiku, tak i lidé z businessu. Například termíny jako "produkt", "obal" nebo "materiál" se nemohou libovolně zaměňovat, a jejich definice musí být zřejmá, nebo jejich význam sjednotit pod jeden termín. Takto definované pojmy by se měly promítnout nejen do společné komunikace, například při analýze požadavků, ale i do zdrojového kódu a dokumentace.
Kontextuální mapa
Jedná se o techniku, která spočívá v mapování okolí aplikace, přičemž se zaměřujeme na identifikaci systémových a organizačních vztahů a závislostí. S ohledem na identifikovaná rizika, 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 v tom, ž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á za, 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čena 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 dva modely, 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 rozhraním (interface), 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 občas místo repositáře používá ORM framework, který funguje jako repositář i pro každou entitu. Nicméně repositář je obecnější řešení a na pozadí může také volat ORM framework a skrýt jeho implementační detaily.
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 ni vytvořit doménovou službu. Často se jedná o operaci, která vyžaduje práci s více objekty nebo se jedná o komplexní operaci, která si zaslouží být samostatná. K třídě vždy vytváříme i rozhraní (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. Použití mediátora (mediator pattern) je volitelné.
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í.
Návrh architektury
Architekturu můžeme navrhnout následovně
Implementace
Protože implementace obsahuje hodně kódu, nahrál jsem celou aplikaci na GitHub, aby si ji 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ž ji chrání před nevyžádanými změnami jiných objektů, které by mohli objednávku dostat do nevalidního stavu. Pouze ona je zodpovědná za své 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.csusing 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 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
- CQRS (Command Query Responsibility Segregation)
- Návrhový vzor double dispatch
- Integer vs GUID jako primární klíč
- Implementace value objektu
- Big Ball of Mud
- DDD: Tackling Complexity in the Heart of Software
- Implementing DDD
- Architecting Applications for the Enterprise
- Data-centric vs domain-centric approach
- Anemic domain model
- Architect Modern Web Applications with ASP.NET Core and Azure
- DDD The Good Parts - Jimmy Bogard
- Popis jak se z aplikační služby stane pomocí parameter object patternu command handler
- Implementace Mediator patternu