Nicolas Ruiz EN
← Retour

InvoiceService ne devrait pas exister

18 min de lecture architecture · php · conception

On créait des factures à deux endroits.

Un client passait par l'API : la requête HTTP arrivait avec un Input (un DTO), les champs obligatoires étaient validés, et la facture se créait proprement.

Un autre flux passait par Kafka : un message arrivait, un consumer appelait InvoiceService, et la facture se créait… sans le DTO, sans la validation. Les champs obligatoires ? Personne ne les vérifiait de ce côté.

HTTP (API)                     Kafka (consumer)
    │                               │
Input (DTO)                    message brut
champs obligatoires ✓          (rien ne vérifie) ✗
    │                               │
Processor                      InvoiceService
    │                               │
    └─────► créer une facture ◄─────┘

Deux portes d'entrée, deux bouts de code, la même intention : « créer une facture ». Sauf que la règle « ces champs sont obligatoires » ne vivait que d'un seul côté.
Résultat : selon la porte empruntée, on créait des factures à qui il manquait des champs — et là, c'est la base de données qui nous rappelait à l'ordre : customer_id NOT NULL, violation de contrainte, ça explose.

La base protégeait la règle. Notre code, non.

Le coupable n'est pas Kafka, ni le DTO. C'est que « créer une facture » soit une méthode d'InvoiceService au lieu d'être Invoice elle-même.

C'est de ça que je veux parler.

Ma thèse tient en une ligne : une règle métier appartient à l'objet qu'elle protège.
Le service qui l'héberge à sa place, lui, ne devrait pas exister — je parle du service/fourre-tout métier.

Je ne vais pas parler de clean architecture ou de DDD, car je sais que des devs vont rouler des yeux :D
Et je ne dis pas que je ne fais pas l'erreur : il m'a fallu des années pour me demander « pourquoi le code devient si vite spaghetti ? ».
Ce n'est pas simple. Mais ça devrait l'être.

Ces fameux services

On les croise tous les jours : PaymentService, OrderService, CustomerManager, ClientManager, etc.

Rien qu'en lisant le nom de la classe, si on ne sait pas ce qu'elle fait, c'est déjà un smell.

On se sert de ce genre de classe pour y mettre des business rules. Car on est habitué à faire ça, très peu de projets font autrement en fait. On a appris comme ça, on continue de travailler sur des projets comme ça.

Je pointe ces services, mais pourquoi ?

Combien de fois on s'est retrouvé avec une classe de + de 1000 lignes ? Ça fait des call DB, des call HTTP, ça appelle plusieurs models métier (entités ?), ça fait des if else partout...

Le service devient galère à tester, car on doit mocker plus que nécessaire pour tester une règle métier.
Un service peut en appeler un autre — spaghetti assuré.

Les règles métier n'ont pas de foyer, avouons-le.
« Il ne faut pas oublier d'appeler la méthode service->x, sinon le calcul ne sera pas cohérent, donc bugué » ¯_(ツ)_/¯.

Exemple de code d'un service fourre-tout. D'abord, le constructeur :

final class InvoiceService
{
    public function __construct(
        private readonly InvoiceRepository $invoices,
        private readonly CustomerRepository $customers,
        private readonly TaxApiClient $taxApi,
        private readonly EntityManagerInterface $em,
        private readonly LoggerInterface $logger,
    ) {}

Cinq dépendances : deux repositories, une API externe, l'ORM, le logger. Cette classe touche à tout — et ses tests doivent souvent mocker une bonne partie de tout ça.

Jim Carrey, l'air sceptique

Une première méthode. Avec nos règles métier.

