Découpler Symfony du temps système : faire du temps une dépendance explicite

Illustration de Découpler Symfony du temps système : faire du temps une dépendance explicite

Pourquoi lire l’horloge système dans la logique métier crée un comportement non déterministe, et comment un port Clock explicite rend prévisibles l’expiration, la validité, la planification, la persistance et les tests sensibles au temps.

Vous est-il déjà arrivé d’écrire un test comme celui-ci ?

PHP
$token = Token::issue();

sleep(2);

self::assertTrue($token->isExpired());

Il passe en local, mais échoue parfois dans la CI. Allonger l’attente ralentit la suite sans rendre le test fiable.

Le véritable problème ne vient pas de l’assertion. La règle métier dépend d’une valeur que le test ne contrôle pas : l’horloge système.

Le temps entre souvent dans une application par un code d’apparence anodine :

PHP
$now = new DateTimeImmutable();

ou :

PHP
if ($subscription->expiresAt < new DateTimeImmutable()) {
    // ...
}

Ce code semble inoffensif, car aucun service n’a été injecté et aucune API externe n’a été appelée. Il a pourtant lu une valeur fournie par le système d’exploitation. Le temps est une entrée externe, au même titre qu’un résultat de base de données, un nombre aléatoire ou la réponse d’un prestataire de paiement.

En construisant une plateforme Symfony avec des jetons de sécurité expirables, des périodes de validité d’abonnement, des nettoyages planifiés, des périodes tarifaires, des rapports et des événements de domaine horodatés, j’ai fait du temps courant une dépendance explicite.

Le résultat repose sur un petit port Clock dans le domaine, un adaptateur Symfony dans l’Infrastructure et une horloge contrôlée dans les tests. Les handlers applicatifs lisent un instant unique et le transmettent aux politiques et aux agrégats.

En tant que cofondateur, j’accorde de l’importance à ce sujet, car les défauts liés au temps sont particulièrement difficiles à expliquer aux clients. « Votre accès a expiré une seconde trop tôt » ou « le rapport mensuel a inclus le mauvais jour » peut sembler mineur sur le plan technique, mais entame rapidement la confiance.

Dans cet article, je montre pourquoi la lecture directe du temps système est risquée et comment reproduire mon architecture d’horloge dans une application Symfony générique.

Le temps système est une dépendance globale cachée

Prenons un défi de réinitialisation du mot de passe :

PHP
final class SecurityChallenge
{
    public function isExpired(): bool
    {
        return new DateTimeImmutable() > $this->expiresAt;
    }
}

La signature de la méthode prétend ne recevoir aucune entrée :

PHP
isExpired(): bool

En réalité, le résultat dépend d’au moins deux entrées :

Plain text
isExpired(expiresAt, operatingSystemTime): bool

La seconde entrée est invisible.

Cette dépendance cachée crée plusieurs problèmes :

  • un même objet peut renvoyer des résultats différents sans que son état change ;

  • les tests ne peuvent pas choisir l’instant évalué ;

  • les conditions aux limites nécessitent une attente ou une modification globale du temps ;

  • deux appels effectués dans un même cas d’utilisation peuvent observer des instants différents ;

  • les horloges de l’application et de la base de données peuvent diverger ;

  • le fuseau horaire par défaut peut modifier le comportement selon l’environnement ;

  • rejouer une commande échouée peut produire un résultat différent.

La solution n’est pas d’abandonner DateTimeImmutable, mais de ne plus utiliser sa valeur implicite comme source du temps courant.

Construire une date n’est pas lire l’horloge

Ce code est parfaitement valide :

PHP
$publishedAt = new DateTimeImmutable($request->publishedAt);

L’instant provient d’une entrée explicite.

Ce code est également valide dans un test unitaire :

PHP
$now = new DateTimeImmutable('2026-07-13T10:00:00+00:00');

Le test a choisi l’instant.

La forme problématique est :

PHP
$now = new DateTimeImmutable();

ou :

PHP
$now = new DateTimeImmutable('now');

