Construire des objets-valeurs typés de bout en bout avec Symfony et PostgreSQL JSONB

Illustration de Construire des objets-valeurs typés de bout en bout avec Symfony et PostgreSQL JSONB

Modéliser des valeurs structurées du domaine en PHP, les persister en PostgreSQL JSONB avec des types Doctrine personnalisés et valider le même contrat en TypeScript avec Zod.

Avez-vous déjà représenté une somme d’argent par deux champs indépendants, comme $amount et $currency, avant de découvrir qu’une partie de l’application avait mis à jour le montant en oubliant la devise ?

Les valeurs primitives sont pratiques, mais elles perdent rapidement leur sens. Une chaîne peut représenter une adresse e-mail, une devise, un code pays ou un libellé arbitraire. Un entier peut représenter une somme d’argent, une quantité, un pourcentage ou un identifiant.

Le compilateur ne voit que string et int. Mon métier, lui, voit des concepts différents.

C’est là que les objets-valeurs deviennent utiles. Dans mon application Symfony, je les emploie pour des concepts comme les montants monétaires, les intervalles de dates, le cycle de vie d’un compte, les métadonnées juridiques et les métadonnées structurées d’un paiement.

Certaines de ces valeurs tiennent naturellement dans une seule colonne PostgreSQL jsonb. Au lieu de mapper chaque propriété avec un objet incorporable Doctrine, j’enregistre un type Doctrine personnalisé qui convertit un document JSON en un objet PHP typé.

Dans cet article, je montre comment préserver cette sûreté de typage dans l’ensemble du système :

Mermaid

Les objets-valeurs en quelques mots

Un objet-valeur représente un concept descriptif dépourvu d’identité propre.

Deux utilisateurs portant le même nom restent deux utilisateurs distincts, car ils possèdent une identité. Deux montants contenant 100 et USD sont égaux parce que leurs valeurs sont égales.

Un objet-valeur utile est généralement :

  • Immuable : toute modification crée un nouvel objet.

  • Auto-validant : les valeurs invalides ne peuvent pas être construites.

  • Comparé par valeur : ses propriétés définissent l’égalité.

  • Centré sur les comportements : les opérations emploient le langage du domaine au lieu de manipuler des primitives ailleurs.

  • Petit et cohérent : ses valeurs vont ensemble et évoluent normalement ensemble.

La monnaie est un exemple classique. Un montant sans devise est incomplet : je modélise donc les deux dans une seule valeur.

Pourquoi ne pas conserver un tableau ?

Je pourrais stocker et transmettre une somme d’argent ainsi :

PHP
$money = [
    'amount' => 1250,
    'currency' => 'USD',
];

Mais chaque consommateur doit alors connaître la structure et la valider à nouveau :

PHP
if (!isset($money['amount'], $money['currency'])) {
    throw new InvalidArgumentException('Invalid money');
}

Les tableaux autorisent également des états impossibles :

PHP
$money = ['amount' => -100];
$money = ['currency' => 'anything'];
$money['amount'] = 'free';

Un objet-valeur effectue la validation une fois lors de sa construction, puis expose partout ailleurs un type fiable.

Étape 1 : modéliser explicitement la devise

Commençons par les devises prises en charge :

PHP
namespace App\Billing\Domain\ValueObject;

enum Currency: string
{
    case Cdf = 'CDF';
    case Usd = 'USD';

    public static function fromString(string $currency): self
    {
        return self::from(strtoupper(trim($currency)));
    }
}

L’énumération élimine les chaînes arbitraires du domaine. Si le métier prend en charge deux devises, le type ne doit pas laisser entendre que toute chaîne de trois lettres est acceptée.

L’ajout d’une devise devient ainsi une modification visible du domaine, plutôt qu’une valeur non documentée dans la base de données.

Étape 2 : créer l’objet-valeur Money

Combinons maintenant le montant et la devise :

PHP
namespace App\Billing\Domain\ValueObject;

use JsonSerializable;
use Override;

