PouryaKit
Concepts

Stream

An aggregate whose stored state is the events it appended, the Kit's second write model, which commits through its own Unit of Work on Marten.

What it is

A Stream is an aggregate whose stored state is the ordered events it has appended, loaded by folding them. It is the Kit's second write model, beside EF Core aggregates, stored by Marten in the same PostgreSQL database. Its Unit of Work commits its events with the Operation's Notifications and Integration Events in one transaction. A Stream belongs to the tenant of the Operation that wrote it.

One commit owner per write model

A Stream Module's Operations commit through the Marten Unit of Work alone, and an EF Core Module's through the EF Core one: no Operation commits through both. A Stream publishes no Snapshot, and a Stream Module and a Saga cannot share one Host yet.

When to use it

When an aggregate is best kept as the events it went through. In the Proving Sample, Ledger is a Stream Module beside the EF Core Modules Catalog, Inventory and Billing: it keeps accounts, posts entries to them, and reads an account's balance by folding its entries. Stream or EF Core aggregate compares the two write models.

How it works

A Stream derives from StreamAggregate. Each behaviour applies its event to the Stream, then raises it, and the Unit of Work appends what was raised. Marten folds the stored events through the same public Apply methods:

Account.cs
// A Stream: every behaviour applies its event, then raises it for the Unit of Work to append, and Marten folds the
// stored events through the same Apply methods, which are public because its source generator dispatches only to public
// ones. Marten stores an event under its type's name without the namespace (account_opened_domain_event), so an event
// type's name stays unique among the Stream Modules of the Host's one store.
internal sealed class Account : StreamAggregate
{
    public string Holder { get; private set; } = "";

    public decimal Balance { get; private set; }

    /// <summary>Whether the other Modules were told of the account, which its activation records.</summary>
    public bool IsActive { get; private set; }

    public static Account Open(AccountId id, string holder)
    {
        var account = new Account();
        var opened = new AccountOpenedDomainEvent(id, holder);
        account.Apply(opened);
        account.Raise(opened);
        return account;
    }

    public void Post(decimal amount)
    {
        var posted = new EntryPostedDomainEvent(new AccountId(Id), amount);
        Apply(posted);
        Raise(posted);
    }

    public void Activate()
    {
        var activated = new AccountActivatedDomainEvent(new AccountId(Id));
        Apply(activated);
        Raise(activated);
    }

    public void Apply(AccountOpenedDomainEvent opened)
    {
        Id = opened.AccountId.Value;
        Holder = opened.Holder;
    }

    public void Apply(EntryPostedDomainEvent posted) => Balance += posted.Amount;

    public void Apply(AccountActivatedDomainEvent activated) => IsActive = true;
}

A handler loads and starts Streams through IStreams, and saves nothing:

PostEntryCommandHandler.cs
// The entry appends at the version the account was read at: another entry appended meanwhile fails this one with the
// Kit's concurrency conflict, and nothing is appended.
internal sealed class PostEntryCommandHandler(IStreams streams) : CommandHandler<PostEntryCommand>
{
    public override async Task<Result> Handle(
        PostEntryCommand command,
        CancellationToken cancellationToken
    )
    {
        if (
            await streams.LoadAsync<Account>(new AccountId(command.AccountId), cancellationToken)
            is not { } account
        )
        {
            return AccountErrors.NotFound;
        }

        account.Post(command.Amount);
        return Result.Success();
    }
}

A Stream Module declares its write model in its Composition Root with AddPouryaKitStreams, with a schema of its own, and the Host registers its one Marten store with UsePouryaKitMarten, after UsePouryaKitWolverine when it also has EF Core Modules. A Stream Module needs no migration. Each event is a Domain Event, so a Notification Handler subscribes to it as to any other. Registering shows Ledger's Composition Root and the Host.

What the Kit guarantees

GuaranteeStated in
One commit, concurrency, a send that fails after the commit, Receipts, tenancy, one message storeWhat the Kit guarantees
What a query may do with a StreamReading and changing Streams
What a Stream Module cannot doLimits

Failure messages

Next steps

On this page