Nous avons des ersatzs d'événements au sein de notre PartieDeChasse.
Ceux-ci sont extrêmement limités :
ils ne portent aucune sémantique métier
pas structurés : ce sont de simples string
privatereadonlyList<Event> _events;publicsealedrecordEvent(DateTime Date,string Message){publicoverridestringToString() =>string.Format("{0:HH:mm} - {1}", Date, Message);}if (TousBrocouilles(classement)){ result ="Brocouille"; EmitEvent("La partie de chasse est terminée, vainqueur : Brocouille", timeProvider);}else{ result = Join(", ",classement[0].Select(c =>c.Nom)); EmitEvent($"La partie de chasse est terminée, vainqueur : {Join(", ",classement[0].Select(c => $"{c.Nom} - {c.NbGalinettes} galinettes"))}", timeProvider);}
On va revoir cette gestion des événements et allons en profiter pour Event-sourcer notre Aggregate. Celà signifie que nous n'allons plus stocker l'état de notre Aggregate mais tous ses événements.
Pour celà, on va :
Prendre du temps pour découvrir ce qu'est l'Event Sourcing
Quelques classes ont déjà été implémenté afin de faciliter l'utilisation d'1 Event Store in memory
Faire 1 checkout du commit 6efde7c3e470e7c84c50da2715c255bd9acd3d6c
Cette version est très minimaliste et ne résolve pas des problématiques telles que la concurrence
Prendre du temps pour comprendre le code du Domain.Core
Ce code est fortement inspiré du travail fait sur NEventStore
Pour comprendre comment utiliser ce code, on peut se focaliser sur les tests qui nous en donnent une bonne idée
[Fact]publicclassAggregateShould{privatereadonlyGuid _id;privatereadonlyMovie _movie;publicAggregateShould() { _id =Guid.NewGuid(); _movie =Oppenheimer.Movie(_id); } [Fact]publicvoidhave_raised_creation_event() {_movie.HasRaisedEvent(newMovieCreated(_id,Data.Now,Oppenheimer.Title,Oppenheimer.ReleaseDate)) .Should() .BeTrue();_movie.Version.Should().Be(1);_movie.Id.Should().Be(_id); } [Fact]publicvoidhave_raised_casting_changed_event() {var newCasting =newList<string> {"Cillian Murphy","Florence Pugh"}.ToSeq();_movie.ChangeCast(newCasting);_movie.HasRaisedEvent(newCastingHasChanged(_id,Data.Now, newCasting)) .Should() .BeTrue();_movie.Version.Should().Be(2); } [Fact]publicvoidthrow_handler_not_found_when_apply_method_not_defined() {var act = () =>_movie.NotWellImplementedBehavior();act.Should() .Throw<HandlerForDomainEventNotFoundException>() .WithMessage("Aggregate of type 'Movie' raised an event of type 'NotWellImplementedDomainBehaviorRaised' but no handler could be found to handle the event."); } ...}publicclassMovie:Aggregate{ // public only for testing purposepublicstring? _title;publicDateTime? _releaseDate;publicSeq<string> _casting =Seq<string>.Empty;privateMovie(Guid id,Func<DateTime> timeProvider) : base(timeProvider,true) => Id = id;publicMovie(Guid id,Func<DateTime> timeProvider,string title,DateTime releaseDate) :this(id, timeProvider)=> RaiseEvent(newMovieCreated(id, Time(), title, releaseDate));privatevoidApply(MovieCreated @event) { _title =@event.Title; _releaseDate =@event.ReleaseDate; }publicvoidChangeCast(Seq<string> casting) => RaiseEvent(newCastingHasChanged(Id, Time(), casting));privatevoidApply(CastingHasChanged @event) => _casting =@event.Casting;publicvoidNotWellImplementedBehavior() => RaiseEvent(newNotWellImplementedDomainBehaviorRaised(Id, Time()));}publicrecordMovieCreated(Guid Id,DateTime Date,string Title,DateTime ReleaseDate) :Event(Id, 1,Date);publicrecordCastingHasChanged(Guid Id,DateTime Date,Seq<string> Casting) :Event(Id, 1,Date);publicrecordNotWellImplementedDomainBehaviorRaised(Guid Id,DateTime Date) :Event(Id, 1,Date);
Identifier quels sont les éléments fondamentaux à mettre en place pour avoir 1 Aggregate "Event-Sourcé"
Changer l'implémentation de Prendre LApéro
Faire en sorte que le flux ressemble à cela :
Pour le moment au sein de notre Domain son implémentation ressemble à ça :
publicEither<Error,PartieDeChasse> PrendreLapéro(Func<DateTime> timeProvider){if (DuringApéro()) {return AnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {return AnError("La partie de chasse est déjà terminée"); } Status = Apéro; EmitEvent("Petit apéro", timeProvider);returnthis;}
Soyons plus explicite en retournant Either<Error, Unit>
On ne stockera plus l'état mais que les Events donc plus besoin de retourner le nouvel état de l'objet
🔴 On commence par adapter 1 test existant afin de spécifier nos attentes vis-à-vis du système
On change le test
On utilise 1 verbe au passé pour décrire notre événement -> quelque chose d'immuable
On génère l'Event "structuré" depuis le test
On choisi d'utiliser 1 record parce qu'ils sont immuables par design
On lui fait hériter de Aggregate et on fixe les warnings
publicsealedclassPartieDeChasse:Aggregate{privatereadonlyArr<Chasseur> _chasseurs =Arr<Chasseur>.Empty; // TODO : à supprimer à termeprivatereadonlyList<Event> _events =new();publicIReadOnlyList<Chasseur> Chasseurs =>_chasseurs.ToImmutableArray();publicTerrain? Terrain { get; }publicPartieStatus Status { get; privateset; }publicIReadOnlyList<Event> Events =>_events.ToImmutableArray(); // Nouveau ctor privatePartieDeChasse(Guid id,Func<DateTime> timeProvider) : base(timeProvider) => Id = id;privatePartieDeChasse(Guid id,Func<DateTime> timeProvider,Terrain terrain,Chasseur[] chasseurs):this(id, timeProvider) { Id = id; _chasseurs =chasseurs.ToArr(); Terrain = terrain; Status = EnCours; _events =newList<Event>(); EmitPartieDémarrée(timeProvider); }
🔴 On fail maintenant plus pour des erreurs de compilation mais bien parce qu'aucun événement n'est présent dans l'Event Store
🟢 On raise l'event
publicEither<Error,PartieDeChasse> PrendreLapéro(Func<DateTime> timeProvider){if (DuringApéro()) {return AnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {return AnError("La partie de chasse est déjà terminée"); } Status = Apéro; RaiseEvent(newApéroDémarré(Id, timeProvider())); EmitEvent("Petit apéro", timeProvider);returnthis;}
🔵 On va désormais adapté notre code pour faire en sorte que cet événement puisse être rejoué sur l'aggrégat
La transition (mutation / changement d'état doit se faire au chargement de l'event) ici Status = Apéro
publicEither<Error,PartieDeChasse> PrendreLapéro(Func<DateTime> timeProvider){if (DuringApéro()) {return AnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {return AnError("La partie de chasse est déjà terminée"); } RaiseEvent(newApéroDémarré(Id, timeProvider())); // On supprimera la méthode EmitEvent une fois qu'on aura finit de changer chaque behavior de l'aggrégat EmitEvent("Petit apéro", timeProvider);returnthis;}// Attention : cette méthode sera appelé par Reflection -> votre IDE voudra la supprimée...// Vos tests diront le contraire 😉privatevoidApply(ApéroDémarré @event) => Status = Apéro;
🔵 On va changer le retour de la méthode à partir de l'appelant
protectedstaticEither<Error,VoidResponse> ToEmpty(Either<Error,Unit> either)=>either.Map(_ =>VoidResponse.Empty);publicEither<Error,Unit> PrendreLapéro(Func<DateTime> timeProvider){if (DuringApéro()) {return AnError("On est déjà en plein apéro"); }if (DéjàTerminée()) {return AnError("La partie de chasse est déjà terminée"); } RaiseEvent(newApéroDémarré(Id, timeProvider())); EmitEvent("Petit apéro", timeProvider);return Default;}
🔵 Quoi d'autre ?
On peut changer l'organisation du Domain afin de grouper ensemble les couples Command | Event
En faisant cela, on brise une règle d'architecture définie précédemment :
On va alors créer 1 ADR (Architecture Decision Record) pour expliquer pourquoi on a voulu dévier de cette règle
Bien sûr, ce genre de décisions doivent être discutées et prises en équipe
# Grouper les Commands et Events- Date : 23/08/2023- Who were involved in the decision : `Yoan Thirion`## ContextDescribe the decision context :Nous avions pris la décision de localiser les commandes dans le Domain dans 1 répertoire `Commands`. Maintenant que nous utilisons des `Event` pour réponse aux `Command`, nous devrions peut-être faire évoluer notre desgin.## DecisionOn préfère grouper ces couples `Command | Event` avec une sémantique métier. Celà permet de créer une `Screaming Architecture`.Exemple pour `Prendre l'apéro` : ![Prendre l'apéro](../facilitation/steps/img/12.event-sourcing/event-with-command.webp)## Status`Accepted`