final readonly class Money implements JsonSerializable
{
    public function __construct(
        public int $amount,
        public Currency $currency,
    ) {
        if ($amount < 0) {
            throw new InvalidArgumentException(
                'Money amount must be zero or greater.',
            );
        }
    }

    /** @param array{amount: int, currency: string} $data */
    public static function fromArray(array $data): self
    {
        return new self(
            amount: $data['amount'],
            currency: Currency::fromString($data['currency']),
        );
    }

    public static function fromJson(string $json): self
    {
        $data = json_decode(
            $json,
            true,
            flags: JSON_THROW_ON_ERROR,
        );

        if (!is_array($data)) {
            throw new InvalidArgumentException(
                'Money must be represented by a JSON object.',
            );
        }

        return self::fromArray($data);
    }

    /** @return array{amount: int, currency: string} */
    public function toArray(): array
    {
        return [
            'amount' => $this->amount,
            'currency' => $this->currency->value,
        ];
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException(
                'Money values must use the same currency.',
            );
        }

        return new self(
            $this->amount + $other->amount,
            $this->currency,
        );
    }

    public function scale(int $quantity): self
    {
        if ($quantity < 1) {
            throw new InvalidArgumentException(
                'Money quantity must be positive.',
            );
        }

        return new self($this->amount * $quantity, $this->currency);
    }

    #[Override]
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}

Le reste du domaine peut désormais exiger un objet Money au lieu d’accepter un montant et une devise dissociés :

PHP
final class Price
{
    public function __construct(
        public readonly PriceId $id,
        public readonly ProductId $productId,
        public readonly Money $amount,
    ) {}
}

J’obtiens ainsi une garantie importante : un Price ne peut jamais avoir un montant sans devise.

Si votre système prend en charge les montants décimaux, choisissez leur représentation avant de continuer. Une solution courante consiste à stocker la plus petite unité monétaire sous forme d’entier. Évitez les nombres binaires à virgule flottante pour les calculs financiers.

Pourquoi PostgreSQL JSONB ?

Dans la base de données, Money est représenté par un petit document unique :

JSON
{
    "amount": 1250,
    "currency": "USD"
}

Le type jsonb de PostgreSQL est utile ici pour plusieurs raisons :

  • La valeur est stockée de manière atomique comme un concept cohérent.

  • PostgreSQL vérifie que la valeur stockée est un JSON valide.

  • Il reste possible de filtrer, trier, indexer et agréger des clés individuelles.

  • Le document peut évoluer avec des champs facultatifs lorsque cela est nécessaire.

  • PHP et TypeScript peuvent utiliser la même structure externe.

Cependant, jsonb seul n’offre aucune sûreté de typage. PostgreSQL acceptera volontiers ceci si je n’ajoute pas de contraintes :

JSON
{
    "amount": "a lot",
    "currency": false
}

Une sûreté de typage de bout en bout exige une validation à chaque frontière, y compris dans la base de données.

Étape 3 : créer un type JSONB Doctrine personnalisé

Le type personnalisé assure la traduction entre PostgreSQL et le domaine.

Avec Doctrine DBAL 4, étendez JsonbType afin que les déclarations de schéma utilisent le type JSONB de PostgreSQL :

PHP
namespace App\Billing\Infrastructure\Persistence\DBAL\Type;

use App\Billing\Domain\ValueObject\Money;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Exception\InvalidType;
use Doctrine\DBAL\Types\Exception\SerializationFailed;
use Doctrine\DBAL\Types\JsonbType;
use Override;
use Throwable;

final class MoneyType extends JsonbType
{
    public const string NAME = 'billing_money';

    #[Override]
    public function convertToPHPValue(
        mixed $value,
        AbstractPlatform $platform,
    ): ?Money {
        if ($value instanceof Money) {
            return $value;
        }

        $value = parent::convertToPHPValue($value, $platform);

        if ($value === null) {
            return null;
        }

        if (!is_array($value)) {
            throw InvalidType::new(
                $value,
                self::NAME,
                ['null', 'JSON object', Money::class],
            );
        }

        try {
            return Money::fromArray($this->normalize($value));
        } catch (Throwable $throwable) {
            throw SerializationFailed::new(
                $value,
                self::NAME,
                $throwable->getMessage(),
                $throwable,
            );
        }
    }

