Repozytorium (repository pattern)

W dzisiejszym wpisie wkraczamy do warstwy dostępu do danych i przy tej okazji omówię znany i lubiany wzorzec projektowy: Repository. O tym, czym jest repozytorium, jak powinno wyglądać i czym tak naprawdę powinno się zajmować.


Wzorzec projektowy repository

Repozytorium w ogólnym ujęciu jest wzorcem projektowym, który służy do oddziału warstwy logiki oraz warstwy dostępu do danych / persystencji. Sama persystencja może odbywać się na bazie danych, plikach lub dotyczyć zewnętrznej usługi. Repozytorium dostarcza metody, które przypominają te stosowane w kontekście kolekcji danych. Używając więc instancji repozytorium możemy zwyczajowo: wyszukiwać, dodawać, usuwać czy zmieniać pewne dane. W najprostszych rozwiązaniach repozytoria spotkać możemy wraz z systemami ORM, gdzie służą one do pośredniej komunikacji z bazą danych. 

Kruczki repozytorium

Tak jak wszędzie, tak i w tym przypadku czyha na nas kilka pułapek, w które jeśli wpadniemy możemy skazać nasz projekt na bycie brzydkim. Pierwszym, bardzo powszechnym błędem jest generowanie kodu sql w metodach lub bezpośrednie operacje na bazie danych z poziomu repozytorium. Takie przypadki widuję bardzo często i wynika to najczęściej z lenistwa i braku chęci stworzenia klasy DAO, która zajmie się niskopoziomową obsługą tego typu operacji.

export class TagRepository extends Repository<TagEntity> {
  private static QUERY_ALIAS = 'tags';

  findOneById = (id: string): Promise<TagEntity> =>
    this.createQueryBuilder(TagRepository.QUERY_ALIAS)
      .leftJoinAndSelect(`${TagRepository.QUERY_ALIAS}.site`, 'site')
      .leftJoinAndSelect('site.pages', 'pages')
      .where(`${TagRepository.QUERY_ALIAS}.id = :id`, { id })
      .getOne();

  findAll = (): Promise<TagEntity[]> =>
    this.createQueryBuilder(TagRepository.QUERY_ALIAS)
      .leftJoinAndSelect(`${TagRepository.QUERY_ALIAS}.site`, 'site')
      .leftJoinAndSelect('site.pages', 'pages')
      .getMany();
} 
Kolejny, bardzo powszechny błąd to brak atomowości. Samo używanie DAO nie rozwiąże wszystkich problemów. Repozytorium powinno dotyczyć tylko jednego typu danych (podobnie, jak kolekcja). Nie możemy w jednym repozytorium mieć metod dla encji A oraz encji B. Cała taka klasa powinna być dedykowana konkretnemu typowi danych.

export class UserRepository {
  constructor(
    private readonly userDAO: UserDAO,
    private readonly accountDAO: AccountDAO,
  ) {}

  findUserById = (id: string): Promise<UserEntity> =>
    this.userDAO.findOneById(id);

  findAccountByUserId = (id: string): Promise<AccountEntity> =>
    this.accountDAO.findOneByUserId(id);
}

Imitacja kolekcji danych

Twórzmy małe, atomowe repozytoria, które swoim interfejsem przypominają operacje dozwolone na kolekcji danych. Niech będą one zasłoną dla rozbudowanych działań na warstwie dostępu do danych, które mogą reprezentować DAO, a o których wkrótce powiem.

export class UserRepository {
  constructor(
    private readonly userDAO: UserDAO
  ) {}

  findOneById = (id: string): Promise<UserEntity> =>
    this.userDAO.findOneById(id);

  findMany = (): Promise<UserEntity[]> =>
    this.userDAO.findMany();

  async add(user: UserEntity): Promise<void> {
    await this.userDAO.save(user);
  }
    
  async remove(user: UserEntity): Promise<void> {
    await this.userDAO.delete({ id: user.id });
  }
}
Rygorystyczne postrzeganie tych klas, jako wspomnianych zbiorów, pozwala nam na posiadanie łatwego interfejsu do manipulacji na konkretnych informacjach. Pojawić się jednak może kilka pytań, co do słuszności tworzenia takich repozytoriów. Po pierwsze, co w przypadku, jeśli musimy udostępnić wiele metod do wybierania danych, które będą obsługiwały różne warunki, np. getByName(), getById(), getByAge() itp. Możemy wtedy posiadać tylko jedną metodę do wybierania rekordów, np. findMany(), która posiadać będzie opcjonalny parametr criteria, będący zbiorem warunków dla naszego zapytania.

export class UserRepository {
  constructor(
    private readonly userDAO: UserDAO
  ) {}

