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](/blog/stub-mock-fake-doublures-de-test/) 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_

**PHP**

```php
// 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;
            },
        ));
    }
}
```

**C#**

```csharp
// 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_

**PHP**

```php
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;
    }
}
```

**C#**

```csharp
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_

**PHP**

```php
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));
    }
}
```

**C#**

```csharp
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_

**PHP**

```php
final class InMemoryOrderFinder implements OrderFinder
{
    public function search(OrderSpecification $specification): array
    {
        return array_values(array_filter(
            $this->orders,
            $specification->isSatisfiedBy(...),
        ));
    }
}
```

**C#**

```csharp
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_

**PHP**

```php
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;
    }
}
```

**C#**

```csharp
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_

**PHP**

```php
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,
            ),
        };
    }
}
```

**C#**

```csharp
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_

**PHP**

```php
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);
    }
}
```

**C#**

```csharp
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](/blog/dependency-inversion-decouplage-base-de-donnees/) :
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_

**PHP**

```php
// 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'));
}
```

**C#**

```csharp
// 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_

**PHP**

```php
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
}
```

**C#**

```csharp
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](/presentations/) dans vos locaux, ou par mail à
[edouard@lacrafterie.tech](mailto:edouard@lacrafterie.tech).*
