La pagination dans l'hexagone

Lorsqu'on utilise une architecture hexagonale pour la première fois, beaucoup de questions se posent. Une de ces questions récurrentes est la gestion de la pagination.

Cet article parle, certes, d'une manière de gérer cette problématique mais, il est surtout un prétexte pour expliciter les questions à se poser lorsqu'on code dans ce type d'environnement.

Vous n'aimez pas lire du code dans un article ? Rendez-vous sur ce repository pour voir l'implémentation dont il est question ici.

Le cas d'utilisation

L'objectif des architectures hexagonales est de remettre le métier au centre des préoccupations. Le besoin qui nous anime ici est l'exposition paginée pour un objet du domain qui existe déjà : DomainObject.

Dans une Page on doit trouver :

  • Le contenu de la page (une List des objets) ;
  • Les informations permettant la gestion de la pagination :
    • Nombre total de résultats ;
    • Nombre d'éléments par pages ;
    • Numéro de la page courante ;
    • ...

Le domain d'une Page

La pagination est une fonctionnalité comme une autre. On va donc commencer par représenter une Page dans notre domain model en assurant sa cohérence et en cherchant à avoir une API la plus claire possible.

Quelques boucles de TDD peuvent nous mener à cette première implémentation d'un objet page :

package com.ippon.pagination.domain;
 
import java.util.Collections;
import java.util.List;
 
public class MyAppPage<T> {
  private static final int MINIMAL_PAGE_COUNT = 1;
 
  private final List<T> content;
  private final int currentPage;
  private final int pageSize;
  private final long totalElementsCount;
  private final int pageCount;
 
  private MyAppPage(MyAppPageBuilder<T> builder) {
    content = buildContent(builder.content);
    currentPage = builder.currentPage;
    pageSize = buildPageSize(builder.pageSize);
    totalElementsCount = buildTotalElementsCount(builder.totalElementsCount);
    pageCount = buildPageCount();
  }
 
  private List<T> buildContent(List<T> content) {
    if (content == null) {
      return List.of();
    }
 
    return Collections.unmodifiableList(content);
  }
 
  private int buildPageSize(int pageSize) {
    if (pageSize == -1) {
      return content.size();
    }
 
    return pageSize;
  }
 
  private long buildTotalElementsCount(long totalElementsCount) {
    if (totalElementsCount == -1) {
      return content.size();
    }
 
    return totalElementsCount;
  }
 
  public int buildPageCount() {
    if (totalElementsCount > 0) {
      return (int) Math.ceil(totalElementsCount / (float) pageSize);
    }
 
    return MINIMAL_PAGE_COUNT;
  }
 
  public static <T> MyAppPageBuilder<T> builder(List<T> content) {
    return new MyAppPageBuilder<>(content);
  }
 
  public static <T> MyAppPage<T> singlePage(List<T> content) {
    return builder(content).build();
  }
 
  public List<T> getContent() {
    return content;
  }
 
  public int getCurrentPage() {
    return currentPage;
  }
 
  public int getPageSize() {
    return pageSize;
  }
 
  public long getTotalElementsCount() {
    return totalElementsCount;
  }
 
  public int getPageCount() {
    return pageCount;
  }
 
  public boolean isNotLast() {
    return currentPage + 1 != getPageCount();
  }
 
  public static class MyAppPageBuilder<T> {
    private final List<T> content;
    private int currentPage;
    private int pageSize = -1;
    private long totalElementsCount = -1;
 
    private MyAppPageBuilder(List<T> content) {
      this.content = content;
    }
 
    public MyAppPageBuilder<T> pageSize(int pageSize) {
      this.pageSize = pageSize;
 
      return this;
    }
 
    public MyAppPageBuilder<T> currentPage(int currentPage) {
      this.currentPage = currentPage;
 
      return this;
    }
 
    public MyAppPageBuilder<T> totalElementsCount(long totalElementsCount) {
      this.totalElementsCount = totalElementsCount;
 
      return this;
    }
 
    public MyAppPage<T> build() {
      return new MyAppPage<>(this);
    }
  }
}

Cette implémentation, relativement complète, sera bien trop complexe dans certains contextes et insuffisante dans d'autres, tout dépend du métier...

Dans le cas qui m'intéresse, j'accepte cette complexité car je trouve le code permettant l'instanciation d'une page raisonnable en terme de taille et tout à fait compréhensible :

MyAppPage.builder(List.of())
    .currentPage(0)
    .pageSize(1)
    .totalElementsCount(0)
    .build();

Pendant cette implémentation certains choix ont été faits :

  • L'utilisation des generics pour avoir des Page de tous types d'objets ;
  • Le nom de la classe : MyAppPage et non pas simplement Page fait ici pour éviter toute confusion avec la classe Page de SpringData ;
  • L'utilisation d'un builder pour la construction d'une page ;
  • L'ajout de la static factory singlePage permettant la construction d'une page depuis une List de résultats.

