You can read events either forwards or backwards. In both cases, reading events is based on the standard IEnumerable<T>
interface.
Events can be written using the AtomEventObserver<T>
class.
If you want to read the events in the order they were written, with the oldest event first, you should use the First-In, First-Out reader:
IEnumerable<object> events = new FifoEvents<object>( eventStreamId, // a Guid storage, // an IAtomEventStorage object serializer); // an IContentSerializer object var firstEvent = events.First();
It's not necessary to explicitly declare events
as IEnumerable<object>
: you can use the var
keyword as well; this example just uses explicit variable declaration in order to make it clearer what's going on.
The storage
variable can be any IAtomEventStorage implementation.
The serializer
variable can be any IContentSerializer implementation.
If you want to read the most recent events first, you can use the LifoEvents<T>
class instead of FifoEvents<T>
: it provides a Last-In, First-Out Iterator over the event stream.
Since both FifoEvents<T>
and LifoEvents<T>
implement IEnumerable<T>
, you can perform any filtering, aggregation, and projection operation you're used to be able to do with LINQ. However, be aware that there's no protocol translation going on (IQueryable<T>
is not in use). All operations on the event stream happen on the Iterator in memory, so if you're not careful, you may inadvertently read the entire event stream into memory from storage.
However, with a bit of care, and judicious selection of FifoEvents<T>
or LifoEvents<T>
you can still make your system efficient.
In object-oriented code with e.g. C#, the simplest (although not the most type-safe) way to interpret event streams is to use the Acyclic Visitor pattern. This enables you to aggregate events into a domain object, using run-time downcasts.
The advantage of this approach is that it's relatively easy to understand and implement. The disadvantage is that it's not type-safe, and, due to downcasting, prone to violate the Liskov Substitution Principle.
Consider a simple user sign-up process. A new user wishes to sign up for a service, so first provides a user name, password, and email address. Upon receiving the data, the system sends an email to the user, asking her or him to validate the address.
The events in the system may include:
(All the code in this example is available in the unit test library in the AtomEventStore code base.)
Such events can be written to the underlying storage, and FifoEvents<T>
can be used to play back the events in the same order they happened. A User
domain object can interpret such an event stream, aggregating the data into a snapshot of the current state of the user:
public class User { private readonly Guid id; private readonly string name; private readonly string password; private readonly string email; private readonly bool emailVerified; public User( Guid id, string name, string password, string email, bool emailVerified) { this.id = id; this.name = name; this.password = password; this.email = email; this.emailVerified = emailVerified; } public static User Create(UserCreated @event) { return new User( @event.UserId, @event.UserName, @event.Password, @event.Email, false); } public User Accept(EmailVerified @event) { return new User( this.id, this.name, this.password, this.email, true); } public User Accept(EmailChanged @event) { return new User( this.id, this.name, this.password, @event.NewEmail, false); } private User Accept(object @event) { var emailChanged = @event as EmailChanged; if (emailChanged != null) return this.Accept(emailChanged); var emailVerified = @event as EmailVerified; if (emailVerified != null) return this.Accept(emailVerified); throw new ArgumentException("Unexpected event type.", "@event"); } public static User Fold(IEnumerable<object> events) { var first = events.First(); var rest = events.Skip(1); var created = first as UserCreated; if (created == null) throw new ArgumentException("The first event must be a UserCreated instance.", "events"); var user = User.Create(created); return rest.Aggregate(user, (u, e) => u.Accept(e)); } public static User Fold(params object[] events) { return User.Fold(events.AsEnumerable()); } public Guid Id { get { return this.id; } } public string Name { get { return this.name; } } public string Password { get { return this.password; } } public string Email { get { return this.email; } } public bool EmailVerified { get { return this.emailVerified; } } }
The User
class understands how to aggregate an event stream into a User
object, but alternatively, a separate Service could have that responsibility. It would also be possible to create a variation on this implementation that uses mutable state in the User
class, instead of relying on copying of instances, but conceptually, it would still be the same solution.
The relevant entry points here are the two Fold
overloads that take a stream of objects as input. Notice that Fold(IEnumerable<object>)
attempts to downcast the first event to a UserCreated
instance. In this implementation, this is the only legal way to initiate a new user, but other implementations may have less strict requirements.
The Fold
method aggregates the rest of the event stream by repeatedly delegating to the Accept(object)
method, which attempts more downcasts. If none of the downcasts succeed, an exception is thrown.
With this implementation, an event stream can be read and interpreted with AtomEventStore like this:
var events = new FifoEvents<object>( eventStreamId, // a Guid storage, // an IAtomEventStorage object serializer); // an IContentSerializer object var user = User.Fold(events);
Notice that events
is an instance of FifoEvents<object>
; although FifoEvents<T>
is a generic class, the events in this particular even stream share no common type, so the lowest common denominator is object
. This underlines the lack of type-safety in this particular implementation.
Some people like to let all event classes implement a common IEvent
interface, but the most common interface definitions are either Marker Interfaces, or interfaces that look like this:
public interface IEvent { Guid Id { get; } }
While such a hypothetical IEvent
interface force all events to have an ID, it doesn't prevent downcasting when interpreting an event stream, so it adds limited value.
While the Acyclic Visitor pattern is easy to get started with, it tends to be brittle because it essentially bypasses the type checking built into languages such as C#.
Interpreting events using a VisitorA more type-safe alternative to using an Acyclic Visitor is to use the original Visitor pattern. The Visitor pattern can be a good choice when you have a well-known set of types that are somehow related, but are structurally different, and share little behaviour.
Compared to using an Acyclic Visitor, using a Visitor has the following advantages:
FifoEvents<T>
and LifoEvents<T>
with a more type-safe type argument than object
.However, the disadvantage compared to Acyclic Visitor is that the Visitor solution tends to be more complex.
Consider a simple user sign-up process. A new user wishes to sign up for a service, so first provides a user name, password, and email address. Upon receiving the data, the system sends an email to the user, asking her or him to validate the address.
The events in the system may include:
(All the code in this example is available in the unit test library in the AtomEventStore code base.)
Each event class implements the IUserEvent
interface:
public interface IUserEvent { IUserVisitor Accept(IUserVisitor visitor); }
They all implement the Accept
method in the same way:
public IUserVisitor Accept(IUserVisitor visitor) { return visitor.Visit(this); }
Such events can be written to the underlying storage, and FifoEvents<T>
can be used to play back the events in the same order they happened.
In order to interpret user events, you can implement the IUserVisitor
, which is defined like this:
public interface IUserVisitor { IUserVisitor Visit(UserCreated @event); IUserVisitor Visit(EmailVerified @event); IUserVisitor Visit(EmailChanged @event); }
Notice how the IUserVisitor
interface provides a finite list of all concrete event types that can be handled by this system (UserCreated
, EmailVerified
, EmailChanged
). This helps to remind you to handle all possible event types when implementing the interface.
You can implement IUserVisitor
to collect all events into one or more User
objects:
public class UserVisitor : IUserVisitor { private readonly IEnumerable<User> users; public UserVisitor(IEnumerable<User> users) { this.users = users; } public UserVisitor(params User[] users) : this(users.AsEnumerable()) { } public IEnumerable<User> Users { get { return this.users; } } public IUserVisitor Visit(UserCreated @event) { var user = new User( @event.UserId, @event.UserName, @event.Password, @event.Email, false); return new UserVisitor(this.users.Concat(new[] { user })); } public IUserVisitor Visit(EmailVerified @event) { var origUser = this.users.Single(u => u.Id == @event.UserId); var newUser = new User( origUser.Id, origUser.Name, origUser.Password, origUser.Email, true); var newUsers = this.users .Where(u => u.Id != newUser.Id) .Concat(new[] { newUser }); return new UserVisitor(newUsers); } public IUserVisitor Visit(EmailChanged @event) { var origUser = this.users.Single(u => u.Id == @event.UserId); var newUser = new User( origUser.Id, origUser.Name, origUser.Password, @event.NewEmail, false); var newUsers = this.users .Where(u => u.Id != newUser.Id) .Concat(new[] { newUser }); return new UserVisitor(newUsers); } }
Notice how UserVisitor
doesn't need to attempt to downcast incoming events, because each event has a well-known Visit
overload.
There are various ways to implement such a Visitor; this particular example collects a sequence of User
objects, enabling it to (potentially) handle events for more than a single user at a time.
In order to keep the example as simple as possible, the Visit(EmailVerified)
and Visit(EmailChanged)
methods implicitly assume that a corresponding user already exists. It would be possible to create a more robust implementation at the cost of more code.
The User
class can use UserVisitor
to aggregate a sequence of events into a User
instance:
public class User { private readonly Guid id; private readonly string name; private readonly string password; private readonly string email; private readonly bool emailVerified; public User( Guid id, string name, string password, string email, bool emailVerified) { this.id = id; this.name = name; this.password = password; this.email = email; this.emailVerified = emailVerified; } public static User Fold(IEnumerable<IUserEvent> events) { var result = events.Aggregate( new UserVisitor(), (v, e) => (UserVisitor)e.Accept(v)); return result.Users.Single(); } public Guid Id { get { return this.id; } } public string Name { get { return this.name; } } public string Password { get { return this.password; } } public string Email { get { return this.email; } } public bool EmailVerified { get { return this.emailVerified; } } }
Notice how the Fold
method uses UserVisitor
to aggregate the events and return a correctly populated User
instance.
A client can use FifoEvents<IUserEvent>
to create a User
object from an event stream:
var events = new FifoEvents<IUserEvent>( eventStreamId, // a Guid storage, // an IAtomEventStorage object serializer); // an IContentSerializer object var user = User.Fold(events);
Using the Visitor pattern enables you to use a proper interface (like IUserEvent
) as the generic type argument for both FifoEvents<T>
, LifoEvents<T>
, and AtomEventObserver<T>
. Compared to the Acyclic Visitor implementation, it offfers a more type-safe solution, but at the expense of additional complexity.
While interpretation and aggregation is either weakly typed or quite complicated using Object-Oriented C#, it can be simple and strongly typed in F#, using Discriminated Unions. Like the Visitor pattern example above, using a Discriminated Union in F# has the following advantages:
FifoEvents<T>
and LifoEvents<T>
with a more type-safe type argument than obj
.Contrary to the Visitor example, there are few disadvantages, as Discriminated Unions is a core feature of F#, so the use of it is very idiomatic.
Consider a simple user sign-up process. A new user wishes to sign up for a service, so first provides a user name, password, and email address. Upon receiving the data, the system sends an email to the user, asking her or him to validate the address.
The events in the system may include:
(All the code in this example is available in the F# unit test library in the AtomEventStore code base.)
The events are defined as record types:
[<CLIMutable>] type UserCreated = { UserId : Guid UserName : string Password : string Email : string } [<CLIMutable>] type EmailVerified = { UserId : Guid Email : string } [<CLIMutable>] type EmailChanged = { UserId : Guid NewEmail : string }
Such events can be written to the underlying storage, because they are adorned with the [<CLIMutable>]
attribute.
A Discriminated Union defines all the events that can occur in the system:
type UserEvent = | UserCreated of UserCreated | EmailVerified of EmailVerified | EmailChanged of EmailChanged
You can use FifoEvents<UserEvent>
to play back the events in the same order they happened.
If, for example, you want to interpret the event stream as one or more users, the first thing you'll need to do is to define a User
record type:
type User = { Id : Guid Name : string Password : string Email : string EmailVerified : bool }
In order to interpret a sequence of UserEvent
records into one or more User
values, you can define a module with the appropriate functions:
module UserEvents = let createUser (uc : UserCreated) = { Id = uc.UserId Name = uc.UserName Password = uc.Password Email = uc.Email EmailVerified = false } let verifyEmail u = { u with EmailVerified = true } let changeEmail ec u = { u with Email = ec.NewEmail; EmailVerified = false } let applyEvent users e = let findUser id = users |> Seq.find (fun x -> x.Id = id) let updateUser u = users |> Seq.filter (fun x -> x.Id <> u.Id) |> Seq.append [u] match e with | UserCreated uc -> users |> Seq.append [createUser uc] | EmailVerified ev -> findUser ev.UserId |> verifyEmail |> updateUser | EmailChanged ec -> findUser ec.UserId |> changeEmail ec |> updateUser let foldEvents events = events |> Seq.fold applyEvent Seq.empty
Notice that createUser
, verifyEmail
, and changeEmail
are helper functions created with the sole purpose of making the applyEvent
function easier to read; each of these three functions are only used once inside of applyEvent
, so they could have been inlined.
All three helper functions take the appropriate event as input, and returns a User
value.
The applyEvent
function has the signature seq<User> -> UserEvent -> seq<User>
; in other words, it takes an already (partially) populated sequence of users, as well as a single UserEvent
, and returns a new sequence of users, with the event applied.
It matches the incoming event with all the cases in the UserEvent
Discriminated Union, and applies the result to the users
. If the event is a UserCreated
event, it creates a new User
value and appends it to users
; otherwise, it finds the affected user by its Id
, applies the event to that value, and updates the users.
Finally, the foldEvents
function has the signature seq<UserEvent> -> seq<User>
; in other words, it takes an event stream of UserEvent
values, and interprets it as a sequence of User
values. Since the event stream may contain more than one UserCreated
event, the result may be more than a single User
value.
A client can use FifoEvents<UserEvent>
to create a User
value from an event stream:
let events = FifoEvents<UserEvent>( eventStreamId, // an UuidIri (basically, a Guid) storage, // an IAtomEventStorage object serializer) // an IContentSerializer object let users = events |> UserEvents.foldEvents
Using a Discriminated Union to interpret the event stream lets you use pattern matching and F#'s built-in aggregation functions to a arrive at a concice and type-safe solution.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4