Strata is an opinionated Event Sourcing library built for Microsoft Orleans.
- ♾️ Tentative & Confirmed State Models & Versioning
- 📷 Snapshotting
- ⏱️ Delayed Writes
- 🗃️ Orleans Provider Model via Durable Framework
- ⚡ Outbox Recipients & Projections
- 🏁 Retry, Replay, and Dry-Run Capabilities
- 📨 OOB Recipients: Direct Grain Calls, Orleans Streams, Recipient Handling
Create an aggregate object that can handle events being applied to it. This will typically represent an aggregate root in your domain model. It can contain objects,
[GenerateSerializer]
public class AccountAggregate
{
public void Apply(BalanceAdjustedEvent @event)
{
Balance = @event.Balance;
}
[Id(0)]
public string Id { get; set; } = null!;
[Id(2)]
public double Balance { get; set; }
}Implement a Journaled Grain that converts commands into events.
internal sealed class AccountGrain :
JournaledGrain<AccountAggregate, BaseAccountEvent>,
IAccountGrain
{
public Task<double> GetBalance() => Task.FromResult(ConfirmedState.Balance);
public async Task Deposit(double amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive.");
var newBalance = ConfirmedState.Balance + amount;
var @event = new BalanceAdjustedEvent(this.GetPrimaryKeyString()) { Balance = newBalance };
await RaiseEvent(@event);
}
public async Task Withdraw(double amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive.");
if (amount > ConfirmedState.Balance) throw new InvalidOperationException("Insufficient funds for withdrawal.");
var newBalance = ConfirmedState.Balance - amount;
var @event = new BalanceAdjustedEvent(this.GetPrimaryKeyString()) { Balance = newBalance };
await RaiseEvent(@event);
}
}Register an IOutboxRecipient<TEvent> to handle outbox messages in the OnActivateAsync method of your grain. This allows you to act on events, connecting other grains or passing the message to a stream or bus.
internal sealed class AccountGrain :
JournaledGrain<AccountAggregate, BaseAccountEvent>,
IAccountGrain
{
protected override void OnRegisterRecipients()
{
RegisterRecipient(nameof(AccountProjection), new AccountProjection(this.GrainFactory));
}
}
public sealed class AccountProjection : IOutboxRecipient<BaseAccountEvent>
{
private readonly IGrainFactory _grainFactory;
public AccountProjection(IGrainFactory grainFactory)
{
_grainFactory = grainFactory;
}
public async Task Handle(int version, BaseAccountEvent @event)
{
if (@event is BalanceAdjustedEvent balanceEvent)
{
var accountId = balanceEvent.Id;
var viewModelGrain = _grainFactory.GetGrain<IAccountViewModelGrain>(accountId);
await viewModelGrain.UpdateBalance(balanceEvent.Balance);
}
}
}Strata provides a Sidecar pattern that allows you to attach auxiliary grains to primary grains for cross-cutting concerns like monitoring, logging, data collection, or background processing. Sidecars are automatically managed by the framework and can be enabled or disabled dynamically.
- Sidecar Grain: A grain that implements
ISidecarGrainand provides auxiliary functionality - Host Grain: A grain that implements
ISidecarHost<TSidecar>to indicate it supports a specific sidecar - Lifecycle Management: Sidecars are automatically activated when their host grain activates (if enabled)
- State Management: Each sidecar has persistent state to track whether it's enabled or disabled
Implement the ISidecarGrain interface to create a sidecar:
[GrainType("user-cdp")]
public class UserCdpGrain : Grain, IUserCdpGrain
{
public async Task InitializeSidecar()
{
// Perform sidecar initialization logic
// Set reminders, collect data, establish connections, etc.
var userGrain = GrainFactory.GetGrain<IUserGrain>(
this.GetPrimaryKeyString()
);
await userGrain.SetReferenceId(Guid.NewGuid().ToString());
// Sidecars can control their own lifecycle
await userGrain.DisableSidecar<IUserCdpGrain>();
}
}
public interface IUserCdpGrain : IGrainWithStringKey, ISidecarGrain
{
// Additional sidecar-specific methods can be added here
}Mark your grain as a sidecar host by implementing ISidecarHost<TSidecar>:
[GrainType("user")]
public class UserGrain : Grain, IUserGrain, ISidecarHost<IUserCdpGrain>
{
private readonly IPersistentState<UserData> _state;
public UserGrain([FromKeyedServices("state")] IPersistentState<UserData> state)
{
_state = state;
}
public async ValueTask SetReferenceId(string referenceId)
{
_state.State.ReferenceId = referenceId;
await _state.WriteStateAsync();
}
// Other grain methods...
}
public interface IUserGrain : IGrainWithStringKey
{
ValueTask SetReferenceId(string referenceId);
// Other methods...
}- Activation: When a host grain activates, the framework checks if any sidecars are enabled
- Initialization: If enabled, the sidecar grain is retrieved and
InitializeSidecar()is called - Runtime: Sidecars operate independently but can interact with their host grain
- Control: Sidecars can be enabled/disabled dynamically using extension methods
Use the provided extension methods to control sidecar state:
// Enable a sidecar
await userGrain.EnableSidecar<IUserCdpGrain>();
// Disable a sidecar
await userGrain.DisableSidecar<IUserCdpGrain>();Add sidecar support to your Orleans silo:
builder.ConfigureSilo((options, siloBuilder) =>
{
siloBuilder.AddSidecars();
// Other configuration...
});