    public function applyDiscount(int $invoiceId, int $amount, string $reason): void
    {
        $invoice = $this->invoices->find($invoiceId);
        if ($invoice === null) {
            throw new InvoiceNotFoundException($invoiceId);
        }

        if ($invoice->getStatus() === 'issued') {
            throw new \LogicException('Facture déjà émise, non modifiable');
        }
        if ($amount <= 0) {
            throw new \InvalidArgumentException('Remise invalide');
        }

        $customer = $this->customers->find($invoice->getCustomerId());
        if ($customer->getType() === 'vip' && $amount > 100_000) {
            $amount = 100_000;    // plafond VIP noyé ici
        }

Trois règles qui pourraient appartenir à des objets comme Invoice ou Discount, mais qui vivent ici bien au chaud :

On continue pour le fun :

        $line = new InvoiceLine();
        $line->setLabel('Remise : ' . $reason);
        $line->setAmount(-$amount);

        $invoice->getLines()->add($line);

        $total = 0;
        foreach ($invoice->getLines() as $l) {
            $total += $l->getAmount();
        }

On ajoute une ligne, et il faut se souvenir de recalculer le total à la main.

Et la fin :

        $vat = $this->taxApi->computeVat($total, $customer->getCountry());
        $invoice->setTotal($total);
        $invoice->setVatAmount($vat);

        $this->em->flush();
        $this->logger->info('Remise appliquée', ['invoice' => $invoiceId]);
    }

    // ... + 900 autres lignes du même acabit
    // (createInvoice, sendInvoice, refund, exportPdf...)
}

Fiouuu, on n'a pas oublié de setter le total ni la TVA.

Napoleon Dynamite, le regard vide

Ce code marche. Il passe même les tests — enfin, si on arrive à les écrire.
On a déjà tous vu ce code, pas vrai ?
Alors, c'est quoi le problème ?

Rien dans Invoice ne protège son état. Les règles vivent dans le service, et il suffit de ne pas passer par lui.

Six mois plus tard, quelqu'un ajoute un remboursement :

// RefundInvoiceHandler, ajouté par quelqu'un d'autre
public function __invoke(RefundInvoice $message): void
{
    $invoice = $this->invoices->find($message->invoiceId);

    $line = new InvoiceLine();
    $line->setLabel('Remboursement');
    $line->setAmount(-$message->amount);
    $invoice->getLines()->add($line);

    $this->em->flush();
}

Il ne passe pas par InvoiceService. Il ne sait même pas que les règles y vivent. Résultat :

Rien ne plante, les tests passent. C'est le client qui s'en rend compte, en lisant sa facture.

Et avec le grand classique — un service qui appelle un autre service :

final class PdfService
{
    public function __construct(
        private readonly InvoiceService $invoiceService, // -> ça smell fort ici
        private readonly PdfRenderer $renderer,
    ) {}

    public function generate(int $invoiceId): string
    {
        $invoice = $this->invoiceService->getInvoice($invoiceId);
        // si on oublie cet appel, le PDF affiche l'ancien total
        $this->invoiceService->recalculateTotal($invoice);

        return $this->renderer->render($invoice);
    }
}

Et attention : ça, tout le monde le fait.
Bien, pas bien ?

Michael Scott, l'air pensif

Pourquoi on fait ça

J'ai une hypothèse : le pattern MVC, que tout le monde apprend pendant ses études. Avec le temps, le Model est devenu anémique1, un 1-1 avec une table de BDD.
Un gros sac de données.

Et encore plus avec l'arrivée des frameworks (Symfony, Spring Boot, etc.)
On se retrouve avec plein de getters et setters dans une classe qui ne fait rien...
Pourquoi y ajouter du comportement ?

Donc on met d'abord les règles dans les controllers, qui orchestrent.

Puis ces controllers grossissent avec le nombre de routes qui augmente, ainsi que l'ajout de nos règles business. Enfin pour les tester unitairement, ça devient compliqué car on traverse toutes les couches : HTTP, métier, DB, etc.

Un nouveau layer est apparu : le « service ». Pour « découper ».

C'est bien beau tout ça, mais alors on fait comment ?

Commençons par redonner leurs responsabilités à nos objets. Quelle est la définition d'un objet ?

Un objet est l'instance d'une classe.

C'est ce qu'on nous apprend, et techniquement c'est ok.
Mais à quoi sert un objet ?

C'est comme si je disais : « c'est quoi, une voiture ? » Et on répond : c'est un assemblage mécanique de plusieurs pièces comme des roues, un moteur, etc.

Donc à quoi sert un objet ?
Selon Alan Kay, l'inventeur du terme « object-oriented » (mail à la liste squeak-dev, octobre 1998) :

The big idea is messaging.

Donc ce n'est pas l'héritage, l'instance, la classe, etc.
C'est le message !
Un objet répond à des messages, tout en protégeant la cohérence de son état.