Ces formes demandent une réponse à l’état global du processus. Dans le code métier de production, cette question doit passer par une dépendance injectée.

L’architecture en un diagramme

Mermaid

La production et les tests utilisent des adaptateurs différents, mais convergent vers le même port du domaine et le même workflow applicatif.

Étape 1 : définir un petit port Clock

Le domaine n’a besoin que d’une capacité : fournir l’instant courant sous une forme immuable.

PHP
namespace App\Shared\Domain\Service;

use DateTimeImmutable;

interface Clock
{
    public function now(): DateTimeImmutable;
}

Gardez cette interface minimale. Le domaine n’a pas besoin de savoir comment Symfony fige le temps, comment le système d’exploitation se synchronise par NTP ni comment un worker attend.

L’interface réside dans la couche stable qui consomme le concept. Son implémentation se trouve hors du domaine.

Évitez une fonction statique :

PHP
final class Time
{
    public static function now(): DateTimeImmutable
    {
        return new DateTimeImmutable();
    }
}

Cela se contente de donner un nom plus élégant à la dépendance globale cachée. Un appel statique reste difficile à remplacer localement et fragilise les tests parallèles.

Étape 2 : adapter le composant Clock de Symfony

L’Infrastructure relie mon port à Symfony :

PHP
namespace App\Shared\Infrastructure\Clock;

use DateTimeImmutable;
use App\Shared\Domain\Service\Clock;
use Symfony\Component\Clock\ClockInterface;

final readonly class SymfonyClock implements Clock
{
    public function __construct(
        private ClockInterface $clock,
    ) {}

    public function now(): DateTimeImmutable
    {
        return DateTimeImmutable::createFromInterface(
            $this->clock->now(),
        );
    }
}

Le domaine est maintenant indépendant de Symfony, tandis que l’Infrastructure peut utiliser l’écosystème d’horloge de Symfony.

Avec l’autowiring des services et une seule implémentation du port d’horloge applicatif, Symfony peut l’injecter automatiquement. Si votre conteneur ne peut pas inférer l’implémentation, ajoutez un alias explicite :

YAML
services:
    App\Shared\Domain\Service\Clock:
        alias: App\Shared\Infrastructure\Clock\SymfonyClock

Le reste de l’application dépend uniquement de Clock.

Étape 3 : capturer un instant par cas d’utilisation

Injectez l’horloge dans le handler ou le service applicatif qui lance l’opération :

PHP
#[AsMessageHandler(bus: 'command.bus')]
final readonly class VerifyEmailAddressHandler
{
    public function __construct(
        private SecurityChallengeRepository $challenges,
        private UserRepository $users,
        private Clock $clock,
        private EventDispatcher $events,
    ) {}

    public function __invoke(VerifyEmailAddress $command): void
    {
        $now = $this->clock->now();
        $challenge = $this->challenges->getByToken($command->token);
        $user = $this->users->get($challenge->userId);

        $challenge->consume($now);
        $user->verifyEmail($now);

        $this->challenges->save($challenge);
        $this->users->save($user);
        $this->events->dispatch([
            ...$challenge->releaseEvents(),
            ...$user->releaseEvents(),
        ]);
    }
}

Il est important de capturer l’instant une seule fois.

Cette version est moins robuste :

PHP
$challenge->consume($this->clock->now());
$user->verifyEmail($this->clock->now());
$event = new EmailVerified($this->clock->now());

Ces horodatages peuvent différer de quelques microsecondes ou franchir une limite d’expiration, de seconde, de jour ou de mois. Une opération métier doit généralement posséder un instant de référence unique.

Je considère $now comme une partie du contexte du cas d’utilisation et le réutilise pour :

  • les décisions des politiques ;

  • les transitions des agrégats ;

  • les requêtes de persistance ;

  • les événements de domaine ;

  • les journaux de diagnostic.

Dans un workflow réellement long, les jalons ultérieurs peuvent nécessiter de nouvelles lectures de l’horloge. Nommez-les selon leur signification, par exemple $startedAt, $completedAt ou $failedAt, au lieu de disperser des appels anonymes à now().

