Ensuring uniqueness in Marten event store
Unique constraint validation is one of those things that looks simple but is not always easy. I explained already that, in Event Sourcing, it may be challenging.
In Marten, we’re trying to show the pragmatic face of Event Sourcing and embrace that whether something is best practice or anti-pattern depends on the context. We also understand that things can often get rougher, and you may get into a situation where you need to take a tradeoff and work on the proper design as the next step. The trick I will present in this article is precisely in this category.
Let’s say that we’d like to enforce the uniqueness of the user email. In theory, you could query a read model keeping all users’ data and check if the user with the selected email already exists. Easy peasy? It sounds like it, but it’s not. Such an approach will give you a false guarantee. In the meantime, between you got the result, ran your business logic and stored new user information, someone could have added a user with the same email. Querying is never reliable, we should tell, not ask. So, in this case, claim the user email end either succeed or fail.
If we had a relational database, we could set a unique constraint and call it a day. And hey, in Marten, that’s what we have under the cover: rock-solid Postgres.
Marten stores all events on the same table. Each event is a separate row, and data is placed in the JSONB column. Postgres allows indexing of JSON data. Such indexes are performant but insufficient to index the whole events table. Yet, there’s another way!
Marten allows running inline projections. They run in the same transaction as appending events. Marten has a built-in unit of work. Pending changes are wrapped in database transactions and stored together when we call SaveChanges. When we append a new event, Marten looks for all registered inline transactions that can handle this event type. For each of them, it runs the apply logic. Projection results are stored in a separate table for the specific document type as Marten document. (Read more in Event-driven projections in Marten explained).
We can use the read model and define a unique constraint on it. If the read model is updated in the same transaction as the event append, the event won’t be appended when the unique constraint fails.
Let’s say that we have the following events:
public record UserCreated(
Guid UserId,
string Email
);
public record UserEmailUpdated(
Guid UserId,
string Email
);
public record UserDeleted(
Guid UserId
);
Of course, typically, we’ll have more event types; I provided only those related to setting, updating or removing user email.
We could define the following read model with projection:
public record UserNameGuard(
Guid Id,
string Email
);
public class UserNameGuardProjection:
SingleStreamProjection<UserNameGuard>
{
public UserNameGuardProjection() =>
DeleteEvent<UserDeleted>();
public UserNameGuard Create(UserCreated @event) =>
new (@event.UserId, @event.Email);
public UserNameGuard Apply(UserEmailUpdated @event, UserNameGuard guard) =>
guard with { Email = @event.Email };
}
Now, if we register this projection we can also define the unique constraint in DocumentStore registration:
var store = DocumentStore.For(options =>
{
// (...)
options.Projections.Add<UserNameGuardProjection>(ProjectionLifecycle.Inline);
options.Schema.For<UserNameGuard>().UniqueIndex(guard => guard.Email);
});
Unique Index provides more customisation. Let’s take an example of cinema ticket reservations. We could define a condition to ensure we have only a single active reservation for the specific seat.
var store = DocumentStore.For(options =>
{
// (...)
options.Schema.For<Reservation>().Index(x => x.SeatId, x =>
{
x.IsUnique = true;
// Partial index by supplying a condition
x.Predicate = "(data ->> 'Status') != 'Cancelled'";
});
}
That’s neat, isn’t it? Check also full sample.
Of course, with great power comes great responsibility. From my experience, a unique constraint requirement is usually a sign that our business people bring us a solution instead of the problem. We should ensure that what we’re trying to provide here is a real requirement and ask enough whys before we commit to it.
Still, sometimes we need to select the hill we’d like today for, or just need to do our stuff. This recipe should be treated as a tradeoff, but it should take you pretty far if used cautiously.
Cheers!
Oskar
p.s. If you liked this article, also check a simple trick for idempotency handling in the Elastic Search read model.
p.s.2. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help. You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to Red Cross, Ukraine humanitarian organisation or donate Ambulances for Ukraine.