    #[Override]
    public function convertToDatabaseValue(
        mixed $value,
        AbstractPlatform $platform,
    ): ?string {
        if ($value === null) {
            return null;
        }

        if (!$value instanceof Money) {
            throw InvalidType::new(
                $value,
                self::NAME,
                ['null', Money::class],
            );
        }

        return parent::convertToDatabaseValue(
            $value->toArray(),
            $platform,
        );
    }

    /**
     * @param array<mixed, mixed> $value
     * @return array{amount: int, currency: string}
     */
    private function normalize(array $value): array
    {
        $amount = $value['amount'] ?? null;
        $currency = $value['currency'] ?? null;

        if (!is_int($amount)) {
            throw InvalidType::new(
                $amount,
                self::NAME,
                ['integer amount'],
            );
        }

        if (!is_string($currency)) {
            throw InvalidType::new(
                $currency,
                self::NAME,
                ['string currency'],
            );
        }

        return [
            'amount' => $amount,
            'currency' => $currency,
        ];
    }
}

Les deux méthodes de conversion ont des responsabilités opposées :

Mermaid

Le type se trouve dans Infrastructure, car Doctrine est un détail d’implémentation. La classe Money reste un simple objet du domaine sans dépendance envers l’ORM.

Si vous utilisez une ancienne version de Doctrine DBAL dépourvue de JsonbType, étendez JsonType et redéfinissez getSQLDeclaration() afin de renvoyer JSONB pour PostgreSQL.

Étape 4 : enregistrer le type personnalisé

La configuration Doctrine de Symfony associe le nom logique à la classe PHP :

YAML
# config/packages/doctrine.yaml
doctrine:
    dbal:
        types:
            billing_money: App\Billing\Infrastructure\Persistence\DBAL\Type\MoneyType

Doctrine peut désormais utiliser billing_money dans les métadonnées des entités.

Étape 5 : mapper l’objet-valeur dans un seul champ

Je maintiens les métadonnées Doctrine hors du domaine et utilise un mapping XML :

XML
<?xml version="1.0" encoding="UTF-8"?>
<!-- config/doctrine/Billing/Entity.Price.orm.xml -->
<doctrine-mapping
    xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
>
    <entity
        name="App\Billing\Domain\Entity\Price"
        table="prices"
    >
        <id name="id" type="price_id" column="id">
            <generator strategy="NONE"/>
        </id>

        <field name="productId" type="product_id" column="product_id"/>
        <field name="amount" type="billing_money" column="amount"/>
    </entity>
</doctrine-mapping>

Du point de vue de Doctrine, amount est un champ unique. Pour PHP, c’est toujours un objet Money. Pour PostgreSQL, il s’agit d’une colonne jsonb unique.

Aucun attribut #[ORM\Embeddable], #[ORM\Embedded] ou autre attribut Doctrine n’est requis dans le domaine.

Si votre projet utilise des attributs, voici le mapping équivalent :

PHP
#[ORM\Column(type: MoneyType::NAME)]
private Money $amount;

Le type personnalisé reste responsable de la conversion ; le style de mapping ne change pas la conception.

Étape 6 : créer la colonne JSONB et ses contraintes

Générez une migration avec Doctrine, puis définissez la colonne jsonb et ses invariants :

SQL
CREATE TABLE prices (
    id UUID NOT NULL,
    product_id UUID NOT NULL,
    amount JSONB NOT NULL,
    PRIMARY KEY (id),

    CONSTRAINT chk_price_amount_is_object
        CHECK (jsonb_typeof(amount) = 'object'),

    CONSTRAINT chk_price_amount_has_required_keys
        CHECK (amount ?& ARRAY['amount', 'currency']),

    CONSTRAINT chk_price_amount_is_non_negative_integer
        CHECK (
            (amount ->> 'amount') ~ '^[0-9]+$'
            AND (amount ->> 'amount')::bigint >= 0
        ),

    CONSTRAINT chk_price_currency_is_supported
        CHECK (amount ->> 'currency' IN ('CDF', 'USD'))
);

