jeudi 31 décembre 2020

Architecture hexagonale

Introduction

Dans une application conventionnel, il ne sera pas rare d'avoir une couche de contrôleur,  de service et de dao. Pour chacune de nos entités, une classe existera sous chacune de ces couches. Il est possible de se retrouver rapidement avec des centaines de classes. Ce type d'architecture utilise un modèle d'objet anémique, c'est-à-dire qu'il ne contient pas de logique métier.

Il n'est pas rare de voir un appel à la couche de persistance directement de la première couche.

Les entités, les domains object n'ont pas de règle métier. Elle est alors mise dans les classes de service, utilitaire. Ces classes chargent des entités, modifient leurs états et persistent le tout. Les services commencent à utiliser d'autres services et il n'est pas rare de se retrouver avec des classes de plusieurs milliers de lignes. Pour une petite application, de type CRUD, ça peut être plus rapide à développer, mais imaginer maintenant une application avec 200K, 300K... lignes de code.

C'est une architecture souvent basée sur le design de la base de données. Une modification au sein de celle-ci et des modifications à chaque couche est nécessaire.

Plus l'application prendra de l'expansion, plus il deviendra difficile de la modifier sans causer de multiples anomalies.

Une bonne architecture doit pouvoir s'adapter à de nouvelles exigences sans tout chambouler.

Une architecture hexagonale pourrait être une approche à songer pour débuter sans trop devoir tout changer si on désirait passer à des microservices par la suite.

Architecture hexagonale

Le but de ce type d'architecture est d'avoir un noyau relié à la logique métier. Tout le reste est externalisé tel que la sauvegarde de donnée ou bien la réception de donnée. Cette conception est priorisée en DDD: domain-driven design.  

 


Avantages

  • Indépendance de la couche métier aux autres couches, technologie
  • Test plus facile à effectuer

 Désavantage

  • Plus de code nécessaire
  • Structure plus complexe

 

Couche

Cette architecte implique de diviser l'application en trois couches:

  1. Application
  2. Domaine
  3. Infrastructure

Application

Cette couche est le point d’interaction de l’application. Il peut s'agir de contrôleur rest, de service de messages. C'est la porte d'entrée pour exécuter le domaine métier.  Cette portion est parfois appelée espace utilisateur.

Domaine

C'est l'implémentation de la couche métier, c'est le noyau central de l'application. Il se trouve aussi les interfaces de communication avec les deux autres couches. Il est possible de rencontrer cette couche sous l’appellation règle d'affaires.

Infrastructure

Cette couche possède l'implémentation pour sauvegarder dans une base de données, fichier, web service externe. C'est le point de sortie du domaine métier. Un synonyme pour cette couche est espace serveur.

Principe

Le domaine n'a pas connaissance de l'application et de l'infrastructure. C'est là qu'entrent en jeu les adapteurs et les ports. Les ports sont des interfaces que les adapteurs implémenteront. Chaque couche peut être testée individuellement, de plus l'utilisation d'interface ajoute un niveau d'indépendance au niveau de l'implémentation. Pour un test, les données pourraient être sauvegardées dans un fichier alors qu'en production la sauvegarde se ferait dans une base de données.

Contexte, limite d'un domaine

Contrairement à une application monolithique, l'architecture hexagonale nécessite de découper selon le contexte (bounded context). On se retrouve avec plusieurs petits modèles au lieu d'un modèle unique. 

Un contexte peut être vu comme les limites d'un sous-domaine dans une application auquel une certaine logique métier peut être appliqué.

Si on prend une application telle qu'amazon: il en existe plusieurs inventaire, prix, produit, commentaire.

 
L'interface graphique accède une multitude de microservice délimité par leur domaine. Si les instances du service de commentaire sont en arrêt, un client pourra tout de même acheter un livre.
 

Objet valeur (Value Object)

C'est un objet immuable qui décrit un aspect du domaine mais qui n'a pas d'identité. De la logique métier est fortement conseillé d'y être présent. Un âge ou bien une adresse pourrait être présenté de la sorte. Il faut considérer que deux VO sont égales si l'ensemble de ses champs sont identiques.

public final class Age{
     private final int value;
 
     public Age(int value){
        if(age < 0 || age > 120){
            throw new InvalidAgeException()
        }
        this.value=value;
     }

     public Age change(int value){
         return new Age(value);
     }
}            

Il serait tout à fait possible de ne pas utiliser un objet dédié, mais il faudrait externaliser la logique métier.  D'ailleurs c'est souvent cette façon de faire qu'il est possible de voir, on se retrouve avec des validations à maint endroit et le risque d'erreur de tout genre s'accroît.

Agrégat

