Carte des contextes
Résumé. Une carte des contextes (context map) est l'image des relations entre les contextes
délimités (bounded contexts) : qui dépend de qui (l'amont et l'aval), et selon quel arrangement.
L'arrangement porte un nom, celui d'un patron de relation choisi dans un petit ensemble, et
chaque nom dit qui s'adapte à qui et ce que cela coûte. Dans ce kit, la carte n'est pas un dessin
rangé à côté du code : les relations sont déclarées dans ddd-ts-kit.config.json, un import
entre contextes est refusé sans relation déclarée, et docs/context-map.md est généré à partir
des déclarations.
Amont et aval
Section intitulée « Amont et aval »Quand le contexte B a besoin de quelque chose du contexte A, A est en amont et B en aval : les changements descendent le courant. L'amont peut casser l'aval en changeant ; l'inverse n'est pas vrai. Le patron dit comment ce pouvoir est géré.
Dans le kit, l'aval nomme son amont et le patron :
"availability": { "classification": "supporting", "upstreams": [ { "context": "calendars", "relationship": "published-language" }, { "context": "appointments", "relationship": "published-language" } ]}Les patrons
Section intitulée « Les patrons »| Patron | Qui s'adapte | Convient quand | Coûte |
|---|---|---|---|
| Langage publié | l'amont conçoit un contrat fait pour ses consommateurs | c'est le choix par défaut entre les contextes d'un même produit | l'amont doit versionner son contrat et le garder stable |
| Client-fournisseur | l'amont inscrit les besoins de l'aval dans son backlog | deux équipes, une organisation, l'aval a voix au chapitre | de la négociation et de la planification des deux côtés |
| Conformiste | l'aval prend le modèle de l'amont tel quel | l'amont ne s'adaptera pas, et son modèle est assez bon | le modèle de l'aval suit chaque changement de l'amont |
| Couche anticorruption | l'aval traduit à sa frontière | le modèle de l'amont est étranger, ancien ou instable | un traducteur à écrire, à tester et à maintenir |
| Service hôte ouvert | l'amont offre un seul protocole à de nombreux consommateurs | de nombreux avals, aucun particulier | une API publique à gouverner ; aucun consommateur n'a un ajustement sur mesure |
| Noyau partagé | deux contextes possèdent ensemble un petit modèle | un concept est vraiment identique et change en même temps | une propriété conjointe : aucun des deux côtés ne le change seul |
| Partenariat | deux équipes se coordonnent, réussissent ou échouent ensemble | deux contextes qui doivent évoluer de concert pendant un temps | une coordination constante ; pas d'amont clair |
| Chemins séparés | personne ne s'adapte : aucune intégration | le coût de l'intégration dépasse son bénéfice | de la duplication, et aucune donnée partagée |
Langage publié
Section intitulée « Langage publié »L'amont publie un contrat documenté et versionné : des types d'événements, des types de charge
utile (payload), des contrats de lecture. C'est le choix par défaut dans ce kit, et le seul code
qu'un autre contexte peut importer : le dossier published-language/ de l'amont. Les événements
d'intégration y sont déclarés une fois, avec un schéma
(Événements du domaine et d'intégration). Dans l'exemple,
appointments publie appointments.appointment.booked, et availability et notifications le
consomment sans connaître l'agrégat Appointment.
Client-fournisseur
Section intitulée « Client-fournisseur »L'aval est un client dont le fournisseur planifie les besoins. Ce patron convient à deux équipes
d'une même organisation aux priorités alignées. Il coûte des réunions : l'aval doit exprimer ses
besoins tôt, l'amont ne doit pas livrer un changement cassant sans prévenir. Si notifications a
besoin du canal préféré du client dans l'événement de réservation, une relation client-fournisseur
signifie que l'équipe d'appointments planifie cet ajout.
Conformiste
Section intitulée « Conformiste »L'aval renonce à traduire et utilise directement le modèle de l'amont. C'est économique et
honnête quand l'aval n'a pas de modèle qui mérite d'être protégé. analytics est conformiste
envers appointments : il stocke les événements tels qu'ils arrivent, et quand une charge utile
gagne un champ, analytics gagne une colonne. Le coût est que l'aval ne peut pas résister à un
changement de l'amont : ne l'utilisez que là où c'est acceptable.
Couche anticorruption
Section intitulée « Couche anticorruption »L'aval protège son modèle par un traducteur. Dans le kit, il vit dans
infrastructure/anti-corruption/ et se génère avec generate acl. notifications en utilise un
envers le fournisseur de courriel et de SMS : le fournisseur parle de « messages », de
« templates » et de statuts de livraison qui lui sont propres, et aucun de ces mots n'entre dans
le glossaire de notifications. Le coût est du vrai code, qui grandit avec la surface de l'amont :
ne traduisez que ce qui sert.
Un traducteur prend ce que le fournisseur envoie, avec ses noms et ses codes, et rend une valeur du modèle de l'aval ; rien du vocabulaire du fournisseur ne passe au-delà :
/** Un rapport de livraison exactement tel que le fournisseur l'envoie : ses noms, ses statuts. */export interface SmsProviderReport { readonly message_sid: string; readonly status: 'queued' | 'sent' | 'delivered' | 'undelivered' | 'failed'; readonly error?: 'invalid_destination' | 'blocked_by_carrier' | 'unknown';}
/** Comment notifications l'appelle, dans son glossaire : l'issue d'un rappel. */export type ReminderOutcome = | { readonly kind: 'on-its-way' } | { readonly kind: 'reached' } | { readonly kind: 'unreachable'; readonly because: 'wrong-number' | 'refused' | 'unknown' };
export class SmsProviderTranslator { translate(report: SmsProviderReport): ReminderOutcome { switch (report.status) { case 'queued': case 'sent': return { kind: 'on-its-way' }; case 'delivered': return { kind: 'reached' }; case 'undelivered': case 'failed': return { kind: 'unreachable', because: this.reasonOf(report.error) }; } }
private reasonOf(error: SmsProviderReport['error']): 'wrong-number' | 'refused' | 'unknown' { if (error === 'invalid_destination') { return 'wrong-number'; }
return error === 'blocked_by_carrier' ? 'refused' : 'unknown'; }}generate acl notifications sms-provider écrit cette classe, vide, avec des marqueurs
TODO(domain), et une spec dont les entrées it.todo demandent de vrais exemples reçus du
fournisseur.
Service hôte ouvert
Section intitulée « Service hôte ouvert »L'amont définit un protocole ouvert à tous ceux qui en ont besoin, au lieu d'une intégration par consommateur. Il va en général de pair avec un langage publié, qui est le vocabulaire du protocole. Le fournisseur d'identité de l'exemple se comporte ainsi : OpenID Connect est son protocole ouvert, et chaque application s'y conforme.
Noyau partagé
Section intitulée « Noyau partagé »Deux contextes partagent une petite partie du modèle, dans le code. Dans le kit, c'est
src/shared-kernel/, qui ne contient que ce qu'au moins deux contextes utilisent. Un objet-valeur
TimeSlot pourrait y vivre si appointments et availability entendent vraiment la même chose
par ce mot. C'est le plus couplant de tous les patrons : chaque changement demande l'accord des
deux propriétaires, donc le noyau doit rester petit, et un noyau qui ne cesse de grossir signale
qu'une frontière est mal placée.
Partenariat et chemins séparés
Section intitulée « Partenariat et chemins séparés »Evans en décrit deux de plus. Partenariat : deux équipes en dépendance mutuelle coordonnent leur planification et intègrent en continu ; aucune n'est en amont. Chemins séparés : les contextes ne sont pas intégrés du tout, et chacun répond seul à son besoin.
Ce qu'accepte la configuration du kit
Section intitulée « Ce qu'accepte la configuration du kit »Six valeurs, pour contexts.<name>.upstreams[].relationship (CONFIG.md) :
published-language (par défaut), customer-supplier, conformist, anti-corruption-layer,
open-host-service, shared-kernel.
Pour le dire simplement :
- Les chemins séparés n'ont pas de valeur, parce qu'ils sont l'absence de relation déclarée.
Deux contextes sans entrée
upstreamsentre eux ne peuvent pas s'importer l'un l'autre (context/declared-relationship), ce qui est exactement ce patron. - Le partenariat n'est pas modélisé. La configuration est orientée (un aval nomme un amont)
et
context/no-cyclerefuse la dépendance mutuelle. Deux contextes en partenariat doivent quand même choisir un sens dans la configuration, ou exprimer l'un des deux sens par un événement, et décrire le partenariat dans la partie écrite de la carte des contextes. L'outil n'en a aucune notion. - La valeur est une étiquette avec un seul effet mécanique : elle autorise l'import du
langage publié de l'amont et apparaît sur la flèche du diagramme généré. Le kit n'impose pas
le comportement qu'implique un patron. Déclarer
anti-corruption-layerne vérifie pas qu'un traducteur existe ; déclarercustomer-supplierne crée pas de réunion de planification. L'étiquette documente l'arrangement ; ce sont les personnes qui le tiennent vrai.
Quel que soit le patron, la mécanique entre deux contextes est la même dans ce kit : des imports
depuis published-language/ seulement (context/published-language-only), des événements
d'intégration pour collaborer, et pour un besoin synchrone un port chez l'appelant avec un
adaptateur gateway.
Au lieu d'aller chercher dans le modèle de l'amont :
import { Appointment } from '@/contexts/appointments/domain/aggregates/appointment.aggregate';// context/published-language-only : l'agrégat d'appointments est désormais figé par availabilityimportez ce que l'amont a publié pour ses consommateurs, et rien d'autre :
import { appointmentBookedV1, type AppointmentBookedV1 } from '@/contexts/appointments/published-language/index';Pour un besoin synchrone, l'aval déclare un port dans ses propres mots, et un adaptateur gateway
appelle le contrat de lecture que publie l'amont. Aujourd'hui l'appel reste dans le processus ;
quand l'amont tournera comme un service à part, seul l'adaptateur changera.
generate gateway availability calendars OpeningTimes écrit les trois fichiers ci-dessous (plus
un fake et une suite de contrat), et nomme le contrat de l'amont d'après le port gateway :
import { Gateway } from '@ddd-ts-kit/kernel/declarations';
// calendars/published-language/opening-times.contract.ts : ce que calendars offre à la lectureexport interface WorkingInterval { readonly startsAt: string; readonly endsAt: string;}
export abstract class OpeningTimesContract { abstract ofPractitioner(practitionerId: string, day: string): Promise<readonly WorkingInterval[]>;}
// availability/application/ports/opening-times-gateway.port.ts : ce dont availability a besoin, dans ses motsexport abstract class OpeningTimesGateway { abstract openOn(practitionerId: string, day: string): Promise<readonly { readonly from: Date; readonly to: Date }[]>;}
// availability/infrastructure/external/in-process-opening-times.gateway.ts : la seule classe qui// connaît le contrat de calendars@Gateway(OpeningTimesGateway)export class InProcessOpeningTimesGateway implements OpeningTimesGateway { constructor(private readonly calendars: OpeningTimesContract) {}
async openOn(practitionerId: string, day: string): Promise<readonly { readonly from: Date; readonly to: Date }[]> { const intervals = await this.calendars.ofPractitioner(practitionerId, day);
return intervals.map((interval) => ({ from: new Date(interval.startsAt), to: new Date(interval.endsAt) })); }}Un amont qui sert de nombreux consommateurs ajoute de son côté un service hôte ouvert. Le choix revient au porteur du projet, et chaque patron autre que celui par défaut mérite une phrase de justification dans la carte, et souvent un enregistrement de décision (ADR).
La carte générée
Section intitulée « La carte générée »npx ddd-ts-kit docs régénère docs/context-map.md entre des marqueurs
<!-- ddd-ts-kit:context-map --> :
- un diagramme Mermaid : un nœud par contexte, dont le style dépend de sa classification, une flèche par relation, de l'amont vers l'aval, étiquetée du nom du patron ;
- une table des contextes : la première phrase de la raison d'être de chacun, ses amonts, ses avals, ses événements publiés, et s'il est déployable seul.
Le texte hors des marqueurs est conservé. Le skill document dit quoi y écrire, et seulement
quand c'est vrai : un guide de lecture du flux métier principal, pourquoi les frontières débattues
ont été tracées où elles sont, pourquoi chaque patron autre que celui par défaut a été choisi et
ce qu'il coûte, et les tensions connues avec le signal qui déclencherait une séparation ou une
fusion. docs --check échoue en CI quand la partie générée est périmée, de sorte que la carte
ne peut pas s'écarter de la configuration.
Limites et quand ne pas l'utiliser
Section intitulée « Limites et quand ne pas l'utiliser »- La carte montre les relations déclarées, pas le couplage réel. Deux contextes qui partagent des hypothèses par une convention de base de données, ou par de la logique dupliquée, sont couplés sans flèche.
- Les systèmes externes ne sont pas des nœuds de la carte générée. Seuls les contextes de la
configuration y apparaissent. Un fournisseur d'identité ou un service de paiement se décrit
dans la partie écrite et dans
docs/integration/README.md. - Les noms des patrons sont des contrats sociaux. Comme dit plus haut, le kit fait respecter la règle des imports et l'absence de cycles, rien de la façon dont les équipes se comportent.
- La carte d'un seul contexte est vide, et c'est très bien ainsi : ne découpez pas un modèle pour avoir quelque chose à dessiner.
- Le noyau partagé est facile à détourner pour contourner les frontières. Rien ne contraint ce que le noyau partagé importe (README de la CLI, « Limits ») : relisez-le en revue.
- Eric Evans, Domain-Driven Design, Addison-Wesley, 2003, chapitre 14 « Maintaining Model Integrity » (la carte des contextes et les patrons de relation).
- Eric Evans, Domain-Driven Design Reference, 2015, section « Context Mapping for Strategic Design » (ajoute le partenariat), domainlanguage.com/ddd/reference.
- Vaughn Vernon, Implementing Domain-Driven Design, Addison-Wesley, 2013, chapitre 3 « Context Maps ».
- Vlad Khononov, Learning Domain-Driven Design, O'Reilly, 2021, chapitre 4 « Integrating Bounded Contexts » (les groupes coopération, client-fournisseur et chemins séparés).