 findMany = (criteria: Criteria): Promise<UserEntity[]> =>
    this.userDAO.findMany(criteria);
}
Kolejny temat to konieczność dokonywania mapowań na poziomie repozytorium. Wyobraźmy sobie, że posiadamy zagnieżdżone dane w polu naszej encji. Niech to będzie user, który posiada pole address, będące również obiektem. Zdecydowaliśmy się, że zapiszemy te dane, jako json w pojedynczej kolumnie tabeli users. Po wybraniu danych z bazy, chcemy w obiekcie użytkownika posiadać obiekt adresu, zamiast ciągu znaków json. Całkowicie dozwolone jest dokonywanie takich mapowań przed zwróceniem danych. Trzeba jednak pamiętać, że nie chodzi tutaj o mapowanie do form porządanych przez wyższe warstwe, a jedynie doprowadzenie surowych danych z bazy do postaci poprawnej encji.

export class UserRepository {  
  constructor(
    private readonly userDAO: UserDAO
  ) {}

  async findOneById(id: string): Promise<UserEntity> {
   const user = await this.userDAO.findOneById(id);

   const address = JSON.parse(user.address);

   user.setAddress(address);

   return user;
  }
}
Dociekliwi zapytają też, a co w przypadku kiedy nie używam ORM i moje DAO zwraca surowe dane? Wtedy musimy utworzyć obiekt encji i jej wszystkie zależności manualnie. Zadanie ułatwi np. zastosowanie konstruktora encji, fabryki lub mappera. Wtedy nasze repozytorium musi użyć zastosowanego rozwiązania przed zwróceniem danych. 

export class UserRepository {  
  constructor(
    private readonly userDAO: UserDAO
  ) {}

  async findOneById(id: string): Promise<UserEntity> {
   const userData = await this.userDAO.findOneById(id);

   return new UserEntity({
    ...userData,
    address: JSON.parse(userData.address)
   });
  }
}

Nie samą bazą człowiek żyje

Repozytorium nie musi się odnosić jedynie do baz danych. Jak wspomniałem we wstępie, możemy użyć tego wzorca do pośrednictwa z systemem plików czy też z zewnętrzną usługą. Bardzo często zdarza mi się integrować z innymi systemami, np. za pomocą Rest API. Jeżeli integracja polega na pobieraniu, dodawaniu czy usuwaniu danych (jest to CRUD, ale "rozproszony"), to aż prosi się ona o zastosowanie repository pattern używającego klienta http zamiast DAO.

export class ExchangeRateRepository {
  constructor(
    private readonly exchangeRateApiClient: ExchangeRateApiClientInterface
  ) {}

  getExchangeRate = (from: CurrencyCodeEnum, to: CurrencyCodeEnum): Promise<number> => 
    this.exchangeRateApiClient.getExchangeRate({
       sourceCurrencyCode: from,
       destinationCurrencyCode: to
    });
}

Podsumowując




Polecane wpisy:

Wzorzec stan (state)

Dziś prezentuję kolejny wzorzec projektowy, który pozwala uniknąć drabinek if-else if. Mowa tutaj o wzorcu state, który idealnie nada się, jeśli posiadasz różne stany w swoim systemie oraz chcesz mieć możliwość płynnego przechodzenia pomiędzy nimi.

Sprawdź ten wpis

Wzorzec pełnomocnik (virtual proxy)

Wzorzec virtual proxy służy do tworzenia obiektów pośrednich, które nadzorują dostęp do obiektów dla których są pełnomocnikami. Dzięki temu, że pełnomocnik i klasa bazowa udostępniają jednakowy interfejs, możemy ukryć w pośredniku dodatkową logikę.

Sprawdź ten wpis

Wzorzec strategia (strategy pattern)

Jeżeli masz dość ifologii w swoim kodzie, to konieczne sprawdź czym jest czynnościowy wzorzec projektowy strategia. Pozwala on mądrze obsługiwać różne scenariusze w procesie i jednocześnie być fancy pod względem zasad SOLID.

Sprawdź ten wpis

Wzorzec adapter

Strukturalny wzorzec projektowy adapter to spore udogodnienie w walce z systemami legacy. Pozwala użyć niepasującego interfejsu w innym. Dzięki temu zabiegowi pomimo, iż nie posiadamy spójnej abstrakcji możemy zastosować pożądaną logikę.

Sprawdź ten wpis

Wzorzec dekorator

Strukturalny wzorzec projektowy dekorator, to lekarstwo na problemy z mnogością różnych podtypów. Pozwala on na dynamiczne generowanie kolejnych typów obiektów bazujących na pierwotnym typie, bez konieczności deklarowania nowych, dedykowanych klas.

Sprawdź ten wpis

Autor wpisu:

Gabriel Ślawski

Fanatyk czystego i prostego kodu. Zwolennik podejść DDD oraz Modular Monolith. Na codzień pracuje jako programista i architekt. Po godzinach spełnia się w projektach open source, udziela się na blogach oraz czyta książki o kosmosie i astrofizyce.