Les mêmes invariants existent désormais à deux frontières importantes :

  • PHP empêche la création d’objets du domaine invalides.

  • PostgreSQL empêche le stockage de documents invalides, y compris les valeurs écrites en dehors de Doctrine.

Il s’agit d’une défense en profondeur. Les contraintes de base de données sont particulièrement importantes avec JSONB, car, sans elles, la colonne accepte de nombreuses structures sans rapport entre elles.

Type personnalisé ou objet incorporable Doctrine

Les objets incorporables Doctrine aplatissent un objet-valeur en plusieurs colonnes dans la table propriétaire :

Plain text
prices
├── amount_value     INTEGER
└── amount_currency  VARCHAR(3)

Mon type JSONB personnalisé le stocke dans une seule colonne :

Plain text
prices
└── amount JSONB
    ├── amount: 1250
    └── currency: USD

Aucune de ces approches n’est systématiquement meilleure que l’autre.

Utilisez un type JSONB personnalisé lorsque :

  • Les propriétés forment une valeur cohérente et évoluent ensemble.

  • Vous souhaitez une frontière de conversion unique et réutilisable.

  • La structure contient des métadonnées facultatives ou susceptibles d’évoluer.

  • La plupart des opérations chargent ou enregistrent la valeur complète.

  • Quelques requêtes ponctuelles sur des clés individuelles suffisent.

Utilisez un objet incorporable ou des colonnes ordinaires lorsque :

  • Les propriétés individuelles sont fréquemment filtrées, jointes ou triées.

  • Une propriété nécessite une clé étrangère.

  • La base de données doit imposer des contraintes relationnelles élaborées.

  • Les mises à jour indépendantes de colonnes sont fréquentes.

  • La portabilité au-delà de PostgreSQL importe davantage que les fonctionnalités de JSONB.

Le type personnalisé renforce également la séparation du domaine. Doctrine voit un champ unique et délègue la conversion à Infrastructure ; l’objet-valeur du domaine n’a pas besoin d’attributs de mapping Doctrine.

En contrepartie, la structure relationnelle devient une structure JSON. Faites ce choix délibérément, et non pour éviter de concevoir le schéma.

L’immutabilité est importante pour le suivi des changements de Doctrine

Doctrine détecte la modification d’un champ au type personnalisé lorsque la propriété reçoit une nouvelle valeur.

Ne modifiez pas l’état interne d’un objet-valeur :

PHP
// Avoid this design.
$price->amount->amount = 1500;

Utilisez un objet readonly et remplacez-le :

PHP
$price->reviseAmount(
    new Money(1500, $price->amount->currency),
);

Cette approche respecte la sémantique des objets-valeurs et rend prévisible le suivi des changements par l’ORM.

Dans mon modèle tarifaire, la révision d’un prix actif crée un prix de remplacement au lieu de réécrire l’historique commercial. La règle générale reste la même : le comportement du domaine crée une nouvelle valeur Money plutôt que d’en modifier une sur place.

Interroger JSONB sans perdre l’objet-valeur

JSONB n’empêche pas des lectures efficaces. PostgreSQL peut cibler des clés individuelles :

SQL
SELECT *
FROM prices
WHERE amount ->> 'currency' = 'USD'
ORDER BY (amount ->> 'amount')::integer DESC;

Dans un modèle de lecture CQRS, je peux sélectionner le document complet et reconstruire le même objet-valeur :

PHP
$row = $connection->fetchAssociative(<<<'SQL'
    SELECT
        id::text AS id,
        amount::text AS amount
    FROM prices
    WHERE id = :id
    SQL,
    ['id' => (string) $priceId],
);

$price = new PriceView(
    id: (string) $row['id'],
    amount: Money::fromJson((string) $row['amount']),
);

Je peux aussi extraire uniquement les champs nécessaires à une requête d’agrégation :

SQL
SELECT
    amount ->> 'currency' AS currency,
    SUM((amount ->> 'amount')::integer) AS total
FROM payment_transactions
WHERE status = 'succeeded'
GROUP BY amount ->> 'currency';

