Event sourcing sounds complicated, so let's explain the concept so that by the end of this post, you are familiar with the terminology and its use cases.

Traditional databases typically store an entity's latest state as a row in a table. But what if, instead of just storing the end result, we could record each change, like a history log, and rebuild the current state from these changes? Well, this is event sourcing.

Our Back to School Sale is now Live on Dometrain. Keep reading the blog to find out how to save up to 30% on any course.

What is Event Sourcing?

Event sourcing is an architectural pattern where the state of a system is stored as a series of immutable events rather than maintaining the current state directly in a database. Instead of saving just the final state of an object, we record every change as an event. Over time, this forms a history that can be replayed to reconstruct the current state of the system.

Let's see a concrete example. In a banking application, instead of updating an account's balance every time a transaction occurs, you store each transaction as an event. You would capture events like AccountOpened, FundsDeposited, FundsWithdrawn, and so on. To calculate the current balance, you sum up all of the FundsDeposited and FundsWithdrawn events for that account.

Key Benefits of Event Sourcing

The event-sourcing approach comes with several advantages over traditional state-based storage:

  1. Immutability: Events are immutable and append-only. Once an event is stored, it cannot be modified or deleted. This guarantees an audit trail, giving you a full history of what happened in your system.

  2. No Updates or Deletes: In a typical CRUD system, you update or delete records directly. However, with event sourcing, you never modify data. Each new event represents a state change and is appended to the history. This ensures you always have a clear timeline of events without losing any data.

  3. Auditability: Every change to an object is explicitly captured as an event. You can easily trace why an object is in its current state when each change occurred and what the system looked like at any point in time.

  4. Rebuilding State: By replaying events, you can reconstruct the current state of an object at any time, as well as see how it evolved. This is extremely useful for debugging or recovering from failures.

  5. Time Travel: You can replay events to not only rebuild the current state but also to see what the state of the system was at any given point in history. This is particularly valuable in financial and regulatory contexts where a full audit trail is required.

  6. Scalability and CQRS: Event sourcing often pairs well with the CQRS (Command Query Responsibility Segregation) pattern. The idea is to split the write model (commands that change state) from the read model (queries that fetch data). This approach allows you to scale your reads and writes independently.

To understand this better, I have created a diagram to showcase the flow of a command when using CQRS:

EventSourcingDiagram.png

Implementing Event Sourcing in .NET

Time to code an example to see how event sourcing would work.
So first, let's create a new base event class:

public abstract class Event
{
    public abstract Guid StreamId { get; }
    
    public DateTime CreatedAtUtc { get; set; }
}

Let's also create some events that will inherit from this class:

public class StudentCreated : Event
{
    public required Guid StudentId { get; init; }

    public required string FullName { get; init; }
    
    public required string Email { get; init; }
    
    public required DateTime DateOfBirth { get; init; }

    public override Guid StreamId => StudentId;
}

public class StudentUpdated : Event
{
    public required Guid StudentId { get; init; }

    public required string FullName { get; init; }
    
    public required string Email { get; init; }

    public override Guid StreamId => StudentId;
}

public class StudentEnrolled : Event
{
    public required Guid StudentId { get; init; }

    public required string CourseName { get; set; }
    
    public override Guid StreamId => StudentId;
}

public class StudentUnEnrolled : Event
{
    public required Guid StudentId { get; init; }

    public required string CourseName { get; set; }

    public override Guid StreamId => StudentId;
}

The streamId is mapped to the unique identifier of my entity. So I have my 4 events that can create, update, enroll and un-enroll a student. To save these events, let's create a StudentDatabase and the Student class and explain what is going on.

public class StudentDatabase
{
    private readonly Dictionary<Guid, SortedList<DateTime, Event>> _studentEvents = new();
    private readonly Dictionary<Guid, Student> _students = new();

    public void Append(Event @event)
    {
        var stream = _studentEvents!.GetValueOrDefault(@event.StreamId, null);

        if (stream == null)
        {
            stream = new SortedList<DateTime, Event>();
        }

        @event.CreatedAtUtc = DateTime.UtcNow;
        _studentEvents[@event.StreamId].Add(@event.CreatedAtUtc, @event);

        _students[@event.StreamId] = GetStudent(@event.StreamId)!;
    }

    public Student? GetStudent(Guid studentId)
    {
        if (!_studentEvents.ContainsKey(studentId))
        {
            return null;
        }

        var student = new Student();
        foreach (var @event in _studentEvents[studentId].Values)
        {
            student.Apply(@event);
        }

        return student;
    }

    public Student? GetStudentView(Guid studentId)
    {
        return _students!.GetValueOrDefault(studentId, null);
    }
}

Now, the Student class

public class Student
{
    public Guid Id { get; set; }

    public string FullName { get; set; }

    public string Email { get; set; }

    public List<string> EnrolledCourses { get; set; } = new();

    public DateTime DateOfBirth { get; set; }