Étape 4 : transmettre le temps aux agrégats plutôt que d’injecter une horloge

Je n’injecte normalement pas Clock dans les entités.

Une méthode d’agrégat reçoit explicitement l’instant :

PHP
final class SecurityChallenge
{
    public function consume(DateTimeImmutable $now): void
    {
        if ($this->consumedAt instanceof DateTimeImmutable) {
            throw new TokenAlreadyUsed();
        }

        if ($now > $this->expiresAt) {
            throw new TokenExpired();
        }

        $this->consumedAt = $now;
    }

    public function isExpiredAt(DateTimeImmutable $now): bool
    {
        return $now > $this->expiresAt;
    }
}

La signature reflète maintenant la réalité :

PHP
isExpiredAt(DateTimeImmutable $now): bool

L’entité est déterministe. Pour un même état et un même instant, elle produit la même réponse.

Cela rend également visible la sémantique de la limite. Dans cet exemple :

PHP
$now > $this->expiresAt

Le jeton reste valide exactement à expiresAt et devient invalide immédiatement après. Si le métier souhaite une expiration exclusive, utilisez >=. L’abstraction d’horloge ne décide pas de cette règle ; elle la rend testable.

Étape 5 : créer les objets temporels depuis une origine explicite

La création d’un défi reçoit elle aussi un instant explicite :

PHP
final class SecurityChallenge
{
    private const string DURATION = 'PT15M';

    public static function issue(
        UserId $userId,
        PlainToken $token,
        DateTimeImmutable $issuedAt,
    ): self {
        $expiresAt = $issuedAt->add(
            new DateInterval(self::DURATION),
        );

        return new self(
            userId: $userId,
            tokenHash: TokenHash::fromPlainToken($token),
            expiresAt: $expiresAt,
        );
    }
}

Le service applicatif obtient issuedAt auprès de l’horloge et le transmet :

PHP
$now = $this->clock->now();

$challenge = SecurityChallenge::issue(
    userId: $userId,
    token: $this->tokenGenerator->generate(),
    issuedAt: $now,
);

Ne laissez pas la fabrique appeler l’horloge système en interne. Un constructeur statique reste du code de domaine et doit demeurer déterministe.

Étape 6 : transmettre le temps aux politiques

Les politiques associent souvent des dépôts et des règles de validité :

PHP
final readonly class SubscriptionPolicy
{
    public function __construct(
        private SubscriptionRepository $subscriptions,
        private OrganizationMemberRepository $members,
    ) {}

    public function ensureCanStartIndividualSubscription(
        UserId $userId,
        DateTimeImmutable $now,
    ): void {
        if ($this->subscriptions->findRunningByUser($userId, $now) instanceof Subscription) {
            throw SubscriptionRuleViolated::alreadySubscribed();
        }

        if ($this->members->hasActiveSubscription($userId, $now)) {
            throw SubscriptionRuleViolated::coveredByOrganization();
        }
    }
}

La politique ne décide pas ce que signifie « courant ». Son appelant fournit l’instant.

Cette approche est particulièrement utile pour évaluer un état passé ou futur :

PHP
$policy->hasAccessAt($userId, $invoiceDate);

Une fois le temps rendu explicite, la même règle métier peut répondre à la question « cet utilisateur avait-il accès au service lors de l’émission de la facture ? » sans modifier temporairement une horloge globale.

Étape 7 : modéliser la validité avec un value object

Une horloge répond à la question « quelle heure est-il ? ». Un value object répond à la question « cet instant appartient-il à cette période ? ».

PHP
final readonly class DateRange
{
    public function __construct(
        public DateTimeImmutable $start,
        public ?DateTimeImmutable $end,
    ) {
        if ($end instanceof DateTimeImmutable && $end <= $start) {
            throw new InvalidDateRange();
        }
    }

    public function contains(DateTimeImmutable $instant): bool
    {
        if ($instant < $this->start) {
            return false;
        }

        return ! $this->end instanceof DateTimeImmutable
            || $instant <= $this->end;
    }
}

Séparez ces responsabilités :