Si ces expressions deviennent courantes, ajoutez des index d’expression :

SQL
CREATE INDEX idx_prices_currency
    ON prices ((amount ->> 'currency'));

CREATE INDEX idx_prices_amount_value
    ON prices (((amount ->> 'amount')::integer));

Utilisez un index GIN lorsque vos principales opérations reposent sur l’opérateur de contenance JSONB @>. Employez des index d’expression lorsque vous filtrez ou triez régulièrement des valeurs extraites particulières.

Validez toujours ce choix avec EXPLAIN ANALYZE : un index n’est pas automatiquement utile au seul motif qu’une colonne contient du JSON.

Étape 7 : valider les données HTTP entrantes

La base de données et le domaine sont typés, mais les entrées HTTP non fiables arrivent toujours sous forme de JSON.

Définissez un modèle de transport à la frontière Presentation :

PHP
namespace App\Billing\Presentation\Model;

use Symfony\Component\Validator\Constraints as Assert;

final readonly class MoneyRequest
{
    public function __construct(
        #[Assert\PositiveOrZero]
        public int $amount,

        #[Assert\Choice(['CDF', 'USD'])]
        public string $currency,
    ) {}

    public function toMoney(): Money
    {
        return new Money(
            amount: $this->amount,
            currency: Currency::fromString($this->currency),
        );
    }
}

La requête parente valide la valeur imbriquée :

PHP
final readonly class CreatePriceRequest
{
    public function __construct(
        #[Assert\Uuid]
        public string $productId,

        #[Assert\Valid]
        public MoneyRequest $amount,
    ) {}
}

Le contrôleur mappe le JSON vers le modèle de transport, puis crée la valeur du domaine :

PHP
#[Route('/admin/prices', methods: ['POST'])]
final class CreatePriceController
{
    public function __invoke(
        #[MapRequestPayload] CreatePriceRequest $request,
    ): JsonResponse {
        $priceId = $this->commandBus->handle(new CreatePrice(
            productId: new ProductId($request->productId),
            amount: $request->amount->toMoney(),
        ));

        return new JsonResponse(
            ['id' => (string) $priceId],
            Response::HTTP_CREATED,
        );
    }
}

La validation du transport produit des erreurs utiles pour le client. L’objet-valeur reste l’autorité finale en matière de validité du domaine.

Étape 8 : sérialiser la même structure stable

Comme Money implémente JsonSerializable, sa représentation dans l’API est prévisible :

JSON
{
    "id": "019b5de3-...",
    "amount": {
        "amount": 1250,
        "currency": "USD"
    }
}

La structure JSON publique constitue un contrat. N’exposez pas accidentellement les détails internes privés de l’objet en espérant que le sérialiseur continuera de produire le même résultat après une refactorisation.

toArray() et jsonSerialize() rendent cette représentation explicite.

Étape 9 : valider et inférer le type dans TypeScript

Un type TypeScript disparaît à l’exécution. Si une API renvoie une structure invalide, une assertion de type ne protégera pas l’application :

TypeScript
const price = (await response.json()) as Price;

Utilisez un schéma d’exécution et déduisez-en le type statique :

TypeScript
import { z } from "zod";

export const supportedCurrencies = ["CDF", "USD"] as const;
export const currencySchema = z.enum(supportedCurrencies);

export const moneySchema = z.object({
  amount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
  currency: currencySchema,
});

export type Money = z.infer<typeof moneySchema>;

Intégrez-le à des contrats d’API plus larges :

TypeScript
export const priceSchema = z.object({
  id: z.string().uuid(),
  productId: z.string().uuid(),
  amount: moneySchema,
});

export type Price = z.infer<typeof priceSchema>;

Analysez ensuite la réponse réseau :

TypeScript
export async function getPrice(priceId: string): Promise<Price> {
  const response = await fetch(`/api/admin/prices/${priceId}`);

  if (!response.ok) {
    throw new Error("Unable to load price");
  }

  return priceSchema.parse(await response.json());
}

Une incompatibilité avec le contrat d’API échoue désormais à la frontière réseau, au lieu de provoquer une erreur d’interface plusieurs composants plus loin.