C'est un groupe d'objet métier: objet de valeur, entité qu'on manipule comme si ça serait qu'une seule entité métier. Pour une facture, il y a différents éléments qui peuvent la composer: émetteur, destinataire, date, liste des produits ou service, coût, taxe. Toute modification devrait se faire au sein de notre agrégat afin de garantir sa cohérence. Si la facture est déjà payée, il ne devrait pas être possible d'ajouter d'élément dedans. Si un agrégat a besoin des informations d'un autre agrégat, son identifiant peut-être utilisé. Dans une transaction, il devrait avoir qu'une sauvegarde pour un agrégat.

Il faut prendre en considération que pour une situation donnée, un objet pourrait être un objet de valeur et dans un autre cas une entité ou même un agrégat.

Est-ce qu'on se soucie des valeurs de l'objet ou bien de l'identification d'une instance? Ce questionnement peut nous aider à choisir entre une value objet ou bien un agrégat. Pour spécifier l'identité d'un agrégat, une bonne pratique est d'utiliser une VO. 

Au lieu de faire

public class Invoice{
    int id;
}
 
Passer par un VO dans l'agrégat 

public class Invoice{
    InvoiceId id;
}

public class InvoiceId{
    int id; 
    InvoiceId(int id){
        if(id < 1){
            throw new ArgumentException("Id must have a minimum value of 1");
        } 
        this.id=id; 
    }
}
 
Dans le cas de notre InvoiceId, le id pourrait être la date du jour: 20201229 suivit d'un chiffre  qui s'incrémente et se réinitialise chaque jour. 

Il est plus aisé de changer le type de donnée par la suite, il y a moins d'endroits à effectuer les changements. Il est possible d'ajouter de la logique pour en autre effectuer des validations. Il est plus difficile de passer une mauvaise valeur que si on utilisait le type primitif.

Mise en pratique

Notre exemple consistera à une application de facturation, il pourrait avoir un contexte de facturation, client, paiement.... nous développerons celui de facturation. Plus précisément le scénario d'aller chercher une Invoice selon son Id. Notre application sera monolithique mais sous-divisée. Chacun des modules devrait utiliser une architecture hexagonale.

Notre objet Invoice va représenter toutce qui doit être facturé à un customer. LineInvoice représenterait chaque item produit ou service acheté. Invoice est un bon candidat pour un agrégat, mais LineInvoice?

public class Invoice {

    private final InvoiceId id;
    private final InvoiceStatus status;
    private final Long customerId;
    private final LocalDate paidDate;
    private final LocalDate dueDate;
    private final Flux<InvoiceItem> invoiceItems;
    
    private Invoice(InvoiceId id, InvoiceStatus status, Long customerId,
            LocalDate paidDate, LocalDate dueDate,
            Flux<InvoiceItem> invoiceItems) {
        this.id = id;
        this.status = status;
        this.customerId = customerId;
        this.paidDate = paidDate;
        this.dueDate = dueDate;
        this.invoiceItems = invoiceItems;
    }
    
    public static Invoice withoutId(InvoiceStatus status, Long customerId,
            LocalDate paidDate, LocalDate dueDate,
            Flux<InvoiceItem> invoiceItems) {
        return new Invoice(null, status, customerId, paidDate, dueDate, invoiceItems);
    }
    
    public BigDecimal calculateTotalPrice() {
        if (invoiceItems == null) {
            return BigDecimal.ZERO;
        }

        return invoiceItems.map(ii -> ii.calculateTotalPrice()).reduce(BigDecimal.ZERO, BigDecimal::add).block();
        
    }
}