Mermaid

Cela évite de reconstruire des comparaisons de dates dans les contrôleurs, les adaptateurs SQL et les entités avec des règles d’inclusion légèrement différentes.

Étape 8 : lier le même instant aux requêtes de base de données

Cette requête est pratique, mais introduit une autre horloge :

SQL
WHERE valid_from <= CURRENT_TIMESTAMP
  AND (valid_until IS NULL OR valid_until >= CURRENT_TIMESTAMP)

PHP prend maintenant ses décisions à partir de l’horloge de l’hôte applicatif, tandis que PostgreSQL utilise celle de l’hôte de la base de données. Elles sont généralement proches, mais « généralement » n’est pas un invariant métier.

Préférez lier l’instant choisi par l’application :

PHP
public function findActivePrices(): array
{
    $now = $this->clock->now();

    return $this->connection->createQueryBuilder()
        ->select('*')
        ->from('prices')
        ->where("(validity->>'start')::timestamptz <= :now")
        ->andWhere(<<<'SQL'
            (validity->>'end') IS NULL
            OR (validity->>'end')::timestamptz >= :now
        SQL)
        ->setParameter('now', $now->format(DATE_ATOM))
        ->executeQuery()
        ->fetchAllAssociative();
}

Mieux encore, acceptez l’instant fourni par le query handler lorsque plusieurs dépôts ou modèles de lecture doivent s’accorder :

PHP
public function findActivePricesAt(DateTimeImmutable $now): array;

Les horodatages générés par la base de données restent utiles pour les colonnes d’audit purement techniques ou les opérations dont elle est propriétaire. La règle n’est pas « ne jamais utiliser le temps de la base de données », mais « ne pas laisser deux horloges non coordonnées décider d’un même résultat métier ».

Étape 9 : remplacer l’horloge dans le conteneur de test

L’implémentation de test encapsule le MockClock de Symfony :

PHP
#[AsAlias(Clock::class, when: 'test')]
final class TestClock implements Clock
{
    private MockClock $clock;

    public function __construct()
    {
        $this->setNow(
            new DateTimeImmutable('2026-07-13T10:00:00+00:00'),
        );
    }

    public function setNow(DateTimeImmutable $now): void
    {
        $this->clock = new MockClock($now);
    }

    public function now(): DateTimeImmutable
    {
        return DateTimeImmutable::createFromInterface(
            $this->clock->now(),
        );
    }
}

L’alias réservé aux tests remplace l’adaptateur de production sans modifier le code de l’application :

PHP
#[AsAlias(Clock::class, when: 'test')]

Les tests peuvent maintenant choisir l’instant immédiatement. Aucune attente, aucune modification globale du processus et aucune dépendance à la charge du runner de CI.

Réinitialisez l’horloge avant chaque scénario afin que le déplacement temporel d’un test ne se propage pas au suivant.

Étape 10 : tester précisément les limites avec des tests unitaires

Puisque les agrégats reçoivent explicitement le temps, les tests unitaires n’ont même pas besoin d’un service d’horloge :

PHP
final class SecurityChallengeTest extends TestCase
{
    public function testExpiryBoundary(): void
    {
        $challenge = SecurityChallenge::issue(
            userId: new UserId(),
            token: new PlainToken('verification-token'),
            issuedAt: new DateTimeImmutable(
                '2026-07-13T10:00:00+00:00',
            ),
        );

        self::assertFalse($challenge->isExpiredAt(
            new DateTimeImmutable('2026-07-13T10:14:59+00:00'),
        ));
        self::assertFalse($challenge->isExpiredAt(
            new DateTimeImmutable('2026-07-13T10:15:00+00:00'),
        ));
        self::assertTrue($challenge->isExpiredAt(
            new DateTimeImmutable('2026-07-13T10:15:01+00:00'),
        ));
    }
}

Ce test se termine immédiatement et documente précisément la décision d’inclusion.

