Persister ses données

Avertissement
Cet article présente des concepts illustrés par des bouts de codes non fonctionnels ! Le but n'est pas de fournir du code à copier/coller, mais de montrer ce qu'il est possible de faire. Pour voir du vrai-code™, il faudra suivre les liens donnés dans chaque section. Ceci étant dit…

Après avoir fait quelque chose d'utile, un programme doit souvent —mais pas tout le temps !—, persister les données du traitement qu'il vient de réaliser.

Le traitement peut être trivial, comme par exemple récupérer les données dans formulaire en ligne, et dans ce cas là, le « cœur du métier » est justement d'enregistrer les informations ; ou il peut être très complexe et coûteux en temps, et dans ce cas, pour pouvoir accéder au résultat sans avoir à refaire le traitement, il est nécessaire de le persister.

L'enregistrement des données peut se faire dans un simple fichier, ou dans le scénario qui nous intéresse, dans une base de données.

Remarque
Afin de faciliter les accès aux données en base, on peut utiliser SQLAlchemy en Python. Ce n'est pas la seule bibliothèque disponible, mais c'est celle que j'utilise le plus souvent. Le sujet traité dans cet article n'est pas spécifique à SQLAlchemy, mais les différentes solutions abordées, elles, le sont. Le même type d'approche doit pouvoir être utilisé pour d'autres bibliothèque et d'autres langages.

Je vais maintenant présenter plusieurs manières de persister les données. Elles sont classées par niveau de complexité d'implémentation.

Niveau 0 : ORM couplé au domaine

Dans son usage le plus classique, la couche ORM (Object Relational Mapper) de SQLAlchemy est assez facile à utiliser.

Base = declarative_base()

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    name = Column(String(30))

Caché dans ce code se trouve une règle métier : un nom ne peut pas faire plus de 30 caractères. Il est cependant possible d'assigner une chaîne de caractère plus longue à l'attribut name et, en fonction du système de gestion de base de données (SGBD) choisi, le résultat sera différent !?

  • SQLite ne dira rien, et enregistrera une chaine de caractères plus longue si elle lui est donnée.
  • Postgresql ne dira rien au moment d'assigner une chaine de caractères trop longue, mais émettra une exception StringDataRightTruncation au moment de l'enregistrement.

Il va sans dire que ce genre de comportement n'est pas admissible. Une application ne devrait pas se comporter différement en fonction du SGBD utilisé.

La première conclusion que l'on peut tirer est qu'il ne faut pas laisser au SGBD la responsabilité de vérifier les règles métier.

Un autre problème plus fondamental du « niveau 0 » est qu'il mélange différentes responsabilités. La même classe représente un concept métier, « un·e utilisat·rice·eur », et implémente la manière de le persister.

Niveau 1 : ORM découplé du domaine

Une première amélioration possible est de découpler le code métier du code relatif à la persistance des données.

Dans un module métier (par exemple domain.something.entities), on conserve la déclaration de la classe :

USER_NAME_MAX_LENGTH: int = 30

@dataclass
class User:
    id: int
    name: str

Dans un module d'infrastructure (par exemple infrastructure.sqlalchemy.entities), on met le code relatif à la persistance :

from domain.something.entities import USER_NAME_MAX_LENGTH, User

mapper_registry = registry()

users = Table(
    "users",
    mapper_registry.metadata,
    Column("id", Integer, primary_key=True),
    Column("name", String(USER_NAME_MAX_LENGTH)),
)
mapper_registry.map_imperatively(User, user_table)

Le problème du couplage est maintenant réglé. Cela a bien sûr un coût, et les deux modules devront être modifiés en parallèle. Une bonne manière de s'en assurer et d'écrire des tests d'infrastructure qui persisteront et chargeront des objets User et qui vérifieront que ce qui est persisté est bien ce qui est récupéré.

La règle métier a été matérialisée par une constante. C'est loin d'être satisfaisant, mais cette constante peut maintenant être réutilisée par, par exemple, une bibliothèque de génération de formulaire et de validation de données afin de s'assurer qu'elle soit respectée.

Le respect de la règle métier sur la taille du nom est donc « sous traité » à la partie du code se chargeant de créer des utilisat·rice·eur·s à partir d'un formulaire. Il est donc toujours possible d'instancier des objets User directement et, donc, de ne pas respecter cette règle métier. La couche métier de notre code est donc « anémique ». Il va falloir y remédier !

Niveau 2 : Persister des objets valeurs

Afin de rapatrier notre règle métier dans notre domaine, nous allons, plutôt que d'utiliser les types de base de Python, utiliser des types plus complexes pour représenter nos concepts métier.

