Vous n’avez pas besoin d’un ORM pour lire des données dans Symfony : CQRS et modèles de lecture

Utiliser CQRS, Doctrine DBAL et des modèles de lecture propres à l’application pour récupérer exactement les données requises par une interface, sans hydrater d’entités ORM.
Avez-vous déjà chargé une page avec Doctrine ORM et constaté que l’affichage de dix lignes simples exigeait d’hydrater plusieurs entités, d’initialiser leurs relations et d’éviter soigneusement un problème de requêtes N+1 ?
Un ORM est très utile pour charger un agrégat, modifier son état et le persister en toute sécurité. Mais un tableau de bord, un résultat de recherche, un export ou une table d’administration répond généralement à un autre besoin : récupérer le plus efficacement possible des données de forme précise.
Imaginez demander à un bibliothécaire une liste contenant le titre, l’auteur et l’année de publication de chaque livre. Vous n’avez besoin ni de la fiche complète de chaque ouvrage, ni de son historique d’emprunt, ni de tous ses objets associés. Il vous faut une projection conçue pour une question précise.
C’est ainsi que j’aborde les lectures dans mon application Symfony. J’utilise des dépôts ORM pour les écritures et des modèles de lecture CQRS fondés sur Doctrine DBAL pour les lectures.
Dans cet article, je vous montre comment mettre en œuvre cette architecture dans un projet Symfony générique, de la requête HTTP à la requête SQL et à la réponse finale.
CQRS en quelques mots
Le Command Query Responsibility Segregation, ou CQRS, sépare les opérations qui modifient l’état de celles qui renvoient des données.
-
Une commande exprime une intention et peut modifier le système.
-
Une requête pose une question et ne doit pas modifier le système.
Les deux côtés peuvent utiliser des modèles différents, car ils résolvent des problèmes distincts.
CQRS n’implique pas automatiquement des microservices, de l’event sourcing, des projections asynchrones ou des bases de données distinctes.
Mon implémentation commence par la forme utile la plus simple :
-
Une seule application Symfony.
-
Une seule base de données PostgreSQL.
-
Des parcours de code distincts pour les commandes et les requêtes.
-
Des agrégats ORM pour les écritures.
-
Des projections DBAL pour les lectures.
Les données sont immédiatement accessibles aux requêtes, car les deux côtés utilisent la même base de données. J’obtiens des modèles distincts sans introduire de cohérence éventuelle ni d’infrastructure supplémentaire.
Pourquoi ne pas utiliser des entités ORM pour toutes les lectures ?
Supposons qu’une page d’administration doive afficher les données suivantes :
L’écran n’a pas besoin d’un agrégat User mutable. Il lui faut une ligne adaptée à la présentation.
Utiliser l’ORM pour cette lecture peut entraîner du travail inutile :
-
Hydrater des champs que la réponse n’utilise jamais.
-
Charger des associations uniquement pour les remettre ensuite à plat.
-
Exposer les entités du domaine au sérialiseur.
-
Ajouter des jointures dans un dépôt dont la véritable responsabilité est la persistance des agrégats.
-
Rendre la requête difficile à optimiser sans affecter le modèle d’écriture.
Avec un modèle de lecture, je sélectionne uniquement les colonnes nécessaires et renvoie un modèle applicatif immuable.
Doctrine DBAL est particulièrement adapté à ce cas. DBAL fournit les connexions, la liaison des paramètres, les constructeurs de requêtes et les API de résultats. Il n’hydrate pas les entités ORM et ne les suit pas dans une unité de travail.
Étape 1 : concevoir d’abord le modèle de réponse
Côté lecture, partez de la question posée par l’appelant.
Pour une liste d’utilisateurs, je peux définir une projection immuable :
namespace App\Identity\Application\Model;
final readonly class UserView
{
/** @param list<string> $roles */
public function __construct(
public string $userId,
public string $name,
public string $email,
public array $roles,
public string $status,
public bool $emailVerified,
public DateTimeImmutable $createdAt,
public ?DateTimeImmutable $lastLoginAt,
) {}
}Cet objet n’est pas une entité de domaine anémique. Ce n’est tout simplement pas une entité de domaine.
Il ne contient aucune méthode de mutation ni aucun invariant métier, car sa seule responsabilité est de transporter la réponse à une requête.
Pour un résultat paginé, je lui associe des métadonnées de pagination :
final readonly class UserList
{
/** @param list<UserView> $items */
public function __construct(
public array $items,
public PaginationInfo $pagination,
) {}
}Les données d’entrée et de sortie de la pagination sont également de simples objets applicatifs :
final readonly class PaginationFilters
{
public function __construct(
public int $page = 1,
public int $limit = 20,
) {}
}
final readonly class PaginationInfo
{
public function __construct(
public int $current,
public int $limit,
public int $offset,
public int $total,
public int $pages,
) {}
}Le modèle de réponse appartient à la couche Application. C’est important : la réponse de l’API ne dépend ni d’une ligne DBAL ni d’une entité ORM.
Étape 2 : définir un contrat de modèle de lecture
Je définis ensuite les besoins de l’application sans décider de la manière dont les données seront récupérées :
namespace App\Identity\Application\ReadModel;
use App\Identity\Application\Model\UserList;
use App\Shared\Application\Pagination\PaginationFilters;
interface UsersReadModel
{
public function list(PaginationFilters $pagination): UserList;
}Cette interface est un port de lecture.
Le gestionnaire de requête en dépend, mais ignore si l’implémentation utilise PostgreSQL, Elasticsearch, une API HTTP, un cache ou une collection en mémoire.
Contrairement à un dépôt de domaine, le contrat est conçu autour d’un cas d’usage de lecture. Il peut renvoyer des données prêtes à l’affichage, joindre plusieurs tables et calculer des valeurs qui n’appartiennent pas à un seul agrégat.
Étape 3 : représenter la question par une requête
L’objet de requête transporte les critères de l’appelant :
namespace App\Identity\Application\Query;
final readonly class ListUsers
{
public function __construct(
public PaginationFilters $pagination = new PaginationFilters(),
) {}
}Une requête est nommée d’après une question : ListUsers, GetUserDetails ou GetRevenueSummary.
Elle ne contient pas de SQL et ne s’exécute pas elle-même. Ce n’est qu’un message qui franchit la frontière de l’application.
Étape 4 : ajouter le gestionnaire de requête
Le gestionnaire reçoit la requête et délègue le traitement au modèle de lecture :
namespace App\Identity\Application\QueryHandler;
use App\Identity\Application\Model\UserList;
use App\Identity\Application\Query\ListUsers;
use App\Identity\Application\ReadModel\UsersReadModel;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class ListUsersHandler
{
public function __construct(
private UsersReadModel $users,
) {}
public function __invoke(ListUsers $query): UserList
{
return $this->users->list($query->pagination);
}
}Le gestionnaire paraît presque trop simple, mais cette simplicité est utile.
Il crée un point d’entrée applicatif stable, maintient le contrôleur indépendant de l’Infrastructure et me donne un emplacement pour ajouter une orchestration du cas d’usage ou une autorisation qui ne relève pas du SQL.
Le SQL reste dans l’adaptateur qui en a la responsabilité.
Étape 5 : distribuer les requêtes avec un bus dédié
J’expose un petit contrat applicatif au lieu d’injecter Symfony Messenger partout :
namespace App\Shared\Application\Bus;
interface QueryBus
{
public function handle(object $query): mixed;
}L’adaptateur Symfony appartient à l’Infrastructure :
namespace App\Shared\Infrastructure\Bus;
use App\Shared\Application\Bus\QueryBus;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;
final class SymfonyQueryBus implements QueryBus
{
use HandleTrait {
HandleTrait::handle as private messengerHandle;
}
public function __construct(
#[Target('query.bus')]
MessageBusInterface $queryBus,
) {
$this->messageBus = $queryBus;
}
public function handle(object $query): mixed
{
return $this->messengerHandle($query);
}
}J’enregistre ensuite un bus synchrone dédié :
# config/packages/messenger.yaml
framework:
messenger:
buses:
command.bus: ~
query.bus: ~Comme les requêtes ne sont pas dirigées vers un transport asynchrone, Symfony les traite immédiatement et renvoie le résultat du gestionnaire au cours de la même requête.
La séparation des bus permet aussi de leur attribuer des middlewares différents. Les commandes peuvent nécessiter des transactions, contrairement aux requêtes dans la plupart des cas.
Étape 6 : implémenter le modèle de lecture avec Doctrine DBAL
Je peux maintenant écrire une requête adaptée à la liste des utilisateurs :
namespace App\Identity\Infrastructure\DBAL;
use App\Identity\Application\Model\UserList;
use App\Identity\Application\Model\UserView;
use App\Identity\Application\ReadModel\UsersReadModel;
use Doctrine\DBAL\Connection;
use Knp\Component\Pager\PaginatorInterface;
final readonly class DbalUsersReadModel implements UsersReadModel
{
public function __construct(
private Connection $connection,
private PaginatorInterface $paginator,
) {}
public function list(PaginationFilters $pagination): UserList
{
$query = $this->connection->createQueryBuilder()
->select(
'u.id::text AS "userId"',
'u.name AS "userName"',
'u.email AS "userEmail"',
'u.roles::text AS "userRoles"',
'u.status AS "userStatus"',
'(u.email_verified_at IS NOT NULL) AS "emailVerified"',
'u.created_at AS "createdAt"',
'u.last_login_at AS "lastLoginAt"',
)
->from('users', 'u')
->orderBy('u.created_at', 'DESC');
$page = $this->paginator->paginate(
$query,
$pagination->page,
$pagination->limit,
);
$items = $page->getItems();
$items = is_array($items) ? $items : iterator_to_array($items);
$total = $page->getTotalItemCount();
return new UserList(
items: array_map($this->mapUser(...), $items),
pagination: new PaginationInfo(
current: $pagination->page,
limit: $pagination->limit,
offset: ($pagination->page - 1) * $pagination->limit,
total: $total,
pages: (int) ceil($total / $pagination->limit),
),
);
}
/** @param array<string, mixed> $row */
private function mapUser(array $row): UserView
{
return new UserView(
userId: (string) $row['userId'],
name: (string) $row['userName'],
email: (string) $row['userEmail'],
roles: array_values(array_filter(
json_decode((string) $row['userRoles'], true) ?: [],
is_string(...),
)),
status: (string) $row['userStatus'],
emailVerified: (bool) $row['emailVerified'],
createdAt: new DateTimeImmutable((string) $row['createdAt']),
lastLoginAt: isset($row['lastLoginAt'])
? new DateTimeImmutable((string) $row['lastLoginAt'])
: null,
);
}
}Il n’y a ni hydratation d’entité User, ni proxy de chargement différé, ni unité de travail. La requête sélectionne les données de la réponse et associe directement chaque ligne à un modèle immuable.
Dans le code de production, j’utilise un petit lecteur de ligne typé au lieu de répéter des conversions brutes. Il fournit des méthodes comme string(), bool(), dateTime() et nullableDateTime(), et échoue à la frontière du mapping lorsque la base de données renvoie une structure inattendue.
private function mapUser(array $row): UserView
{
$reader = RowReader::from($row);
return new UserView(
userId: $reader->string('userId'),
name: $reader->string('userName'),
email: $reader->string('userEmail'),
roles: $reader->stringList('userRoles'),
status: $reader->string('userStatus'),
emailVerified: $reader->bool('emailVerified'),
createdAt: $reader->dateTime('createdAt'),
lastLoginAt: $reader->nullableDateTime('lastLoginAt'),
);
}Cette étape de mapping mérite de rester explicite. Elle empêche les types de la base de données de se propager dans le reste de l’application et donne à chaque projection un point de construction clairement défini.
Étape 7 : relier le contrat à l’adaptateur DBAL
Si Symfony ne découvre qu’une seule implémentation, l’autowiring peut résoudre automatiquement l’interface du modèle de lecture. Je recommande tout de même de comprendre la configuration explicite, car elle devient nécessaire dès que plusieurs implémentations existent.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Identity\Application\ReadModel\UsersReadModel:
alias: App\Identity\Infrastructure\DBAL\DbalUsersReadModel
App\Shared\Application\Bus\QueryBus:
alias: App\Shared\Infrastructure\Bus\SymfonyQueryBusL’application dépend désormais de UsersReadModel, tandis que la racine de composition sélectionne DBAL.
Vous pourrez ensuite décorer l’adaptateur avec un cache ou le remplacer par une implémentation fondée sur un index de recherche sans modifier le gestionnaire de requête.
Étape 8 : garder un contrôleur simple
Le contrôleur HTTP mappe les paramètres de la chaîne de requête, distribue la requête et renvoie son résultat :
#[Route('/admin/users', name: 'identity.list_users', methods: ['GET'])]
final class ListUsersController
{
public function __construct(
private QueryBus $queryBus,
) {}
public function __invoke(
#[MapQueryString] PaginationRequest $request,
): JsonResponse {
$result = $this->queryBus->handle(
new ListUsers($request->toPaginationFilters()),
);
return new JsonResponse($result);
}
}Le flux complet est désormais le suivant :
Chaque couche n’a qu’une seule raison de changer. Les détails HTTP restent dans la Présentation, le cas d’usage dans l’Application et le SQL dans l’Infrastructure.
Filtrer et trier sans exposer le SQL
Les listes dynamiques introduisent un problème de sécurité important.
Les valeurs SQL peuvent être paramétrées en toute sécurité, mais les noms de colonnes et les expressions de tri ne doivent pas être traités comme des entrées utilisateur arbitraires. Ne transmettez jamais directement ?sort=email;DROP TABLE users à ORDER BY.
Mes modèles de lecture exposent une liste blanche qui associe les noms publics des filtres à des expressions SQL fiables :
/** @return array<string, string> */
private function sortFields(): array
{
return [
'name' => 'u.name',
'email' => 'u.email',
'status' => 'u.status',
'createdAt' => 'u.created_at',
'lastLoginAt' => 'u.last_login_at',
];
}Le composant de filtrage n’accepte ensuite qu’une clé connue :
private function applySort(
QueryBuilder $query,
?SortFilter $sort,
): void {
$fields = $this->sortFields();
$column = $fields[$sort?->field ?? 'createdAt']
?? $fields['createdAt'];
$direction = strtoupper($sort?->direction ?? 'DESC');
$direction = $direction === 'ASC' ? 'ASC' : 'DESC';
$query->orderBy($column, $direction);
}Le même modèle s’applique à la recherche et aux filtres de colonnes :
private function filterFields(): array
{
return [
'status' => 'u.status',
'role' => 'u.roles::text',
'emailVerified' => '(u.email_verified_at IS NOT NULL)',
];
}Les valeurs restent paramétrées :
$query
->andWhere(sprintf('%s = :filter_value', $trustedColumn))
->setParameter('filter_value', $filter->value);Je réutilise ce mécanisme dans les modèles de lecture pour :
-
La recherche textuelle dans les colonnes autorisées.
-
Les filtres exacts et les valeurs multiples.
-
Les plages de dates.
-
Le tri.
-
Les métadonnées de pagination.
Les objets de filtrage de la couche Application restent indépendants du SQL. Chaque adaptateur DBAL décide des champs qu’il prend en charge et de la manière dont leurs noms publics correspondent à son schéma.
Quand le SQL direct devient nettement préférable à l’ORM
La différence est plus nette avec les tableaux de bord.
Supposons qu’un tableau de bord des revenus ait besoin d’un point par jour, y compris pour les jours sans transaction, avec les montants encaissés, les commandes payées et les commandes échouées.
Avec des entités, je pourrais charger de nombreuses transactions et commandes dans PHP, les regrouper par date et compléter moi-même les jours manquants. La base de données est déjà bien mieux adaptée à ce travail.
Un modèle de lecture peut utiliser une requête conçue spécifiquement pour ce besoin :
WITH days AS (
SELECT generate_series(
date_trunc('day', :start_at::timestamptz),
GREATEST(
date_trunc('day', :end_at::timestamptz) - interval '1 day',
date_trunc('day', :start_at::timestamptz)
),
interval '1 day'
) AS day_start
),
revenue AS (
SELECT
date_trunc('day', initiated_at) AS day_start,
SUM((amount ->> 'amount')::integer) AS captured_amount
FROM payment_transactions
WHERE status = 'succeeded'
AND initiated_at >= :start_at
AND initiated_at < :end_at
GROUP BY 1
),
orders_by_day AS (
SELECT
date_trunc('day', created_at) AS day_start,
COUNT(*) FILTER (WHERE status IN ('paid', 'fulfilled')) AS successful,
COUNT(*) FILTER (WHERE status IN ('failed', 'cancelled')) AS unsuccessful
FROM orders
WHERE created_at >= :start_at
AND created_at < :end_at
GROUP BY 1
)
SELECT
to_char(days.day_start, 'YYYY-MM-DD') AS date,
COALESCE(revenue.captured_amount, 0) AS "capturedAmount",
COALESCE(orders_by_day.successful, 0) AS "successfulOrderCount",
COALESCE(orders_by_day.unsuccessful, 0) AS "unsuccessfulOrderCount"
FROM days
LEFT JOIN revenue USING (day_start)
LEFT JOIN orders_by_day USING (day_start)
ORDER BY days.day_start;Cette requête renvoie exactement la structure attendue par le graphique. Elle ne prétend pas que ce graphique est un agrégat du domaine.
final readonly class TrendPoint
{
public function __construct(
public string $date,
public int $capturedAmount,
public int $successfulOrderCount,
public int $unsuccessfulOrderCount,
) {}
}C’est l’un des principaux avantages de la séparation CQRS : le modèle d’écriture peut rester expressif et orienté comportement, tandis que le modèle de lecture peut utiliser des jointures, des expressions de table communes, des opérateurs JSON, des fonctions de fenêtrage et des optimisations propres à la base de données.
Un modèle de lecture ne correspond pas nécessairement à une seule table
Un dépôt ORM représente généralement l’accès à un type d’agrégat. Un modèle de lecture représente une réponse.
Cette réponse peut combiner plusieurs sources :
SELECT
u.id AS "userId",
u.name AS "userName",
o.name AS "organizationName",
s.status AS "subscriptionStatus",
COUNT(ag.id) AS "activeGrantCount"
FROM users u
LEFT JOIN organization_members om ON om.user_id = u.id
LEFT JOIN organizations o ON o.id = om.organization_id
LEFT JOIN subscriptions s ON s.organization_id = o.id
LEFT JOIN access_grants ag
ON ag.user_id = u.id
AND ag.revoked_at IS NULL
WHERE u.id = :user_id
GROUP BY u.id, u.name, o.name, s.status;Créer un agrégat User contenant des entités Organization, Subscription et AccessGrant imbriquées uniquement pour produire cette réponse couplerait plusieurs modèles d’écriture.
La projection évite ce couplage. Elle peut traverser plusieurs tables tout en renvoyant un modèle ciblé qui appartient à l’application.
Cela ne signifie pas que chaque requête doit joindre toute la base de données. Le modèle de lecture doit toujours appartenir à un contexte délimité et exposer un contrat explicite. Il reste toutefois libéré des règles d’hydratation des agrégats.
Diffuser les lectures volumineuses
La pagination convient à une table destinée aux utilisateurs. Les exports et les traitements en arrière-plan ont une autre contrainte : ne pas charger l’intégralité du résultat en mémoire.
DBAL peut parcourir les lignes une par une :
/** @return iterable<UserView> */
public function streamAll(): iterable
{
$query = $this->connection->createQueryBuilder()
->select(/* required columns */)
->from('users', 'u')
->orderBy('u.id', 'ASC');
foreach ($query->executeQuery()->iterateAssociative() as $row) {
yield $this->mapUser($row);
}
}L’appelant consomme une projection à la fois. Cette approche est plus simple et utilise moins de mémoire que l’hydratation de milliers d’entités gérées dont l’ORM doit suivre l’état.
Tester le côté lecture
Le contrat du modèle de lecture m’offre deux niveaux de test utiles.
Premièrement, les tests applicatifs peuvent remplacer DBAL par une implémentation en mémoire :
#[AsAlias(UsersReadModel::class, when: 'test')]
final class InMemoryUsersReadModel implements UsersReadModel
{
/** @var list<UserView> */
private array $users = [];
public function list(PaginationFilters $filters): UserList
{
$offset = ($filters->page - 1) * $filters->limit;
$items = array_slice($this->users, $offset, $filters->limit);
return new UserList(
items: $items,
pagination: new PaginationInfo(
current: $filters->page,
limit: $filters->limit,
offset: $offset,
total: count($this->users),
pages: (int) ceil(count($this->users) / $filters->limit),
),
);
}
}Un test de comportement peut ainsi distribuer la véritable requête ListUsers à travers le véritable bus de requêtes sans nécessiter de fixtures SQL pour chaque scénario.
Deuxièmement, l’adaptateur DBAL reçoit des tests d’intégration exécutés sur la véritable base de données. Ces tests doivent vérifier ce que l’implémentation en mémoire ne peut pas couvrir :
-
Les jointures et alias SQL.
-
La conversion des types de la base de données.
-
Le mapping de la recherche, des filtres et du tri.
-
Les totaux de pagination.
-
Les résultats vides et les colonnes nullables.
-
Les expressions propres à la base de données.
Ne simulez pas Connection pour vérifier qu’une chaîne SQL précise a été appelée. Vous ne testeriez que la configuration du mock. Exécutez l’adaptateur sur le moteur de base de données pour lequel il a été écrit et vérifiez la projection renvoyée.
Les modèles de lecture ont aussi besoin d’observabilité
Le SQL direct est puissant, mais les erreurs surviennent à une frontière d’intégration. Je journalise les échecs des modèles de lecture avec un message unique et un contexte métier utile :
try {
return $this->executeList($pagination);
} catch (Throwable $throwable) {
$this->logger->critical('Unable to list identity users from DBAL read model', [
'exception' => $throwable,
'exceptionClass' => $throwable::class,
]);
throw $throwable;
}Pour une requête de détail, incluez l’identifiant. Pour un tableau de bord fondé sur des dates, incluez la plage concernée. Évitez de consigner l’intégralité des filtres ou des résultats, en particulier lorsqu’ils peuvent contenir des informations personnelles.
La surveillance des requêtes lentes et EXPLAIN ANALYZE font également partie de la conception des modèles de lecture. CQRS sécurise les optimisations, car l’ajout d’un index ou la réécriture d’une projection n’exige pas de modifier le modèle d’agrégat.
ORM ou DBAL ? Utilisez chacun là où il excelle
Cette architecture ne plaide pas pour la suppression de Doctrine ORM.
Utilisez un dépôt ORM lorsque vous devez :
-
Charger un agrégat et faire respecter ses invariants.
-
Modifier l’état du domaine.
-
Suivre les modifications des entités dans une unité de travail.
-
Persister les relations qui appartiennent à la frontière de l’agrégat.
Utilisez un modèle de lecture DBAL lorsque vous devez :
-
Renvoyer une liste, un tableau de bord, un rapport, un résultat de recherche ou un export.
-
Sélectionner un petit sous-ensemble de colonnes.
-
Joindre des données destinées à la présentation.
-
Agréger, regrouper, compter ou calculer des tendances.
-
Utiliser des fonctionnalités propres à la base de données.
-
Diffuser un résultat volumineux sans suivre des entités.
Une règle utile est la suivante : les entités sont optimisées pour le comportement ; les modèles de lecture le sont pour les questions.
Erreurs courantes
CQRS peut devenir un cérémonial inutile s’il est appliqué sans discipline.
Renvoyer des entités ORM depuis les gestionnaires de requêtes
Cette pratique reconnecte l’API de lecture au modèle d’écriture et peut déclencher un chargement différé pendant la sérialisation. Renvoyez plutôt des projections explicites.
Placer le SQL dans le contrôleur
Retirer l’ORM ne supprime pas l’architecture. Le SQL appartient toujours à un adaptateur de l’Infrastructure placé derrière un contrat applicatif.
Réutiliser un modèle de lecture universel
Une classe nommée DatabaseReadModel qui contient cinquante méthodes sans rapport devient une nouvelle couche de services. Gardez des contrats de lecture cohérents et rattachés à une capacité métier.
Accepter directement des colonnes de tri et de filtrage
Les paramètres protègent les valeurs, pas les identifiants. Associez les noms publics des filtres à des valeurs autorisées à l’aide d’une liste blanche stricte.
Cacher des décisions métier dans le SQL
Le SQL doit structurer et agréger les données. Une règle qui modifie le sens métier appartient au domaine ou à la couche Application. Ce n’est pas parce qu’une condition peut être écrite en SQL qu’elle doit lui appartenir.
Construire deux bases de données trop tôt
Un magasin de lecture distinct ajoute des projections, des garanties de livraison, des mécanismes de rejeu, de la surveillance et de la cohérence éventuelle. Commencez par des parcours de code séparés sur une seule base de données. N’introduisez un autre magasin que lorsqu’un besoin mesuré le justifie.
Conclusion
Vous n’avez pas besoin d’un ORM pour chaque lecture de données dans une application Symfony.
-
Les commandes chargent les agrégats et modifient leur état à travers des dépôts ORM.
-
Les requêtes décrivent des questions et passent par un bus de requêtes synchrone.
-
Les gestionnaires de requêtes dépendent d’interfaces de modèles de lecture propres à l’application.
-
Les adaptateurs DBAL exécutent du SQL conçu pour un besoin précis.
-
Les projections immuables renvoient exactement ce dont l’appelant a besoin.
-
Les listes blanches de filtres gardent les requêtes dynamiques sous contrôle.
-
Les adaptateurs en mémoire testent les cas d’usage, tandis que les tests d’intégration à la base de données vérifient le SQL.
Le principal gain ne concerne pas seulement les performances, mais aussi la clarté. Une requête ne prétend plus être une opération sur un agrégat, et un agrégat ne doit plus répondre aux besoins de tous les écrans de l’application.
Utilisez l’ORM pour protéger vos écritures. Utilisez les modèles de lecture pour répondre à vos questions.
Bon développement !