Les partitions temporelles utiles à tester comprennent :

  • un instant avant le début ;

  • exactement au début ;

  • un instant à l’intérieur de l’intervalle ;

  • exactement à la fin ;

  • un instant après la fin ;

  • un intervalle sans fin définie ;

  • le passage à l’heure d’été ou la conversion de fuseau horaire lorsque cela s’applique ;

  • un rejeu après l’achèvement de l’opération.

Étape 11 : déplacer explicitement le temps dans les scénarios Behat

Les tests applicatifs exposent l’horloge contrôlée dans le langage métier :

PHP
final readonly class TimeContext implements Context
{
    public function __construct(
        private TestClock $clock,
    ) {}

    #[Given('current time is :time')]
    public function currentTimeIs(string $time): void
    {
        $this->clock->setNow(new DateTimeImmutable($time));
    }
}

Le scénario peut franchir une limite sans attendre :

Plain text
Scenario: An expired security challenge cannot be consumed
    Given current time is "2026-07-13T10:00:00+00:00"
    And an email-verification challenge exists
    And current time is "2026-07-13T10:16:00+00:00"
    When the security challenge is consumed
    Then it should be rejected because the token expired

Le command handler reçoit le même port Clock qu’en production. Seul son adaptateur change.

Ne masquez pas une date importante dans la définition d’une étape. Si le temps modifie le résultat, rendez-le visible dans le scénario.

Étape 12 : tester les fenêtres de nettoyage sans attendre sept jours

Le nettoyage planifié illustre bien la nécessité de contrôler le temps.

PHP
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CleanupUnconfirmedUsersHandler
{
    private const string CONFIRMATION_PERIOD = 'P7D';

    public function __construct(
        private UserRepository $users,
        private Clock $clock,
    ) {}

    public function __invoke(CleanupUnconfirmedUsers $command): int
    {
        $now = $this->clock->now();
        $registeredBefore = $now->sub(
            new DateInterval(self::CONFIRMATION_PERIOD),
        );

        $users = $this->users->findUnconfirmedWithExpiredVerification(
            registeredBefore: $registeredBefore,
            expiredAt: $now,
            limit: $command->limit,
        );

        foreach ($users as $user) {
            $this->users->remove($user);
        }

        return count($users);
    }
}

Le scénario applicatif peut tester les deux côtés de la limite de sept jours :

Plain text
Given current time is "2026-07-08T10:00:00+00:00"
And the following unconfirmed accounts exist:
    | email                    | registeredAt             |
    | older@example.test       | 2026-07-01T09:59:59+00:00 |
    | exact@example.test       | 2026-07-01T10:00:00+00:00 |
    | recent@example.test      | 2026-07-01T10:00:01+00:00 |
When expired unconfirmed accounts are cleaned up
Then 2 accounts should be deleted
But "recent@example.test" should remain

Aucune attente en temps réel n’est nécessaire. Le scénario est assez rapide pour s’exécuter à chaque commit.

Étape 13 : séparer le temps du scheduler du temps métier

Un scheduler détermine quand déclencher une commande :

PHP
#[AsCronTask(
    '30 2 * * *',
    timezone: 'Africa/Lubumbashi',
    schedule: 'cleanup_unconfirmed_users',
)]
final readonly class CleanupUnconfirmedUsersConsole
{
    public function __construct(private CommandBus $commands) {}

    public function __invoke(): int
    {
        $this->commands->handle(new CleanupUnconfirmedUsers());

        return Command::SUCCESS;
    }
}

Le command handler demande toujours l’instant courant à l’horloge injectée.

Cette séparation signifie que :

  • la commande peut être exécutée manuellement sans fausser le temps ;

  • la configuration du scheduler peut être testée indépendamment ;

  • le cas d’utilisation reste déterministe avec une horloge de test ;

  • un worker retardé évalue volontairement la sémantique temporelle.

Ne placez pas la règle métier directement dans la commande cron. La console déclenche l’opération, mais ne possède pas la règle d’expiration.

Étape 14 : distinguer les instants du temps calendaire métier

Un instant et une date calendaire ne représentent pas le même concept.

Stockez et échangez les instants avec un décalage explicite, de préférence normalisé de manière cohérente :

