
Apache Iceberg pour une architecture lakehouse sur AWS
Ce guide présente Apache Iceberg, un format de table moderne pour les données volumineuses, la gestion des versions et des performances optimisées.
Progression
Comme vous pouvez le constater, notre architecture est celle par défaut proposée par Symfony lorsqu'on crée un nouveau projet: tous les dossiers sont dans src/
et dans un namespace App
.
Et c'est très bien comme ça, surtout pour un projet de cette taille.
Mais comme tout projet, il peut être amené à grossir, et là, on regrettera peut-être de ne pas s'être imposé à l'avance des contraintes d'architecture.
Il est donc l'heure de prendre le problème à la racine et d'identifier le coeur de métier de notre application.
Première bonne pratique quand on adopte la Clean Architecture, toujours commencer son développement par le Domain
, toujours from bottom to top. On commence donc par créer un nouveau dossier src/Domain
.
Et pour savoir ce que l'on va mettre dedans, on va essayer d'identifier 2 concepts:
Pour cela on veut se poser la question "Que fait mon application ?", et en l'occurence, j'aimerais répondre: "Mon application sert à me présenter des cartes de révision automatiquement certains jours. Je veux aussi être capable de gérer (création, suppression, ...) ces cartes".
C'est plutôt simple, j'estime n'avoir qu'un seul objet métier, la Card
.
Maintenant, un objet tout seul, ça ne sert à rien. Cet objet doit pouvoir se comporter.
Prenons un peu de temps sur cette notion, car elle est cruciale, et souvent oubliée des développeurs qui utilisent des ORMs comme Doctrine (dont je fais partie !).
Comme très bien expliqué dans cet article de Martin Fowler, un fléau s'est abattu sur le monde de l'orienté objet: l'Anemic Domain Model, ou le Domaine Anémique en bon français.
Il s'agit d'un anti-pattern, dans lequel nos objets se sont appauvris pour ne contenir plus que la donnée: c'est-à-dire uniquement des propriétés, des relations avec d'autres objets, ainsi que tout un panel de getter et setter. En résumé, ça ressemble grandement à notre bonne vieille entité Doctrine.
Le problème avec cette approche, c'est que nos objets ne sont plus que des coquilles vides de sens, qui ne font que transiter de la donnée entre la base de données et notre application, mais qui n'ont aucun comportement, aucun behavior.
Martin Fowler, dans son article, explique selon lui qu'il n'y a aucun intérêt à faire de l'orienté objet si on n'utilise pas ce pourquoi l'Objet a été créé. Un objet se doit de combiner donnée et logique, et contenir des méthodes qui lui permettent de se comporter.
Ainsi, la tendance des dernières années à systèmatiquement séparer les données dans les objets et la logique dans des services serait alors un non-sens total.
Le Service ne devrait qu'orchestrer et coordonner les objets ensemble et avec le reste du programme, mais pas contenir de connaissance ou de comportement métier.
J'insiste sur ce point car il s'agit, à mon sens, d'un concept qui m'a vraiment aidé à comprendre ce que je faisais "de travers", et m'a donné un nouvel angle de compréhension de la solution qu'est la Clean Architecture: redonner le pouvoir au Domain.
Note
Pour info, il existe un article entier sur l'Anémie du Domain sur le Blog d'Eleven Labs, si vous souhaitez aller plus loin.
Reprenons notre objet Card
qui n'est pour le moment qu'une entité Doctrine, ne reflettant que de la donnée persitée en base.
Oublions la base de donnée. De quoi à besoin ma Card
pour fonctionner ? On ne conservera que les propriétés qui ont un sens fonctionnellement. Pas besoin de garder des timestamp tels que $updatedAt
par exemple (à moins que cette valeur ait une vraie utilité fonctionnelle).
Dans notre cas, la Card
était déjà plutôt bien définie, avec peu de propriétés.
Voici donc ma proposition:
<?php
declare(strict_types=1);
namespace Domain;
/**
* Immutable domain object representing a Card in the Leitner box system
*/
readonly class Card
{
/**
* Delay schedule for the Leitner box system (in days)
*/
private const array TEST_DELAY = [1, 3, 7, 15, 30, 60];
public function __construct(
public string $question,
public string $answer,
public ?\DateTimeInterface $initialTestDate,
public ?bool $active,
public int $delay = 1,
public ?string $id = null,
) {
}
// ...
}
Premier élément à noter, l'utilisation du readonly
pour rendre cet objet immuable. Il est intéressant de garder tous ses Domain Object Models immuables pour la lisibilité du code et pour minimiser les erreurs. Une fois que je crée un objet Card, il ne changera jamais, donc aucun risque qu'un appel à une fonction cachée change secrètement la valeur d'une propriété avant que je persiste le tout en base: mon code gagne en fiabilité.
Question
La solution dorénavant dans ce cas est assez simple. Imaginons que nous souhaitons désactiver une Card
car on ne souhaite plus qu'elle apparaisse dans notre boîte.
On rajoute cette fonction à notre classe :
// ...
/**
* Creates a new Card with the updated active status
*/
public function withActive(bool $active): self
{
return new self(
$this->question,
$this->answer,
$this->initialTestDate,
$active,
$this->delay,
$this->id,
);
}
// ...
Puis j'appelle cette fonction ainsi, pour qu'elle me retourne un nouvel objet, avec la propriété active
modifiée :
public function disableCard($card): Card
{
$disabledCard = $card->withActive(false);
return $disabledCard;
}
Quels sont les avantages d'une telle méthode ?
immutable
, stable dans le temps, il garde le même state
.with*()
uniquement pour définir des situations dans lesquelles j'autorise des propriétés à changer. Le nom de ces fonctions ont un sens, contrairement à de simples setters
.Card
, que je modifie plusieurs fois au cours de mon programme, ici je retourne à chaque fois des objets différents. Chacun à son propre état, et si je les nomme bien, il est beaucoup plus aisé de les manipuler et de suivre ce qui se passe dans le code.Cela permet une meilleure fluidité de développement, plutôt que d'avoir par exemple un seul objet $card
qui passe à la machine à laver, se faisant muter de fonction en fonction, durant plusieurs dizaines de lignes, et à la fin on ne sait plus quelles sont les valeurs de ses propriétés.
Vous aurez également remarqué que nous avons changé le namespace en supprimant le préfixe App
. Pour que ce changement fonctionne, n'oublions pas de modifier notre composer.json
au niveau de l'autoload ainsi:
"autoload": { "psr-4": { "Domain\\": "src/Domain/", "Application\\": "src/Application/", "Infrastructure\\": "src/Infrastructure/" } }
Je vous offre un petit spoil des dossiers que nous allons créer par la suite, c'est cadeau !
Note
src/feature1/Domain/feature1.model.php
src/feature1/Infrastructure/feature1.controller.php
src/feature1/UseCase/feature1.useCase.php
Domain
, Application
, et Infrastructure
à la racine, quitte à diviser par fonctionnalité en dessous.
Très bien, il ne nous reste plus qu'une chose à rajouter à notre objet, dont nous avons parlé plus tôt: un comportement.
Moins de blabla, plus de code, voici quelques comportements à ajouter à notre Card
:
/**
* Resolves the provided answer to the card
*/
public function resolve(string $answer): self
{
if ($this->isAnswerCorrect($answer)) {
return $this->handleSuccessfulAnswer();
}
return $this->handleFailedAnswer();
}
/**
* Checks if this card is due for testing today
*/
public function isDueForTesting(): bool
{
if (!$this->active) {
return false;
}
if (!$this->initialTestDate instanceof \DateTime) {
return false;
}
$dueDate = (clone $this->initialTestDate)->modify($this->delay . 'days');
return $dueDate <= new \DateTime('today');
}
/**
* Checks if the provided answer is correct for this card
*/
public function isAnswerCorrect(string $answer): bool
{
return strtolower(trim($answer)) === strtolower($this->answer);
}
/**
* Handles a failed answer attempt by resetting the card's delay and setting the initial test date to now
*/
private function handleFailedAnswer(): self
{
return $this->withInitialTestDate(new \DateTime())
->withDelay(1);
}
Note
Card.php
du repo !
Super ! Notre Card
est dorénavant capable de se comporter. On distinguera les règles métier qui vérifient et renvoient un résultat comme notre isAnswerCorrect
, des comportements qui renvoient une nouvelle instance de Card
suite à une modification.
À noter que nos comportements peuvent combiner des appels aux règles métier pour décider quoi faire, avec des appels à d'autres comportements.
Prenons un exemple. La méthode resolve
va décider d'appeler handleFailedAnswer
OU handleSuccessfulAnswer
en fonction du retour de la règle isAnswerCorrect
!
Et si la réponse est mauvaise, handleFailedAnswer
va renvoyer une nouvelle Card
avec son délai réinitialisé suite à la mauvaise réponse.
Et vous l'aurez deviné, la fonction disableCard
, que nous évoquions plus haut fait aussi partie du comportement de notre Card
!
On a presque finit de construire le Domain, mais on va être confronté à un problème: comment pourrai-je plus tard communiquer avec l'Infrastructure ? Deux réponses à cela:
En fait, il faut imaginer ce que l'on appelle un flow of control en Clean Archi.
Ce flow est une flèche qui ne se dirige que dans un sens: elle part de l'Infra, passe par l'Application, et atteri dans le Domain.
Elle permet de schématiser la gestion des dépendances: L'infra peut dépendre de l'Application, qui peut dépendre du Domain, mais jamais dans le sens inverse. Le Domain n'a aucune dépendance vers l'Application, qui n'a aucune dépendance vers l'Infra.
Le seul moyen de communiquer avec notre Infra, c'est de se rappeler du D de nos bons vieux principes SOLID: le Dependency Inversion Principle.
Pour rappel, ce principe nous aide à découpler nos couches comme ceci:
Et voilà on sait comment régler notre problème ! On va ajouter dans notre Domain des contrats d'interface que notre Application pourra utiliser, sans avoir besoin de savoir quelles sont les implémentations concrètes de ces interfaces côté Infrastructure.
Par exemple, c'est typiquement ce genre d'interface que nous allons mettre ici :
interface CardRepositoryInterface
{
public function listAllCards(): iterable;
public function findCard(string $id): ?Card;
/** @throws CannotCreateCard */
public function createNewCard(Card $card): void;
/** @throws CannotEditCard */
public function editCard(Card $card): void;
/** @throws CannotRemoveCard */
public function removeCard(string $id): void;
/** @return iterable<Card> */
public function findTodayCards(): iterable;
}
On n'ajoutera dans nos interfaces que les méthodes dont nous sommes sûr qu'elles seront utiles à notre Application, ni plus, ni moins. Cela permet notamment de ne pas être parasité par les nombreuses autres méthodes et propriétés que nous mettent à disposition Framework, librairies, APIs, ORMs,..
Pour les ORMs comme Doctrine par exemple, on n'exposera pas la Connection
à la base de donnée, le QueryBuilder
, ou encore toutes les fonctions toutes faites telles que find
, findOneBby
, etc... qui n'ont aucun sens métier dans notre Application.
C'est nous qui définissons, avec des termes clairs et logiques, le nom de nos interfaces et de leurs méthodes, et on laisse le soin à l'Infrastructure de se débrouiller avec cela.
Important
Enfin, on remarquera que mon Interface peut lever des Exceptions particulières et personnalisées. Celles-ci permettent de communiquer à l'Infrastructure quelle Exception lever à quel moment, pour que notre couche Applicative sache à quoi l'erreur correspond, et comment réagir.
Ces Exceptions sont rangées dans le Domain au même titre que les Interfaces, comme par exemple:
<?php declare(strict_types=1);
namespace Domain\Exception;
class CannotRemoveCard extends \Exception
{
public function __construct(string $message = 'Failed to remove card', int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
Pareil ici, ces Exceptions ont des noms clairs, on doit comprendre ce qu'il s'est passé simplement en lisant le nom de la classe, et il n'y pas forcément besoin d'ajouter le suffixe Exception
si on ne veut pas être répétitif.
Rendez-vous sur le repo Github pour voir plus d'Interfaces et d'Exceptions.
Auteur(s)
Arthur Jacquemin
Développeur de contenu + ou - pertinent @ ElevenLabs_🚀
Vous souhaitez en savoir plus sur le sujet ?
Organisons un échange !
Notre équipe d'experts répond à toutes vos questions.
Nous contacterDécouvrez nos autres contenus dans le même thème
Ce guide présente Apache Iceberg, un format de table moderne pour les données volumineuses, la gestion des versions et des performances optimisées.
Plongez dans le monde des AST et découvrez comment cette structure de données fondamentale révolutionne le développement moderne.
Retour sur les deux journées de conférences pour la SymfonyLive Paris 2025 à Paris.