Langage des exemples de code

Specification pattern : des requêtes maintenables, portables d'un SGBD à l'autre

Des requêtes qu'on teste sans base, et qu'on modifie sans trembler


Tu as fait les choses bien. Ton code métier ne connaît pas la base de données, il parle à une interface. En test, tu branches un fake en mémoire : pas de conteneur à démarrer, une suite qui tourne en deux secondes.

Tout est vert. Et un matin, le support remonte que le filtre « prêtes à expédier » ne renvoie pas en production les commandes que tes tests annoncent.

Personne n’a rien cassé. Tes tests décrivent juste une application qui n’existe pas.

Cet article montre comment le Specification pattern règle ce problème, et ce qu’il apporte au passage : des règles de filtrage testables sans base de données, des requêtes qu’on peut faire évoluer pendant des années sans rouvrir tout le code qui les entoure, et un comportement identique d’un SGBD à l’autre, prouvé plutôt que supposé.

Le fake qui dérive

Prenons un back-office de commandes. L’écran de liste propose deux filtres : une recherche sur le nom du client, et une case « prêtes à expédier ».

Côté production, un finder SQL. Côté tests, un finder en mémoire. Les deux implémentent la même interface, et chacun applique les filtres à sa façon.

La même règle, écrite deux fois
// En production
final class SqlOrderFinder implements OrderFinder
{
    public function search(OrderFilters $filters): array
    {
        $query = DB::table('orders');

        if ($filters->readyToShip) {
            $query->whereNotNull('paid_at')
                  ->whereNotNull('stock_reserved_at');
        }
        if ($filters->customer !== null) {
            $query->where('customer_name', 'ilike', "%{$filters->customer}%");
        }

        return $query->get()->map($this->toView(...))->all();
    }
}

// Dans les tests
final class InMemoryOrderFinder implements OrderFinder
{
    public function search(OrderFilters $filters): array
    {
        return array_values(array_filter(
            $this->orders,
            function (OrderView $order) use ($filters): bool {
                if ($filters->readyToShip && $order->paidAt === null) {
                    return false;
                }
                if ($filters->customer !== null
                    && !str_contains($order->customerName, $filters->customer)) {
                    return false;
                }

                return true;
            },
        ));
    }
}
// En production
public sealed class SqlOrderFinder(ShopDbContext db) : IOrderFinder
{
    public IReadOnlyList<OrderView> Search(OrderFilters filters)
    {
        IQueryable<OrderRow> query = db.Orders;

        if (filters.ReadyToShip)
            query = query.Where(o => o.PaidAt != null && o.StockReservedAt != null);

        if (filters.Customer is not null)
            query = query.Where(o =>
                EF.Functions.ILike(o.CustomerName, $"%{filters.Customer}%"));

        return query.Select(ToView).ToList();
    }
}

// Dans les tests
public sealed class InMemoryOrderFinder : IOrderFinder
{
    private readonly List<OrderView> _orders = [];

    public IReadOnlyList<OrderView> Search(OrderFilters filters) =>
        _orders
            .Where(o => !filters.ReadyToShip || o.PaidAt is not null)
            .Where(o => filters.Customer is null
                        || o.CustomerName.Contains(filters.Customer))
            .ToList();
}

Relis les deux moitiés. Elles ne disent pas la même chose.

  • « Prête à expédier » exige un paiement et un stock réservé en SQL. Le fake ne regarde que le paiement. La deuxième condition a été ajoutée un jour dans la requête, et personne n’a pensé au fake.
  • La recherche ignore la casse en SQL. Le fake, lui, distingue dupont de Dupont.

Aucun test ne peut le voir. Les tests du cas d’usage tournent contre le fake, et le fake est cohérent avec lui-même. Il est faux, mais avec assurance.

Le problème n’est pas le fake. Le problème, c’est qu’une règle métier est écrite deux fois, dans deux langages, et que rien ne relie les deux copies. Chaque nouveau filtre creuse l’écart, et chaque écart est invisible jusqu’à la production.

Donner un nom à chaque critère

Le Specification pattern, décrit par Eric Evans et Martin Fowler, part d’une idée simple : un critère de sélection est un objet. Il a un nom, des paramètres éventuels, et il sait répondre à une question : « est-ce que cet élément me satisfait ? »