On ne lui demande pas ses données pour décider à sa place ce qu'il doit faire. On lui demande de faire directement.

Tell, don't ask.2

Un objet (métier) n'est pas un sac de données (entities, on vous voit ;)
Exemple : au lieu de lire l'état, puis de décider quoi faire (comme ici)

class Service
// ...
if ($invoice->getStatus() === 'issued') {
    // ... do something
}
$invoice->setStatus('issued'); // don't think, take this
$invoiceRepository->save($invoice);

On peut faire parler l'objet.

Et le comportement vit dans l'objet, pas dans un service :

class Invoice
{
    private string $status;

    public function isIssued(): bool
    {
        return $this->status === 'issued';
    }

    public function markAsIssued(): void
    {
        if ($this->isIssued()) {
            // l'objet refuse l'état incohérent
            throw new \DomainException('Facture déjà émise');
        }
        $this->status = 'issued';
    }
}

Il y a quelque chose de primordial ici à comprendre.
Invoice n'est pas juste un 1-1 avec la table invoice… (voire pas du tout).
L'objet redevient responsable de ses propres règles ; il protège son état et refuse ce qui est incohérent.

« Ok, mais là l'objet est simple. Moi, mes objets ont des relations, des sous-objets… »
Heureusement ! Imaginons des InvoiceLine maintenant.

Quand l'objet a des sous-objets

Invoice garde ses lines et protège la règle qui les lie. Concrètement :

final class Invoice
{
    private string $status = 'draft';
    private int $total = 0;
    /** @var Collection<int, InvoiceLine> */
    private Collection $lines;

    // constructeur PRIVÉ : personne ne fabrique une Invoice "à la main"
    private function __construct(
        private Uuid $id,
        // obligatoire : pas de facture sans client
        private int $customerId,
    ) {
        $this->lines = new ArrayCollection();
    }

    // le seul "entrant" pour créer une facture
    // — quelle que soit la source (HTTP, Kafka, cli …)
    public static function create(int $customerId): self
    {
        return new self(Uuid::v4(), $customerId);
    }

    public function id(): Uuid
    {
        return $this->id;
    }

Constructeur privé, une factory : Invoice::create(), ou rien. Pas de facture sans client, peu importe la porte d'entrée. Et l'identité naît avec l'objet : pas besoin d'attendre la base pour avoir un id.

    // SEUL moyen d'ajouter une ligne → impossible de contourner le recalcul
    public function addLine(string $label, int $amount): void
    {
        if ($this->isIssued()) {
            throw new \DomainException('Facture émise : lignes non modifiables');
        }

        $this->lines->add(new InvoiceLine($label, $amount));

        // l'invariant "total = somme" est garanti ICI
        $this->recalculateTotal();
    }

Invoice vérifie l'état, ajoute, recalcule — n'oublie rien.

    private function recalculateTotal(): void
    {
        $this->total = 0;
        foreach ($this->lines as $line) {
            $this->total += $line->amount();
        }
    }
}

Résultat : ajouter une ligne en oubliant le recalcul devient impossible. Invoice et ses lignes forment ce qu'on appelle un agrégat, dont Invoice est la racine.

Kronk, l'air satisfait, mission accomplie

HTTP, Kafka, CLI, n'importe quel input : tous passent par create(), tous subissent la même règle.
Le bug du tout début — la validation ne vivait que d'un seul côté. Vous vous souvenez ?
Ici elle vit dans l'objet : plus de facture incomplète qui va exploser sur un NOT NULL.

« Mais on aurait pu corriger ça autrement : valider aussi le message côté consumer, ou faire passer les deux portes par le même use case. »

C'est vrai. Sauf que si la règle vit dans les portes d'entrée, il faut penser à la reproduire partout — HTTP, Kafka, CLI… Et ces validations finiront par diverger à la prochaine évolution.
Le constructeur privé, lui, n'est pas une convention : c'est une contrainte. L'état invalide n'est plus représentable.

Ce qui reste au service

Ok… mais du coup, ce code, on le met où ?
Je l'ai cité sans faire exprès au-dessus : « use case ».