LineInvoice pourrait être aussi un bon candidat pour un agrégat,  d'autres entités auraient besoin d'un identifiant. Il pourrait être possible d'appliquer une taxe, un rabais ou un frais d'envois spécifiques.

 public class InvoiceItem {
    private String description;
    private int quantity;
    private BigDecimal unitPrice;
    
    public InvoiceItem(String description, int quantity, BigDecimal unitPrice){
        if(quantity < 1){
            //quantity must be over 0
        }
        
        if(unitPrice.compareTo(BigDecimal.ZERO)!=1){
            //unit price must be over 0
        }
        this.description=description;
        this.quantity=quantity;
        this.unitPrice=unitPrice;
    }
    
    public BigDecimal calculateTotalPrice(){
      return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
    
}

 L'adapteur web

@RestController
@RequestMapping("/invoices")
public class GetInvoiceController {

    LoadInvoiceQuery loadInvoiceQuery;
    
    GetInvoiceController(LoadInvoiceQuery loadInvoiceQuery){
        this.loadInvoiceQuery=loadInvoiceQuery;
    }
    
    @GetMapping("/{id}")
    public Mono<Invoice> getInvoicesById(@PathVariable Long id){
        return loadInvoiceQuery.getInvoice(new InvoiceId(id));
    }
}

Le port pour la partie web    

public interface LoadInvoiceQuery {
    Mono<Invoice> getInvoice(InvoiceId invoiceId);
}

Le service au niveau du domaine

@Service
public class GetInvoiceService implements LoadInvoiceQuery{

    private final LoadInvoicePort loadInvoicePort;

    public GetInvoiceService(final LoadInvoicePort loadInvoicePort) {
        this.loadInvoicePort = loadInvoicePort;
    }
    
    @Override
    public Mono<Invoice> getInvoice(InvoiceId invoiceId){
        return loadInvoicePort.loadInvoice(invoiceId);
    }

}

L'adapteur de persistence

@Component
public class InvoicePersistenceAdapter implements LoadInvoicePort {

    @Autowired
    InvoiceMapper invoiceMapper;

    SpringPostgresInvoiceRepository invoiceRepository;
    SpringPostgresInvoiceLineRepository invoiceLineRepository;
    
    public InvoicePersistenceAdapter(SpringPostgresInvoiceRepository invoiceRepository,
            SpringPostgresInvoiceLineRepository invoiceLineRepository) {
        this.invoiceRepository = invoiceRepository;
        this.invoiceLineRepository=invoiceLineRepository;
    }


    @Override
    public Mono<Invoice> loadInvoice(InvoiceId invoiceId) {

        Mono<InvoiceEntity> invoiceEntity = invoiceRepository.findById(invoiceId.getValue());
        Flux<InvoiceLineEntity> invoiceLineEntity = invoiceLineRepository.findByInvoiceId(invoiceId.getValue());
        return invoiceMapper.mapToDomainEntity(invoiceEntity, invoiceLineEntity);
    }

}

Pour la persistence, il est possible d'utiliser une base de données ou un schéma par contexte ou bien dans utiliser un global à l'application. Seul un contexte a été développé, cependant même en optant pour une seule base de données pour l'ensemble des contextes, si on a besoin d'une information d'un autre contexte, en utilisant le id tel que customerId dans notre exemple,  pour aller effectuer la requête. Ce qui est une bonne approche pour bien séparer les domaines.

Si vous avez besoin d'augmenter les performances, vous pouvez utiliser un load balancer ou un reverse proxy pour démarrer de multiple instance de votre application avant d'opter pour des microservice. 

 Les packages sont organisés par couche. Certains préfèrent une organisation par fonctionnalité.

Responsabilité de l'adapteur pour la bd

  • Prendre les données en entrée
  • Les convertir dans le format de la bd
  • Les envoyer à la bd
  • Retourner les donner de sortie de la bd

Si vous désirez utiliser un ORM,  il faut faire le pont entre la structure objet et ceux utilisés pour le domaine, car tel que déjà dit, la couche du domaine ne devrait pas être imprégnée d'un framework. Cependant, j'ai pu constater à quelques reprises que ce n'était pas toujours respecté. 

Dans une architecture hexagonale, on se retrouve avec un petit domaine, je trouve alors l'emploi d'un ORM moins intéressant. Si vous optez pour cette solution, MapStruct peut grandement simplifier la conversion en vos objets métier.

Dans notre exemple, nous avons utilisé r2dbc, afin que l'application soit de type reactive. Contrairement au ORM, les relations ne sont pas géré, il faut le faire manuellement.

Autrement, Spring Data JDBC peut être une autre solution.

À partir d'un Id, nous avons été chercher un objet de type InvoiceEntity, il a été ensuite convertir en Invoice pour la couche domaine, nous aurions pu le convertir dans un autre format selon les besoins au niveau du web. Certains pourraient trouver que le passage entre ces couches est inutile dans ce cas. Un appel direct à partir du web vers la bd pourrait être effectué. Je suis d'accord, à moins de gros problème de performance, je continuerais d'utiliser cette approche, une fois que des raccourcies sont mis en place, ils deviennent rapidement la norme et on se trouve avec une architecture loin des attentes initiales.


Communication / synchronisation

Vous voudriez développer un autre module tel que le Customer. Comment les différents modules pourraient communiquer entre eux?

Dépendant si le type de communication: synchrone, asynchrone, vous pouvez optez pour des requêtes HTTP, service d'agent de message (RabbitMQ, JMS...)

Conclusion

Vous êtes une startup, vous voyez en grand. Vous regardez ce qu'utilise Netflix et vous voulez faire pareil. Vous n'avez aucun produit, aucune ligne de code de fait, mais vous pensez que la seule solution pour gérer votre hypothétique centaine de millions de requêtes est les microservices.

Il ne faut pas négliger la complexité niveau infrastructure: une base de données par microservice, la gestion des multiples instances des microservices, la communication entre eux et leur surveillance. Ces particularités amènent à avoir une équipe devops très efficace.

L'utilisation d'une architecture hexagonale pourrait grandement simplifier la mise en place de votre application avant qu'un passage au microservice soit réellement nécessaire. Le but de l'article était de vous montrer une façon différente de faire une architecture logiciel, libre à vous de l'adapter selon vos besoins.


 
 

Aucun commentaire:

Publier un commentaire