    private void Apply(StudentCreated studentCreated)
    {
        Id = studentCreated.StudentId;
        FullName = studentCreated.FullName;
        Email = studentCreated.Email;
        DateOfBirth = studentCreated.DateOfBirth;
    }
    
    private void Apply(StudentUpdated updated)
    {
        FullName = updated.FullName;
        Email = updated.Email;
    }
    
    private void Apply(StudentEnrolled enrolled)
    {
        if (!EnrolledCourses.Contains(enrolled.CourseName))
        {
            EnrolledCourses.Add(enrolled.CourseName);
        }
    }
    
    private void Apply(StudentUnEnrolled unEnrolled)
    {
        if (EnrolledCourses.Contains(unEnrolled.CourseName))
        {
            EnrolledCourses.Remove(unEnrolled.CourseName);
        }
    }

    public void Apply(Event @event)
    {
        switch (@event)
        {
            case StudentCreated studentCreated:
                Apply(studentCreated);
                break;
            case StudentUpdated studentUpdated:
                Apply(studentUpdated);
                break;
            case StudentEnrolled studentEnrolled:
                Apply(studentEnrolled);
                break;
            case StudentUnEnrolled studentUnEnrolled:
                Apply(studentUnEnrolled);
                break;
        }
    }
}

As you can see, the Student Database not only has the events stored for every student but also the latest view of a student, so we don't have to calculate it each time. If our application crashes, when we restart it, we can get the latest view of a student by applying all the events in order!

To see this all in action, you can write something like this:

var studentDatabase = new StudentDatabase();

var studentId = Guid.Parse("410efa39-917b-45d4-83ff-f9a618d760a3");

var studentCreated = new StudentCreated
{
    StudentId = studentId,
    Email = "nick@dometrain.com",
    FullName = "Nick Chapsas",
    DateOfBirth = new DateTime(1993, 1, 1)
};

studentDatabase.Append(studentCreated);

var studentEnrolled = new StudentEnrolled
{
    StudentId = studentId,
    CourseName = "From Zero to Hero: REST APIs in .NET"
};
studentDatabase.Append(studentEnrolled);

var studentUpdated = new StudentUpdated
{
    StudentId = studentId,
    Email = "nickchapsas@dometrain.com",
    FullName = "Nick Chapsas"
};
studentDatabase.Append(studentUpdated);

var student = studentDatabase.GetStudent(studentId);

var studentFromView = studentDatabase.GetStudentView(studentId);

The GetStudentView method would retrive the student from the cache and the GetStudent method would create a new student and apply all of the events on the student to get the latest version. After all these, let's end it by talking about the pros and cons.

Adding these events would have these results in a database:

Id StreamId EventType EventData CreatedAtUtc
1 410efa39-917b-45d4-83ff-f9a618d760a3 StudentCreated {"StudentId":"410efa39-917b-45d4-83ff-f9a618d760a3","Email":"nick@dometrain.com","FullName":"Nick Chapsas","DateOfBirth":"1993-01-01T00:00:00Z"} 2023-10-01 10:00:00
2 410efa39-917b-45d4-83ff-f9a618d760a3 StudentEnrolled {"StudentId":"410efa39-917b-45d4-83ff-f9a618d760a3","CourseName":"From Zero to Hero: REST APIs in .NET"} 2023-10-01 10:05:00
3 410efa39-917b-45d4-83ff-f9a618d760a3 StudentUpdated {"StudentId":"410efa39-917b-45d4-83ff-f9a618d760a3","Email":"nickchapsas@dometrain.com","FullName":"Nick Chapsas"} 2023-10-01 10:10:00

For this example, we used the StudentId as StreamId.

Pros and Cons of Event Sourcing

Pros:

  • Auditability: Full history of changes.
  • Scalability: Especially when combined with CQRS.
  • Flexibility: Ability to evolve the system over time.

Cons:

  • Complexity: Steeper learning curve.
  • Event Versioning: Managing changes to event schemas.
  • Tooling: Requires more sophisticated infrastructure.

Conclusion

Event sourcing might seem a bit scary at first, but hopefully, this post has made the concept clearer and more approachable. By shifting our perspective from simply storing the current state to recording every change as an event, we gain a rich history of how our system evolves over time. This approach doesn't just help with debugging or auditing; it fundamentally changes how we think about data and its lifecycle.

Imagine being able to replay every action that led your application to its current state, just like rewinding a movie to understand how the plot unfolds. This level of insight can be invaluable, especially when dealing with complex systems or when you need to comply with strict regulatory requirements.

Now I want hear more from you, did you find this post useful? Have you worked on something like it? Hope you enjoyed it and as always, keep coding.

Next steps

If you're interested in learning more about Event Sourcing or any other topic, check out my learning platform, Dometrain, where 35,000 students are learning how to excel in their careers as software engineers.

Until the end of September you can use discount code BTS30 to get 30% off any course, BTS15 to get 15% off any (already discounted) bundle and BTS20 to get 20% off your first year of Dometrain Pro.

cover-promo copy.jpg