Architecture métier : des classes pour représenter les entités
Appliquer une architecture métier dans un projet consiste à regrouper, dans un même dossier ou package, des classes qui représentent les entités du projet : des produits, des personnes, des éléments abstraits, tout ce qui découle directement des besoins fonctionnels. Les attributs de chaque classe contiennent les données que le système doit traiter, et ses méthodes correspondent aux fonctionnalités associées à l'entité.
Le principe de conception repose sur une observation : une entité est manipulée de la même façon à de nombreux endroits du projet. En lui consacrant une classe, on centralise ces traitements et on les rend plus faciles à comprendre. C'est la couche « métier » décrite dans l'article Architecture logicielle.
Ce que cela apporte
- Factorisation. Le code d'un traitement fonctionnel est écrit dans une méthode de l'objet métier, puis utilisé partout où il est nécessaire. S'il doit évoluer, la modification ne concerne qu'un seul endroit.
- Différenciation. En regroupant toutes les méthodes métier dans un package dédié, les développeurs savent immédiatement où les trouver et comment les utiliser.
- Abstraction. Pour se faire une idée du système avant de le modifier, le développeur habitué à cette architecture sait qu'il peut « attaquer » le projet par ses classes métier.
- Tests. Une classe métier ne dépend ni de l'affichage ni de la page web qui l'appelle : ses règles se vérifient avec de simples tests automatisés.
Mise en œuvre
Pour chaque entité, on crée deux classes :
- la classe métier (par exemple
Livre), qui représente un objet. Sa responsabilité de base est de se charger depuis la base de données et de s'y sauvegarder, puis d'appliquer les règles qui concernent un seul objet ; - la classe collection (par exemple
LivreCollection), qui représente une liste d'objets. Ses responsabilités minimales sont de charger et sauvegarder des listes, et de retrouver un objet dans la liste. Elle porte aussi les traitements qui concernent plusieurs objets : recherche, statistiques, opérations en lot.
Quelques règles aident à garder ces classes saines :
- nommez-les avec le vocabulaire du client (
Abonne,Emprunt), pas avec celui de la technique ; - aucune ligne d'affichage dans une classe métier : pas de HTML, pas d'
echo, pas de lecture de$_POST. Elle renvoie des valeurs ou lève une exception, c'est la page qui décide comment les présenter ; - les règles de gestion sont vérifiées dans la classe, pas dans le formulaire : elles s'appliquent ainsi quelle que soit la porte d'entrée (site, interface interne, import, script) ;
- aucune requête SQL en dehors de ces classes (ou, si vous séparez la persistance, en dehors de la couche qui en est chargée).
Exemple : la gestion d'une bibliothèque
Dans une bibliothèque municipale, un système informatique assure la gestion des abonnés, des commandes de livres, des stocks, des réservations et des emprunts. Il est multi-utilisateurs et possède deux types d'accès :
- un accès public sur internet, qui permet aux abonnés de consulter les livres, de connaître leur disponibilité en temps réel et de réserver ;
- un accès privilégié, utilisé par les bibliothécaires à l'accueil, qui donne accès à des fonctionnalités plus larges qu'un simple accès web.
Cet exemple se limite à la table Livre et aux classes Livre et LivreCollection.
La table
CREATE TABLE Livre (
IDLivre INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Titre VARCHAR(255) NOT NULL,
fkAuteur INT NOT NULL,
Resume TEXT NULL,
Image VARCHAR(255) NULL,
QuantiteEnStock INT NOT NULL DEFAULT 0, -- exemplaires en rayon
QuantiteEnLocal INT NOT NULL DEFAULT 0, -- exemplaires à lire sur place uniquement
QuantiteEnPret INT NOT NULL DEFAULT 0, -- exemplaires empruntés
QuantiteReservee INT NOT NULL DEFAULT 0, -- exemplaires mis de côté pour des réservations
QuantiteEnCommande INT NOT NULL DEFAULT 0, -- exemplaires commandés, pas encore reçus
Type ENUM('suggestion','valide') NOT NULL DEFAULT 'suggestion'
);
La classe métier
Version simplifiée : seuls les champs utiles aux règles sont repris, et la gestion des erreurs techniques est omise.
class Livre
{
private $id;
private $titre;
private $stock = 0; // exemplaires en rayon
private $reservee = 0; // exemplaires mis de côté pour des réservations
private $enPret = 0;
private $enCommande = 0;
private $type = 'suggestion';
// Un bibliothécaire crée un livre sur son interface ; un visiteur du site suggère un livre à commander.
public static function creer(PDO $pdo, string $titre, int $idAuteur, string $type = 'suggestion'): Livre
{
$st = $pdo->prepare('INSERT INTO Livre (Titre, fkAuteur, Type) VALUES (?, ?, ?)');
$st->execute(array($titre, $idAuteur, $type));
return self::charger($pdo, (int) $pdo->lastInsertId());
}
// Appelée sur le poste du bibliothécaire comme sur la fiche détaillée du site.
public static function charger(PDO $pdo, int $id): ?Livre
{
$st = $pdo->prepare('SELECT * FROM Livre WHERE IDLivre = ?');
$st->execute(array($id));
$ligne = $st->fetch(PDO::FETCH_ASSOC);
return $ligne ? self::depuisLigne($ligne) : null;
}
public static function depuisLigne(array $ligne): Livre
{
$livre = new Livre();
$livre->id = (int) $ligne['IDLivre'];
$livre->titre = $ligne['Titre'];
$livre->stock = (int) $ligne['QuantiteEnStock'];
$livre->reservee = (int) $ligne['QuantiteReservee'];
$livre->enPret = (int) $ligne['QuantiteEnPret'];
$livre->enCommande = (int) $ligne['QuantiteEnCommande'];
$livre->type = $ligne['Type'];
return $livre;
}
// Exemplaires qu'on peut encore emprunter ou réserver.
public function exemplairesLibres(): int
{
return $this->stock - $this->reservee;
}
// Lire sur place ou emporter à la maison.
public function emprunter(string $typeEmprunt): void
{
if ($typeEmprunt === 'a_la_maison') {
if ($this->exemplairesLibres() < 1) {
throw new RegleMetierException('Aucun exemplaire n\'est disponible.');
}
$this->stock--;
$this->enPret++;
}
}
public function ramener(): void
{
if ($this->enPret < 1) {
throw new RegleMetierException('Aucun exemplaire de ce livre n\'est en prêt.');
}
$this->enPret--;
$this->stock++;
}
// Un visiteur du site réserve en ligne ; à l'accueil, le bibliothécaire peut saisir la même réservation.
public function reserver(): void
{
if ($this->exemplairesLibres() < 1) {
throw new RegleMetierException('Tous les exemplaires sont déjà réservés ou empruntés.');
}
$this->reservee++;
}
// Un bibliothécaire décide d'acheter des exemplaires.
public function commander(int $quantite): void
{
if ($quantite < 1) {
throw new RegleMetierException('La quantité commandée doit être positive.');
}
$this->enCommande += $quantite;
}
// Lors d'un inventaire, les quantités sont mises à jour (lecteur de codes-barres, par exemple).
public function sauvegarder(PDO $pdo): void
{
$st = $pdo->prepare(
'UPDATE Livre SET Titre = ?, QuantiteEnStock = ?, QuantiteReservee = ?, QuantiteEnPret = ?,
QuantiteEnCommande = ?, Type = ? WHERE IDLivre = ?'
);
$st->execute(array($this->titre, $this->stock, $this->reservee, $this->enPret, $this->enCommande, $this->type, $this->id));
}
// Par exemple pour supprimer un doublon.
public function supprimer(PDO $pdo): void
{
$pdo->prepare('DELETE FROM Livre WHERE IDLivre = ?')->execute(array($this->id));
}
}
class RegleMetierException extends Exception {}
La classe collection
class LivreCollection
{
private $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
// Moteur de recherche du site et de l'interface des bibliothécaires.
public function rechercher(string $motsDuTitre = '', int $limite = 20, int $decalage = 0): array
{
$st = $this->pdo->prepare('SELECT * FROM Livre WHERE Titre LIKE ? ORDER BY Titre LIMIT ' . $limite . ' OFFSET ' . $decalage);
$st->execute(array('%' . $motsDuTitre . '%'));
return array_map(array('Livre', 'depuisLigne'), $st->fetchAll(PDO::FETCH_ASSOC));
}
// Statistiques : certaines sont visibles sur le site, d'autres seulement dans l'interface d'administration.
public function compter(string $type = 'valide'): int
{
$st = $this->pdo->prepare('SELECT COUNT(*) FROM Livre WHERE Type = ?');
$st->execute(array($type));
return (int) $st->fetchColumn();
}
// Réception d'un lot de livres commandés : array(idLivre => quantité reçue).
public function recevoir(array $quantitesParLivre): void
{
$this->pdo->beginTransaction();
$st = $this->pdo->prepare(
'UPDATE Livre SET QuantiteEnStock = QuantiteEnStock + ?, QuantiteEnCommande = QuantiteEnCommande - ?
WHERE IDLivre = ? AND QuantiteEnCommande >= ?'
);
foreach ($quantitesParLivre as $idLivre => $quantite) {
$st->execute(array($quantite, $quantite, $idLivre, $quantite));
if ($st->rowCount() !== 1) {
$this->pdo->rollBack();
throw new RegleMetierException('Quantité reçue incohérente pour le livre ' . $idLivre . '.');
}
}
$this->pdo->commit();
}
}
La même classe, deux interfaces
C'est tout l'intérêt de la démarche : le site public et l'interface des bibliothécaires utilisent les mêmes objets, donc les mêmes règles.
// Site public : un abonné réserve un livre
$livre = Livre::charger($pdo, (int) $_POST['livre']);
try {
$livre->reserver();
$livre->sauvegarder($pdo);
echo 'Réservation enregistrée.';
} catch (RegleMetierException $e) {
echo htmlspecialchars($e->getMessage());
}
// Interface du bibliothécaire : réception d'un lot
$collection = new LivreCollection($pdo);
$collection->recevoir(array(12 => 3, 48 => 1));
Lire une classe comme un sommaire
En parcourant la liste des méthodes de Livre et de LivreCollection, un développeur découvre d'un coup d'œil l'étendue des fonctionnalités de l'entité : on peut créer, charger, emprunter, ramener, réserver, commander, sauvegarder et supprimer un livre, rechercher et compter des livres, et recevoir des livres commandés. Il comprend ainsi non seulement le code, mais aussi les processus de fonctionnement du système. C'est le grand intérêt des classes métier.
Les limites, et comment les gérer
- Une classe qui grossit trop. Si
Livrese met à gérer les prêts, les amendes et les statistiques, créez d'autres entités (Emprunt,Reservation) plutôt que d'ajouter des méthodes. Une entité par notion du métier. - La persistance dans la classe métier. Faire charger et sauvegarder l'objet par lui-même (variante dite « Active Record ») est simple et efficace pour la plupart des projets. Quand le projet grandit ou que vous voulez tester le métier sans base de données, déplacez le SQL dans une classe dédiée (un « repository ») : voir l'exemple de l'article Architecture logicielle.
- Les listes volumineuses. Une collection qui charge toute la table en mémoire ne tiendra pas la charge : paginez (
LIMITetOFFSET, comme dansrechercher()) et filtrez dans la requête. - Une requête par objet. Charger cent livres puis interroger la base pour l'auteur de chacun fait cent requêtes de plus. Récupérez ces données dans la requête de la collection, avec une jointure.
- Les opérations sur plusieurs lignes. Une réception de lot doit réussir ou échouer en entier : utilisez une transaction, comme dans
recevoir(). - Les accès concurrents. Deux personnes peuvent réserver le dernier exemplaire en même temps. Quand le cas est réel, faites la vérification et la mise à jour dans une seule requête SQL (
UPDATE … WHERE QuantiteEnStock - QuantiteReservee > 0) et testez le nombre de lignes modifiées.
Pour aller plus loin
- Architecture logicielle : la place de la couche métier parmi les cinq couches d'une application.
- Structure de fichiers d'un projet web : où ranger ces classes dans l'arborescence.