LoggerInterface dans vos Use Cases : le piège de la dépendance inversée
Ouvrez le constructeur de votre use case "Passer une commande". À côté du repository et de la passerelle de paiement, vous trouverez probablement une ligne discrète : Psr\Log\LoggerInterface $logger. Plus bas, trois appels à $this->logger->info(...).
Rien d'alarmant à première vue. Et pourtant, c'est l'une des manières les plus courantes de laisser un détail d'infrastructure s'infiltrer dans une couche métier que vous avez mis des semaines à garder propre.
Cet article s'appuie sur les idées développées par Gabriel Anhaia dans A Domain Logger Port: Decoupling From PSR-3 Without Losing Context, publié sur dev.to. Nous les replaçons ici dans le contexte d'un projet PHP/Symfony classique.
1. Le problème n'est pas PSR-3, c'est sa place
Disons-le clairement : PSR-3 est un excellent standard, et Monolog est un choix par défaut tout à fait légitime dans la majorité des projets Symfony ou Laravel. Le problème n'est pas la bibliothèque elle-même, mais l'endroit où vous la branchez.
En architecture hexagonale (et plus largement en Clean Architecture), la règle de dépendance est simple : les couches internes (domaine, use cases) ne doivent rien connaître des couches externes (frameworks, bibliothèques tierces, infrastructure). Or, dès que Psr\Log\LoggerInterface apparaît dans le constructeur d'un use case, votre couche applicative dépend :
- d'un package dont vous ne contrôlez pas la surface (
psr/log), - d'une taxonomie de niveaux que vous n'avez pas choisie,
- de conventions de contexte définies par quelqu'un d'autre.
La flèche de dépendance pointe dans le mauvais sens. Et comme souvent en architecture, ce n'est pas un problème "esthétique" : il a des conséquences très concrètes sur la testabilité et la lisibilité de votre code métier.
2. Ce que PSR-3 impose réellement à votre domaine
Psr\Log\LoggerInterface expose huit méthodes de niveau, plus une méthode générique log(). Cette taxonomie vient directement de la RFC 5424 (syslog) :
emergency, alert, critical, error, warning, notice, info, debug
C'est un vocabulaire d'administration système. Il décrit l'état d'une infrastructure, pas le déroulement d'un processus métier. Quand votre use case "Passer une commande" appelle $logger->info('Order placed', [...]), il fait deux choses en même temps :
- il décrit un événement métier (« la commande a été placée ») ;
- il choisit un niveau de criticité technique (
info), une décision qui relève de l'observabilité, pas du domaine.
Ces deux préoccupations n'ont rien à faire ensemble dans la même ligne de code. Le use case ne devrait avoir à dire qu'une seule chose : "cet événement métier vient de se produire". Le mapping vers info, warning ou error, ainsi que le format du message et le contexte technique (trace ID, nom du service, etc.), relèvent de l'infrastructure.
3. La solution : un port de logging métier
La réponse de l'architecture hexagonale est connue : on définit un port (une interface) dans le domaine, exprimé dans le vocabulaire du domaine, et on l'implémente via un adaptateur qui sait comment le traduire vers PSR-3/Monolog.
Le port, côté domaine
namespace App\Domain\Order;
interface OrderLoggerPort
{
public function orderPlaced(string $orderId, float $amount): void;
public function paymentFailed(string $orderId, string $reason): void;
public function stockReserved(string $orderId, array $items): void;
}
Aucune trace de "info", "warning" ou "debug" ici. Seulement des événements métier, nommés comme votre équipe en parlerait en réunion. Le use case devient lisible par n'importe qui, y compris quelqu'un qui ne connaît rien à Monolog :
namespace App\Application\Order;
final class PlaceOrder
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payments,
private OrderLoggerPort $logger,
) {
}
public function __invoke(PlaceOrderCommand $command): void
{
$order = Order::create($command->customerId, $command->items);
$this->payments->charge($order);
$this->orders->save($order);
$this->logger->orderPlaced($order->id(), $order->total());
}
}
L'adaptateur, côté infrastructure
C'est ici, et seulement ici, que PSR-3 et Monolog reprennent leurs droits. L'adaptateur décide du niveau de log, du format du message et du contexte technique à attacher :
namespace App\Infrastructure\Logging;
use App\Domain\Order\OrderLoggerPort;
use Psr\Log\LoggerInterface;
final class Psr3OrderLogger implements OrderLoggerPort
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function orderPlaced(string $orderId, float $amount): void
{
$this->logger->info('order.placed', [
'order_id' => $orderId,
'amount' => $amount,
]);
}
public function paymentFailed(string $orderId, string $reason): void
{
$this->logger->error('order.payment_failed', [
'order_id' => $orderId,
'reason' => $reason,
]);
}
public function stockReserved(string $orderId, array $items): void
{
$this->logger->debug('order.stock_reserved', [
'order_id' => $orderId,
'items' => $items,
]);
}
}
Dans services.yaml, le câblage est trivial :
services:
App\Domain\Order\OrderLoggerPort:
class: App\Infrastructure\Logging\Psr3OrderLogger
arguments: ['@monolog.logger.order']
4. Les bénéfices concrets pour une équipe Symfony/Laravel
✅ Tests unitaires plus simples : dans les tests du use case, vous injectez un NullOrderLogger ou un InMemoryOrderLogger qui enregistre les événements appelés. Plus besoin de mocker LoggerInterface et de vérifier des chaînes de caractères fragiles.
✅ Cohérence du vocabulaire : tous les événements liés aux commandes passent par OrderLoggerPort. Si vous voulez renommer un événement, changer son niveau de log, ou l'envoyer en plus vers un outil de monitoring métier (Sentry, un bus d'événements, etc.), vous modifiez l'adaptateur — le use case ne bouge pas.
✅ Indépendance du framework : si demain vous migrez de Monolog vers un autre système de journalisation, ou si vous changez de framework, seul l'adaptateur est concerné. Les use cases, qui constituent le cœur de votre logique métier, restent intacts.
⚠️ Un point d'attention : il ne s'agit pas de créer un port par use case. Un OrderLoggerPort regroupant les événements liés au cycle de vie d'une commande est généralement le bon niveau de granularité. L'objectif est de capturer le vocabulaire métier, pas de multiplier les interfaces.
Conclusion
Injecter Psr\Log\LoggerInterface directement dans un use case est une habitude tellement répandue qu'elle semble inoffensive. Mais c'est un exemple typique d'inversion de dépendance qui ne va pas dans le bon sens : le domaine se retrouve à connaître les conventions d'une bibliothèque d'infrastructure.
La solution n'est pas de se passer de Monolog ou de PSR-3 — ces outils restent pertinents — mais de les repousser à leur juste place : l'adaptateur. Le domaine, lui, ne devrait connaître que son propre vocabulaire métier, via un port de logging dédié.
C'est un changement modeste en termes de lignes de code, mais qui change durablement la lisibilité et la testabilité des use cases d'une application Symfony ou Laravel.
Article inspiré de A Domain Logger Port: Decoupling From PSR-3 Without Losing Context par Gabriel Anhaia, publié sur dev.to.