En utilisant les objets valeurs fournis par bl3d, nous pouvons écrire un domaine plus « riche ».

Dans un module du domaine (par exemple domain.something.value_objects), nous définissons nos classes représentant nos valeurs (nos « objets valeurs ») :

from bl3d import Integer, String

class Id(Integer):
    pass

class Name(String):
    MAX: int = 30

Nous voyons ici que la contrainte est définie directement sur la classe Name. Il n'est donc plus possible d'instancier un Name faisant plus de 30 caractères.

Aparté sur l'instanciation des objets valeurs…

Les contraintes ne sont appliquées que lorsque la méthode instanciate est utilisée, pas lors d'un appel à __init__. Il est en effet important de respecter les règles métiers lors de la création d'un nouvel objet, mais il est indispensable de recharger exactement ce qui a été persisté en base ! Si la base contient une données ne respectant pas une des règles métier, c'est sûrement que la règle a changé depuis que l'objet a été persisté. Il est donc impératif de migrer les données lorsque les règles métier changent.

Nous pouvons alors reprendre notre classe User :

@dataclass
class User:
    id: Id
    name: Name

Pour instancier un User, il faudra lui passer un Name qui sera, par définition, toujours valide.

Mais la magie a un coût qui, dans notre cas, se paie au niveau du code de persistance :

mapper_registry = registry()

# Coût 1 :
# définir la manière dont les objets valeurs vont être transformés
# pour être enregistrés.
Integer.__composite_values__ = lambda self: (int(self),)
String.__composite_values__ = lambda self: (str(self),)

users = Table(
    "user",
    mapper_registry.metadata,
    Column("_id", Integer, primary_key=True),
    Column("_name", String(Name.MAX)),
)
mapper_registry.map_imperatively(
    User,
    users,
    # Coût 2 :
    # définir la manière dont les objets valeurs vont être instanciés.
    # Il faut donner le nom de la classe à utiliser puis les attributs
    # à passer au `__init__`.
    properties={
        "id": composite(Id, users.c._id),
        "name": composite(Name, users.c._name),
    }
)

Une exemple complet peut être trouvé dans le module seneo.infrastructure.sqlalchemy.repositories de SENéo.

Niveau 3 : Persister les évènements du domaine

Il est possible de supprimer toute cette complexité, due à la persistance des objets valeurs, en utilisant un système évènementiel.

Plutôt que de persister l'état du système, ce qui impose de connaitre des informations internes aux objets, on enregistre tous les évènements qui ont permis d'aboutir à l'état du système. Ces évènements, de par leur nature même, sont simples à persister et peuvent être rejoués pour atteindre un état donné.

Nos entités, en plus de changer leur état interne, vont émettre des évènements représentant ces changements.

@dataclass
class User:
    id: Id
    name: Name

    def change_name(self, new_name: Name) -> list[DomainEvent]: ...

En utilisant les event stores fournis par blessql, nous pouvons écrire un domaine plus riche et un mécanisme de persistance plus simple.

  • Le domaine est plus riche, car toute ce qui s'y produit est exprimé sous forme de code.
  • La persistance est plus simple, car les évènements sont de simples dataclass.

La persistance des données étant gérée dans blessql, le seul travail laissé au domaine est la transformation des évènements du domaine en évènements génériques et inversement.

# Persister et recharger les évènements
event_store = EventStore(db_session)
# Transformer les évènements du domain en évènements générique
domain_history = DomainHistory(event_store)
# Accéder aux `User`
users = Users(domain_history)
id = Id.instanciate(0)
# Reconstruire un `User` à partir des évènements du domaine
user = users.get(id)
new_name = Name.instanciate("New Name")
# Émettre des évènements du domaine (ex. `UserNameChanged`)
domain_events = user.change_name(new_name)
# Persister les évènements du domaine
domain_history << domain_events

Un exemple complet peut être trouvé dans le cas d'usage « confirmer demande » (listsem.application.use_cases.confirmer_demande) de Listsém.

Conclusion

Nous venons de voir plusieurs stratégies pour persister les données. Mais, au delà de la persistance, nous avons vu pourquoi et comment il était possible d'enrichir son domaine pour mieux gérer ses règles métier. Bien sûr, tout ceci a un coût, mais l'avantage en terme de maintenabilité et de testabilité est à mes yeux bien supérieur.

Il faut aussi prendre en compte le fait qu'une application peut être —devrait être !— divisée en « sous domaines » et que chacun d'entre eux peut être —devrait être !— implémenté différemment. C'est un des principaux enseignements du Domain Driver Design (DDD) : identifier les sous-domaines d'une application et choisir l'implémentation dont le coût est la plus adaptée à chacun.