Suivant les conventions et les teams, on parle de UseCase ou d'Application Service (attention : ce n'est PAS le layer auquel on veut tordre le cou).
Lui ne s'occupe que d'orchestrer.

Exemple :

final class CreateInvoice
{
    public function __construct(
        // un PORT (interface), pas la DB
        private readonly InvoiceRepository $invoices,
        private readonly CustomerRepository $customers,
        // pour annoncer le fait
        private readonly EventDispatcher $events,
    ) {}

    public function __invoke(CreateInvoiceCommand $command): void
    {
        $customer = $this->customers->get($command->customerId);
        $invoice = Invoice::create($customer->id());

        foreach ($command->lines as $line) {
            // c'est l'AGRÉGAT qui décide
            $invoice->addLine($line->label, $line->amount);
        }

        // ...

        $this->invoices->save($invoice);

        // fait métier, au PASSÉ
        $this->events->dispatch(new InvoiceCreated($invoice->id())); // on devrait faire de l'outbox pattern ici
    }
}

Pour info : CreateInvoice annonce un fait; il n'envoie PAS l'email lui-même. Ailleurs, un handler réagit à l'event passé — et ça, c'est de l'infra :

final class SendInvoiceEmail
{
    public function __invoke(InvoiceCreated $event): void
    {
        // ... on envoie l'email
    }
}

Demain un SMS, une notif compta ? On ajoute un handler, le use case ne bouge pas.4

Le nom

Le smell du début : « Service », « Manager » Si tu ne peux pas nommer ta classe sans « Service », « Manager » ou « Helper », c'est qu'elle en fait trop.

Alors on nomme par ce qu'elle fait : InvoiceService devient CreateInvoice, IssueInvoice, ApplyDiscount. Une action, une classe.
Et le nom devient un signal : un envoi de mail dans CreateInvoice, ça saute aux yeux en review — c'est d'ailleurs pour ça qu'il vit dans SendInvoiceEmail.
Ce n'est pas une contrainte comme le constructeur privé : rien ne t'empêche de le faire. Mais le fourre-tout ne peut plus grossir en silence.

Un use case peut-il en appeler un autre ?

Si la question te vient : non.

Ce qu'on pourrait imaginer faire :

final class IssueInvoice
{
    public function __construct(
        private readonly CreateInvoice $createInvoice,
        private readonly AddShippingFees $addShippingFees,
    ) {}

    public function __invoke(IssueInvoiceCommand $command): void
    {
        // un use case qui en pilote deux autres
        // et qui a dû faire retourner un id « juste pour ce cas »
        $invoiceId = ($this->createInvoice)(new CreateInvoiceCommand($command->customerId, $command->lines));

        ($this->addShippingFees)(new AddShippingFeesCommand($invoiceId, $command->shippingFees));
    }
}

Trois choses clochent.

  1. L'id d'abord : CreateInvoice ne retournait rien, il n'en avait pas besoin. Maintenant si, et pour un seul appelant. Il n'écrit plus pour le métier, il écrit pour un autre use case.

  2. Les transactions ensuite : chaque use case commit la sienne et publie ses events.
    Donc CreateInvoice commit, InvoiceCreated part, l'email arrive chez le client. Puis AddShippingFees échoue.
    Trop tard : la facture est en base sans ses frais de port, et le client a déjà reçu le mauvais montant. Le premier commit, lui, ne revient pas en arrière.

  3. Et surtout, IssueInvoice redevient un chef d'orchestre qui connaît le détail des autres. Le fourre-tout qu'on vient de sortir par la porte rentre par la fenêtre, avec des use cases à la place des méthodes.

Alors on fait quoi ?
Une règle métier ? Elle va dans l'objet : il protège son état et reste cohérent.
Une réaction à un fait déjà arrivé ? C'est un handler sur l'event, comme SendInvoiceEmail.
Et si le métier veut créer la facture et ajouter les frais de port d'un coup ? Un seul use case, qui fait les deux lui-même : Invoice::create(), puis addLine().

Et si tu as deux use cases qui doivent vraiment s'enchaîner, pose-toi la question de la transaction :

Et là on entre dans un autre sujet : outbox pattern5, saga et compensation, eventual consistency. Ça mérite ses propres articles.

Pour conclure

