Domain-Driven Design – používanie repositories

Zdeno Jašek

14.09.2018

Repository je v rámci Domain-Driven Designu (DDD) jeden zo základných stavebných prvkov. Ide o rozhranie (interface), ktoré obsahuje metódy pre prístup k úložisku objektov – čiže typicky k databáze. Príkladom môže byť rozhranie CustomerRepository, ktoré na prístup k objektu Customer poskytuje metódy:

Customer read( long customerId );	// nacita objekt podla ID
void create( Customer customer );	// zapise novy objekt typu Customer do databázy, t.j. vykona SQL INSERT
Collection<Customer> findByName( String customerName ); // vrati zoznam objektov s danym menom zakaznika, t.j. vykona SQL SELECT

Uvedené metódy nemusia byť nutne súčasťou rozhrania, t.j. hociktorá metóda môže byť vypustená. Obvykle sa do rozhrania vkladajú metódy typu CRUD (create, read, update, delete) a potom „findByXXX“, kde XXX je názov vyhľadávaného atribútu. Implementácia takéhoto rozhrania je realizovaná niektorou z perzistenčných knižníc typu JPA, alebo aj priamo využitím SQL cez JDBC.

Použitie repository ilustruje nasledujúci príklad: nech v doméne objednávok tovaru existuje pravidlo, že po skončení mesiaca je zákazník s najvyššou hodnotou objednávky odmenený špeciálnym bonusom – 10 % zľavou pri ďalšom nákupe. Ide o jednoduché doménové pravidlo implementované v metóde OrderServices.discountForTheBestCustomer nad týmto modelom:

Implementácia OrderServices:

/**
 * Domain service sets 10 % discount for the best customer.
 * The best customer is a customer with highest order in the last month.
 * 
 * @param orderRepository		Repository for Order
 * @param customerRepository	Repository for Customer
 */
public static void discountForTheBestCustomer( OrderRepository orderRepository, CustomerRepository customerRepository ) {
  LocalDate lastMonth = LocalDate.now().minusMonths(1);
  
  Collection<Order> orders = orderRepository.findHighestOrderInMonth( lastMonth );
  Discount tenPercent = new Discount( 10 );
  for (Order order : orders) {
    Customer bestCustomer = order.getCustomer();
    bestCustomer.addDiscount( tenPercent ); 
    customerRepository.update( bestCustomer );
  }
}

Metóda OrderRepository.findHighestOrderInMonth je súčasťou repository s názvom OrderRepository. Názov metódy je zrozumiteľný – vyjadruje presne to, čo od nej klient očakáva. Trpí však dvomi chybami:

  1. Je príliš špecializovaná
  2. Implementácia musí obsahovať doménovú logiku

Ad1 – príliš špecializovaná

Špecializácia bude v budúcnosti brániť jej širšiemu využitiu – napríklad na vyhľadanie najvyššej objednávky za kvartál alebo za rok. Lepšie by bolo navrhnúť ju na interval:

OrderRepository.findHighestIn( LocalDate dateFrom, LocalDate dateTo );

Ad2 – doménová logika

Druhým problémom ostáva príliš veľká doménová znalosť obsiahnutá v tejto metóde. Implementácia repository by mala byť čisto technologická. V uvedenom prípade však implementácia musí vedieť, že „najvyššia objednávka“ je objednávka s najvyššou hodnotou atribútu „total“. Tento problém sa dá odstrániť jednoduchou zmenou názvu:

OrderRepository.findMaxTotalIn( LocalDate dateFrom, LocalDate dateTo );

Teraz už repository nesľubuje, že nájde „najvyššiu objednávku v mesiaci“, ale nájde objednávku s maximálnou hodnotou atribútu „total“ v danom časovom období. V implementácii repository teda ostal už len technologický problém – ako takúto objednávku v databáze nájsť. To, že treba vyhľadať „najvyššiu objednávku v mesiaci“ prešlo do doménovej časti aplikácie:

public static void discountForTheBestCustomer( OrderRepository orderRepository, CustomerRepository customerRepository ) {
  LocalDate lastMonth = LocalDate.now().minusMonths(1);
  LocalDate dateFrom = lastMonth.withDayOfMonth(1);
  LocalDate dateTo = lastMonth.withDayOfMonth( lastMonth.lengthOfMonth() );
  
  Collection<Order> orders = orderRepository.findMaxTotal( dateFrom, dateTo );
  …

Zvyšok zdrojového kódu ostáva bez zmeny.

Výhody

Čitateľnosť

Použitie oboch repositories – OrderRepository a CustomerRepository – umožnilo sprehľadniť kód. Namiesto skladania SQL príkazu na vyhľadanie ponuky s najvyššou sadzbou používa metóda iba volanie repository a nestará sa o jeho implementáciu.

Rovnako aj použitie CustomerRepository umožňuje skryť implementačné detaily ukladania objektu Customer do databázy.

Testovateľnosť

Doménová logika uvedená v zadaní sa dá ľahko testovať unit testom bez použitia databázy. OrderRepository a CustomerRepository sú totiž iba rozhrania:

public interface OrderRepository {

  /**
   * Returns all orders with the highest total in the given interval.
   * 
   * @param dateFrom	Order has to be created on this date or later.
   * @param dateTo	Order has to be created on this date or earlier.
   * @return
   */
  Collection<Order> findMaxTotal( LocalDate dateFrom, LocalDate dateTo );

}

Uvedenú metódu je preto veľmi ľahké zavolať v unit teste, ktorému stačí podstrčiť vlastné implementácie oboch rozhraní repository:

@BeforeEach
void setup() {
  customer = new Customer(1, "Fero" );
  order = new Order(customer);
  
  orderRepository = new OrderRepository() {
    @Override
    public Collection<Order> findMaxTotal(LocalDate dateFrom, LocalDate dateTo) {
      return Arrays.asList( order );
    }
  };
  customerRepository = new CustomerRepository() {
    @Override
    public void update(Customer customer) {
    }
  };
}

Technika mockovania rozhraní (Mockito) ponúka jednoduchší a prehľadnejší spôsob implementácie rozhraní. Dôležité pri mockovaní je dodržať pravidlo, že mockujú sa vždy iba rozhrania, nie triedy. Nutnosť mockovať triedy v testoch ukazuje na nekvalitný dizajn.

Samotný test je už jednoduchý:

@Test
void testCustomerFromOrderHasDiscount() {
  OrderService.discountForTheBestCustomer(orderRepository, customerRepository);
  
  assertEquals( 1, customer.getDiscounts().size() );
  Discount discount = customer.getDiscounts().iterator().next();
  assertEquals( 0, DISCOUNT_PERCENT.compareTo( discount.getAmount() ) );
}

Tento test je zameraný na „main success story“. K uvedenému testu by bolo vhodné dopísať ďalšie okrajové podmienky a otestovať napríklad prípady, čo sa stane, ak Customer už má nejaký Discount nastavený, alebo ak v danom období neexistuje Order. Takisto by bolo vhodné otestovať, či sa do vyhľadávania posiela správny dátumový interval. Chýbajú aj kontroly na odolnosť voči NullPointerException apod. Príklad je zameraný len na demonštráciu využitia repository v unit testoch.

Výhoda testovania bez databázy spočíva v lepšej udržiavateľnosti testov a vo vyššej rýchlosti ich spúšťania.

Znovupoužiteľnosť

O znovupoužiteľnosť by sa programátor nikdy nemal snažiť. Znovupoužiteľnosť prichádza s dobrým dizajnom sama. Pri správnom využívaní repositories programátor častokrát narazí na to, že metódu repository, ktorú potrebuje, už niekto pred ním implementoval. Rovnako ani princíp „Don’t repeat yourself“ netreba vedome dodržiavať, pretože v dobrom dizajne sa dodržiava sám.

Ladenie prístupu k databáze

Implementovať repository obvykle znamená napísať SQL príkazy na databázu. Môže to byť aj nepriamo prostredníctvom knižníc (QueryDSL, HQL, JPQL). Implementácia repository tak združí všetky dopyty na jednu či viac databázových tabuliek. Vďaka tomu je možné tieto dopyty presne vyladiť tak, aby ich databázový server vykonával optimálne.

Nevýhody

Posielanie repositories do metód

V uvedenom príklade vyžadovala metóda zaslanie dvoch repositories. V praxi sa môže stať, že náročnejšia doménová logika bude vyžadovať aj tucet repositories. V takom prípade je nutné repositories združovať, prípadne využívať iné techniky na ich poslanie viacerých parametrov do jednej metódy.

Strata flexibility

Ako príklad naznačuje, metódy v repositories by mali byť veľmi konkrétne. Obvykle sa používa konvencia „findBy“. Napríklad CustomerRepository môže mať metódy findByName, findByCity, findByAddress apod. Takýto prístup sa nehodí na sofistikované používateľské filtre typu „zákazník, ktorý býva v Bratislave, jeho meno začína na ‚LE‘ alebo ‚EL‘ a zároveň nebýva na ulici Sibírska“. Sofistikované používateľské vyhľadávanie je vhodné riešiť v zmysle „Command and Query Responsibility Segregation“.

Strata domény

Veľkým nebezpečenstvom pri používaní repository je stiahnutie doménových pravidiel do databázových príkazov. Uvedený príklad tiež začínal s nevhodným návrhom „findHighestOrderInMonth“, ktorý bol následne zmiernený na „findMaxTotal“. Čím viac špecializovaných „findBy“ metód do repository prepadne, tým nižšiu váhu má objektový model domény a tým viac doménovej logiky obsluhuje dátový model. Častokrát je však nutné hľadať kompromis medzi udržaním doménovej logiky v modeli a využitím sily rýchleho databázového vyhľadávania.

Zhrnutie

Používanie techniky repositories je možné aj v aplikáciách, ktoré nevyužívajú myšlienku Domain-Driven Designu na vytvorenie modelu domény. V takom prípade je dokonca možné obetovať doménovú logiku v prospech sily repository, keďže aplikácia stojí už sama o sebe na dátovom modeli. Skúsenosti však ukazujú, že z pohľadu testovania, čitateľnosti a znovupoužiteľnosti je používanie repository dobrá taktika.

Ďalšie blog posty, čo by ťa mohli zaujímať