Le même schéma peut valider les données sortantes :

TypeScript
const payload = moneySchema.parse({
  amount: form.amount,
  currency: form.currency,
});

await fetch("/api/admin/prices", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ amount: payload }),
});

Le typage est sûr à chaque frontière, mais les définitions PHP et Zod restent deux déclarations susceptibles de diverger. Si vous maintenez un schéma OpenAPI fiable, la génération du schéma client ou des types peut réduire cette duplication. L’analyse à l’exécution doit rester présente aux frontières non fiables.

Étape 10 : utiliser la valeur sans l’aplatir

Le front-end peut lui aussi conserver la cohérence de la valeur monétaire :

TypeScript
export function formatMoney(money: Money): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: money.currency,
    maximumFractionDigits: 0,
  }).format(money.amount);
}
TSX
export function PriceAmount({ price }: { price: Price }) {
  return <strong>{formatMoney(price.amount)}</strong>;
}

Je ne transmets pas amount et currency séparément à travers chaque composant. La même frontière conceptuelle subsiste du domaine jusqu’à l’interface utilisateur.

Faire évoluer un objet-valeur JSONB en toute sécurité

JSONB facilite l’ajout de champs. Cela ne rend pas pour autant toute modification de structure sûre.

Imaginons l’ajout d’un champ de précision :

JSON
{
    "amount": 1250,
    "currency": "USD",
    "precision": 2
}

Un lecteur rétrocompatible peut fournir une valeur par défaut :

PHP
$precision = isset($data['precision'])
    ? (int) $data['precision']
    : 0;

Pour des changements plus importants, versionnez le document stocké :

JSON
{
    "version": 2,
    "amount": 1250,
    "currency": "USD"
}

Conservez ensuite la normalisation des anciennes versions dans le type personnalisé ou dans un upcaster dédié :

PHP
private function upcast(array $data): array
{
    return match ($data['version'] ?? 1) {
        1 => [
            'version' => 2,
            'amount' => $data['amount'],
            'currency' => $data['currency'],
        ],
        2 => $data,
        default => throw new UnexpectedValueException(
            'Unsupported money document version.',
        ),
    };
}

Utilisez une migration de base de données pour mettre à niveau les grands jeux de données, au lieu de dépendre indéfiniment d’une conversion à la lecture. Le type personnalisé constitue une bonne frontière de compatibilité, mais il ne doit pas devenir un musée de tous les schémas historiques.

Surtout, ne transformez pas silencieusement des données obligatoires mal formées en valeurs par défaut anodines. Un champ facultatif absent peut recevoir une valeur par défaut. En revanche, l’absence de devise doit produire une erreur explicite, car la valeur stockée est corrompue.

Tester le contrat complet

La sûreté de typage de bout en bout exige plus d’un test unitaire.

Tester l’objet-valeur

PHP
public function testItRejectsNegativeAmounts(): void
{
    $this->expectException(InvalidArgumentException::class);

    new Money(-1, Currency::Usd);
}

public function testItAddsMoneyWithTheSameCurrency(): void
{
    $total = new Money(100, Currency::Usd)
        ->add(new Money(50, Currency::Usd));

    self::assertEquals(new Money(150, Currency::Usd), $total);
}

Tester la conversion Doctrine

PHP
public function testMoneySurvivesDatabaseConversion(): void
{
    $type = new MoneyType();
    $platform = new PostgreSQLPlatform();
    $money = new Money(1250, Currency::Usd);

    $databaseValue = $type->convertToDatabaseValue($money, $platform);
    $restored = $type->convertToPHPValue($databaseValue, $platform);

    self::assertSame('{"amount":1250,"currency":"USD"}', $databaseValue);
    self::assertEquals($money, $restored);
}

Tester la persistance ORM avec PostgreSQL

Persistez une entité contenant Money, videz le gestionnaire d’entités, rechargez l’entité et vérifiez que la propriété est toujours une instance de Money. Ce test vérifie conjointement le mapping, le type enregistré, la plateforme réelle et la colonne de base de données.

Tester les contraintes de la base de données