Un critère, un objet
interface OrderSpecification
{
    public function isSatisfiedBy(OrderView $order): bool;
}

final readonly class ReadyToShip implements OrderSpecification
{
    public function isSatisfiedBy(OrderView $order): bool
    {
        return $order->paidAt !== null && $order->stockReservedAt !== null;
    }
}

final readonly class CustomerNameContains implements OrderSpecification
{
    public function __construct(public string $term) {}

    public function isSatisfiedBy(OrderView $order): bool
    {
        return mb_stripos($order->customerName, $this->term) !== false;
    }
}
public interface IOrderSpecification
{
    bool IsSatisfiedBy(OrderView order);
}

public sealed record ReadyToShip : IOrderSpecification
{
    public bool IsSatisfiedBy(OrderView order) =>
        order.PaidAt is not null && order.StockReservedAt is not null;
}

public sealed record CustomerNameContains(string Term) : IOrderSpecification
{
    public bool IsSatisfiedBy(OrderView order) =>
        order.CustomerName.Contains(Term, StringComparison.OrdinalIgnoreCase);
}

Ces classes ne connaissent ni SQL, ni ORM, ni framework. Leur nom vient du vocabulaire du métier : « prête à expédier » a enfin une adresse dans le code.

Et surtout, ce sont des fonctions pures. Les tester ne demande rien d’autre qu’un objet en entrée.

Tester la règle, sans base
final class ReadyToShipTest extends TestCase
{
    public function test_a_paid_order_with_reserved_stock_is_ready(): void
    {
        $order = anOrder(paidAt: '2026-09-01', stockReservedAt: '2026-09-02');

        self::assertTrue((new ReadyToShip)->isSatisfiedBy($order));
    }

    public function test_a_paid_order_without_stock_is_not_ready(): void
    {
        $order = anOrder(paidAt: '2026-09-01', stockReservedAt: null);

        self::assertFalse((new ReadyToShip)->isSatisfiedBy($order));
    }

    public function test_an_unpaid_order_is_not_ready(): void
    {
        $order = anOrder(paidAt: null, stockReservedAt: '2026-09-02');

        self::assertFalse((new ReadyToShip)->isSatisfiedBy($order));
    }
}
public sealed class ReadyToShipTest
{
    [Fact]
    public void A_paid_order_with_reserved_stock_is_ready()
    {
        var order = AnOrder(paidAt: "2026-09-01", stockReservedAt: "2026-09-02");

        Assert.True(new ReadyToShip().IsSatisfiedBy(order));
    }

    [Fact]
    public void A_paid_order_without_stock_is_not_ready()
    {
        var order = AnOrder(paidAt: "2026-09-01", stockReservedAt: null);

        Assert.False(new ReadyToShip().IsSatisfiedBy(order));
    }

    [Fact]
    public void An_unpaid_order_is_not_ready()
    {
        var order = AnOrder(paidAt: null, stockReservedAt: "2026-09-02");

        Assert.False(new ReadyToShip().IsSatisfiedBy(order));
    }
}

Pas de conteneur, pas de migration, pas de jeu de données à insérer puis à nettoyer. Trois tests, quelques millisecondes, et ils se lisent comme la règle elle-même. Compare avec ce qu’il fallait avant pour vérifier la même chose : démarrer une base, y insérer trois lignes, lancer la requête complète, et espérer qu’aucun autre filtre ne vienne brouiller le résultat.

C’est le premier gain du pattern, avant même de parler de fake : la règle est sortie de la requête, donc on peut la tester sans la requête. Les cas limites (un champ vide, une date nulle, une casse inattendue) coûtent une ligne chacun. On les écrit, au lieu de les remettre à plus tard.

Le finder ne reçoit plus un sac de filtres optionnels. Il reçoit une spécification. Et le fake n’a plus rien à réimplémenter :

Le fake délègue
final class InMemoryOrderFinder implements OrderFinder
{
    public function search(OrderSpecification $specification): array
    {
        return array_values(array_filter(
            $this->orders,
            $specification->isSatisfiedBy(...),
        ));
    }
}
public sealed class InMemoryOrderFinder : IOrderFinder
{
    private readonly List<OrderView> _orders = [];