Pendant la construction de cet objet du domain model (qui est le début de l'implémentation de cette feature), nous nous concentrons uniquement sur les besoins métier, rien d'autre.

Voilà, nous avons maintenant les outils métier pour faire de la pagination, je peux donc modifier mon ApplicationService pour renvoyer non plus une liste mais un MyAppPage !

Vous avez vu l'erreur dans la phrase précédente ? Parce que moi je ne l'ai vue qu'en modifiant un premier test pour renvoyer une page : je n'ai pas fait d'objet représentant la pagination, je ne peux pas manipuler de pages...

Quelques boucles de TDD de plus me donnent :

package com.ippon.pagination.domain;
 
import com.ippon.error.domain.Assert;
 
public class MyAppPageable {
  private final int page;
  private final int pageSize;
  private final int offset;
 
  public MyAppPageable(int page, int pageSize) {
    Assert.field("page", page).min(0);
    Assert.field("pageSize", pageSize).min(1).max(100);
 
    this.page = page;
    this.pageSize = pageSize;
    offset = page * pageSize;
    assertOffset();
  }
 
  private void assertOffset() {
    if (offset > 10000) {
      throw PaginationException.overTenThousand();
    }
  }
 
  public int getPage() {
    return page;
  }
 
  public int getPageSize() {
    return pageSize;
  }
 
  public int getOffset() {
    return offset;
  }
}

J'utilise ici une implémentation de Assert semblable à celle présentée dans Des Objets, pas des Data Classes !

J'ai aussi profité de cette implémentation pour forcer une règle métier importante dans mon cas : les utilisateurs ne doivent pas pouvoir paginer après le 10 000e résultat.

Bien, je peux maintenant modifier mon ApplicationService et avoir un feedback très rapide des modifications à faire : là où le code ne compile plus.

Les primary adapters

Pour retrouver un état vert (qui compile) je décide de commencer par les modifications dans les primary adapters. Il n'y a cependant pas d'ordre prédéfini sur les implémentations, cela dépend de chaque cas d'utilisation.

Je fais une implémentation pour exposer les Pageables en REST :

package com.ippon.pagination.infrastructure.primary;
 
import com.ippon.error.domain.ValidationMessage;
import com.ippon.pagination.domain.MyAppPageable;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
 
@ApiModel(value = "MyAppPageable", description = "Pagination information")
public class RestMyAppPageable {
  @ApiModelProperty(value = "Page to display (starts at 0)", example = "0")
  private int page;
 
  @ApiModelProperty(value = "Number of elements on each page", example = "10")
  private int pageSize = 10;
 
  @Min(message = ValidationMessage.VALUE_TOO_LOW, value = 0)
  public int getPage() {
    return page;
  }
 
  public void setPage(int page) {
    this.page = page;
  }
 
  @Min(message = ValidationMessage.VALUE_TOO_LOW, value = 1)
  @Max(message = ValidationMessage.VALUE_TOO_HIGH, value = 100)
  public int getPageSize() {
    return pageSize;
  }
 
  public void setPageSize(int pageSize) {
    this.pageSize = pageSize;
  }
 
  public MyAppPageable toPageable() {
    return new MyAppPageable(page, pageSize);
  }
}

La conversion depuis un RestMyAppPageable vers un MyAppPageable est faite directement dans RestMyAppPageable avec la méthode toDomain().

Et une pour les Page :

package com.ippon.pagination.infrastructure.primary;
 
import com.ippon.error.domain.Assert;
import com.ippon.pagination.domain.MyAppPage;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
 
@ApiModel(value = "Page", description = "Paginated content")
public class RestMyAppPage<T> {
  @ApiModelProperty(value = "Page content")
  private final List<T> content;
 
  @ApiModelProperty(value = "Current page (starts at 0)", required = true)
  private final int currentPage;
 
  @ApiModelProperty(value = "Number of elements on each page", required = true)
  private final int pageSize;
 
  @ApiModelProperty(value = "Total number of elements to paginate", required = true)
  private final long totalElementsCount;
 
  @ApiModelProperty(value = "Number of resulting pages", required = true)
  private final int pagesCount;
 
  private RestMyAppPage(RestMyAppPageBuilder<T> builder) {
    content = builder.content;
    currentPage = builder.currentPage;
    pageSize = builder.pageSize;
    totalElementsCount = builder.totalElementsCount;
    pagesCount = builder.pageCount;
  }
 
  public static <S, T> RestMyAppPage<T> from(MyAppPage<S> source, Function<S, T> mapper) {
    Assert.notNull("source", source);
    Assert.notNull("mapper", mapper);
 
    List<T> content = source
      .getContent()
      .stream()
      .map(mapper)
      .collect(Collectors.toList());
    
    return new RestMyAppPageBuilder<>(content)
      .currentPage(source.getCurrentPage())
      .pageSize(source.getPageSize())
      .totalElementsCount(source.getTotalElementsCount())
      .pageCount(source.getPageCount())
      .build();
  }
 
  public List<T> getContent() {
    return content;
  }
 
  public int getCurrentPage() {
    return currentPage;
  }
 
  public int getPageSize() {
    return pageSize;
  }
 
  public long getTotalElementsCount() {
    return totalElementsCount;
  }
 
  public int getPagesCount() {
    return pagesCount;
  }
 
  private static class RestMyAppPageBuilder<T> {
    private final List<T> content;
    private int currentPage;
    private int pageSize;
    private long totalElementsCount;
    private int pageCount;
 
    private RestMyAppPageBuilder(List<T> content) {
      this.content = content;
    }
 
    public RestMyAppPageBuilder<T> pageSize(int pageSize) {
      this.pageSize = pageSize;
 
      return this;
    }
 
    public RestMyAppPageBuilder<T> currentPage(int currentPage) {
      this.currentPage = currentPage;
 
      return this;
    }
 
    public RestMyAppPageBuilder<T> totalElementsCount(long totalElementsCount) {
      this.totalElementsCount = totalElementsCount;
 
      return this;
    }
 
    public RestMyAppPageBuilder<T> pageCount(int pageCount) {
      this.pageCount = pageCount;
 
      return this;
    }
 
    public RestMyAppPage<T> build() {
      return new RestMyAppPage<>(this);
    }
  }
}

La conversion depuis un MyAppPage vers un RestMyAppPage est faite directement dans RestMyAppPage avec la static factory from(...). La difficulté ici étant qu'il nous faut une Function de mapping pour convertir les objets du domain model en leur équivalent Rest :

RestMyAppPage.from(results, RestDomainObject::from)

Pour faire ces implémentations, je me concentre essentiellement sur la qualité des APIs exposées. Je vais, par exemple, me poser la question du numéro de la première page : ici j'ai choisi de commencer à 0 mais, selon les cas, je pourrais commencer à 1 !

Je vais aussi faire attention à l'intégration avec les outils, ici Spring et Swagger (springfox). Mon but est vraiment de tirer le meilleur de ces outils sans avoir d'impact sur le code métier.

Les secondary adapters

Il ne reste qu'à construire des pages depuis les résultats renvoyés par les Frameworks. Dans ce cas, je ne peux pas modifier les implémentations des Frameworks pour ajouter des méthodes de conversion dans leurs objets. Je vais donc créer des classes avec cette responsabilité.

Par exemple, pour la conversion vers et depuis la pagination SpringData je vais faire :

package com.ippon.pagination.infrastructure.secondary;
 
import com.ippon.error.domain.Assert;
import com.ippon.pagination.domain.MyAppPage;
import com.ippon.pagination.domain.MyAppPageable;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
 
public final class MyAppPages {
 
  private MyAppPages() {}
 
  public static Pageable from(MyAppPageable pagination) {
    return from(pagination, Sort.unsorted());
  }
 
  public static Pageable from(MyAppPageable pagination, Sort sort) {
    Assert.notNull("pagination", pagination);
    Assert.notNull("sort", sort);
 
    return PageRequest.of(pagination.getPage(), pagination.getPageSize(), sort);
  }
 
   public static <S, T> MyAppPage<T> from(Page<S> springPage, Function<S, T> mapper) {
    Assert.notNull("springPage", springPage);
    Assert.notNull("mapper", mapper);
 
    List<T> content = springPage
      .getContent()
      .stream()
      .map(mapper)
      .collect(Collectors.toList());
 
    return MyAppPage
      .builder(content)
      .currentPage(springPage.getNumber())
      .pageSize(springPage.getSize())
      .totalElementsCount(springPage.getTotalElements())
      .build();
  }
}

La conversion pourra alors se faire avec :

MyAppPages.from(repository.findAll(MyAppPages.from(pagination)), MyEntity::toDomain);

Why so serious?

Lorsqu'on commence à utiliser ce type d'architectures (qui remettent le métier au centre des préoccupations), on se demande souvent pourquoi passer autant de temps pour coder quelque chose de "natif".

La pagination est un bon exemple de ce genre de cas. De prime abord, le code à produire paraît juste "trop" :

  • Trop complexe ;
  • Trop coûteux ;
  • Trop...

Comme toujours, la valeur ne peut être mesurée qu'en faisant un rapport entre le coût et les bénéfices. Ici, le coût pour adapter le code de cet article à votre application devrait être relativement faible. Pour les apports, on peut immédiatement penser à :

  • La réelle isolation des "détails" des différents Frameworks ;
  • La facilité de gestion de la pagination si vous avez plusieurs persistances (par exemple : Un SGBDR, une base de documents et un moteur d'indexes). Dans ce cas, vous n'aurez qu'à ajouter les implémentations côté secondary, le reste sera totalement transparent ;
  • Le gain de qualité sur les API exposées qui sont maintenant clairement documentées. Il sera aussi plus facile d'éviter les changements cassants sur ces APIs ;
  • La possibilité d'ajouter, très simplement, des règles globales de pagination en ne modifiant que le domain model.

Selon le contexte, vous pouvez voir d'autres avantages à ce découpage (par exemple pour faciliter les appels à des APIs paginées) mais, c'est à vous de voir si le coût est justifié !