Tentez d’insérer un JSON mal formé avec DBAL et vérifiez que PostgreSQL le rejette. Cela prouve que les scripts et les intégrations ne peuvent pas contourner l’invariant.

Tester le schéma front-end

TypeScript
it("rejects an unsupported currency", () => {
  expect(() =>
    moneySchema.parse({ amount: 1250, currency: "BTC" }),
  ).toThrow();
});

Enfin, conservez un test du contrat d’API qui appelle l’endpoint et analyse la réponse avec le même schéma que celui utilisé par le client.

Erreurs fréquentes

Considérer JSONB comme une échappatoire sans schéma

JSONB possède toujours un schéma ; celui-ci est simplement imposé par votre code et vos contraintes, au lieu d’être représenté par une colonne par propriété. Documentez-le, validez-le et faites-le évoluer délibérément.

Accepter des tableaux dans le domaine

Les tableaux appartiennent aux frontières de sérialisation. Convertissez-les en objet-valeur avant que la logique métier ne les utilise.

Placer Doctrine dans l’objet-valeur

Le domaine ne doit pas savoir comment il est stocké. Conservez le type personnalisé dans Infrastructure et l’objet-valeur dans Domain.

Rendre l’objet-valeur mutable

Les objets mutables affaiblissent la sémantique de valeur et peuvent perturber le suivi des changements de Doctrine. Préférez des valeurs readonly et leur remplacement.

Dupliquer la validation de manière incohérente

La validation Symfony, le constructeur de l’objet-valeur, les contraintes PostgreSQL et Zod interviennent à des frontières différentes, mais doivent décrire les mêmes valeurs acceptées.

Utiliser JSONB pour des données relationnelles

Si une clé nécessite des clés étrangères, des jointures fréquentes, des mises à jour indépendantes ou des contraintes d’unicité complexes, utilisez des colonnes relationnelles. JSONB doit modéliser une valeur cohérente, et non dissimuler un modèle de données entier.

Oublier la limite des entiers en JavaScript

Les entiers de PHP et PostgreSQL peuvent dépasser la plage d’entiers sûrs de JavaScript. Si ce cas est possible, sérialisez le montant sous forme de chaîne décimale et validez-le comme une chaîne dans Zod, au lieu de perdre silencieusement en précision.

Faire aveuglément confiance aux outils de comparaison de schémas

PostgreSQL déclare la colonne physique comme jsonb, et non comme votre type logique billing_money. Selon votre version de Doctrine, une comparaison de schémas peut ne pas retrouver le type personnalisé de chaque colonne et proposer des modifications parasites. Générez les migrations, inspectez leur SQL et conservez un enregistrement explicite du type personnalisé. Ne mappez pas globalement toutes les colonnes jsonb vers billing_money lorsque la base contient d’autres documents JSON.

Conclusion

Un objet-valeur donne un sens métier aux primitives. Un type Doctrine personnalisé permet de préserver ce sens lors de la persistance sans coupler le domaine à l’ORM.

  • PHP construit une valeur Money unique, immuable et validée.

  • Doctrine convertit cette valeur au moyen d’un adaptateur d’Infrastructure.

  • PostgreSQL la stocke de manière atomique en jsonb et impose sa structure avec des contraintes.

  • Les modèles de lecture peuvent interroger des clés JSONB individuelles lorsque cela est nécessaire.

  • Symfony valide les entrées non fiables avant de construire la valeur du domaine.

  • L’API expose une représentation JSON stable et unique.

  • Zod valide la réponse et infère le type TypeScript.

  • Les composants de l’interface continuent à transmettre la somme d’argent comme une valeur cohérente.

Les objets incorporables Doctrine restent utiles lorsque les colonnes relationnelles constituent le meilleur modèle. Mais pour de petites valeurs structurées et cohérentes, un type JSONB personnalisé offre une frontière claire en limitant la connaissance de la persistance dans le domaine.

L’objectif ne consiste pas seulement à stocker un objet en JSON. Il s’agit de préserver un même concept métier, avec le même sens, de la base de données jusqu’à l’interface utilisateur.

Bon développement !

Articles liés