    public IReadOnlyList<OrderView> Search(IOrderSpecification specification) =>
        _orders.Where(specification.IsSatisfiedBy).ToList();
}

Le fake ne contient plus aucune règle. Il ne peut plus oublier une condition, puisqu’il n’en écrit aucune. Ajouter un filtre ne demande plus d’y toucher.

Les tests de cas d’usage en profitent directement. Un test qui vérifie que l’écran de préparation ne liste que les commandes prêtes s’appuie sur le fake, et le fake applique la vraie règle, pas une imitation écrite un vendredi soir.

Un seul combinateur suffit

Les présentations classiques du pattern arrivent avec tout un attirail : And, Or, Not, une classe de base abstraite, des opérateurs surchargés. Sur le projet où mon équipe et moi l’avons mis en place, on a gardé une seule composition.

Toutes les conditions à la fois
final readonly class AllOf implements OrderSpecification
{
    /** @param list<OrderSpecification> $specifications */
    public function __construct(public array $specifications) {}

    public static function none(): self
    {
        return new self([]);
    }

    public function isSatisfiedBy(OrderView $order): bool
    {
        foreach ($this->specifications as $specification) {
            if (!$specification->isSatisfiedBy($order)) {
                return false;
            }
        }

        return true;
    }
}
public sealed record AllOf(IReadOnlyList<IOrderSpecification> Specifications)
    : IOrderSpecification
{
    public static AllOf None => new([]);

    public bool IsSatisfiedBy(OrderView order) =>
        Specifications.All(s => s.IsSatisfiedBy(order));
}

Pourquoi seulement un ET ? Parce que c’est ce que fait un écran de liste : les filtres se cumulent. Quand un filtre accepte plusieurs valeurs (les statuts « payée » ou « expédiée »), le OU reste interne au critère : un StatusIn qui prend une liste. On ajoutera Or et Not le jour où un vrai besoin les réclamera.

Deux détails qui comptent :

  • Un AllOf vide accepte tout. « Aucun filtre » n’est pas un cas particulier avec un null à tester, c’est une conjonction de zéro condition.
  • Un paramètre absent ne produit aucun critère. Le contrôleur transforme la requête HTTP en spécification une fois pour toutes, à la frontière. Passé ce point, un filtre à moitié rempli n’existe plus.

Reste le SQL

Tu ne vas pas charger toute la table pour la filtrer en mémoire. Il faut bien que la base fasse le travail. La traduction vit côté infrastructure, dans une classe dédiée, et les spécifications continuent d’ignorer son existence.

