Event Store Multi-Tenancy
The event store feature in Marten supports an opt-in multi-tenancy model that captures events by the current tenant. Use this syntax to specify that:
var store = DocumentStore.For(opts =>
{
opts.Connection("some connection string");
// And that's all it takes, the events are now multi-tenanted
opts.Events.TenancyStyle = TenancyStyle.Conjoined;
});
Global Streams & Projections Within Multi-Tenancy 8.5
Document storage allows you to mix conjoined- and single-tenanted documents in one database. You can now do the same thing with event storage and projected aggregate documents from SingleStreamProjection<TDoc, TId>
projections.
Let's say that you have a document (cut us some slack, this came from testing) called SpecialCounter
that is aggregated from events in your system that otherwise has a conjoined tenancy model for the event store, but SpecialCounter
should be global within your system.
Let's start with a possible implementation of a single stream projection:
public class SpecialCounterProjection: SingleStreamProjection<SpecialCounter, Guid>
{
public void Apply(SpecialCounter c, SpecialA _) => c.ACount++;
public void Apply(SpecialCounter c, SpecialB _) => c.BCount++;
public void Apply(SpecialCounter c, SpecialC _) => c.CCount++;
public void Apply(SpecialCounter c, SpecialD _) => c.DCount++;
}
Or this equivalent, but see how I'm explicitly registering event types, because that's going to be important:
public class SpecialCounterProjection2: SingleStreamProjection<SpecialCounter, Guid>
{
public SpecialCounterProjection2()
{
// This is normally just an optimization for the async daemon,
// but as a "global" projection, this also helps Marten
// "know" that all events of these types should always be captured
// to the default tenant id
IncludeType<SpecialA>();
IncludeType<SpecialB>();
IncludeType<SpecialC>();
IncludeType<SpecialD>();
}
public void Apply(SpecialCounter c, SpecialA _) => c.ACount++;
public void Apply(SpecialCounter c, SpecialB _) => c.BCount++;
public void Apply(SpecialCounter c, SpecialC _) => c.CCount++;
public void Apply(SpecialCounter c, SpecialD _) => c.DCount++;
public override SpecialCounter Evolve(SpecialCounter snapshot, Guid id, IEvent e)
{
snapshot ??= new SpecialCounter { Id = id };
switch (e.Data)
{
case SpecialA _:
snapshot.ACount++;
break;
case SpecialB _:
snapshot.BCount++;
break;
case SpecialC _:
snapshot.CCount++;
break;
case SpecialD _:
snapshot.DCount++;
break;
}
return snapshot;
}
}
And finally, let's register our projection within our application's bootstrapping:
var builder = Host.CreateApplicationBuilder();
builder.Services.AddMarten(opts =>
{
opts.Connection(builder.Configuration.GetConnectionString("marten"));
// The event store has conjoined tenancy...
opts.Events.TenancyStyle = TenancyStyle.Conjoined;
// But we want any events appended to a stream that is related
// to a SpecialCounter to be single or global tenanted
// And this works with any ProjectionLifecycle
opts.Projections.AddGlobalProjection(new SpecialCounterProjection(), ProjectionLifecycle.Inline);
});
The impact of this global registration is that any events appended to a stream with an aggregate type of SpecialCounter
or really any events at all of the types known to be included in the globally registered single stream projection will be appended as the default tenant id no matter what the session's tenant id is. There's a couple implications here:
- The event types of a globally applied projection should not be used against other types of streams
- Marten "corrects" the tenant id applied to events from globally projected aggregates regardless of how the events are appended or how the session was created
- Marten automatically marks the storage for the aggregate type as single tenanted
- Live, Async, or Inline projections have all been tested with this functionality
AppendOptimistic()
andAppendPessimistic()
do not work (yet) with this setting, but you should probably be usingFetchForWriting()
instead anyway.