Plain text
2026-07-13T10:00:00+00:00

Convertissez vers un fuseau horaire métier uniquement lorsque la règle est calendaire :

PHP
private function previousMonth(DateTimeImmutable $now): DateRange
{
    $firstDayOfThisMonth = $now->setTimezone(
        new DateTimeZone('Africa/Lubumbashi'),
    )
        ->modify('first day of this month')
        ->setTime(0, 0);

    return new DateRange(
        start: $firstDayOfThisMonth->modify('-1 month'),
        end: $firstDayOfThisMonth->modify('-1 microsecond'),
    );
}

Cet exemple utilise un DateRange inclusif : sa fin correspond donc au dernier instant représentable du mois précédent. Si votre intervalle suit la convention semi-ouverte, souvent préférable, start <= instant < end, conservez plutôt firstDayOfThisMonth comme fin exclusive. Quelle que soit la convention choisie, encodez-la une seule fois et testez sa limite.

Le mois de facturation précédent dépend d’un fuseau horaire métier. Un jeton valable quinze minutes dépend généralement d’une durée écoulée, pas du nom calendaire de l’heure.

Intégrez le fuseau horaire à la règle ou à la configuration. N’héritez pas accidentellement de date_default_timezone_get() depuis un ordinateur portable, une image de conteneur ou un serveur.

Étape 15 : utiliser une horloge monotone pour mesurer une durée

L’heure civile peut faire des sauts. Les corrections NTP, les changements manuels et la virtualisation peuvent la déplacer vers l’avant ou l’arrière.

Les horodatages métier ont besoin du temps civil :

PHP
$occurredAt = $clock->now();

La mesure des performances a besoin d’une source monotone :

PHP
$started = hrtime(true);

$result = $service->execute();

$durationNanoseconds = hrtime(true) - $started;

N’utilisez pas l’horloge du domaine pour mesurer les performances d’une requête et ne stockez pas les valeurs de hrtime() comme des horodatages métier. Ces outils répondent à des questions différentes.

Mermaid

Étape 16 : décider quel temps représente une commande asynchrone

Supposons qu’une requête HTTP envoie un message à 10 h, mais que le worker l’exécute à 10 h 05.

Quel instant la règle métier doit-elle utiliser ?

Deux réponses sont valides.

Si la règle concerne le temps d’exécution, laissez le handler lire l’horloge du worker :

PHP
$processedAt = $this->clock->now();

Si la règle concerne l’instant où l’utilisateur a agi, incluez cet instant dans le message :

PHP
final readonly class ConfirmOrder
{
    public function __construct(
        public OrderId $orderId,
        public DateTimeImmutable $requestedAt,
    ) {}
}

N’utilisez pas accidentellement le temps du worker pour une règle liée au temps de la requête. N’appelez pas non plus « maintenant » l’horodatage ancien d’un message. Nommez chaque instant selon sa signification.

Les nouvelles tentatives rendent cette distinction encore plus importante. Une commande rejouée demain ne doit pas réinterpréter silencieusement l’action du client effectuée hier, sauf si la politique le prévoit.

Étape 17 : migrer progressivement une base de code existante

Vous n’avez pas besoin de refactoriser toutes les dates dans une seule pull request.

Commencez par rechercher les lectures implicites du temps courant :

Bash
rg "new DateTimeImmutable\\(\\)|new DateTimeImmutable\\('now'\\)|new DateTime\\(\\)|CURRENT_TIMESTAMP|NOW\\("

Classez ensuite chaque résultat :

  • analyse d’une entrée explicite : conservez la construction de la date ;

  • création d’une fixture de test connue : gardez-la explicite ;

  • lecture du temps métier courant : remplacez-la par Clock ;

  • mesure d’une durée : utilisez une source monotone ;

  • horodatage technique appartenant à la base de données : examinez-le séparément ;

  • fuseau horaire d’un déclencheur planifié : rendez-le explicite.

