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
private readonly List<Event> _events;
public sealed record Event(DateTime Date, string Message)
{
public override string ToString() => 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]
public class AggregateShould
{
private readonly Guid _id;
private readonly Movie _movie;
public AggregateShould()
{
_id = Guid.NewGuid();
_movie = Oppenheimer.Movie(_id);
}
[Fact]
public void have_raised_creation_event()
{
_movie.HasRaisedEvent(new MovieCreated(_id, Data.Now, Oppenheimer.Title, Oppenheimer.ReleaseDate))
.Should()
.BeTrue();
_movie.Version.Should().Be(1);
_movie.Id.Should().Be(_id);
}
[Fact]
public void have_raised_casting_changed_event()
{
var newCasting = new List<string> {"Cillian Murphy", "Florence Pugh"}.ToSeq();
_movie.ChangeCast(newCasting);
_movie.HasRaisedEvent(new CastingHasChanged(_id, Data.Now, newCasting))
.Should()
.BeTrue();
_movie.Version.Should().Be(2);
}
[Fact]
public void throw_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.");
}
...
}
public class Movie : Aggregate
{
// public only for testing purpose
public string? _title;
public DateTime? _releaseDate;
public Seq<string> _casting = Seq<string>.Empty;
private Movie(Guid id, Func<DateTime> timeProvider) : base(timeProvider, true) => Id = id;
public Movie(Guid id, Func<DateTime> timeProvider, string title, DateTime releaseDate) : this(id, timeProvider)
=> RaiseEvent(new MovieCreated(id, Time(), title, releaseDate));
private void Apply(MovieCreated @event)
{
_title = @event.Title;
_releaseDate = @event.ReleaseDate;
}
public void ChangeCast(Seq<string> casting) => RaiseEvent(new CastingHasChanged(Id, Time(), casting));
private void Apply(CastingHasChanged @event) => _casting = @event.Casting;
public void NotWellImplementedBehavior() => RaiseEvent(new NotWellImplementedDomainBehaviorRaised(Id, Time()));
}
public record MovieCreated(Guid Id, DateTime Date, string Title, DateTime ReleaseDate) : Event(Id, 1, Date);
public record CastingHasChanged(Guid Id, DateTime Date, Seq<string> Casting) : Event(Id, 1, Date);
public record NotWellImplementedDomainBehaviorRaised(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 :
public Either<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);
return this;
}
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 ajoute la référence sur le projet Domain.Core
public record ApéroDémarré(Guid Id, DateTime Date) : Event(Id, 1, Date);
On doit maintenant ajouter 1 overload sur notre class PartieDeChasseAssertions afin de pouvoir faire des assertions sur des Domain Events :
public AndConstraint<PartieDeChasseAssertions> HaveEmittedEvent<TEvent>(TEvent expectedEvent) where TEvent : class, IEvent
=> // Comment écrire cette Assertion ???
On doit vérifier que l'événemt a bien été émis par notre aggrégat et commité dans notre EventStore
On va donc vérifier que dans le stream d'events associé à l'instance de notre aggrégat notre event est bien présent
On ajoute alors dans l'assertion 1 dépendance sur le repository
On lui fait hériter de Aggregate et on fixe les warnings
public sealed class PartieDeChasse : Aggregate
{
private readonly Arr<Chasseur> _chasseurs = Arr<Chasseur>.Empty;
// TODO : à supprimer à terme
private readonly List<Event> _events = new();
public IReadOnlyList<Chasseur> Chasseurs => _chasseurs.ToImmutableArray();
public Terrain? Terrain { get; }
public PartieStatus Status { get; private set; }
public IReadOnlyList<Event> Events => _events.ToImmutableArray();
// Nouveau ctor
private PartieDeChasse(Guid id, Func<DateTime> timeProvider) : base(timeProvider) => Id = id;
private PartieDeChasse(Guid id,
Func<DateTime> timeProvider,
Terrain terrain,
Chasseur[] chasseurs)
: this(id, timeProvider)
{
Id = id;
_chasseurs = chasseurs.ToArr();
Terrain = terrain;
Status = EnCours;
_events = new List<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
public Either<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(new ApéroDémarré(Id, timeProvider()));
EmitEvent("Petit apéro", timeProvider);
return this;
}
🔵 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
public Either<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(new Apé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);
return this;
}
// Attention : cette méthode sera appelé par Reflection -> votre IDE voudra la supprimée...
// Vos tests diront le contraire 😉
private void Apply(ApéroDémarré @event) => Status = Apéro;
🔵 On va changer le retour de la méthode à partir de l'appelant
protected static Either<Error, VoidResponse> ToEmpty(Either<Error, Unit> either)
=> either.Map(_ => VoidResponse.Empty);
public Either<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(new Apé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`
## Context
Describe 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.
## Decision
On 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` :

## Status
`Accepted`
Au niveau de la partie de chasse on expose quelques propriétés mais uniquement pour des besoins de testing
On doit se poser la question : Sommes nous assez confiant en testant uniquement que les events sont bien raised par notre PartieDeChasse ?
private Arr<Chasseur> _chasseurs = Arr<Chasseur>.Empty;
// Exposé uniquement pour des besoins de testing
public IReadOnlyList<Chasseur> Chasseurs => _chasseurs.ToImmutableArray();
public Terrain? Terrain { get; private set; }
public PartieStatus Status { get; private set; }
private PartieDeChasse(Guid id, Func<DateTime> timeProvider) : base(timeProvider) => Id = id;
// Exemple de test
Then((_, savedPartieDeChasse) =>
savedPartieDeChasse
.Should()
.HaveEmittedEvent(Repository, new PartieReprise(savedPartieDeChasse!.Id, Now))
.And
.BeEnCours());
Pour moi, on peut se dire que oui :
On a couvert les transitions qui, intrinsèquement, vont valider l'application des events