Le traducteur
final class OrderSpecificationTranslator
{
    public function apply(Builder $query, OrderSpecification $specification): void
    {
        if ($specification instanceof AllOf) {
            foreach ($specification->specifications as $child) {
                $this->apply($query, $child);
            }

            return;
        }

        match (true) {
            $specification instanceof ReadyToShip => $query
                ->whereNotNull('paid_at')
                ->whereNotNull('stock_reserved_at'),
            $specification instanceof CustomerNameContains => $query->whereRaw(
                "LOWER(customer_name) LIKE LOWER(?) ESCAPE '\\'",
                [self::likePattern($specification->term)],
            ),
            default => throw new \RuntimeException(
                'Pas de traduction SQL pour '.$specification::class,
            ),
        };
    }
}
public sealed class OrderSpecificationTranslator
{
    public IQueryable<OrderRow> Apply(
        IQueryable<OrderRow> query, IOrderSpecification specification) =>
        specification switch
        {
            AllOf all => all.Specifications.Aggregate(query, Apply),

            ReadyToShip => query.Where(o =>
                o.PaidAt != null && o.StockReservedAt != null),

            CustomerNameContains c => query.Where(o => EF.Functions.Like(
                o.CustomerName.ToLower(), LikePattern(c.Term).ToLower(), @"\")),

            _ => throw new NotSupportedException(
                $"Pas de traduction SQL pour {specification.GetType().Name}"),
        };
}

La branche par défaut lève une exception, et c’est voulu. Un critère sans traduction qu’on ignorerait en silence renverrait toutes les lignes : un filtre qui ne filtre pas, sans la moindre erreur. Ici, l’oubli explose au premier appel.

Si tu fais du C#, tu te dis sans doute qu’une Expression<Func<OrderRow, bool>> par spécification ferait les deux à la fois : compilée pour la mémoire, traduite par EF Core pour la base. C’est une variante répandue du pattern, et elle supprime le traducteur. Elle ne supprime pas le problème. La même expression n’a pas la même sémantique des deux côtés : Contains est sensible à la casse en mémoire, et dépend de la collation en base ; une comparaison avec null ne se comporte pas pareil en LINQ et en SQL. Une seule écriture, toujours deux exécutions.

Car soyons honnêtes : on a toujours deux définitions. isSatisfiedBy d’un côté, sa traduction de l’autre. Ce qui a changé, c’est qu’elles sont rangées par critère, nommées, face à face. Reste à prouver qu’elles disent la même chose.

La preuve : une suite, deux exécutions

C’est là que le pattern paie vraiment. Puisque le fake et le finder SQL acceptent la même spécification, ils peuvent passer exactement les mêmes tests.

On écrit la suite une fois, sous forme abstraite. Elle ne sait pas quel finder elle teste, elle sait seulement lui fournir des commandes et l’interroger.

Le contrat du finder
abstract class OrderFinderContract extends TestCase
{
    abstract protected function finderWith(OrderView ...$orders): OrderFinder;

    public function test_ready_to_ship_requires_payment_and_reserved_stock(): void
    {
        $finder = $this->finderWith(
            anOrder(id: 'payee-seulement', paidAt: '2026-09-01'),
            anOrder(id: 'prete', paidAt: '2026-09-01', stockReservedAt: '2026-09-02'),
        );

        $found = $finder->search(new ReadyToShip);

        self::assertSame(['prete'], array_column($found, 'id'));
    }

    public function test_customer_search_ignores_case(): void
    {
        $finder = $this->finderWith(
            anOrder(id: 'dupont', customerName: 'Marie Dupont'),
            anOrder(id: 'martin', customerName: 'Paul Martin'),
        );

        $found = $finder->search(new CustomerNameContains('DUPONT'));

        self::assertSame(['dupont'], array_column($found, 'id'));
    }
}

final class InMemoryOrderFinderTest extends OrderFinderContract
{
    protected function finderWith(OrderView ...$orders): OrderFinder
    {
        return new InMemoryOrderFinder($orders);
    }
}

final class SqlOrderFinderTest extends OrderFinderContract
{
    protected function finderWith(OrderView ...$orders): OrderFinder
    {
        foreach ($orders as $order) {
            $this->insertRow($order); // une vraie base, dans un conteneur
        }

        return new SqlOrderFinder(new OrderSpecificationTranslator);
    }
}
public abstract class OrderFinderContract
{
    protected abstract IOrderFinder FinderWith(params OrderView[] orders);

    [Fact]
    public void Ready_to_ship_requires_payment_and_reserved_stock()
    {
        var finder = FinderWith(
            AnOrder(id: "payee-seulement", paidAt: "2026-09-01"),
            AnOrder(id: "prete", paidAt: "2026-09-01", stockReservedAt: "2026-09-02"));

        var found = finder.Search(new ReadyToShip());

        Assert.Equal(["prete"], found.Select(o => o.Id));
    }

    [Fact]
    public void Customer_search_ignores_case()
    {
        var finder = FinderWith(
            AnOrder(id: "dupont", customerName: "Marie Dupont"),
            AnOrder(id: "martin", customerName: "Paul Martin"));

        var found = finder.Search(new CustomerNameContains("DUPONT"));

        Assert.Equal(["dupont"], found.Select(o => o.Id));
    }
}

public sealed class InMemoryOrderFinderTest : OrderFinderContract
{
    protected override IOrderFinder FinderWith(params OrderView[] orders) =>
        new InMemoryOrderFinder(orders);
}

public sealed class SqlOrderFinderTest(PostgresFixture postgres)
    : OrderFinderContract, IClassFixture<PostgresFixture>
{
    protected override IOrderFinder FinderWith(params OrderView[] orders)
    {
        postgres.Insert(orders); // une vraie base, dans un conteneur

        return new SqlOrderFinder(postgres.Db, new OrderSpecificationTranslator());
    }
}

Rejoue maintenant le scénario du début. Quelqu’un ajoute la condition sur le stock dans la traduction SQL et oublie isSatisfiedBy. La suite en mémoire devient rouge, ou la suite SQL, selon le côté oublié. La divergence n’attend plus la production : elle casse le build.

La suite SQL est lente, et ce n’est pas grave. Elle ne tourne que pour le finder. Tous les autres tests, ceux des cas d’usage, continuent de s’appuyer sur le fake en deux secondes. La différence, c’est que tu as désormais une raison de lui faire confiance.

C’est la même idée que derrière l’inversion de dépendance : une interface ne vaut que si ses implémentations sont réellement interchangeables. Le contrat le vérifie au lieu de le supposer.

On obtient trois niveaux de tests, chacun à sa place :

  • les tests de spécification vérifient la règle, en mémoire, par dizaines ;
  • les tests de contrat vérifient que le SQL dit la même chose, un ou deux cas par critère ;
  • les tests de cas d’usage tournent sur le fake et ne se soucient plus du filtrage.

Six mois plus tard : la requête change

Une requête de liste ne reste jamais telle qu’elle a été livrée. On lui ajoute des filtres, le métier précise une règle, un index manque et il faut réécrire le SQL. C’est là qu’on mesure une conception : pas le jour de la livraison, mais à la dixième modification.

Un nouveau filtre

Le métier veut voir les commandes en retard : celles dont la date promise est dépassée et qui ne sont pas parties.

Tout ce qu'il faut écrire
// 1. La règle
final readonly class LateAt implements OrderSpecification
{
    public function __construct(public DateTimeImmutable $today) {}

    public function isSatisfiedBy(OrderView $order): bool
    {
        return $order->shippedAt === null && $order->promisedFor < $this->today;
    }
}

// 2. Sa traduction : une branche de plus dans le traducteur
$specification instanceof LateAt => $query
    ->whereNull('shipped_at')
    ->where('promised_for', '<', $specification->today),

// 3. Sa preuve : un test de plus dans le contrat
public function test_late_orders_are_unshipped_and_past_their_promised_date(): void
{
    $finder = $this->finderWith(
        anOrder(id: 'en-retard', promisedFor: '2026-09-01'),
        anOrder(id: 'partie', promisedFor: '2026-09-01', shippedAt: '2026-09-02'),
        anOrder(id: 'dans-les-temps', promisedFor: '2026-09-20'),
    );

    $found = $finder->search(new LateAt(new DateTimeImmutable('2026-09-10')));

    self::assertSame(['en-retard'], array_column($found, 'id'));
}
// 1. La règle
public sealed record LateAt(DateOnly Today) : IOrderSpecification
{
    public bool IsSatisfiedBy(OrderView order) =>
        order.ShippedAt is null && order.PromisedFor < Today;
}

// 2. Sa traduction : une branche de plus dans le traducteur
LateAt late => query.Where(o =>
    o.ShippedAt == null && o.PromisedFor < late.Today),

// 3. Sa preuve : un test de plus dans le contrat
[Fact]
public void Late_orders_are_unshipped_and_past_their_promised_date()
{
    var finder = FinderWith(
        AnOrder(id: "en-retard", promisedFor: "2026-09-01"),
        AnOrder(id: "partie", promisedFor: "2026-09-01", shippedAt: "2026-09-02"),
        AnOrder(id: "dans-les-temps", promisedFor: "2026-09-20"));

    var found = finder.Search(new LateAt(new DateOnly(2026, 9, 10)));

    Assert.Equal(["en-retard"], found.Select(o => o.Id));
}

Regarde surtout ce qui n’apparaît pas. L’interface du finder n’a pas bougé. Le fake n’a pas bougé. Le finder SQL n’a pas bougé. Aucun test existant n’a été modifié. Tu as ajouté du code, tu n’en as pas rouvert.

Avec l’objet OrderFilters du début, le même besoin demandait un champ de plus dans l’objet, un if de plus dans le finder SQL, un if de plus dans le fake, et la relecture de chaque appelant pour vérifier qu’il construit toujours correctement ses filtres. Au cinquième filtre, la méthode search fait cent lignes et plus personne n’ose y toucher.

Note aussi le paramètre today. La date du jour est passée à la spécification, elle n’est pas lue dans l’horloge. La règle reste pure, et le test fixe la date qu’il veut.

Une règle qui se précise

Le métier revient : une commande n’est prête à expédier que si l’adresse de livraison a été vérifiée.

On commence par le contrat. Un nouveau test décrit une commande payée, au stock réservé, mais à l’adresse non vérifiée, et affirme qu’elle ne doit pas sortir. Il est rouge deux fois : en mémoire et en SQL. On corrige isSatisfiedBy, la suite en mémoire passe. On corrige la traduction, la suite SQL passe.

Deux endroits à modifier, tous deux désignés par un test rouge. Il n’y a pas à chercher où la règle est appliquée : elle a un nom, et ce nom ne correspond qu’à une classe et une branche du traducteur. Tous les écrans qui utilisent ReadyToShip héritent du changement sans qu’on les ouvre.

Un SQL à réécrire

Six mois plus tard, la recherche par nom rame. Le LOWER(...) LIKE empêche la base d’utiliser son index, et il faut passer à un index dédié, ou à une colonne normalisée.

C’est une modification purement technique : la règle ne change pas, seule sa traduction change. Et tu as exactement le filet qu’il faut. La spécification ne bouge pas, le contrat ne bouge pas, et la suite SQL te dit en une exécution si la nouvelle requête renvoie toujours les mêmes commandes. Tu réécris le SQL avec le comportement attendu déjà écrit sous forme de tests.

Sans ce filet, optimiser une requête de liste revient à comparer des résultats à la main sur une copie de la production.

Un deuxième usage

Enfin, le métier veut un compteur à côté de la liste, puis un export. Les deux doivent appliquer les mêmes filtres que l’écran.

Le finder gagne une méthode count, qui prend la même spécification. Le contrôleur construit la spécification une fois et la passe aux deux. Il n’existe aucun moyen pour que le compteur affiche 12 quand la liste montre 11 lignes, parce qu’il n’y a pas de seconde copie des conditions à garder synchronisée.

Un deuxième SGBD

C’est le changement qu’on redoute le plus, et celui pour lequel le pattern m’a le plus servi. Sur le projet dont je parle, l’application tourne sur Oracle, et une bonne partie des tests d’intégration sur PostgreSQL. La même requête doit donc renvoyer les mêmes lignes sur deux moteurs qui ne sont d’accord sur rien de subtil.

Trois choses rendent ça tenable.

Les règles ne savent pas qu’un SGBD existe. Une spécification ne contient pas une ligne de SQL. Tout ce qui dépend d’un moteur est rassemblé dans le traducteur : c’est le seul fichier à relire quand on change de base, et le seul endroit où un dialecte a le droit d’apparaître.

Le traducteur vise le SQL commun. Tu l’as peut-être remarqué plus haut : la recherche utilise LOWER(...) LIKE LOWER(?) ESCAPE, pas l’ILIKE du tout premier exemple. ILIKE n’existe que chez PostgreSQL. La forme longue est du SQL standard, comprise à l’identique partout. Quand un moteur exige vraiment une syntaxe à lui, la différence se loge dans une branche du traducteur, et la règle reste intacte.

Le contrat tourne une fois par moteur. La suite abstraite avait deux héritières. Elle en gagne une troisième, et pas un seul test à réécrire.

Un moteur de plus, zéro test de plus
final class PostgresOrderFinderTest extends OrderFinderContract
{
    use StartsPostgres;

    // finderWith() insère les lignes dans PostgreSQL
}

final class OracleOrderFinderTest extends OrderFinderContract
{
    use StartsOracle;

    // finderWith() insère les mêmes lignes dans Oracle
}
public sealed class PostgresOrderFinderTest(PostgresFixture database)
    : SqlOrderFinderContract(database), IClassFixture<PostgresFixture>;

public sealed class OracleOrderFinderTest(OracleFixture database)
    : SqlOrderFinderContract(database), IClassFixture<OracleFixture>;

// SqlOrderFinderContract hérite de OrderFinderContract et implémente
// FinderWith() une seule fois, à partir de la base qu'on lui fournit.

Chaque test du contrat devient une affirmation sur trois implémentations : la mémoire, PostgreSQL, Oracle. Et c’est là qu’on découvre ce que les moteurs font différemment, avant les utilisateurs.

Un exemple vécu. Un de nos critères dit : « le code d’origine se lit dans la colonne de référence si elle est renseignée, sinon dans le libellé ». En SQL, ce « sinon » s’écrit avec un IS NULL. Or Oracle range une chaîne vide comme un NULL, et PostgreSQL non. Une colonne contenant '' aurait pris le repli sur un moteur et pas sur l’autre. La parade n’est pas dans la requête : on normalise à l’écriture, pour qu’un champ vide soit toujours enregistré NULL. Mais parce que la règle est isolée, nommée et testée sur les deux moteurs, cette différence est écrite à côté du critère qu’elle concerne, et un test la surveille. Elle ne dort pas au fond d’une requête de cent lignes.

La portabilité n’est jamais gratuite. Ce que le pattern change, c’est qu’elle devient une propriété vérifiée à chaque build, au lieu d’une promesse faite par l’ORM.

Ce que le contrat ne couvre pas

Trois limites, pour ne pas survendre.

Certains comportements ne sont pas portables, et il faut le dire. Chez nous, la recherche ignore la casse sur les caractères ASCII, et le contrat s’arrête là. Ce que deviennent É et é dépend du moteur et de sa configuration : PostgreSQL et Oracle ne répondent pas pareil, et aucun des deux ne coïncide avec ce que fait le langage en mémoire. On l’a écrit noir sur blanc : les accents ne font pas partie du contrat. Un test séparé, propre à chaque base, fige le comportement réel pour qu’un changement de configuration ne passe pas inaperçu.

Les listes parallèles restent un risque. Une recherche plein texte parcourt une liste de champs en mémoire et une liste de colonnes en SQL. Ajoute une colonne en oubliant l’une des deux, et aucun test de contrat ne le verra tant que personne n’écrit de cas pour ce champ. On a verrouillé ça avec un test structurel : il compare par réflexion les propriétés texte de l’objet, la liste de la spécification et celle du traducteur. Trois listes, forcées de rester identiques.

Le pattern a un coût d’entrée. Une classe par critère, un traducteur, une suite de contrat. Pour un écran avec un seul filtre qui ne bougera jamais, c’est trop. Le seuil, de mon expérience, c’est le moment où tu écris la même condition une deuxième fois : dans un fake, dans un export, dans un compteur affiché à côté de la liste.

En résumé

Un fake qui réimplémente des filtres est une copie de la règle, et les copies dérivent. Le Specification pattern ne supprime pas la dualité entre la mémoire et le SQL. Il la rend visible, nommée, et vérifiable :

  • chaque critère est un objet pur, avec un nom du métier, testable sans base ;
  • le fake délègue à isSatisfiedBy et ne contient plus aucune règle ;
  • la traduction SQL est isolée, et refuse bruyamment un critère inconnu ;
  • une même suite de tests tourne contre le fake et contre la vraie base.

Le bénéfice se mesure dans la durée : Ajouter un filtre, c’est une classe, une branche et un test, sans rien rouvrir. Préciser une règle, c’est suivre deux tests rouges. Réécrire le SQL, c’est s’appuyer sur un contrat qui existe déjà. Supporter un autre SGBD, c’est faire tourner ce même contrat une fois de plus.

Le jour où ton fake et ta base passent les mêmes tests, le fake n’est plus une approximation commode. C’est une deuxième implémentation, prouvée équivalente. Et ta requête n’est plus un bloc qu’on redoute de modifier.


Tu veux creuser ces sujets avec ton équipe ? J’interviens en Brown Bag Lunch dans vos locaux, ou par mail à edouard@lacrafterie.tech.

Version markdown de cet article