Close the books
Goal: a ledger, a monthly report or a billing run works in periods. When a period is finished, nothing more may be appended to it, and new facts must go to the next period.
This recipe uses closing streams and gives the kernel no rollover policy. You decide what the next stream is called. The kernel guarantees that the old one stays closed.
The shape
Section titled “The shape”- The closing fact (or a carry-over fact) ends the old scope. It is an event type declared with
[ClosesStream], so the append and the closure happen in the same step. - The application derives the next stream id. It can come from the period itself (
2026-05), from a counter, or from a read model that tracks the current open instance. - The first fact in the new stream carries over what the old one ended with, for example the closing balance.
Close the period and open the next
Section titled “Close the period and open the next”The closing event takes the stream id from its Period property, so it closes the period it names whichever stream it is appended to:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;using Cratis.Chronicle.EventSequences;
[EventType][ClosesStream(EventStreamIdFrom = nameof(Period))]public record CloseTheBooksBooksClosed(string Period, decimal ClosingBalance);
[EventType]public record CloseTheBooksPeriodOpened(string Period, decimal OpeningBalance);
public class CloseTheBooksLedger(IEventLog eventLog){ static readonly EventStreamType _ledger = new("ledger");
public async Task<AppendResult> CloseApril(EventSourceId ledgerId, decimal closingBalance) { // The closing fact closes (ledger, "ledger", "2026-04") in the same step as the append. var closing = await eventLog.Append( ledgerId, new CloseTheBooksBooksClosed("2026-04", closingBalance), _ledger, new EventStreamId("2026-04")); if (!closing.IsSuccess) return closing;
// The application chooses the next stream id and records the carry-over as its first fact. return await eventLog.Append( ledgerId, new CloseTheBooksPeriodOpened("2026-05", closingBalance), _ledger, new EventStreamId("2026-05")); }}After the first append succeeds, every append to ledger / 2026-04 for that ledger is refused with StreamClosed. Other ledgers and the 2026-05 stream are unaffected. The two appends are separate calls. If the process stops between them, the old period is closed and the new one is not open yet. Make the second step safe to repeat: before opening the new period, check whether it already has its opening fact.
Keep appending to the right period
Section titled “Keep appending to the right period”Producers that post entries need the current period. Take it from a read model of the open instance rather than from the clock, so a late-closing period keeps accepting entries until it is actually closed.
A read model lags the log slightly. Right after a rollover, a producer can still read the old period and append to it. The kernel then rejects the append with StreamClosed. Treat that as a normal outcome: read the current period again and retry.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;using Cratis.Chronicle.EventSequences;
[EventType]public record CloseTheBooksEntryPosted(string Period, decimal Amount);
public interface ICloseTheBooksCurrentPeriod{ Task<string> For(EventSourceId ledgerId);}
public class CloseTheBooksPosting(IEventLog eventLog, ICloseTheBooksCurrentPeriod currentPeriod){ public async Task<AppendResult> Post(EventSourceId ledgerId, decimal amount) { AppendResult result; var attempts = 0;
do { // The current period comes from a read model of the open instance. var period = await currentPeriod.For(ledgerId); result = await eventLog.Append( ledgerId, new CloseTheBooksEntryPosted(period, amount), new EventStreamType("ledger"), new EventStreamId(period)); attempts++; } while (IsClosed(result) && attempts < 3);
return result; }
static bool IsClosed(AppendResult result) => result.ConstraintViolations.Any(violation => violation.ConstraintType == ConstraintType.StreamClosed);}The rejection is the safety net. Without the closure the late entry would be committed to a period that is already final.
Allow a correction
Section titled “Allow a correction”If a closed period sometimes needs a correction, declare a reopening event with ReopenedBy. It is a fact in the log with its own reason, and it lifts only the closure that the closing event wrote. See Closing events. A period that an operator closed manually with CompleteStream stays closed.
Carry-over and mementos
Section titled “Carry-over and mementos”Today the carry-over is an ordinary event, as in the first example. A memento, a snapshot of the old stream’s state written as the first event of the new one, is planned and tracked in Chronicle issue 1370. It will replace the hand-written carry-over fact, not the closing step.
See also
Section titled “See also”- Closing events: dimensions,
EventStreamIdFrom, reopening. - Closing streams: the guarantees and limits.
- Enforce a unique value: the other constraint recipe.