Refactorisez depuis la frontière applicative vers l’intérieur :

  1. Introduisez l’interface Clock.

  2. Ajoutez l’adaptateur Symfony de production.

  3. Injectez-le dans un handler.

  4. Capturez $now une seule fois.

  5. Transmettez l’instant aux politiques, aux entités, aux dépôts et aux événements.

  6. Ajoutez l’implémentation de test contrôlée.

  7. Remplacez les tests fondés sur une attente par des cas limites explicites.

Cette approche limite l’ampleur du changement et révèle les méthodes qui dépendaient secrètement du temps.

Erreurs courantes liées à la dépendance au temps

Masquer new DateTimeImmutable() derrière une fonction statique

La dépendance reste globale. Injectez plutôt un port d’horloge.

Injecter l’horloge dans chaque entité

La couche applicative doit généralement choisir l’instant et le transmettre à l’agrégat. Les entités restent ainsi déterministes et leurs signatures fidèles à leurs entrées.

Appeler now() plusieurs fois dans un même cas d’utilisation

Capturez l’instant une seule fois, sauf si l’opération modélise volontairement plusieurs jalons.

Utiliser sleep() dans les tests

L’attente ralentit la suite sans rendre le temps déterministe. Déplacez plutôt une horloge de test.

Figer le temps globalement

Les modifications globales peuvent se propager d’un test à l’autre et perturber l’exécution parallèle. Préférez l’injection de dépendances et une réinitialisation propre à chaque scénario.

Mélanger le temps de PHP et celui de la base de données

Liez l’instant sélectionné par l’application lorsque les deux couches participent à une même décision métier.

Ignorer l’inclusion des limites

Décidez si expiresAt est lui-même valide. Testez immédiatement avant, exactement à la limite et immédiatement après.

Dépendre du fuseau horaire par défaut du serveur

Utilisez des décalages explicites pour les instants et des fuseaux horaires métier explicites pour les règles calendaires et les planifications.

Confondre le temps de l’événement et celui du traitement

Un message asynchrone peut posséder requestedAt, occurredAt, receivedAt et processedAt. Donnez à chacun un nom précis.

Utiliser le temps civil pour mesurer les performances

Utilisez une horloge monotone comme hrtime() pour mesurer la durée écoulée.

Conclusion

Le temps système est un état externe. Le lire directement dans la logique métier crée une dépendance cachée qui rend le comportement plus difficile à reproduire, à tester et à comprendre.

  • Un petit port Clock dans le domaine rend le temps courant explicite.

  • Un adaptateur Symfony dans l’Infrastructure relie ce port à l’horloge de production.

  • Un adaptateur de test contrôlé le remplace sans modifier le code de l’application.

  • Les handlers applicatifs capturent un instant de référence par cas d’utilisation.

  • Les agrégats et les politiques reçoivent le temps au moyen des paramètres de leurs méthodes.

  • Les value objects de validité possèdent la sémantique des intervalles et de leurs limites.

  • Les requêtes de base de données lient le même instant au lieu de consulter silencieusement une autre horloge.

  • Les tests unitaires exercent les limites temporelles exactes sans attente.

  • Les scénarios Behat déplacent le temps au moyen d’étapes lisibles dans le langage métier.

  • Les commandes de nettoyage peuvent tester instantanément plusieurs jours ou semaines de comportement.

  • Le fuseau horaire du scheduler et l’évaluation du temps métier restent des préoccupations distinctes.

  • Les règles calendaires effectuent explicitement la conversion vers leur fuseau horaire métier.

  • Le temps monotone mesure une durée, tandis que le temps civil enregistre les événements métier.

  • Les messages asynchrones distinguent le temps de l’action du temps de traitement.

Le principal avantage n’est pas l’accélération des tests, même s’ils deviennent beaucoup plus rapides. Le vrai bénéfice est que le temps cesse d’être une donnée ambiante et implicite.

Une fois le temps rendu explicite, une règle peut être évaluée aujourd’hui, demain, à une limite d’expiration ou pendant un rejeu, et le code indique précisément l’instant utilisé.

C’est ce qui rend fiable un comportement sensible au temps.

Bon code !

Articles liés