Quick Start
Build your first event-sourced feature with Opossum in 5 minutes.
What We'll Build
A minimal student registration system that:
- Defines domain events
- Appends events to the store
- Reads events back
- Maintains a projection (read model)
- Enforces a business rule with DCB concurrency control
Step 1 — Install and Configure
dotnet add package Opossum
using Opossum.DependencyInjection;
using Opossum.Projections;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOpossum(options =>
{
options.RootPath = @"D:\MyData\EventStore";
options.UseStore("QuickStart");
})
.AddProjections(options =>
{
options.ScanAssembly(typeof(Program).Assembly);
});
var app = builder.Build();
app.Run();
Step 2 — Define Your Events
Events are immutable records implementing IEvent:
using Opossum;
public sealed record StudentRegisteredEvent(
Guid StudentId,
string FirstName,
string LastName,
string Email) : IEvent;
public sealed record StudentEnrolledToCourseEvent(
Guid CourseId,
Guid StudentId) : IEvent;
Step 3 — Append Events
Inject IEventStore and append events using the fluent builder from Opossum.Extensions:
using Opossum;
using Opossum.Core;
using Opossum.Extensions;
public class StudentService(IEventStore eventStore)
{
public async Task<Guid> RegisterAsync(
string firstName, string lastName, string email)
{
var studentId = Guid.NewGuid();
// Fluent builder — implicitly converts to NewEvent
NewEvent evt = new StudentRegisteredEvent(
studentId, firstName, lastName, email)
.ToDomainEvent()
.WithTag("studentId", studentId.ToString())
.WithTag("studentEmail", email)
.WithTimestamp(DateTimeOffset.UtcNow);
// Single-event convenience extension (from Opossum.Extensions)
await eventStore.AppendAsync(evt, condition: null);
return studentId;
}
}
ToDomainEvent()andWithTag()are extension methods fromOpossum.Extensions. TheDomainEventBuilderhas an implicit conversion toNewEvent.
Step 4 — Read Events
using Opossum.Core;
// Read all events for a specific student
var query = Query.FromItems(new QueryItem
{
Tags = [new Tag("studentId", studentId.ToString())]
});
var events = await eventStore.ReadAsync(query, readOptions: null);
foreach (var e in events)
{
Console.WriteLine($"[{e.Position}] {e.Event.EventType}");
}
Step 5 — Create a Projection
Projections are materialized views maintained automatically by IProjectionManager:
using Opossum.Core;
using Opossum.Projections;
public sealed record StudentView(
Guid StudentId,
string FirstName,
string LastName,
string Email,
int EnrolledCourses);
[ProjectionDefinition("StudentView")]
public sealed class StudentViewProjection : IProjectionDefinition<StudentView>
{
public string ProjectionName => "StudentView";
public string[] EventTypes =>
[
nameof(StudentRegisteredEvent),
nameof(StudentEnrolledToCourseEvent)
];
public string KeySelector(SequencedEvent evt) =>
evt.Event.Tags.First(t => t.Key == "studentId").Value;
public StudentView? Apply(StudentView? current, SequencedEvent evt) =>
evt.Event.Event switch
{
StudentRegisteredEvent r => new StudentView(
r.StudentId, r.FirstName, r.LastName, r.Email, 0),
StudentEnrolledToCourseEvent when current is not null =>
current with { EnrolledCourses = current.EnrolledCourses + 1 },
_ => current
};
}
Query the projection via IProjectionStore<T>:
using Opossum.Projections;
// Inject IProjectionStore<T> via DI — one registration per projection type
var student = await projectionStore.GetAsync(studentId.ToString());
// Or query all with a predicate
var enrolled = await projectionStore.QueryAsync(
s => s.EnrolledCourses > 0);
Step 6 — Enforce a Business Rule with DCB
Use AppendCondition to prevent duplicate registrations — the DCB read → decide → append pattern:
public async Task<CommandResult> RegisterUniqueAsync(
RegisterStudentCommand command, IEventStore eventStore)
{
// 1. READ — find any existing registration for this email
var emailQuery = Query.FromItems(new QueryItem
{
Tags = [new Tag("studentEmail", command.Email)]
});
var existing = await eventStore.ReadAsync(emailQuery, ReadOption.None);
// 2. DECIDE — enforce the "no duplicate email" invariant
if (existing.Length != 0)
return CommandResult.Fail("A user with this email already exists.");
// 3. APPEND — with a guard: fail if a conflicting event appeared since our read
NewEvent newEvent = new StudentRegisteredEvent(
command.StudentId,
command.FirstName,
command.LastName,
command.Email)
.ToDomainEvent()
.WithTag("studentId", command.StudentId.ToString())
.WithTag("studentEmail", command.Email)
.WithTimestamp(DateTimeOffset.UtcNow);
// The AppendCondition guards against concurrent writes:
// If any event with the same email tag appeared between our read and this
// append, the event store throws AppendConditionFailedException.
await eventStore.AppendAsync(
newEvent,
condition: new AppendCondition { FailIfEventsMatch = emailQuery });
return CommandResult.Ok();
}
For more complex scenarios with multiple business rules, use BuildDecisionModelAsync which composes multiple projections and returns a combined AppendCondition automatically. See the Mediator article for a full example.
What Happens on Disk
After running the above, your event store directory looks like:
D:\MyData\EventStore\
QuickStart\
.ledger
Events\
0000000001.json
0000000002.json
Indices\
EventType\
StudentRegisteredEvent.idx
Tags\
studentId_<guid>.idx
Projections\
StudentView\
<student-guid>.json
Every event is a plain JSON file. Projections are JSON files too. No binary formats, no proprietary encodings — you can inspect everything with any text editor.
Next Steps
→ Configuration — tune flush, auto-rebuild, polling interval
→ Concepts: Event Store — understand the storage model
→ Concepts: DCB — deep dive on the specification
→ Concepts: Projections — advanced projection patterns
→ Use Cases — see Opossum in real-world scenarios