Qu'est-ce qu'on peut faire pour améliorer notre projet un peu chaque jour ? On ne va pas faire un big bang ou refactorer 1000 lignes d'un service. Ça serait dangereux, et contre-productif.

La prochaine fois qu'on a une règle métier à écrire, on peut se poser la question. Cette règle peut s'auto-gérer dans un objet, ou pas ?

Si oui, très bien : on la met dedans, et nos services existants l'appelleront. C'est pas grave, c'est mieux qu'hier : la règle n'existe plus qu'à un seul endroit.

Si non, on peut réfléchir : est-ce qu'il manque un objet dans le code, qui existe pourtant dans notre langage métier ? Une classe n'est pas forcément une table en BDD, n'oubliez pas :D.

Et au fur et à mesure, nos services maigriront, car la responsabilité changera de place. Peut-être même qu'ils disparaîtront, remplacés par des classes d'intention : CreateInvoice, IssueInvoice, ApplyDiscount.

On n'a pas parlé des cas où on récupère de la data juste pour l'afficher. Spoiler : pas besoin d'agrégat ni de use case pour ça — souvent, une simple query suffit6. (Encore un autre article !)

Et le bonus, celui qu'on attendait depuis le début : cette règle, maintenant, tu la testes en quatre lignes.

public function test_le_total_est_la_somme_des_lignes(): void
{
    $invoice = Invoice::create(customerId: 42);   // la seule porte d'entrée
    $invoice->addLine('Prestation', 10000);      // 100,00 €
    $invoice->addLine('Frais de port', 500);     //   5,00 €

    // pas de DB, pas de HTTP, pas de mock
    self::assertSame(10500, $invoice->total());
}

Au début de l'article, tester cette règle voulait dire mocker la DB, le client HTTP, la Terre entière. Là ? Quatre lignes.

Et la facture sans client, celle qui explosait sur un NOT NULL selon la porte empruntée ? Elle n'est plus représentable — même par la porte que personne n'a encore imaginée.

Maintenant, c'est notre code qui protège la règle. La base n'a plus rien à nous dire.

Le code complet

Cet article s'arrête à l'essentiel. Dans la vraie vie, il y a l'autoliquidation entre pays, l'émission qui fige la facture, un taux lu en table ou via une API — bref, tout ce qui finit d'habitude dans le fourre-tout.

J'ai donné mon article à Claude Code, qui en a généré le projet — agrégat, ports7, use cases et tests compris : nicolasrz/article-invoice-exemple.
Je le trouve très représentatif de ce dont je parle.

Notes

  1. Martin Fowler a donné un nom à ce travers en 2003 : Anemic Domain Model. ↩

  2. Principe popularisé par Andy Hunt et Dave Thomas, les auteurs de The Pragmatic Programmer. Martin Fowler le résume ici : TellDontAsk. ↩

  3. « Attends, une entity sans setters ? Doctrine ne saura jamais la relire. »

    Si. Doctrine hydrate par réflexion : il écrit directement dans les propriétés privées, sans passer par le moindre accesseur — et il n'appelle même pas le constructeur.
    Les getters et les setters publics ne sont pas une contrainte de l'ORM. C'est une habitude, et un make:entity qui les génère par défaut. ↩

  4. Pour les puristes DDD : on pourrait lever l'event dans l'agrégat lui-même — Invoice enregistre InvoiceCreated — puis boucler sur ses events après la persistance pour les dispatcher. C'est pas trop le sujet ici. ↩

  5. Dans CreateInvoice, on sauvegarde puis on dispatch. Si le dispatch échoue après le commit, l'event est perdu : la facture existe, mais personne n'est prévenu. L'outbox pattern écrit l'event dans une table, dans la même transaction que la facture ; un process à part le publie ensuite. ↩

  6. Séparer l'écriture (agrégats, use cases) de la lecture (queries directes), ça porte un nom : CQRS, pour Command Query Responsibility Segregation. ↩

  7. Terme de l'architecture hexagonale (« Ports and Adapters », Alistair Cockburn). Le métier définit l'interface, c'est le port ; l'infra fournit l'implémentation, c'est l'adapter : Doctrine, un client HTTP, ou une version en mémoire pour les tests. ↩