Invariants, permissions et validation
Résumé. Il existe trois sortes de « cette requête est refusée » dans une application métier,
et ce sont des choses différentes. La validation demande si l'entrée est bien formée. Une
permission demande si cet acteur peut faire ceci, sur cet élément. Un invariant demande si
l'action est valide tout court, qui que ce soit qui la demande. Ce kit donne à chacune sa propre
couche, son propre mécanisme et sa propre sorte d'erreur : la validation à la frontière derrière
le port Schema, les permissions dans la couche application derrière le port Authorizer, les
invariants dans le domaine sous forme de DomainError. Les mélanger est la façon habituelle dont
les règles métier finissent dans les contrôleurs et les règles d'accès dans les entités.
Trois questions
Section intitulée « Trois questions »| Validation | Permission | Invariant | |
|---|---|---|---|
| Question | l'entrée est-elle bien formée ? | cet acteur peut-il faire ceci, sur ceci ? | est-ce une chose valide à faire, tout court ? |
| Dépend de | la requête HTTP seule | l'acteur, l'action, parfois l'élément | l'état du modèle |
| Connaît l'acteur | non | oui | jamais |
| Connaît l'état enregistré | non | parfois (une politique) | oui |
| Vit dans | presentation/ |
application/authorization/ |
domain/ |
| Mécanisme | un schéma de la bibliothèque de validation du projet, derrière le port Schema |
une permission déclarée sur le cas d'usage, décidée par le port Authorizer ; une politique (policy) pour une règle sur un élément |
un contrôle à l'intérieur d'un agrégat, d'un objet-valeur ou d'un service du domaine |
| Vérifiée | avant qu'une commande existe | avant que le gestionnaire s'exécute (et par le gestionnaire, pour une politique) | quand la méthode de l'agrégat s'exécute |
| Refus | des problèmes de validation, un 422 | non authentifié ou interdit, un 401 ou un 403 | une DomainError avec un code stable, associé une seule fois à un statut |
| Exemple | startsAt est une date-heure ISO |
seul le client qui a réservé, ou le praticien, peut annuler | un rendez-vous annulé ne peut pas être reprogrammé |
Un critère simple : la règle tiendrait-elle encore si un administrateur doté de tous les droits envoyait une requête parfaitement formée ? Si oui, c'est un invariant. Si la réponse dépend de qui demande, c'est une permission. Si la requête ne pouvait même pas être comprise, c'est de la validation.
La même fonctionnalité, vue de trois façons
Section intitulée « La même fonctionnalité, vue de trois façons »Annuler un rendez-vous.
1. La validation, à la frontière. La route déclare la forme de l'entrée. Elle ne sait rien des rendez-vous au-delà du format de leur identifiant.
import { zodSchema } from '@ddd-ts-kit/validation/zod';import { z } from 'zod';
// presentation/http : le schéma de requête de la route (zod ici)export const cancelAppointmentBody = zodSchema( z.object({ reason: z.enum(['client-request', 'practitioner-unavailable', 'other']), note: z.string().max(500).optional(), }),);2. La permission, dans l'application. Le cas d'usage déclare ce qu'il exige ; un
middleware du bus interroge l'authorizer avant que le gestionnaire (handler) s'exécute. Rien
n'est accessible par défaut (authz/declared).
import { definePermissions } from '@ddd-ts-kit/auth';import { AuthorizedCommand, type Actor } from '@ddd-ts-kit/kernel';import { Requires } from '@ddd-ts-kit/kernel/declarations';
// application/authorization/appointments.permissions.tsexport const permissions = definePermissions('appointments', { 'appointment.cancel': { description: 'Cancel an appointment' },});
// application/commands/cancel-appointment/cancel-appointment.command.ts@Requires(permissions['appointment.cancel'], { resource: (command: CancelAppointment) => ({ type: 'appointment', id: command.appointmentId }),})export class CancelAppointment extends AuthorizedCommand<void> { constructor( actor: Actor, readonly appointmentId: string, readonly reason: string, ) { super(actor); }}« Seul le client qui l'a réservé, ou son praticien » dépend du rendez-vous chargé. C'est une
politique dans application/authorization/*.policy.ts : une règle nommée et testée unitairement,
que le gestionnaire évalue une fois l'agrégat chargé, par le port Access de @ddd-ts-kit/auth,
qui interroge chaque politique de la permission, puis l'authorizer.
import { Access, definePermissions, type PolicyContext } from '@ddd-ts-kit/auth';import { AuthorizedCommand, ConceptError, isAuthenticated, type Actor, type Clock } from '@ddd-ts-kit/kernel';import { CommandHandler, Policy } from '@ddd-ts-kit/kernel/declarations';
const permissions = definePermissions('appointments', { 'appointment.cancel': { description: 'Cancel an appointment' },});
declare class CancelAppointment extends AuthorizedCommand<void> { readonly appointmentId: string; readonly reason: string;}
declare class Appointment { readonly clientId: string; readonly practitionerId: string; cancel(reason: string, at: Date): void;}
declare abstract class AppointmentRepository { abstract findById(id: string): Promise<Appointment | undefined>; abstract save(appointment: Appointment): Promise<void>;}
declare class AppointmentError extends ConceptError { static notFound(appointmentId: string): AppointmentError;}
// application/authorization/own-appointment.policy.ts/** Seul le client qui a réservé le rendez-vous, ou son praticien, peut l'annuler. */@Policy(permissions['appointment.cancel'], { name: 'appointments.own-appointment' })export class OwnAppointmentPolicy { appliesTo(subject: unknown): subject is Appointment { return subject instanceof Appointment; }
allows(actor: Actor, appointment: Appointment, _context: PolicyContext): boolean { return isAuthenticated(actor) && (actor.id === appointment.clientId || actor.id === appointment.practitionerId); }}
// application/commands/cancel-appointment/cancel-appointment.handler.ts@CommandHandler(CancelAppointment)export class CancelAppointmentHandler implements CommandHandler<CancelAppointment> { constructor( private readonly appointments: AppointmentRepository, private readonly access: Access, private readonly clock: Clock, ) {}
async execute({ actor, appointmentId, reason }: CancelAppointment): Promise<void> { const appointment = await this.appointments.findById(appointmentId);
if (appointment === undefined) { throw AppointmentError.notFound(appointmentId); }
await this.access.authorize(actor, permissions['appointment.cancel'], appointment); // 403 quand une politique refuse appointment.cancel(reason, this.clock.now()); // les invariants : une DomainError quand l'un d'eux est violé await this.appointments.save(appointment); }}3. Les invariants, dans le domaine. L'agrégat refuse ce qui n'est jamais valide.
import { AggregateRoot, ConceptError } from '@ddd-ts-kit/kernel';
const FREE_CANCELLATION_MS = 24 * 60 * 60_000;
export class AppointmentError extends ConceptError { static alreadyCancelled(appointmentId: string) { return AppointmentError.refusal('APPOINTMENT_ALREADY_CANCELLED', `Appointment ${appointmentId} is already cancelled.`, { appointmentId, }); }
static cancellationWindowPassed(appointmentId: string, startsAt: Date) { return AppointmentError.refusal( 'CANCELLATION_WINDOW_PASSED', `Appointment ${appointmentId} starts at ${startsAt.toISOString()}: free cancellation ended 24 hours before.`, { appointmentId, startsAt }, ).exposing('startsAt'); }}
export class Appointment extends AggregateRoot<string> { private cancelledFor: string | undefined;
constructor( id: string, private readonly startsAt: Date, ) { super(id); }
/** Aucun acteur : ces règles tiennent qui que ce soit qui demande. */ cancel(reason: string, at: Date): void { this.assertNotCancelled(); this.assertCancellationStillFree(at); this.cancelledFor = reason; // puis enregistrer l'événement, voir Agrégats }
private assertNotCancelled(): void { if (this.cancelledFor !== undefined) { throw AppointmentError.alreadyCancelled(this.id); } }
private assertCancellationStillFree(at: Date): void { if (this.startsAt.getTime() - at.getTime() < FREE_CANCELLATION_MS) { throw AppointmentError.cancellationWindowPassed(this.id, this.startsAt); } }}La méthode de l'agrégat ne prend aucun acteur. Elle ne peut pas être trompée par un contrôle oublié ailleurs, et ses tests n'ont besoin d'aucun utilisateur.
Où chacun vit, et pourquoi
Section intitulée « Où chacun vit, et pourquoi »La validation reste à l'extérieur parce que la forme d'une requête HTTP est une affaire de
transport. Le même cas d'usage peut être atteint par HTTP, par un événement consommé ou par un
job planifié, chacun avec son propre format d'entrée. Le domaine n'importe jamais de
bibliothèque de validation (layer/domain-purity).
Les objets-valeurs brouillent la limite de façon utile : TimeSlot.create refuse une fin avant
son début. Ce n'est pas de la validation d'entrée, c'est la définition d'un créneau, et elle tient
pour tout appelant. Le schéma de la frontière vérifie que deux dates-heures ont été envoyées ;
l'objet-valeur vérifie qu'elles forment un créneau. Un léger recouvrement entre les deux est
normal et sans danger.
Les permissions restent dans l'application parce que qui peut faire quoi est la politique du
contexte sur ses cas d'usage, pas une propriété de son modèle. Les rôles, les attributs ou les
relations peuvent changer sans toucher à un agrégat, et le modèle d'autorisation peut être
remplacé derrière le port (Authentification et autorisation).
Les identifiants de règle : authz/declared, authz/in-application, authz/actor-explicit.
Les invariants restent dans le domaine parce qu'ils sont le modèle. Ils doivent tenir sur chaque chemin : HTTP, événement, job, test, un futur script d'import. Le seul endroit par où passent tous les chemins est la méthode de l'agrégat.
La référence énonce une exception : un contexte dont le modèle est l'accès lui-même (gérer des membres et des rôles est son métier) peut tenir des rôles dans son propre état. Un rôle est alors une donnée du domaine, pas les droits de l'utilisateur qui agit.
Pièges courants
Section intitulée « Pièges courants »| Piège | Ce qui ne va pas | Où cela doit vivre |
|---|---|---|
if (appointment.status === 'cancelled') return 409 dans une route |
la règle est contournée par tout autre point d'entrée | un invariant dans appointment.reschedule |
un schéma qui refuse un startsAt dans le passé |
une règle métier (« pas de réservation dans le passé ») cachée dans la présentation, et dépendante d'une horloge | un invariant, avec now passé en paramètre |
if (user.role === 'admin') dans un gestionnaire ou une route |
des droits éparpillés, invisibles pour l'audit | une permission exigée par le cas d'usage |
appointment.cancel(reason, actor) qui vérifie actor.id === this.clientId |
un savoir sur l'accès dans le modèle ; chaque test a besoin d'un acteur | une politique dans application/authorization/ |
un indicateur isAdmin sur une entité du domaine qui décide de l'accès |
l'accès qui fuit dans le modèle | des rôles ou des relations dans l'authorizer |
une DomainError pour « champ manquant » |
une requête mal formée atteint le domaine | la validation à la frontière |
| un 403 pour « rendez-vous déjà annulé » | on dit au client qu'il lui manque un droit qu'il a | une erreur du domaine associée à un conflit |
| charger une liste, puis la filtrer par droits en mémoire | fuite des décomptes, pagination cassée, lenteur | un périmètre d'accès fourni par l'authorizer, remis au port de lecture |
| se fier au schéma et sauter l'invariant | le prochain point d'entrée n'a pas de schéma | garder les deux : chacun répond à sa propre question |
Les deux erreurs qui mettent l'accès dans le modèle, à côté de ce qu'il faut écrire à la place :
import { isAuthenticated, type Actor } from '@ddd-ts-kit/kernel';
declare function forbidden(): Error;
// Au lieu de : l'agrégat vérifie qui demande ; chaque test de cancel a besoin d'un acteur,// et une règle sur les droits se cache dans le modèle, invisible pour le journal d'auditexport class AppointmentDecidingAccess { constructor( readonly clientId: string, readonly isAdmin: boolean, ) {}
cancel(reason: string, actor: Actor): void { if (!this.isAdmin && (!isAuthenticated(actor) || actor.id !== this.clientId)) { throw forbidden(); } // ... }}
// Écrire : l'agrégat garde ses propres règles et ne prend aucun acteur ; qui peut annuler est la// politique de la permission, évaluée par le gestionnaire (ci-dessus)export class Appointment { constructor(readonly clientId: string) {}
cancel(reason: string, at: Date): void { // les invariants seulement }}Les règles qui semblent ambiguës se séparent en général nettement avec le critère ci-dessus :
| Règle | Sorte | Raisonnement |
|---|---|---|
| l'annulation gratuite prend fin 24 heures avant le début | invariant | vrai pour tout le monde |
| un praticien peut annuler dans les 24 heures, un client non | politique | dépend de qui demande ; l'agrégat offre alors deux intentions, ou le gestionnaire choisit la méthode que l'acteur a le droit d'appeler |
| pas après le début du rendez-vous | invariant | tient qui que ce soit qui demande |
| un tenant ne voit que ses propres rendez-vous | ni l'un ni l'autre : le cloisonnement par tenant, appliqué par l'infrastructure à partir de la portée d'exécution | Acteur et portée d'exécution |
Les erreurs, trois formes
Section intitulée « Les erreurs, trois formes »- La validation produit une liste de problèmes avec leurs chemins (
items.0.sku), répondue par le pipeline HTTP sous forme de document problem avec le statut 422. - Les refus d'authentification et d'autorisation sont produits par la plateforme et le middleware d'autorisation, répondus en 401 ou 403.
- Les invariants lèvent des
DomainErroravec uncodestable (SLOT_TAKEN,CANCELLATION_WINDOW_PASSED). La présentation associe les codes aux statuts du transport en un seul endroit, et chaque route déclare les codes qu'elle peut répondre, qui alimentent le contrat OpenAPI (errors/known-codegarantit que chaque code déclaré existe). UneDomainErrorest une réponse, pas un incident : elle n'est pas envoyée à l'outil de suivi des erreurs.
Comment s'écrit un refus
Section intitulée « Comment s'écrit un refus »Une classe d'erreur par concept du langage, une fabrique (factory) statique par refus :
import { ConceptError } from '@ddd-ts-kit/kernel';
declare const calendar: { readonly id: string };declare const slot: { readonly startsAt: Date };
export class AppointmentError extends ConceptError { static slotTaken(calendarId: string, startsAt: Date) { return AppointmentError.refusal( 'SLOT_TAKEN', `The slot starting at ${startsAt.toISOString()} on calendar ${calendarId} is already held.`, { calendarId, startsAt }, ).exposing('startsAt'); }}
// là où la règle est gardéethrow AppointmentError.slotTaken(calendar.id, slot.startsAt);- Le nom est celui du métier, deux fois. La fabrique est ce que lit le développeur qui
refuse ; le code, ce que lisent le client, le mapper, le journal d'audit et les tests. Ils
disent les mêmes mots :
slotTakenetSLOT_TAKEN(errors/factory-name). Un code nomme un seul refus d'un contexte (errors/code-unique). - Le concept est ce qui a été demandé, pas la classe qui détecte le problème : un calendrier
qui trouve un créneau occupé refuse avec
AppointmentError.slotTaken, parce que l'appelant a demandé un rendez-vous. Les refus d'un objet-valeur sont des fabriques du concept qu'il décrit :PersonError.invalidEmail(raw), levée parEmail.create(ou renvoyée dans sonResultsous"stack.valueObjects": "result"). - Les faits sont des données, et privées. Ce à partir de quoi le message a été construit est
gardé sur l'erreur sous
facts, pour qu'un gestionnaire ou un test lise une date et non une phrase. Un fait n'atteint le client, sousfactsdans le document problem, que lorsque la fabrique le nomme avec.exposing(..); rien de ce qui identifie une personne ne l'atteint jamais. Le message est répondu commedetail: n'y écrivez rien que l'appelant n'ait pas envoyé. - Rien en aval ne connaît la classe.
isRefusal(error, 'SLOT_TAKEN'),AppointmentError.is(error, 'SLOT_TAKEN'),thenError('SLOT_TAKEN', { calendarId }),expectProblem(response, { status: 409, code: 'SLOT_TAKEN' }): le code est la clé. - Une classe qui grossit nomme un second concept. Au-delà d'une vingtaine de fabriques (un fichier tient 350 lignes), scindez-la selon un terme du glossaire, jamais selon une ligne technique.
Un projet écrit avec une classe par erreur (new SlotTakenError(..)) continue de fonctionner,
et ddd-ts-kit error-factories le convertit : CONFIG.md, stack.refusals.
Limites, et quand ne pas l'utiliser
Section intitulée « Limites, et quand ne pas l'utiliser »- La séparation est une discipline que les outils ne tiennent qu'en partie.
authz/in-applicationrepère des noms et des formes : le README de la CLI précise qu'un test de propriété écritappointment.clientId === actor.idéchappe au checker, qui travaille au niveau du texte. La revue reste nécessaire. - Un cas d'usage sans aucune règle (un contrôle de santé public, une liste de référence) n'a besoin d'aucune des trois, au-delà de se déclarer public. N'ajoutez pas de cérémonie par symétrie.
- Les invariants sur un ensemble ne s'expriment pas entièrement dans un agrégat. « Pas de double réservation » demande aussi une contrainte en base de données (Agrégats).
- La validation côté client est une quatrième chose, pour le seul confort de l'utilisateur. Elle ne remplace jamais le schéma de la frontière.
- Certaines règles sont les deux. « Un client peut réserver au plus trois rendez-vous par semaine » est un invariant sur un ensemble, coûteux à garder strictement ; décidez avec le métier s'il doit être exact (une contrainte ou un compteur dans la même transaction) ou s'il peut être approximatif (vérifié sur une projection).
- Eric Evans, Domain-Driven Design, Addison-Wesley, 2003, chapitre 6 (invariants et agrégats) et chapitre 9 (spécifications, rendre explicites les règles implicites).
- Vaughn Vernon, Implementing Domain-Driven Design, Addison-Wesley, 2013, chapitre 5 (validation des entités) et chapitre 10.
- Robert C. Martin, Clean Architecture, Prentice Hall, 2017, chapitre 22 (les frontières et ce qui les traverse).
- RFC 9457, « Problem Details for HTTP APIs », rfc-editor.org/rfc/rfc9457.
- Dépôt : ARCHITECTURE.md, section 4, « Authentification et
autorisation » et « Validation » ;
plugin/skills/access-control/SKILL.md.