Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Cronus is a lightweight framework for building event driven systems with DDD/CQRS/ES in mind
Cronus is an open-source framework that helps you solve business problems with less time on infrastructure concerns. Applications built with Cronus are centred around three core concepts:
While many applications can be built using Cronus, it has proven very effective for microservices architectures. Cronus provides an innovative and powerful way of sensibly evolving to event-driven microservices within a microservice architecture.
Cronus has its roots way back in 2012 when a couple of passionate software engineers started building the infrastructure for a software project using DDD/CQRS/ES. The results were quite impressive and the team started growing. Everybody was sharing the same vision and passion for going beyond the boundaries of their skills. The amazing part of this story is that this hasn't changed. Cronus is a reflection of our philosophy for building great software solutions.
Cronus is built to let you express Domain-Driven Design tactical patterns with minimal friction. Aggregates, entities, value objects, commands, events, and signals all have first-class primitives and serialize/route through a predictable, conventions-first pipeline. If you are new to the DDD vocabulary, start with concepts/ddd.md; if you are new to event sourcing, read concepts/es.md.
The pages below cover the tactical building blocks you assemble into a Cronus-powered service.
Each page is self-contained and cross-references the others where a single concept spans multiple files.
Prepare a two-process TaskManager skeleton — API + worker — with Cronus, Cassandra and RabbitMQ.
This quick start builds a tiny task-management service in two processes:
TaskManager.Api — an ASP.NET Core Web API that accepts requests from clients and publishes commands to Cronus.
TaskManager.Service — a worker host that consumes commands, persists events, builds projections, and runs the rest of the Cronus pipeline.
Splitting API and worker is the recommended production topology. The API process stays fast and stateless; the worker process owns all long-running background work.
.NET 8 or .NET 9 SDK
Docker (for Cassandra and RabbitMQ)
An IDE — Visual Studio, Rider, or VS Code
The API only publishes commands, so it needs the core package; the worker persists events, builds projections and moves data over RabbitMQ, so it needs the full transport and persistence story.
From a clean Docker environment, the simplest way to get Cassandra and RabbitMQ running is:
The image tags above match the ones used by the Elders platform's shared docker-compose.yml. Use them to keep local development aligned with CI/CD.
Both processes share the same Cronus configuration. Create identical appsettings.json files in TaskManager.Api and TaskManager.Service:
//This should be int the Service and in the Api.
The full list of configuration keys lives on the configuration page:
The API process must not run application-service or projection consumers — that is the worker's job. Switch the relevant feature flags off in TaskManager.Api/appsettings.json:
services.AddCronus(configuration) registers every Cronus core service, scans your assemblies for application services, projections, sagas, ports, triggers and gateways, and binds the configuration options. ICronusHost.StartAsync() starts the subscribers based on your feature flags.
The API can now inject IPublisher<ICommand> into controllers and dispatch commands.
Open two terminals:
If both processes start cleanly (no exceptions in the logs) your Cronus skeleton is ready. Proceed to the next page to model a domain and persist your first event.
mkdir TaskManager && cd TaskManager
dotnet new sln --name TaskManager
dotnet new webapi --name TaskManager.Api
dotnet new worker --name TaskManager.Service
dotnet sln add TaskManager.Api TaskManager.Service# API — publisher only
dotnet add TaskManager.Api package Cronus
# Worker — full Cronus host
dotnet add TaskManager.Service package Cronus
dotnet add TaskManager.Service package Cronus.Transport.RabbitMQ
dotnet add TaskManager.Service package Cronus.Persistence.Cassandra
dotnet add TaskManager.Service package Cronus.Projections.Cassandra
dotnet add TaskManager.Service package Cronus.Serialization.NewtonsoftJson# Cassandra 4.0 — Cronus EventStore
docker run --restart=always -d --name cassandra \
-p 9042:9042 \
cassandra:4.0
# RabbitMQ 3.9.11 (eldersoss image — management plugin included)
docker run --restart=always -d --name rabbitmq \
-p 5672:5672 -p 15672:15672 \
-e RABBITMQ_DEFAULT_USER=user \
-e RABBITMQ_DEFAULT_PASS=pass \
-e RABBITMQ_DEFAULT_VHOST=rabbit \
eldersoss/rabbitmq:3.9.11{
"Cronus": {
"BoundedContext": "taskmanager",
"Tenants": [ "tenant" ],
"Transport": {
"RabbitMQ": {
"Server": "127.0.0.1",
"Port": 5672,
"VHost": "rabbit",
"Username": "user",
"Password": "pass"
}
},
"Persistence": {
"Cassandra": {
"ConnectionString": "Contact Points=127.0.0.1;Port=9042;Default Keyspace=taskmanager_es"
}
},
"Projections": {
"Cassandra": {
"ConnectionString": "Contact Points=127.0.0.1;Port=9042;Default Keyspace=taskmanager_projections"
}
}
}
}{
"Cronus": {
"ApplicationServicesEnabled": false,
"ProjectionsEnabled": false,
"PortsEnabled": false,
"SagasEnabled": false,
"GatewaysEnabled": false,
"TriggersEnabled": false
}
}using Elders.Cronus;
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices((ctx, services) =>
{
services.AddCronus(ctx.Configuration);
services.AddHostedService<CronusBackgroundService>();
})
.Build();
await host.RunAsync();
sealed class CronusBackgroundService : BackgroundService
{
private readonly ICronusHost cronus;
public CronusBackgroundService(ICronusHost cronus) => this.cronus = cronus;
protected override async Task ExecuteAsync(CancellationToken _)
{
await cronus.StartAsync().ConfigureAwait(false);
}
}using Elders.Cronus;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddCronus(builder.Configuration);
var app = builder.Build();
app.MapControllers();
await app.RunAsync();# Terminal 1 — worker
dotnet run --project TaskManager.Service
# Terminal 2 — API
dotnet run --project TaskManager.ApiAggregateRoot<TState> and AggregateRootState<TAggregate, TId> — the unit of consistency.
Entity<TAggregate, TState> — identified objects that live inside an aggregate boundary.
Immutable, equality-by-value types — Cronus does not ship a base class; use record or hand-written equality.
Urn, AggregateRootId, EntityId<TAggregateRootId> — stable identity on the wire.
The Cronus:BoundedContext setting and the DataContract.Namespace convention.
[DataContract(Name = "<guid>")] and how GUIDs decouple serialization from class names.
CronusContext.Tenant, TenantsOptions, and the resolver chain.
ISignal, ISignalHandle<T>, and ITrigger — ambient pub/sub broadcasts.
Commands, events, public events, signals — the four lanes of the message bus.
Application services, gateways, ports, projections, sagas, triggers.
With a TaskCreated event in Cassandra we can now build a projection — a queryable read model derived from events. This page walks through adding a TaskProjection and exposing it through the API.
Projections are persisted by the Cronus.Projections.Cassandra package. It should already be added to TaskManager.Service from the setup step; double-check:
dotnet list TaskManager.Service package | grep ProjectionsMake sure the Cronus:Projections:Cassandra:ConnectionString is set in both API and worker appsettings.json:
"Projections": {
"Cassandra": {
"ConnectionString": "Contact Points=127.0.0.1;Port=9042;Default Keyspace=taskmanager_projections"
}
}We want to query all tasks belonging to a given user. The projection ID will therefore be UserId, and the projection subscribes to TaskCreated.
Inject IProjectionReader into a controller and call GetAsync<TProjection>(id). The reader returns a ReadResult<TaskProjection>; always branch on its NotFound / HasError / IsSuccess flags.
Start the worker and the API (dotnet run --project ...).
POST /Task/CreateTask with userId=alice.
Wait a moment for the worker to handle the command and update the projection.
If the projection returns NotFound, check the worker logs for CronusWorkflowHandle entries involving TaskProjection. A missing entry means the event was not routed — usually a Subscribe<TEvent> is missing or the tenant does not match.
is a browser UI that inspects running hosts: tenants, projections, versions, rebuilds, and event traffic. It talks to the Cronus RPC endpoint that the worker exposes.
Enable the RPC endpoint in the worker's appsettings.json:
Then open the dashboard, add a connection to http://localhost:7477, and navigate to the Projections tab. A green "live" badge means the projection is synchronised with the event store.
— deeper on the write model.
— snapshots, versioning, non-event-sourced projections.
— every Cronus option.
Cronus is a framework for building services that are honest about the way their business works. Three ideas are present in every Cronus service and are worth reading about before the rest of the documentation:
— is what the services are about. Cronus assumes you have already decided what your core domain is, worked out the ubiquitous language with domain experts, and have settled on the boundaries of each bounded context. Without that, the rest of the framework is a set of answers to questions you have not asked yet.
— is how Cronus remembers what happened. Aggregates emit events when they change; events are appended to an immutable log; any current-state representation (the aggregate itself, a projection, an audit report) is derived from that log by replaying events. The log is the source of truth and everything else is disposable.
CQRS — is how Cronus separates writes from reads. The write side is a stack of aggregates, application services and an event store tuned for appends. The read side is a stack of projections tuned for the queries each UI actually makes. Commands and queries live on different types, often on different hosts, scaled independently.
You can build a Cronus service without being deeply fluent in all three — but you cannot build a good one. The rest of this documentation assumes the vocabulary of these three pages.
GET /TaskQuery/GetByUser?userId=alice → the created task appears in the list.
[DataContract(Name = "c135893e-b9e3-453a-b0e0-53545094ec5d")]
public class TaskProjectionState
{
public TaskProjectionState() { Tasks = new List<Entry>(); }
[DataMember(Order = 1)]
public List<Entry> Tasks { get; set; }
[DataContract(Name = "317b3cbb-593a-4ffc-8284-d5f5c599d8ae")]
public class Entry
{
[DataMember(Order = 1)] public TaskId Id { get; set; }
[DataMember(Order = 2)] public string Name { get; set; }
[DataMember(Order = 3)] public DateTimeOffset CreatedAt { get; set; }
[DataMember(Order = 4)] public DateTimeOffset Deadline { get; set; }
}
}[ApiController]
[Route("[controller]/[action]")]
public class TaskQueryController : ControllerBase
{
private readonly IProjectionReader reader;
public TaskQueryController(IProjectionReader reader)
{
this.reader = reader;
}
[HttpGet]
public async Task<IActionResult> GetByUser(string userId, CancellationToken ct)
{
const string tenant = "tenant";
var id = new UserId(tenant, userId);
ReadResult<TaskProjection> result = await reader.GetAsync<TaskProjection>(id).ConfigureAwait(false);
if (result.NotFound) return NotFound();
if (result.HasError) return Problem(result.Error);
return Ok(result.Data.State.Tasks);
}
}{
"Cronus": {
"RpcApiEnabled": true
}
}[DataContract(Name = "c94513d1-e5ee-4aae-8c0f-6e85b63a4e03")]
public class TaskProjection : ProjectionDefinition<TaskProjectionState, UserId>,
IEventHandler<TaskCreated>
{
public TaskProjection()
{
// one event can fan out to many projection instances — here, one per user
Subscribe<TaskCreated>(e => e.UserId);
}
public Task HandleAsync(TaskCreated @event)
{
// HandleAsync runs on every event; design it to be idempotent
if (State.Tasks.Any(x => x.Id.Equals(@event
return Task.CompletedTask;
State.Tasks.Add(new TaskProjectionState.Entry
{
Id = @event.Id,
Name = @event.Name,
CreatedAt = @event.Timestamp,
Deadline = @event.Deadline
});
return Task.CompletedTask;
}
public IEnumerable<TaskProjectionState.Entry> WithName(string name)
=> State.Tasks.Where(x => x.Name.Equals(name,
}With the skeleton from the setup page running, we can model a tiny slice of the task manager and send a command that ends up as an event in Cassandra.
The slice is:
Two aggregate IDs — TaskId and UserId.
A command — CreateTask.
An event — TaskCreated.
An aggregate root and its state — TaskAggregate / TaskState.
An application service — TaskAppService.
An API controller that publishes the command.
Put commands/events/IDs in a shared project (for example TaskManager.Contracts) that both the API and the worker reference. Aggregates, states and app services live in the worker project only.
AggregateRootId's ctor takes (tenant, arName, id) — in that order.
The aggregate is the only object allowed to call Apply. The state folds each event into itself via a When(TEvent) handler.
ApplicationService<TaskAggregate> gives you the repository field. ICommandHandler<CreateTask>.HandleAsync is the async entry point — load the aggregate, decide whether to create it, save it.
Inject IPublisher<ICommand> and await publisher.PublishAsync(...). The method returns true when the transport accepted the command.
With both the worker and the API running (see ), POST /Task/CreateTask:
The API returns 202 Accepted with the task URN (for example urn:tenant:task:c9b3…). Internally:
The controller publishes CreateTask onto RabbitMQ.
The worker's subscriber picks it up and runs TaskAppService.HandleAsync.
The service constructs a new TaskAggregate, which applies a TaskCreated event.
Take the task URN from the response, Base64-encode it, and query the Cassandra taskmanagerevents table:
You should see exactly one row — the TaskCreated event, serialised, tagged with its [DataContract(Name = …)] GUID. Restart the worker and run a LoadAsync<TaskAggregate>(taskId): Cronus rebuilds the aggregate by replaying that single event back into the state.
Next stop — turning those events into a read model.
ES
An event-sourced service does not persist the current state of its business objects. It persists the sequence of events that describe every change that ever happened to them, and derives the state on demand by replaying those events. Cronus treats the event store as the single source of truth for the write side of the service — everything else (aggregates in memory, projections, indices, analytical reports) is a derived artefact that can be thrown away and recomputed.
Contrast this with an EF- or ORM-based service. There you persist the current row of a Concert table, and a change is an UPDATE. Any insight about how the concert got to its current shape — when performers were added, when the venue changed, which commands were accepted and rejected — is lost unless you manually write an audit trail. An event-sourced service gets the audit trail for free because the events are the persistence format.
A Cronus aggregate inherits from AggregateRoot<TState> and changes its state by calling Apply(IEvent):
public class Concert : AggregateRoot<ConcertState>
{
Concert() { }
public Concert(string name, Venue venue, DateTimeOffset startTime, TimeSpan duration)
{
Apply(new ConcertAnnounced(...));
}
public void RegisterPerformer(Performer performer)
{
if (state.HasStarted) throw new InvalidOperationException("...");
Apply(new PerformerRegistered(...));
}
}The state lives in a separate class with When(TEvent) handlers — the only place the state is allowed to change:
A command is turned into zero or more events by the aggregate; those events become an AggregateCommit in the event store. Rehydrating the aggregate later is symmetric: Cronus loads the stream of past events for that aggregate id, feeds them through When, and the state you started from is the state you left off at. For the full walkthrough see .
An event-sourced workload has a very specific shape: every write is an append, and the vast majority of reads are "give me all events for this aggregate id in revision order". Cassandra's storage model is a direct match — a wide row per aggregate id, ordered clustering by revision, append-only inserts, no updates. Reads are a single partition scan; writes do not compete for a shared row lock. Distribution and replication come for free because that is what Cassandra is for.
Cronus's canonical event-store backend is and it has been in production use since 2013. See for the details of how the contract is implemented on top of Cassandra.
The property you buy with event sourcing — that the event log is the source of truth and everything else is derived — is most visible at the projection layer. A projection is a read model that handlers update as events flow through the system. If you change the projection's shape, the old rows in its store no longer match the new code. With a relational model, that would mean writing a schema migration. With event sourcing, it means bumping the projection version and replaying the events through the new handler; see .
The same property makes things like "backfill a new subscriber with every event it cares about since the start of time" and "recompute this analytical report after fixing a bug" routine. Both are a call to with the right event-type filter.
Event sourcing is not a free meal. The write side is more complex than a CRUD service, the read side requires an explicit projection pipeline, and the investment only pays off once you lean on the event log for rebuilding state, backfilling subscribers and auditing history. The shape of business that benefits most is also the shape DDD prefers — established, non-trivial, long-lived, with a rich set of distinct state transitions that people actually care about.
See also and — the three concepts reinforce each other.
public class ConcertState : AggregateRootState<Concert, ConcertId>
{
public List<Performer> Performers { get; private set; } = new();
public void When(ConcertAnnounced @event) { /* mutate state */ }
public void When(PerformerRegistered @event) { /* append performer */ }
}repository.SaveAsync(task) writes the event to Cassandra.
[DataContract(Name = "857d960c-4b91-49cc-98fd-fa543906c52d")]
public class CreateTask : ICommand
{
CreateTask() { }
public CreateTask(TaskId id, UserId userId, string name, DateTimeOffset deadline, DateTimeOffset timestamp)
{
if (id is null) throw new ArgumentNullException(nameof(id));
if (userId is null) throw new ArgumentNullException(nameof(userId));
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name is required", nameof(name));
Id = id;
UserId = userId;
Name = name;
Deadline = deadline;
Timestamp = timestamp;
}
[DataMember(Order = 1)] public TaskId Id { get; private set; }
[DataMember(Order = 2)] public UserId UserId { get; private set; }
[DataMember(Order = 3)] public string Name { get; private set; }
[DataMember(Order = 4)] public DateTimeOffset Deadline { get; private set; }
[DataMember(Order = 5)] public DateTimeOffset Timestamp { get; private set; }
public override string ToString() => $"Create task '{Name}' ({Id}) for user {UserId}.";
}[DataContract(Name = "728fc4e7-628b-4962-bd68-97c98aa05694")]
public class TaskCreated : IEvent
{
TaskCreated() { }
public TaskCreated(TaskId id, UserId userId, string name, DateTimeOffset deadline, DateTimeOffset timestamp)
{
Id = id;
UserId = userId;
Name = name;
Deadline = deadline;
Timestamp = timestamp;
}
[DataMember(Order = 1)] public TaskId Id { get; private set; }
[DataMember(Order = 2)] public UserId UserId { get; private set; }
[DataMember(Order = 3)] public string Name { get; private set; }
[DataMember(Order = 4)] public DateTimeOffset Deadline { get; private set; }
[DataMember(Order = 5)] public DateTimeOffset Timestamp { get; private set; }
public override string ToString() => $"Task '{Name}' ({Id}) created for user {UserId}.";
}public class TaskAggregate : AggregateRoot<TaskState>
{
TaskAggregate() { }
public void CreateTask(TaskId id, UserId userId, string name, DateTimeOffset deadline)
{
Apply(new TaskCreated(id, userId, name, deadline, DateTimeOffset.UtcNow));
}
}public class TaskState : AggregateRootState<TaskAggregate, TaskId>
{
public override TaskId Id { get; set; }
public UserId UserId { get; set; }
public string Name { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset Deadline { get; set; }
public void When(TaskCreated e)
{
Id = e.Id;
UserId = e.UserId;
Name = e.Name;
CreatedAt = e.Timestamp;
Deadline = e.Deadline;
}
}public class TaskAppService : ApplicationService<TaskAggregate>,
ICommandHandler<CreateTask>
{
public TaskService(IAggregateRepository repository) : base(repository) { }
public async Task HandleAsync(CreateTask command)
{
ReadResult<TaskAggregate> existing = await repository.LoadAsync<TaskAggregate>(command.Id).ConfigureAwait(false);
if (existing.IsSuccess) return; // idempotent — already created
var task = new TaskAggregate(command.Id, command.UserId, command.Name, command.Deadline);
await repository.SaveAsync(task).ConfigureAwait(false);
}
}[ApiController]
[Route("[controller]/[action]")]
public class TaskController : ControllerBase
{
private readonly IPublisher<ICommand> publisher;
public TaskController(IPublisher<ICommand> publisher)
{
this.publisher = publisher;
}
[HttpPost]
public async Task<IActionResult> CreateTask(CreateTaskRequest request, CancellationToken ct)
{
const string tenant = "tenant"; // must match Cronus:Tenants from appsettings
var taskId = new TaskId(tenant, Guid.NewGuid().ToString());
var userId = new UserId(tenant, request.UserId);
var deadline = DateTimeOffset.UtcNow.AddDays(request.DaysActive);
var command = new CreateTask(taskId, userId, request.Name, deadline, DateTimeOffset.UtcNow);
if (await publisher.PublishAsync(command).ConfigureAwait(false) == false)
return Problem($"Unable to publish {command}.");
return Accepted(taskId.Value);
}
}public class CreateTaskRequest
{
[Required] public string UserId { get; set; }
[Required] public string Name { get; set; }
[Required] public int DaysActive { get; set; }
}{
"userId": "alice",
"name": "Write the quick start",
"daysActive": 7
}echo -n 'urn:tenant:task:c9b3...' | base64
# e.g. dXJuOnRlbmFudDp0YXNrOmM5YjMu...
cqlsh -e "select * from taskmanager_es.taskmanagerevents where id = 'dXJuOnRlbmFudDp0YXNrOmM5YjMu...';"[DataContract(Name = "d5e50e1f-5886-4608-9361-9fe0eb440a6b")]
public class TaskId : AggregateRootId
{
TaskId() { }
public TaskId(string tenant, string id) : base(tenant, "
}[DataContract(Name = "00f5463f-633a-49f4-9fbe-f98e0911c2f5")]
public class UserId : AggregateRootId
{
UserId() { }
public UserId(string tenant, string id) : base(tenant, "
}CQRS
CQRS is a structural decision: the write side of a service and the read side of a service are different programs. They have different types, different stores, different scaling characteristics, and — often — different hosts. They share the business domain and the event log; they do not share their runtime.
In Cronus the split is explicit:
Writes happen against an Aggregate. A command arrives, an application service loads the aggregate from the Event Store, calls a method, and saves the resulting events. The write side is tuned for correctness and for append-only throughput.
Reads happen against a Projection. Projections subscribe to events, update their own store, and are queried through IProjectionReader. The read side is tuned for query shape — each projection exists to serve one or a few UI or API reads cheaply.
Neither side knows about the other at the type level. An application service never references a projection type; a projection never issues a command. Between them sits the and the layer.
The division shows up in the message taxonomy. Every command in Cronus is an ICommand — a strongly-typed instance with a [DataContract] attribute, routed through IPublisher<ICommand>, delivered through the application-services consumer, handled by a method on an application service that loads an aggregate, mutates it and calls SaveAsync. See the .
Queries, by contrast, do not travel on the bus at all. A query is a GetAsync(IBlobId) call against IProjectionReader, returning a ReadResult<T> — an in-process function call. The UI layer or the API controller injects the reader and asks; no routing, no broker, no retry policy, no "eventually delivered". The read side is synchronous from the caller's point of view and the latency is bounded by how fast the projection store can answer.
That distinction is load-bearing. Commands have to be durable, at-least-once, and ordered per aggregate — they are business intent. Queries have to be cheap and right-now — they are user experience. Treating them as the same kind of thing would compromise both.
Because the two sides are runtime-separate, they scale independently. A read-heavy service might run three projection hosts and one application-services host; a write-heavy migration might run a single projection host and ten application-services hosts. The flags are exactly the knobs: turn ApplicationServicesEnabled off on a host and it becomes a pure projection reader; turn ProjectionsEnabled off and it becomes a pure command processor.
Evolution is similarly decoupled. A new projection can be added without touching the aggregate — you add the projection type, deploy, let it catch up from the event store. A new command can be added without touching existing projections — the new event it produces only affects projections that subscribe to it. This is the operational payoff of CQRS, and it is the reason Cronus makes the split structural rather than stylistic.
CQRS does not require event sourcing and event sourcing does not require CQRS, but in a Cronus service they always appear together. The event log is what lets the read side be rebuildable — a new projection version replays events through its updated handler; see . Without the event log, a CQRS service would need a separate write-to-read synchronisation mechanism, and without the CQRS split, the event log would be trying to serve both the latest-state query and the append-only write path at once. Cronus picks the combination that makes the most of both.
A useful way to think about it:
The write side owns the rules. Can this performer be added to this concert? The aggregate knows. Can this order be cancelled after shipping? The aggregate knows. Rules are enforced by AggregateRoot methods that either produce an event or throw.
The read side owns the shape. What does the list of upcoming concerts look like to a mobile client? A projection. What does the ops dashboard show? A projection. The read side is free to denormalise, aggregate across multiple aggregates, and expose exactly the shape the consumer wants.
Rules are written once and do not change shape with the UI. Shapes change often and do not change the rules. That is why keeping them in separate pipelines pays.
See also and — the three concepts reinforce each other; they are the load-bearing assumptions behind every Cronus service.
Identity in Cronus is built on URNs. Every aggregate root, every entity, every projection, and every message that carries an id uses a URN-shaped value object. This page walks through the concrete types you will use, what they encode, and how to derive your own.
The base class is Urn (from Elders.Cronus.DomainModeling):
[DataContract(Name = "d3ff08b5-38e2-4aaf-b3a8-ccc423ed096d")]
public class Urn : IEquatable<Urn>, IBlobId
{
public const char PARTS_DELIMITER = ':';
public const char HIERARCHICAL_DELIMITER = '/';
public const string UriSchemeUrn = "urn";
// ...
}A URN has three interesting parts for our purposes:
the scheme, always urn,
the NID (namespace identifier) — in Cronus, the tenant,
the NSS (namespace-specific string) — everything that identifies the resource inside the tenant.
Cronus also accepts r-, q-, and f-components per , and by default is case-insensitive (Urn.UseCaseSensitiveUrns = false). NIDs must be 2–32 characters, alphanumeric plus -, and may not start or end with - nor contain the string urn.
AggregateRootId is the concrete URN used for aggregate roots. Its NSS is arname:id, so a full aggregate id looks like:
The load-bearing constructor takes those three pieces in order:
Argument order is tenant, arName, id — a frequent source of copy-paste bugs. Three companion statics make parsing easy:
Parse throws ArgumentException on an invalid URN; TryParse returns false.
In practice you never pass around a raw AggregateRootId. You derive a named subclass per aggregate, decorate it with a stable [DataContract(Name = "<guid>")], and forward the three constructor arguments:
The private parameterless constructor is required for deserialisation. The aggregate-root name ("experience") is the only free-form part — keep it lower-case, short, and stable. Changing it later is a store-fork event.
For entities hanging off an aggregate root, Cronus provides EntityId<TAggregateRootId>:
Deriving an entity id looks like this:
The NSS becomes arname:arid/entityname:entityid, so a full entity URN reads:
The hierarchical / separates the aggregate from the entity, the : separates name from id on each side. This is what EntityId.EntityRegex() validates against.
Several names you may see in older code, blog posts, or scratch branches are not part of the current framework:
AggregateRootId<T> — a generic base class was sketched but left commented-out inside AggregateRootId.cs; there is no such generic today. Derive from the non-generic AggregateRootId instead.
AggregateUrn — not defined anywhere in Elders.Cronus.DomainModeling. The previous separate type was collapsed into AggregateRootId.
If you are porting older code, delete the references and lean on AggregateRootId directly.
IUrn — no such interface. Urn implements IBlobId.
IAggregateRootId<T> — no generic form of IAggregateRootId exists in master.
StringTenantId — not in the current sources. Legacy sample code that references it is stale.
urn:acme:order:a2c19b5f-0d2e-45f3-81d1-7a5b6c9d4ee8
│ │ └─ the per-tenant id part
│ └─ the aggregate-root name
└─ the tenant (NID)[DataContract(Name = "b78e63f3-1443-4e82-ba4c-9b12883518b9")]
public partial class AggregateRootId : Urn
{
public AggregateRootId(string tenant, string arName, string id)
: base(tenant, $"{arName}{PARTS_DELIMITER}{id}")
{ /* ... */ }
public string Id { get; }
public string Tenant { get; }
public string AggregateRootName { get; }
}AggregateRootId parsed = AggregateRootId.Parse("urn:acme:order:a2c1...");
bool ok = AggregateRootId.TryParse("urn:acme:order:a2c1...", out AggregateRootId id);[DataContract(Name = "1e8bf099-2cca-4760-bfbd-f029ca02d359")]
public class ExperienceId : AggregateRootId
{
protected ExperienceId() { }
public ExperienceId(string id, string tenant)
: base(tenant, "experience", id) { }
public ExperienceId(string tenant)
: this(Guid.NewGuid().ToString(), tenant) { }
}public abstract class EntityId<TAggregateRootId> : EntityId
where TAggregateRootId : AggregateRootId
{
protected EntityId() { }
public EntityId(ReadOnlySpan<char> idBase, TAggregateRootId rootId) { /* ... */ }
protected abstract ReadOnlySpan<char> EntityName { get; }
public TAggregateRootId AggregateRootId { get; }
}[DataContract(Name = "1d23c591-219f-491e-bfb1-a775fe2751b6")]
public class WalletId : EntityId<UserId>
{
protected override ReadOnlySpan<char> EntityName => "wallet";
WalletId() { }
public WalletId(string idBase, UserId rootId) : base(idBase.AsSpan(), rootId) { }
}urn:acme:user:u-001/wallet:mainIn Domain-Driven Design a bounded context is the explicit boundary inside which a model is consistent and unambiguous. Two teams working on the same company may use the word "Order" to mean different things — DDD accepts that and asks you to name the context each meaning lives in. For the conceptual foundation, see concepts/ddd.md.
Cronus turns that concept into a single, load-bearing configuration value: Cronus:BoundedContext.
The Cronus:BoundedContext setting is one alphanumeric string. It prefixes:
every RabbitMQ exchange and queue the host declares,
every Cassandra keyspace the event store and projection store write to,
the default Namespace stored against contracts whose type does not set one explicitly.
Change that string on a running system and you fork your whole store: new queues, new keyspaces, and the old streams become invisible.
The setting is bound to the BoundedContext options class:
Two rules are enforced by the class itself:
The value is required — a host will not start without it.
The value must match ^\b([\w\d_]+$): alphanumeric characters and underscores only. No dots, dashes, or spaces.
BoundedContextProvider lower-cases and trims the input, which matches RabbitMQ's case-sensitivity and keeps names canonical.
Inject IOptionsMonitor<BoundedContext> to read the current value at runtime.
Every message type in Cronus carries a [DataContract] attribute. The Name parameter is the contract id (a GUID). The Namespace parameter is the bounded context the type belongs to.
If the type sets Namespace explicitly, that wins; otherwise the caller's default is used. For a service's own contracts the default is "implicit", meaning "route under this service's own bounded context". For contracts shared with other services, set Namespace explicitly so routing stays stable regardless of which host serializes the message.
A common pattern is to keep a static constants class in your .Contracts project:
and reference it from every contract:
That keeps the bounded-context string in exactly one place per contract assembly.
public class BoundedContext
{
[Required(AllowEmptyStrings = false,
ErrorMessage = "The configuration `Cronus:BoundedContext` is required.")]
[RegularExpression(@"^\b([\w\d_]+$)",
ErrorMessage = "Characters are not allowed for configuration `Cronus:BoundedContext`.")]
public string Name { get; set; }
public override string ToString() => Name;
}
public class BoundedContextProvider : CronusOptionsProviderBase<BoundedContext>
{
public const string SettingKey = "cronus:boundedcontext";
public override void Configure(BoundedContext options)
{
options.Name = configuration[SettingKey]?.ToLower()?.Trim();
}
}{
"cronus": {
"boundedcontext": "billing",
"tenants": [ "acme" ]
}
}public static string GetBoundedContext(this Type messageType, string defaultBoundedContext = "implicit")
{
string boundedContext;
if (!typeToBoundedContext.TryGetValue(messageType, out boundedContext))
{
boundedContext = GetAndCacheBoundedContextFromAttribute(messageType, defaultBoundedContext);
}
return boundedContext;
}public static class BC
{
public const string IdentityAndAccess = "IdentityAndAccess";
}[DataContract(Namespace = BC.IdentityAndAccess, Name = "73ffd67d-b775-4e53-ac87-90de404fc58a")]
public class TenantId : AggregateRootId { /* ... */ }An aggregate is a cluster of domain objects treated as a single consistency boundary. Every change enters through the root; every invariant is enforced inside the root. With Cronus, an aggregate is event-sourced — its state is the fold of the events it has produced.
Three classes work together:
AggregateRoot<TState> — the entry point for behaviour. It calls Apply(IEvent) to record a change.
AggregateRootState<TRoot, TRootId> — the snapshot of current data. It reacts to events via public void When(TEvent e).
AggregateRootId — the aggregate's URN-based identity.
Inherit AggregateRoot<TState>. Keep a private parameterless constructor so Cronus can rehydrate the aggregate during replay, and expose behaviour as plain methods that call Apply to record events.
Inherit AggregateRootState<TRoot, TRootId>. State exposes the current data, maintains the Id, and folds events into itself via public void When(TEvent) handlers. Cronus discovers the handlers by reflection once per process lifetime.
An AggregateRootId is a URN with three segments: tenant, aggregateRootName, and id. The base class's primary constructor is:
Define your own typed ID to avoid stringly-typed code elsewhere:
An aggregate is persisted through IAggregateRepository:
Application services are the only place that should call the repository — see:
public class TaskAggregate : AggregateRoot<TaskState>
{
TaskAggregate() { }
public TaskAggregate(TaskId id, UserId userId, string name, DateTimeOffset deadline)
{
if (id is null) throw new ArgumentNullException(nameof(id));
if (userId is null) throw new ArgumentNullException(nameof(userId));
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name is required", nameof(name));
Apply(new TaskCreated(id, userId, name, deadline, DateTimeOffset.UtcNow));
}
public void Rename(string newName)
{
if (string.IsNullOrWhiteSpace(newName)) throw new ArgumentException("Name is required", nameof(newName));
if (state.Name == newName) return; // idempotent
Apply(new TaskRenamed(state.Id, state.Name, newName, DateTimeOffset.UtcNow));
}
public void Close(UserId closedBy)
{
if (state.IsClosed) return; // already closed
Apply(new TaskClosed(state.Id, closedBy, DateTimeOffset.UtcNow));
}
}public class TaskState : AggregateRootState<TaskAggregate, TaskId>
{
public override TaskId Id { get; set; }
public UserId UserId { get; set; }
public string Name { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset Deadline { get; set; }
public bool IsClosed { get; set; }
public void When(TaskCreated e)
{
Id = e.Id;
UserId = e.UserId;
Name = e.Name;
CreatedAt = e.Timestamp;
Deadline = e.Deadline;
}
public void When(TaskRenamed e) => Name = e.NewName;
public void When(TaskClosed e) => IsClosed = true;
}public AggregateRootId(string tenant, string arName, string id)[DataContract(Name = "d5e50e1f-5886-4608-9361-9fe0eb440a6b")]
public class TaskId : AggregateRootId
{
TaskId() { }
public TaskId(string tenant, string arName, string id) : base(tenant, arName, id) { }
public TaskId(string tenant, string id) : base(tenant, "task", id) { }
}if (AggregateRootId.TryParse(urnString, out AggregateRootId parsed))
{
// parsed.Tenant, parsed.AggregateRootName, parsed.Id
}public interface IAggregateRepository
{
Task SaveAsync<AR>(AR aggregateRoot) where AR : IAggregateRoot;
Task<ReadResult<AR>> LoadAsync<AR>(AggregateRootId id) where AR : IAggregateRoot;
}An entity is a domain object with identity. Two entities are equal when their IDs match, regardless of their other properties. In Cronus, an entity lives inside an aggregate and shares the aggregate's consistency boundary — the aggregate root is the only thing that mutates entities directly.
Use entities when a part of the aggregate needs its own identity-based lifecycle, but that lifecycle is still owned by the root. Common examples are versions inside a document, line items in an order, or rooms inside a building.
Inherit Entity<TAggregateRoot, TEntityState> and pass the root plus the entity ID into the base constructor. Events are emitted via the protected Apply(IEvent) method, just like in the aggregate root — but the event is wrapped in an EntityEvent so the right entity's state handler receives it.
public class Wallet : Entity<UserAggregate, WalletState>
{
public Wallet(UserAggregate root, WalletId entityId, string name, decimal openingBalance) : base(root, entityId)
{
Apply(new WalletOpened(entityId, name, openingBalance, DateTimeOffset.UtcNow));
}
public void AddMoney(decimal amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
Apply(new MoneyAdded(state.EntityId, amount, DateTimeOffset.UtcNow));
}
}Inherit EntityState<TEntityId>. Like the aggregate state, the entity state folds its events into itself via public void When(TEvent e) handlers.
Inherit the generic EntityId<TAggregateRootId>. The base ctor takes the ID base plus the parent aggregate's ID, and you must override EntityName:
The URN of a wallet then looks like: urn:tenant:user:<user-id>/wallet:<wallet-id> — you can see the entity name appears after the hierarchical delimiter /.
The aggregate's state reacts to the "entity created" event, constructs the entity, and adds it to its collection. From that point on, the root can route calls to the entity's methods.
See the aggregate page for the companion pieces (root, state, ID):
public class WalletState : EntityState<WalletId>
{
public override WalletId EntityId { get; set; }
public string Name { get; set; }
public decimal Balance { get; set; }
public void When(WalletOpened e)
{
EntityId = e.EntityId;
Name = e.Name;
Balance = e.OpeningBalance;
}
public void When(MoneyAdded e) => Balance += e.Amount;
}public abstract class EntityId<TAggregateRootId> : EntityId
where TAggregateRootId : AggregateRootId
{
public EntityId(ReadOnlySpan<char> idBase, TAggregateRootId rootId);
protected abstract ReadOnlySpan<char> EntityName { get; }
}[DataContract(Name = "1d23c591-219f-491e-bfb1-a775fe2751b6")]
public class WalletId : EntityId<UserId>
{
WalletId() { }
public WalletId(string id, UserId rootId) : base(id.AsSpan(), rootId) { }
protected override ReadOnlySpan<char> EntityName => "wallet";
}public class UserAggregate : AggregateRoot<UserState>
{
public void OpenWallet(WalletId walletId, string name, decimal openingBalance)
{
new Wallet(this, walletId, name, openingBalance); // ctor applies WalletOpened
}
public void AddToWallet(WalletId walletId, decimal amount)
{
var wallet = state.Wallets.First(w => w.EntityId.Equals(walletId));
// In a real aggregate, state exposes wallets as live entities, not just state rows.
// See the handlers pages for the full pattern.
}
}The Cronus framework supports multitenancy, enabling a single application instance to serve multiple tenants while ensuring data isolation and security for each. This design allows for efficient resource utilization and simplified maintenance across diverse client bases.
Tenant Isolation: Each tenant's data and configurations are isolated, preventing unauthorized access and ensuring privacy.
Dynamic Tenant Management: Cronus allows for the addition or removal of tenants at runtime, facilitating scalability and adaptability to changing business needs.
Shared Infrastructure: While tenants share the same application infrastructure, their data and processes remain segregated, optimizing resource usage without compromising security.
Tenant Identification: Assign a unique identifier to each tenant to distinguish their data and operations within the system.
Data Segregation: Utilize strategies such as separate databases, schemas, or tables with tenant-specific identifiers to ensure data isolation.
Configuration Management: Maintain tenant-specific configurations to cater to individual requirements and preferences.
Access Control:
Consistent Tenant Context: Ensure that the tenant context is consistently applied throughout the application to maintain data integrity and security.
Scalability Planning: Design the system to handle varying numbers of tenants, considering factors like data volume, performance, and resource allocation.
Monitoring and Auditing: Implement monitoring and auditing tools to track tenant-specific activities, aiding in compliance and troubleshooting.
By adhering to these practices, developers can leverage Cronus's multitenancy capabilities to build scalable, secure, and efficient applications that serve multiple clients effectively.
Cronus treats the tenant as a first-class dimension of every message. One running service can host many tenants side-by-side; their event streams, projections, message routing, and per-tenant services are all isolated by a single string.
A tenant in Cronus is a short alphanumeric string (for example acme, contoso, northwind) that scopes everything the host does while processing a message:
the keyspace / table prefix used by the event store,
the keyspace / table prefix used by the projection store,
the RabbitMQ exchange and queue names,
any service you register with a per-tenant lifetime.
The tenant is not a property you carry around manually. Cronus takes the inbound message, resolves the tenant from it once, pins that tenant on the current async scope, and every component downstream that asks "which tenant am I in?" gets the same answer.
The set of tenants a host serves is controlled by the Cronus:Tenants configuration key. See for the full schema.
Each entry is lower-cased, trimmed, and validated by TenantsOptions against the regex ^\b([\w\d_]+$), so tenant names may only contain alphanumeric characters and underscores. The binding is performed by TenantsOptionsProvider under the setting key cronus:tenants.
Inject IOptionsMonitor<TenantsOptions> anywhere you need to enumerate the configured tenants at runtime.
While handling a message, Cronus creates an IServiceScope and attaches a CronusContext to it. The context holds the tenant and the scope's service provider:
The context is exposed via ICronusContextAccessor, which stores it in an AsyncLocal<CronusContextHolder> so it flows across await boundaries without you passing it around. The IsNotInitialized short-circuit exists so callers outside a message-handling scope can detect that there is no current tenant rather than accidentally reading null.
Cronus resolves the tenant from the inbound message via a two-layer chain.
A non-generic dispatcher, TenantResolver : ITenantResolver, receives the raw object and looks up a typed resolver for its runtime type. It also implements the single-tenant fallback: if no resolver returns a value but Cronus:Tenants has exactly one entry, that entry is used.
Typed resolvers implement ITenantResolver<T>. DefaultTenantResolver ships implementations for AggregateRootId,
Registration is automated by MultitenancyDiscovery: every non-abstract type implementing ITenantResolver<T> found in the loaded assemblies is registered as a singleton for each ITenantResolver<T> interface it closes. To add your own resolver (for example one that pulls the tenant from a custom transport header), just implement ITenantResolver<YourSource> and let discovery pick it up.
Two helpers make tenant-scoped singletons painless:
SingletonPerTenant<T> resolves, caches, and returns a T per tenant, setting IHaveTenant.Tenant on the instance if the type implements that marker.
SingletonPerTenantContainer<T> is the shared dictionary behind SingletonPerTenant<T> and disposes cached instances when the host shuts down.
Register them with AddTenantSingleton<TService, TImplementation> from :
AddTenantSupport wires SingletonPerTenant<> as transient and SingletonPerTenantContainer<> as singleton, so every resolution of TService transparently dispatches to the correct tenant-specific instance.
Every AggregateRootId encodes the tenant in its URN. The constructor takes the tenant first:
See for the full URN layout. DefaultTenantResolver.Resolve(AggregateRootId id) simply returns id.Tenant; this is why messages that expose an AggregateRootId property do not need to carry a separate Tenant string.
AggregateCommitIMessageIBlobIdCronusMessagestringIMessageTenantIBlobId{
"cronus": {
"boundedcontext": "billing",
"tenants": [ "acme", "contoso" ]
}
}public class TenantsOptions
{
[Required(AllowEmptyStrings = false, ErrorMessage = "The configuration `Cronus:Tenants` is required.")]
[CollectionRegularExpression(@"^\b([\w\d_]+$)")]
public IEnumerable<string> Tenants { get; set; }
}public sealed class CronusContext
{
public CronusContext(string tenant, IServiceProvider serviceProvider)
{
if (string.IsNullOrEmpty(tenant))
throw new ArgumentException(
"Unknown tenant. CronusContext is not properly built. " +
"Make sure that you have properly configured `cronus:tenants`.");
if (serviceProvider is null) throw new ArgumentNullException(nameof(serviceProvider));
Tenant = tenant;
ServiceProvider = serviceProvider;
Trace = new Dictionary<string, object>();
}
public string Tenant { get; private set; }
public IServiceProvider ServiceProvider { get; private set; }
public Dictionary<string, object> Trace { get; }
public bool IsNotInitialized => string.IsNullOrEmpty(Tenant) || ServiceProvider is null;
public bool IsInitialized => IsNotInitialized == false;
}string tenant = resolverCache.GetTenantFrom(source);
if (string.IsNullOrEmpty(tenant) == false)
return tenant;
if (tenants.Tenants.Count() == 1)
return tenants.Tenants.Single();
throw new UnableToResolveTenantException("Unable to resolve tenant.");public static IServiceCollection AddTenantSingleton<TService, TImplementation>(this IServiceCollection services)
where TService : class
where TImplementation : class, TService
{
services.AddTransient<TImplementation>();
services.AddTransient<TService>(provider =>
provider.GetRequiredService<SingletonPerTenant<TImplementation>>().Get());
return services;
}public AggregateRootId(string tenant, string arName, string id)// 1. Boot Cronus with two tenants.
var builder = Host.CreateDefaultBuilder(args)
.ConfigureServices((ctx, services) => services.AddCronus(ctx.Configuration));
// 2. Register a tenant-scoped service.
builder.ConfigureServices(s => s.AddTenantSingleton<IRateLimiter, MemoryRateLimiter>());
// 3. Inside any handler, read the current tenant from the context.
public class OrderCreatedHandler : IEventHandler<OrderCreated>
{
private readonly ICronusContextAccessor ctx;
public OrderCreatedHandler(ICronusContextAccessor ctx) => this.ctx = ctx;
public Task HandleAsync(OrderCreated @event)
{
var tenant = ctx.CronusContext.Tenant; // "acme" or "contoso"
// ...
return Task.CompletedTask;
}
}A value object is an immutable, identity-less piece of the domain model. Two value objects are equal when their properties are equal — there is no "same-ness" tracked separately from the data itself. Prices, coordinates, phone numbers, date ranges, colour codes: these are all value objects. See Martin Fowler's short piece for the canonical definition.
The two idiomatic options on modern .NET are records and hand-written immutable classes.
record types give you structural equality for free:
[DataContract(Name = "1b6187f0-88c7-46d5-a22d-b39301765412")]
public record Money
{
Money() { }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount));
if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency is required", nameof(currency));
Amount = amount;
Currency = currency;
}
[DataMember(Order = 1)] public decimal Amount { get; init; }
[DataMember(Order = 2)] public string Currency { get; init; }
public Money Add(Money other)
{
if (Currency != other.Currency) throw new InvalidOperationException("Cannot add different currencies.");
return new Money(Amount + other.Amount, Currency);
}
}Two Money(10, "EUR") instances are equal by definition. GetHashCode, Equals and the == / != operators are generated for you.
If you need more control (base classes, private backing fields, bespoke comparisons) write a regular class:
If a property is a collection of value objects, make sure the collection itself supports element-by-element equality. Standard List<T> does not — two List<T> with the same contents are not equal under default equality. Either use HashSet<T> with a value-equality comparer, or compute equality explicitly.
Value objects travel inside events, commands and projection state, so they must serialize cleanly:
[DataContract(Name = "5d0b3b92-87bf-4f3c-b1a6-70de54d2cbfd")]
public sealed class GeoCoordinate : IEquatable<GeoCoordinate>
{
GeoCoordinate() { }
public GeoCoordinate(double latitude, double longitude)
{
if (latitude < -90 || latitude > 90) throw new ArgumentOutOfRangeException(nameof(latitude));
if (longitude < -180 || longitude > 180) throw new ArgumentOutOfRangeException(nameof(longitude));
Latitude = latitude;
Longitude = longitude;
}
[DataMember(Order = 1)] public double Latitude { get; private set; }
[DataMember(Order = 2)] public double Longitude { get; private set; }
public bool Equals(GeoCoordinate other) =>
other is not null && Latitude == other.Latitude && Longitude == other.Longitude;
public override bool Equals(object obj) => Equals(obj as GeoCoordinate);
public override int GetHashCode() => HashCode.Combine(Latitude, Longitude);
public static bool operator ==(GeoCoordinate left, GeoCoordinate right) =>
left is null ? right is null : left.Equals(right);
public static bool operator !=(GeoCoordinate left, GeoCoordinate right) => !(left == right);
}A message is the unit of communication that flows through a Cronus bounded context. Every message implements IMessage and carries a Timestamp. Cronus recognises four kinds of messages and each has its own role in the domain model.
Each kind is documented on its own page:
To send a message from outside an aggregate (for example from an API controller, a port, or a saga) inject the typed IPublisher<T> and await one of its PublishAsync overloads:
Command
Request a business change. May be accepted or rejected by the aggregate.
API, saga, port
Event
Record a business change that already happened inside the bounded context.
Aggregate root / entity
Public event
Announce a change to the outside world (published language). Carries the originating tenant.
Aggregate root
Signal
Trigger arbitrary side-effects (heartbeats, rebuilds, process pings).
Anything
public interface IMessage { DateTimeOffset Timestamp { get; } }
public interface ICommand : IMessage { }
public interface IEvent : IMessage { }
public interface IPublicEvent : IMessage { string Tenant { get; } }
public interface ISignal : IMessage { }
public interface IScheduledMessage : IMessage { DateTime PublishAt { get; } }Task<bool> PublishAsync(TMessage message, Dictionary<string, string> headers = null);
Task<bool> PublishAsync(TMessage message, DateTime publishAt, Dictionary<string, string> headers = null);
Task<bool> PublishAsync(TMessage message, TimeSpan publishAfter, Dictionary<string, string> headers = null);Build a small task-management service end-to-end with Cronus: API, worker, commands, events, and projections.
To drive the examples in the following pages, we'll build a task-management service that meets these real-world requirements:
A new task-management system for multiple tenants.
Data must be consistent — no partial updates, no silent loss of state.
Tasks can be reassigned inside the same user group.
Every user has an accurate progress report.
Group progress reports are restricted to group members.
When a user finishes a task, the group is notified.
A screen shows the historical changes in user activity.
When a user closes their account, an optional exit survey is recorded.
A monthly report summarises why lost users closed their accounts.
We won't implement every single bullet in the quick start. The goal is to get a working skeleton in place and see a command become an event become a projection.
— create two processes (API + worker), add the Cronus packages, start Cassandra & RabbitMQ in Docker, and wire AddCronus(Configuration).
— model TaskAggregate, publish a CreateTask command, persist a TaskCreated event.
By the end you will have a two-process Cronus service you can grow into a real domain. Each page ends with a pointer to the deeper documentation on the building block it introduced.
TaskProjection over the events and query it from the API.DDD
It is our job, as software engineers, to effectively transfer real-world business problems and solutions into the digital world. This is not an easy task. You have to understand how the business operates and what are its key advantages over the competitors. Exploring models in collaboration with domain experts and software experts is essential. To be successful you need to uncover and concentrate on how the business generates profit. We call this area the Core Domain.
There are different supporting pieces as part of the overall puzzle which enhance the Core Domain. We call those Supporting Domains. You need to pay special attention to the details because sometimes a so-called "very important part of the business" could be something generic and commonly used in other applications - a.k.a Generic Domain. For such cases, it is recommended to use an off-the-shelf product or leverage an open-source project and concentrate on the Core and Supporting domains. Otherwise, the software engineers will build something which does not bring value nor a competitive advantage to the client.
Domain-Driven Design is a concept that helps to reduce the overall complexity of a domain. Software solutions built using the Domain-Driven Design approach, tend to reflect the reality closer and enable the business to grow and expand faster in a reliable and predictable way.
There are different opinions about the situations a DDD is appropriate and when you should use it.
There are several factors that make Domain-Driven Design shine. If you have everything in place you should expect good, if not great, results:
It is recommended to use other techniques, different from Domain Driven Design if:
you are building a software solution for a totally new business. The argument behind this is that Domain-Driven Design relies on the experience and the know-how of the domain experts. It is very likely that you will invest a great amount of time designing models which are not useful. This for sure will lead you to a failure.
you are building a concept/MVP solution for a new feature. It is better to do it the "quick-and-dirty" way so you could discover if the business concept will be successful or not. This will give you a deeper understanding of the problems you are trying to solve.
Find a domain expert!
Strategic design
Tactical design
Fail fast
Anticipate the steep learning curve
A domain event is a fact: something that already happened inside the bounded context. It is the unit of change in an event-sourced system — the aggregate's state is computed by replaying its events, and projections are built by handling them.
Implement IEvent. Keep the parameterless constructor private so serializers can hydrate the instance, and expose every property with a private setter so the event is immutable from the outside.
[DataContract(Name = "728fc4e7-628b-4962-bd68-97c98aa05694")]
public class TaskCreated : IEvent
{
TaskCreated() { }
public TaskCreated(TaskId id, UserId userId, string name, DateTimeOffset deadline, DateTimeOffset timestamp)
{
Id = id;
UserId = userId;
Name = name;
Deadline = deadline;
Timestamp = timestamp;
}
[DataMember(Order = 1)] public TaskId Id { get; private set; }
[DataMember(Order = 2)] public UserId UserId { get; private set; }
[DataMember(Order = 3)] public string Name { get; private set; }
[DataMember(Order = 4)] public DateTimeOffset Deadline { get; private set; }
[DataMember(Order = 5)] public DateTimeOffset Timestamp { get; private set; }
public override string ToString() => $"Task '{Name}' ({Id}) created for user {UserId}.";
}Events are never published directly — they are always emitted through an aggregate root (or an entity inside one) by calling the protected Apply method. The aggregate's state handler (public void When(TEvent e)) is invoked synchronously and the event is added to the uncommitted stream. Persistence happens when the application service calls repository.SaveAsync(aggregate).
public class TaskAggregate : AggregateRoot<TaskState>
{
TaskAggregate() { }
public TaskAggregate(TaskId id, UserId userId, string name, DateTimeOffset deadline)
{
Apply(new TaskCreated(id, userId, name, deadline, DateTimeOffset.UtcNow));
}
}Any handler that needs to react to an event (projection, saga, port, trigger) declares IEventHandler<TEvent>:
See the dedicated handler pages for the specifics of each subscriber kind.
AccountSuspendedan event must be emitted by exactly one aggregate via Apply(IEvent)
an event should carry every piece of information a consumer might need — an event is a historical record and will never be enriched
an event must keep its [DataContract(Name = "<guid>")] forever; renaming or retyping breaks replay
public interface IEventHandler<in T> where T : IEvent
{
Task HandleAsync(T @event);
}A command expresses the intent to change the state of a single aggregate. It is an immutable message, authored by the caller, that the domain model can accept or reject based on the current state and its invariants. One command maps to one aggregate and one application service.
Implement the ICommand marker. Keep a private parameterless constructor (serializers need it) and assign all properties through the public constructor so instances are effectively immutable.
[DataContract(Name = "857d960c-4b91-49cc-98fd-fa543906c52d")]
public class CreateTask : ICommand
{
CreateTask() { }
public CreateTask(TaskId id, UserId userId, string name, DateTimeOffset timestamp)
{
if (id is null) throw new ArgumentNullException(nameof(id));
if (userId is null) throw new ArgumentNullException(nameof(userId));
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name is required", nameof(name));
Id = id;
UserId = userId;
Name = name;
Timestamp = timestamp;
}
[DataMember(Order = 1)] public TaskId Id { get; private set; }
[DataMember(Order = 2)] public UserId UserId { get; private set; }
[DataMember(Order = 3)] public string Name { get; private set; }
[DataMember(Order = 4)] public DateTimeOffset Timestamp { get; private set; }
public override string ToString() => $"Create task '{Name}' ({Id}) for user {UserId}.";
}Inject IPublisher<ICommand> and await the PublishAsync method. The call returns true when the transport accepted the command. A false result means the command was not dispatched and the caller must decide how to recover.
[ApiController]
[Route("[controller]/[action]")]
public class TaskController : ControllerBase
{
private readonly IPublisher<ICommand> publisher;
public TaskController(IPublisher<ICommand> publisher)
{
this.publisher = publisher;
}
[HttpPost]
public async Task<IActionResult> CreateTask(CreateTaskRequest request, CancellationToken ct)
{
var taskId = new TaskId(request.Tenant, "task", Guid.NewGuid().ToString());
var userId = new UserId(request.Tenant, "user", request.UserId);
var command = new CreateTask(taskId, userId, request.Name, DateTimeOffset.UtcNow);
if (await publisher.PublishAsync(command).ConfigureAwait(false) == false)
return Problem($"Unable to publish {command}.");
return Accepted(taskId.Value);
}
}IPublisher<TMessage> exposes overloads for deferred delivery:
Deferred delivery requires the scheduled-message transport to be configured. See the transport-specific documentation (e.g. RabbitMQ) for setup.
Commands are consumed by an application service — the write-side entry point for a specific aggregate.
SuspendAccounta command must update at most one aggregate
a command can be rejected when validation or an invariant fails — return early in the application service
a command should not be broadcast from the UI directly; go through an API
await publisher.PublishAsync(command, TimeSpan.FromMinutes(5)); // delay
await publisher.PublishAsync(command, DateTime.UtcNow.AddHours(1)); // scheduleA handler is a class that reacts to an incoming message and produces some outcome. In Cronus, handlers come in six shapes, each with a different job.
Every handler implements the marker interface IMessageHandler. Beyond that, the kind of handler determines which types of messages it listens to and what it is allowed to do in response.
The choice is about intent, not capability.
Use an Application Service when a command must mutate one aggregate root.
Use a Projection when you need a queryable read model built from the event stream.
Use a Saga when the outcome of one event must lead to new commands that coordinate several aggregates.
Use a Port
Each kind of handler is served by a dedicated subscriber that can be enabled or disabled per host process via :
Cronus:ApplicationServicesEnabled
Cronus:ProjectionsEnabled
Cronus:SagasEnabled
All default to true. Turn one off when you want to split a monolith into specialised processes — for example, a projections host that never runs ports or sagas.
Use a Trigger when an event or signal should kick off a long-running job or workflow.
Use a Gateway when you need a Port with a small amount of persistent infrastructure state — for example, last-known device token badges.
Cronus:PortsEnabledCronus:TriggersEnabled
Cronus:GatewaysEnabled
ICommand
New events on a single aggregate
No — should not perform I/O outside the event store
IEvent
A read model (snapshot or external store)
No — must not publish commands or events
IEvent, IScheduledMessage
New ICommand messages; scheduled timeouts
No business-facing side effects — coordinate aggregates
IEvent
New ICommand messages
Yes — the classic "send email", "call external API" place
IEvent, ISignal
Anything — typically starts a job or a downstream workflow
Yes
IEvent
New ICommand messages, with tracked infrastructure state
Yes — owns metadata required by an external system
A public event is an event you explicitly choose to share with the outside world — other bounded contexts, integration consumers, downstream services. It is the Cronus expression of a published language: a deliberate, versioned contract that outlives the internal representation of your domain events.
A public event implements IPublicEvent, which adds a Tenant property on top of IMessage:
public interface IPublicEvent : IMessage
{
string Tenant { get; }
}[DataContract(Name = "c6d9d1ae-5e54-4e1e-9121-9ab0c4f3f7a5")]
public class AccountSuspended_public : IPublicEvent
{
AccountSuspended_public() { }
public AccountSuspended_public(string tenant, string accountUrn, DateTimeOffset timestamp)
{
Tenant = tenant;
AccountUrn = accountUrn;
Timestamp = timestamp;
}
[DataMember(Order = 1)] public string Tenant { get; private set; }
[DataMember(Order = 2)] public string AccountUrn { get; private set; }
[DataMember(Order = 3)] public DateTimeOffset Timestamp { get; private set; }
public override string ToString() => $"Account {AccountUrn} suspended in tenant {Tenant}.";
}Public events are emitted from the aggregate root via an overload of Apply:
public void Suspend()
{
if (state.IsSuspended) return;
Apply(new AccountSuspended(state.Id, DateTimeOffset.UtcNow));
Apply(new AccountSuspended_public(state.Id.Tenant, state.Id.Value, DateTimeOffset.UtcNow));
}Cronus tracks uncommitted public events separately from domain events (see IUnderstandPublishedLanguage.UncommittedPublicEvents). They are published after the aggregate commit has been persisted, so the outside world only ever sees events that actually happened.
Use IPublicEventHandler<T> when another bounded context needs to react to the published language:
a public event should contain enough context to be consumed without round-tripping back to the source
you should keep public events thinner than internal events — only what the outside world actually needs
you must not change existing [DataContract(Name)] GUIDs or property Order values
public class OnAccountSuspendedPort : IPort,
IPublicEventHandler<AccountSuspended_public>
{
public Task HandleAsync(AccountSuspended_public @event)
{
// translate external fact into a local command / projection update
return Task.CompletedTask;
}
}A signal is a fire-and-forget trigger that tells something in the system to run. Unlike a command it has no aggregate target, no invariants, and no success/failure contract — its only purpose is to fan out work.
Cronus itself uses signals for heartbeats, projection rebuilds, and index maintenance. In your domain you might use them to pulse periodic reports, announce cron-like events, or ping a saga to re-evaluate its state.
public interface ISignal : IMessage { }[DataContract(Name = "c04b3c09-4ad4-4a62-b2f6-d5d86d4f0e55")]
public class RefreshDailyReportSignal : ISignal
{
RefreshDailyReportSignal() { }
public RefreshDailyReportSignal(string tenant, DateTimeOffset timestamp)
{
Tenant = tenant;
Timestamp = timestamp;
}
[DataMember(Order = 1)] public string Tenant { get; private set; }
[DataMember(Order = 2)] public DateTimeOffset Timestamp { get; private set; }
public override string ToString() => $"Refresh daily report for {Tenant}.";
}Implement ISignalHandle<TSignal>:
public interface ISignalHandle<in T> where T : ISignal
{
Task HandleAsync(T signal);
}public class DailyReportTrigger : ITrigger,
ISignalHandle<RefreshDailyReportSignal>
{
private readonly IPublisher<ICommand> commandPublisher;
public DailyReportTrigger(IPublisher<ICommand> commandPublisher)
{
this.commandPublisher = commandPublisher;
}
public async Task HandleAsync(RefreshDailyReportSignal signal)
{
var cmd = new RebuildDailyReport(signal.Tenant, signal.Timestamp);
await commandPublisher.PublishAsync(cmd).ConfigureAwait(false);
}
}Inject IPublisher<ISignal> and call PublishAsync:
await signalPublisher.PublishAsync(new RefreshDailyReportSignal("acme", DateTimeOffset.UtcNow));You can also schedule a signal with one of the PublishAsync overloads that accepts a DateTime or a TimeSpan.
Two real signal types ship inside the framework and are good references when you write your own.
CronusHeartbeat wakes up every Cronus:Heartbeat:IntervalInSeconds seconds, builds a and publishes it through IPublisher<ISignal>. The signal carries the bounded context and the full tenant list, so downstream services can use it as a liveness probe — see for the consumer side.
The publisher on the heartbeat side typically attaches a TTL header so a delayed consumer can drop a stale beat:
is a system trigger that handles ReplayPublicEventsRequested — a signal asking the host to republish a slice of the public-event stream to a newly-added subscriber. It is a small but representative example of an ISystemTrigger that reacts to a signal by kicking off a :
The pattern — receive a signal, build a job, hand it to the runner — is the canonical way to express "kick off long-running work in response to an ambient nudge".
a signal can be broadcast to many handlers at once; design them to be independent
a signal handler must be idempotent — the same signal can arrive more than once
[DataContract(Namespace = "cronus", Name = "c80739a6-b5dc-483e-8c11-06a85542416e")]
public sealed class HeartbeatSignal : ISignal
{
HeartbeatSignal() { Tenants = new List<string>(); }
public HeartbeatSignal(string boundedContext, List<string> tenants)
{
BoundedContext = boundedContext;
Tenants = tenants;
Timestamp = DateTimeOffset.Now;
Tenant = "cronus";
MachineName = Environment.MachineName;
EnvironmentConfig = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
}
[DataMember(Order = 0)] public string Tenant { get; private set; }
[DataMember(Order = 1)] public string BoundedContext { get; private set; }
[DataMember(Order = 2)] public List<string> Tenants { get; private set; }
[DataMember(Order = 3)] public DateTimeOffset Timestamp { get; private set; }
[DataMember(Order = 4)] public string MachineName { get; private set; }
[DataMember(Order = 5)] public string EnvironmentConfig { get; private set; }
}var headers = new Dictionary<string, string> { { MessageHeader.TTL, "5000" } };
await publisher.PublishAsync(signal, headers);public sealed class PublicEventsPlayer : ISystemTrigger,
ISignalHandle<ReplayPublicEventsRequested>
{
private readonly ICronusJobRunner jobRunner;
private readonly ReplayPublicEvents_JobFactory jobFactory;
private readonly ILogger<PublicEventsPlayer> logger;
public async Task HandleAsync(ReplayPublicEventsRequested signal)
{
ReplayPublicEvents_Job job = jobFactory.CreateJob(signal);
await jobRunner.ExecuteAsync(job).ConfigureAwait(false);
}
}An application service is the write-side entry point of an aggregate. It is the handler where commands are received and translated into operations on a single aggregate root. This is the "write" side in CQRS.
An application service orchestrates a command: it loads (or creates) the aggregate, calls the right method on it, and saves the resulting events. It is the only place that bridges the infrastructure (the command bus, the repository) and the domain model. Nothing outside of an application service should ever touch the aggregate directly.
AggregateInherit from ApplicationService<AR> where AR is the aggregate root type. Implement ICommandHandler<T> for every command you want to handle. The base class gives you a protected repository field of type IAggregateRepository and a UpdateAsync helper that loads, mutates, and saves in one call.
ICommandHandler<T> requires one async method:
Use the explicit LoadAsync + SaveAsync pattern when the command can create the aggregate. ReadResult<AR> exposes IsSuccess, NotFound and HasError:
Use UpdateAsync(id, action) when you are 100% sure the aggregate already exists. It throws if the load fails and saves automatically on success:
public abstract class ApplicationService<AR> : IApplicationService where AR : IAggregateRoot
{
protected readonly IAggregateRepository repository;
public ApplicationService(IAggregateRepository repository) { ... }
public virtual async Task UpdateAsync(AggregateRootId id, Action<AR> update) { ... }
}public interface ICommandHandler<in T> where T : ICommand
{
Task HandleAsync(T command);
}public class TaskAppService : ApplicationService<TaskAggregate>,
ICommandHandler<CreateTask>,
ICommandHandler<RenameTask>,
ICommandHandler<CloseTask>
{
public TaskAppService(IAggregateRepository repository) : base(repository) { }
public async Task HandleAsync(CreateTask command)
{
ReadResult<TaskAggregate> result = await repository.LoadAsync<TaskAggregate>(command.Id).ConfigureAwait(false);
if (result.IsSuccess)
return; // already created — commands are idempotent
var task = new TaskAggregate(command.Id, command.UserId, command.Name, command.Deadline);
await repository.SaveAsync(task).ConfigureAwait(false);
}
public Task HandleAsync(RenameTask command)
{
return UpdateAsync(command.Id, task => task.Rename(command.NewName));
}
public Task HandleAsync(CloseTask command)
{
return UpdateAsync(command.Id, task => task.Close(command.ClosedBy));
}
}ReadResult<TaskAggregate> result = await repository.LoadAsync<TaskAggregate>(command.Id);
if (result.NotFound)
{
var task = new TaskAggregate(...);
await repository.SaveAsync(task);
}await UpdateAsync(command.Id, task => task.Rename(command.NewName));A published language is the shared vocabulary between two bounded contexts — the set of messages they agree on so one can consume what the other emits without coupling internal models. In Cronus the published language is defined by the messages you mark with [DataContract].
Every serialisable message, aggregate id, entity id, and value object in Cronus carries [DataContract(Name = "<guid>")]. The GUID is the stable serialization id for the type. It is not a version number and not a human-friendly name — it is the thing the wire format and the event store use to address a CLR type.
If you rename the class OrderPlaced to OrderSubmitted, the GUID in the attribute stays the same. Serialized events already on disk continue to resolve to the new class name, because look-up goes through the GUID, not through typeof(T).FullName.
MessageInfo implements this indirection:
A type without [DataContract(Name = ...)] will throw the moment Cronus tries to serialise it — there is no fallback.
DataContract.Namespace doubles as the bounded context for the type:
If a type sets Namespace explicitly, that value wins; otherwise the default "implicit" is used. The value is always lower-cased, because RabbitMQ exchanges and queues are case-sensitive and Cronus keeps the routing keys canonical.
See for how that value flows into RabbitMQ routing and Cassandra keyspaces.
The convention is uniform across the Elders ecosystem. A few live attributes:
Framework-level: Urn and AggregateRootId both opt in.
An event in a product code-base (no explicit namespace, so it routes under the service's own bounded context):
A contract shared across services — the namespace is set so the routing stays stable regardless of which host serialises it:
Note the companion convention: a static constants class (BC) holds the bounded-context strings so each contract references the same literal.
Event sourcing persists events forever. If a rename, a move, or a refactor broke the mapping between a serialized payload and a CLR type, replay would stop working. The GUID in [DataContract(Name = ...)] is the insurance against that: it pins the serialization id to the type across every rename and every namespace move.
[DataMember(Order = N)] on every persisted property is the field-level equivalent: removing or renaming a field without keeping its Order number stable breaks replay.
When the shape of an event has to change in a way that is not backwards-compatible, do not mutate the existing type. Create a new type with a new GUID, then add a migration to transform old payloads into the new shape during replay.
private static string GetAndCacheContractIdFromAttribute(Type contractType)
{
DataContractAttribute contract = contractType
.GetCustomAttributes(false).Where(attr => attr is DataContractAttribute)
.SingleOrDefault() as DataContractAttribute;
if (contract == null || String.IsNullOrEmpty(contract.Name))
{
throw new Exception(String.Format(
"The message type '{0}' is missing a DataContract attribute. " +
"Example: [DataContract(\"00000000-0000-0000-0000-000000000000\")]",
contractType.FullName));
}
return contract.Name;
}public static string GetBoundedContext(this Type messageType, string defaultBoundedContext = "implicit")
{
// ... cached look-up ...
if (contract is null == false && contract.IsNamespaceSetExplicitly)
boundedContext = contract.Namespace;
return boundedContext.ToLower();
}// Cronus.DomainModeling/Urn.cs
[DataContract(Name = "d3ff08b5-38e2-4aaf-b3a8-ccc423ed096d")]
public class Urn : IEquatable<Urn>, IBlobId { /* ... */ }
// Cronus.DomainModeling/AggregateRootId.cs
[DataContract(Name = "b78e63f3-1443-4e82-ba4c-9b12883518b9")]
public partial class AggregateRootId : Urn { /* ... */ }// locus.backend/Experiences/Events/ExperienceMovedToBin.cs
[DataContract(Name = "945df1c4-ae46-4849-a094-e83a67abc95b")]
public class ExperienceMovedToBin : IEvent
{
[DataMember(Order = 1)] public ExperienceId Id { get; private set; }
[DataMember(Order = 2)] public UserId MovedBy { get; private set; }
[DataMember(Order = 3)] public DateTime UpdatedAt { get; private set; }
}// Elders.IdentityAndAccess.Contracts/Profiles/ProfileName.cs
[DataContract(Namespace = BC.IdentityAndAccess,
Name = "6f353a50-34ec-47dc-ba9f-1862caff7010")]
public sealed record ProfileName
{
[DataMember(Order = 1)] public string Name { get; init; }
[DataMember(Order = 2)] public string FirstName { get; init; }
/* ... */
}Sometimes called a Process Manager
A saga is a message handler that watches the event stream and, as a reaction, publishes new commands. Its job is to coordinate a business process that spans more than one aggregate, or to schedule work for the future.
In Cronus, a saga is any class that implements ISaga. Nearly every real saga derives from the abstract Saga base class in the same file, which wires up the two publishers a saga needs:
public abstract class Saga : ISaga
{
protected readonly IPublisher<ICommand> commandPublisher;
protected readonly IPublisher<IScheduledMessage> timeoutRequestPublisher;
public Saga(IPublisher<ICommand> commandPublisher, IPublisher<IScheduledMessage> timeoutRequestPublisher)
{
this.commandPublisher = commandPublisher ?? throw new ArgumentNullException(nameof(commandPublisher));
this.timeoutRequestPublisher = timeoutRequestPublisher ?? throw new ArgumentNullException(nameof(timeoutRequestPublisher));
}
public Task RequestTimeoutAsync<T>(T timeoutMessage) where T : IScheduledMessage
{
return timeoutRequestPublisher.PublishAsync(timeoutMessage, timeoutMessage.PublishAt);
}
}A saga reacts to events through the standard IEventHandler<T> interface. On top of that, it can receive scheduled messages through ISagaTimeoutHandler<T>, where T : IScheduledMessage.
Both are message handlers, but they sit on opposite sides of the command/event boundary.
An Application Service mutates one aggregate. A Saga never loads an aggregate directly — it publishes commands so that the appropriate Application Services do that work. If you catch yourself wanting to load aggregate state inside a saga, you almost certainly belong in an Application Service or in a new projection.
A saga often needs to do something later — "if the customer has not confirmed in 24 hours, cancel the reservation". It expresses this by publishing an :
Call RequestTimeoutAsync(message) on the base class. Cronus will deliver the message back to the saga at PublishAt, and the saga handles it through ISagaTimeoutHandler<T>.
A production saga rarely has a single handler. It usually reacts to a broad slice of the lifecycle it owns. For a realistic shape look at in the Locus backend — it handles 13 events covering publish-for-approval, approval confirmation, rejection and every edit that invalidates a pending version — and in every case its only job is to publish a command to the right aggregate:
The saga does not touch aggregate state. Each handler inspects the event, decides on the next command and publishes it through commandPublisher. That is the whole pattern.
The subscriber that feeds sagas is toggled by Cronus:SagasEnabled (default: true). Turn it off on processes that must not advance saga state.
A Saga can send new commands to drive the process forward.
a saga can subscribe to events from any aggregate in the bounded context
a saga can publish new commands through commandPublisher
a saga
Fulfil a single command
Orchestrate a multi-aggregate process
RequestTimeoutAsynca saga must be idempotent — the same event may arrive more than once
a saga must decide what to do from the incoming event alone, not from external state
Reacts to
ICommand
IEvent, IScheduledMessage
Produces
New events on one aggregate
New ICommand messages
Loads aggregate state?
Yes — from the event store
No
Purpose
public interface IScheduledMessage : IMessage
{
/// <summary>
/// The date when this message will be published.
/// </summary>
DateTime PublishAt { get; }
}[DataContract(Name = "d4eb8803-2cc7-48dd-9ca1-4512b8d9b88f")]
public class WelcomeSaga : Saga,
IEventHandler<UserCreated>,
ISagaTimeoutHandler<SendWelcomeMessageTimeout>
{
public WelcomeSaga(IPublisher<ICommand> commandPublisher, IPublisher<IScheduledMessage> timeoutRequestPublisher)
: base(commandPublisher, timeoutRequestPublisher)
{
}
public Task HandleAsync(UserCreated @event)
{
var timeout = new SendWelcomeMessageTimeout
{
UserId = @event.Id,
PublishAt = DateTime.UtcNow.AddDays(1)
};
return RequestTimeoutAsync(timeout);
}
public Task HandleAsync(SendWelcomeMessageTimeout timeout)
{
return commandPublisher.PublishAsync(new SendWelcomeMessage(timeout.UserId));
}
}
[DataContract(Name = "543e8e28-0dcb-4d41-98de-f701e403dbb2")]
public class SendWelcomeMessageTimeout : IScheduledMessage
{
[DataMember(Order = 1)] public UserId UserId { get; set; }
[DataMember(Order = 2)] public DateTime PublishAt { get; set; }
}[DataContract(Name = "0528cd68-62f8-40e8-b258-72ced75d0f03")]
public class ExperienceVersionApprovalSaga : Saga,
IEventHandler<ExperienceVersionPublishedForApproval>,
IEventHandler<ApprovalWasConfirmed>,
IEventHandler<ExperienceVersionCanceled>,
IEventHandler<ExperienceApprovalWasRejected>,
IEventHandler<ExperienceVersionInfoWasAdded>,
IEventHandler<LocationInfoWasAdded>,
/* ... nine more events ... */A gateway is a port that remembers things. It reacts to events and talks to an external system, just like a port, but in addition it maintains a small amount of persistent state that the external system — not the business — needs.
In Cronus, a gateway is any class that implements IGateway:
public interface IGateway : IMessageHandler { }The marker interface is minimal. A gateway earns its behaviour through IEventHandler<T> implementations, just like a port or a saga.
The XML doc on IGateway sums up the niche it fills:
Compared to
IPort, which can dispatch a command, anIGatewaycan do the same but it also has a persistent state. A scenario could be sending commands to external BC like push notifications, emails etc. There is no need to event source this state and it's perfectly fine if this state is wiped. Example: iOS push notifications badge. This state should be used only for infrastructure needs and never for business cases. Compared to projections, which track events and project their data and are not allowed to send any commands at all, anIGatewaystore and track a metadata required by external systems. Also,IGatewayare restricted and not touched when events are replayed.
Three rules follow from that:
A gateway is the right choice when the external system needs state (an APNS badge count, a last-seen device id, a third-party handle). Business state belongs on aggregates.
A gateway is not replayed. Unlike projections, gateway state survives a projection rebuild. That makes it safe to store values that are meaningful only in combination with live external resources.
A gateway, unlike a projection, may publish commands. That is why it is shaped more like a port than a projection.
Calling an external service by hand from an application service is a trap: you either hand-roll retry, timeout and serialisation logic every time, or you forget it. A gateway is the canonical place to centralise those concerns for a given external system, so the application services above it stay clean.
In practice a gateway wraps the SDK of the external system (HTTP client, gRPC stub, legacy RPC proxy) and applies the retry policy you want for that system's failure modes.
A realistic gateway would inject the external-system client plus whatever persistent store holds the infrastructure metadata (for example a table of APNS device tokens and current badge counts).
The subscriber that dispatches events to gateways is toggled by Cronus:GatewaysEnabled (default: true). Disable it on hosts that should not talk to the external system, for instance when you split the host into a write-only and a read-only process.
public class SampleGateway : IGateway,
IEventHandler<SampleReserved>
{
public Task HandleAsync(SampleReserved @event)
{
Console.WriteLine($"Sample with ID: '{@event.Id}' was reserved!");
return Task.CompletedTask;
}
}A port is the place where an event from the domain meets the outside world. It reacts to events and does I/O: sending emails, calling third-party APIs, writing to disk, pushing a message to another system, or publishing a follow-up command to a related aggregate.
In Cronus, a port is any class that implements IPort:
public interface IPort : IMessageHandler { }
public abstract class Port : IPort
{
protected readonly IPublisher<ICommand> publisher;
public Port(IPublisher<ICommand> publisher)
{
this.publisher = publisher;
}
}The Port base class gives you a command publisher for convenience; you are not required to use it. What makes a class a port is the IPort marker plus one or more IEventHandler<T> implementations.
Keep Application Services focused on a single aggregate and free of I/O. That leaves the domain model fast, testable and replayable. All the messy things — latency, failure, retries — belong in a port.
A port is a natural retry boundary. If the third-party API is down, the port fails, the message is not acknowledged, and the subscriber delivers it again later. If the same side effect lived in an application service, a transient failure would also block the aggregate's write path.
Both react to events; both can publish commands. The difference is intent:
A port reaches out. Its reason to exist is the side effect — call the outside world, or fan out a single follow-up command.
A saga orchestrates. Its reason to exist is a multi-step business process that spans several aggregates.
Use a port when the reaction is a one-shot "when X happens, do Y". Use a saga when Y is followed by Z and possibly a compensating W.
And a port that reacts to an event by issuing a command to a different aggregate:
The subscriber that dispatches events to ports is toggled by Cronus:PortsEnabled (default: true). Turn it off on processes that must not perform outbound side effects, for example a read-only replica or a dedicated projections host.
By utilizing Ports appropriately, developers can design systems that are both modular and maintainable, adhering to the principles of Domain-Driven Design and Event Sourcing.
[DataContract(Name = "a44e9a38-ab13-4f86-844a-86fefa925b53")]
public class WelcomeEmailPort : IPort,
IEventHandler<UserCreated>
{
private readonly IEmailSender emailSender;
public WelcomeEmailPort(IEmailSender emailSender)
{
this.emailSender = emailSender;
}
public Task HandleAsync(UserCreated @event)
{
return emailSender.SendAsync(@event.Email, "Welcome", $"Hi {@event.Name}, welcome aboard.");
}
}public class RegisterUserOnAccountRegistered : IPort,
IEventHandler<AccountRegistered>
{
private readonly IPublisher<ICommand> commandPublisher;
public RegisterUserOnAccountRegistered(IPublisher<ICommand> commandPublisher)
{
this.commandPublisher = commandPublisher;
}
public Task HandleAsync(AccountRegistered @event)
{
var userId = new UserId(@event.Id.Tenant, @event.Id.Id);
return commandPublisher.PublishAsync(new CreateUser(userId, @event.Email));
}
}A projection is a read model. It is a derived representation of the events in the event store, shaped for the way a particular reader wants to consume data — an API endpoint, a dashboard, a search index. A projection never mutates the source of truth; it only applies events to its own state.
Cronus supports two flavours:
Event-sourced projections — inherit ProjectionDefinition<TState, TId> and subscribe to the events they care about. Their state is rebuilt by replaying events, optionally from a stored snapshot.
Non-event-sourced projections — implement IProjection directly and persist state to an external store (for example Elasticsearch or a relational database).
Inherit ProjectionDefinition<TState, TId> where TState is a serializable plain class and TId is any type that implements IBlobId. Declare IEventHandler<TEvent> once per event you want to handle. In the constructor, call Subscribe<TEvent>(event => projectionId) for every event — that's how Cronus knows which projection instance a given event belongs to.
The state class is serialised into a snapshot, so it needs a parameterless constructor, a [DataContract] attribute, and [DataMember(Order = n)] on every persisted property — the same rules as for events.
Cronus does not guarantee that an event is delivered exactly once, and out-of-order delivery can happen during catch-up or rebuild. Design your projection accordingly:
Sometimes the read model lives in an external store that tracks its own consistency — a SQL database via EF Core, for example. Implement IProjection directly and skip the snapshot machinery:
You own the write path; Cronus only fans the event into your handler.
Inject IProjectionReader and call GetAsync<TProjection>(id). The returned ReadResult<TProjection> tells you whether the projection was found and whether there was an error.
GetAsOfAsync rebuilds the projection as it was at a given point in time — useful for time-travel queries and diagnostics.
When a projection's shape changes (new event subscription, different state schema, new aggregation) Cronus treats it as a new version: the old version keeps serving reads while the new one rebuilds in the background, then traffic switches over atomically. That flow is covered in the projections versioning documentation.
[DataContract(Name = "c94513d1-e5ee-4aae-8c0f-6e85b63a4e03")]
public class TaskProjection : ProjectionDefinition<TaskProjectionState, UserId>,
IEventHandler<TaskCreated>,
IEventHandler<TaskClosed>
{
public TaskProjection()
{
Subscribe<TaskCreated>(e => e.UserId);
Subscribe<TaskClosed>(e => e.UserId);
}
public Task HandleAsync(TaskCreated @event)
{
State.Tasks.Add(new TaskProjectionState.Entry
{
Id = @event.Id,
Name = @event.Name,
CreatedAt = @event.Timestamp,
IsClosed = false
});
return Task.CompletedTask;
}
public Task HandleAsync(TaskClosed @event)
{
var entry = State.Tasks.FirstOrDefault(x => x.Id.Equals(@event.Id));
if (entry is not null) entry.IsClosed = true;
return Task.CompletedTask;
}
public IEnumerable<TaskProjectionState.Entry> TasksByName(string name)
=> State.Tasks.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
}[DataContract(Name = "c135893e-b9e3-453a-b0e0-53545094ec5d")]
public class TaskProjectionState
{
public TaskProjectionState() { Tasks = new List<Entry>(); }
[DataMember(Order = 1)]
public List<Entry> Tasks { get; set; }
[DataContract(Name = "317b3cbb-593a-4ffc-8284-d5f5c599d8ae")]
public class Entry
{
[DataMember(Order = 1)] public TaskId Id { get; set; }
[DataMember(Order = 2)] public string Name { get; set; }
[DataMember(Order = 3)] public DateTimeOffset CreatedAt { get; set; }
[DataMember(Order = 4)] public bool IsClosed { get; set; }
}
}[DataContract(Name = "af157a4d-7608-4c9d-8e42-63bd483a8ad4")]
public class TaskSqlProjection : IProjection,
IEventHandler<TaskCreated>
{
private readonly TaskDbContext db;
public TaskSqlProjection(TaskDbContext db)
{
this.db = db;
}
public async Task HandleAsync(TaskCreated @event)
{
db.Tasks.Add(new TaskRow(@event.Id.Value, @event.Name, @event.Timestamp));
await db.SaveChangesAsync().ConfigureAwait(false);
}
}public interface IProjectionReader
{
Task<ReadResult<T>> GetAsync<T>(IBlobId projectionId) where T : IProjectionDefinition;
Task<ReadResult<T>> GetAsOfAsync<T>(IBlobId projectionId, DateTimeOffset timestamp) where T : IProjectionDefinition;
Task<ReadResult<IProjectionDefinition>> GetAsync(IBlobId projectionId, Type projectionType);
Task<ReadResult<IProjectionDefinition>> GetAsOfAsync(IBlobId projectionId, Type projectionType, DateTimeOffset timestamp);
}[ApiController]
[Route("[controller]/[action]")]
public class TaskQueryController : ControllerBase
{
private readonly IProjectionReader reader;
public TaskQueryController(IProjectionReader reader)
{
this.reader = reader;
}
[HttpGet]
public async Task<IActionResult> GetByUser(string tenant, string userId)
{
var id = new UserId(tenant, "user", userId);
ReadResult<TaskProjection> result = await reader.GetAsync<TaskProjection>(id).ConfigureAwait(false);
if (result.NotFound) return NotFound();
if (result.HasError) return Problem(result.Error);
return Ok(result.Data.State.Tasks);
}
}A trigger is a message handler that starts something new in response to an event or a signal. The "something new" is typically a job, a long-running workflow, or a downstream orchestration that the current message flow is not supposed to wait for.
In Cronus, a trigger is any class that implements ITrigger:
public interface ITrigger : IMessageHandler { }The marker interface is deliberately minimal. A trigger gets its behaviour from the handler interfaces it implements on top — usually IEventHandler<T> or ISignalHandle<T>.
Use a trigger when the reaction is:
Starting a background job that runs on its own schedule.
Kicking off a workflow that is not bounded by the lifetime of the incoming message.
Fanning a signal out into the job subsystem — signals are the idiomatic message type here because they are fire-and-forget and are not meant to participate in event sourcing.
If you just need to publish a command or call the outside world, you want a or a instead.
Jobs (see ) run under the Cronus job manager and execute independently of the subscriber that received the original message. A trigger is the common way to connect the two worlds: the trigger receives an event or signal, decides whether a job should run, and starts it.
The trigger itself does no business-state work — it is a thin adapter between the message bus and the job subsystem.
A trigger that starts a push-notification job when a signal arrives:
The subscriber that dispatches messages to triggers is toggled by Cronus:TriggersEnabled (default: true). Turn it off on hosts where triggers should not fire.
public class PushNotificationTrigger : ITrigger,
ISignalHandle<NotificationMessageSignal>
{
private readonly IProjectionReader projections;
private readonly MultiPlatformDelivery delivery;
private readonly ILogger<PushNotificationTrigger> logger;
public PushNotificationTrigger(IProjectionReader projections, MultiPlatformDelivery delivery, ILogger<PushNotificationTrigger> logger)
{
this.projections = projections;
this.delivery = delivery;
this.logger = logger;
}
public Task HandleAsync(NotificationMessageSignal signal)
{
// resolve recipients, look up tokens, hand over to the delivery subsystem
// ...
return Task.CompletedTask;
}
}The event store is the append-only log of domain events that backs every Cronus aggregate. It is the single source of truth for the write side of your service: everything your service can "remember" about what happened — every command outcome, every state change — is derived from the events that live in this log. Projections, indices and read models are all derived artefacts that can be thrown away and rebuilt from the event store.
Every time an aggregate successfully handles a command it produces zero or more events. Cronus groups those events together with the aggregate root id, the new revision and a timestamp into an AggregateCommit and appends it to the event store. An aggregate is later rehydrated by loading all commits for its id and replaying the events through the When methods of its state.
The contract is deliberately small. It appends, loads, paginates and deletes — that is all:
public interface IEventStore
{
Task AppendAsync(AggregateCommit aggregateCommit);
Task AppendAsync(AggregateEventRaw eventRaw);
Task<EventStream> LoadAsync(IBlobId aggregateId);
Task<bool> DeleteAsync(AggregateEventRaw eventRaw);
Task<LoadAggregateRawEventsWithPagingResult> LoadWithPagingAsync(IBlobId aggregateId, PagingOptions pagingOptions);
Task<AggregateEventRaw> LoadAggregateEventRaw(IndexRecord indexRecord);
}The event-store subsystem exposes a handful of types that you will see repeatedly throughout the framework:
IEventStore — append, load and delete aggregate commits. The canonical implementation you talk to from domain code is wrapped by , which adds structured logging on top of the backend store.
— enumerates every event in the store without caring about aggregate boundaries. Used by index and projection rebuilds. See .
— the on-the-wire unit of a write: AggregateRootId, Revision, list of private IEvents, list of IPublicEvents and a timestamp.
— the result of loading a single aggregate. It holds the ordered list of commits and exposes TryRestoreFromHistory<T>(out T aggregateRoot) to rebuild an aggregate instance.
— the repository you normally interact with from an application service. It delegates to IEventStore and handles integrity checks, atomic actions and duplicate-timestamp detection.
A write therefore looks like: application service loads the aggregate via AggregateRepository.LoadAsync, calls a domain method, which Applys events, then SaveAsync wraps those events into an AggregateCommit and calls IEventStore.AppendAsync.
The production-grade event-store backend for Cronus is . It has been in production use since 2013 and is marked olympus in the . Cassandra is a natural fit: the access pattern of an event store (append-only, partitioned by aggregate id, ordered by revision) maps cleanly to Cassandra's wide rows, and its distributed replication story covers the durability requirements without extra effort.
If you are wiring up a new service, use the Cassandra persister. The connection strings and replication settings are documented under Cronus:Persistence:Cassandra:* in .
Cronus ships with a handful of alternative stores; their maturity and intended use are described in the reference:
— alternative cloud-native store.
— styx. Worked for Cronus v1, but a relational database is a poor match for an append-only log and we do not recommend it for new work.
— tartarus. Exists "just for fun".
The events you persist today will outlive your current code. When a business rule changes, you rename a field or split an event in two, the old events stay on disk forever — you do not get to "edit migrations". Cronus provides a set of migration primitives for those situations (copy an event store with transformations, delete events, validate a store after a migration). See and .
Reading events by aggregate id is cheap. Reading events by type, or counting them, is not — the store is partitioned by aggregate, not by event type. Cronus maintains a secondary index subsystem to make those queries possible. See for the list of built-in indices and how they are rebuilt.
A projection is a read model derived from events. It lives in its own store, keeps its own versions and can be rebuilt from scratch by replaying events through . See and .
A migration is a one-time transformation applied to the durable state of a Cronus service. You reach for one whenever the shape of persisted data has to change in a way that the runtime cannot repair on its own — renaming a contract that is still referenced in historical events, scrubbing a field that legally has to leave the store, splitting an event into two, or moving the entire store into a new keyspace.
There is a hard asymmetry between event stores and projection stores in Cronus, and it drives the whole migrations design:
Projections are derived and disposable. You can always drop the projection tables and replay the events back in to rebuild them. Projection-shape changes are handled by the projection versioning mechanism, not by migrations. Cronus will detect that the shape hash changed, request a new version and replay events from the event store through the projection handler.
Events are forever. Every change that ever happened to the business is recorded as an event, and the events are the only record. If an event's shape must change, you need to migrate the event-store data itself — and because the original store is the source of truth for the entire system you cannot mutate it blindly.
A rule of thumb: if you are changing the shape of a write (an event, an aggregate-commit, a tenant-owned keyspace), you need a migration. If you are changing the shape of a read (a projection, an index, a dashboard), you rebuild the derived artefact from the existing events.
The abstractions live under . The shapes you will interact with most are:
and IMigration<T, V> — the single-event predicate-plus-transformation:
Implementations decide whether a given AggregateEventRaw or AggregateCommit should be rewritten, and return the replacement.
— the base class all runners inherit from. It holds an IEventStorePlayer (the source) and an IEventStore (the destination) and declares
The flag that turns the whole subsystem on is Cronus:MigrationsEnabled (see ). It defaults to false because you almost never want a production host to be also a migrator.
The pattern that comes up most in practice:
Stand up a separate migrator host, configured with the new, target store as its IEventStore and the old store as its IEventStorePlayer.
Subscribe the migrator to the same live events the production host subscribes to, and write them straight into the new store through CronusMigrator.MigrateAsync so nothing is lost while the backfill runs.
Run against the old store to copy history into the new store, applying the required
Related packaging lives in the satellite , which ships the middleware needed to drop the above pipeline into a host.
Task RunAsync(IEnumerable<IMigration<TDataFormat>> migrations)— the runner used for the "move everything from store A to store B, applying these transformations on the way" workflow. See Copy EventStore for the end-to-end description.
— the runner that walks the store and deletes events for which migration.ShouldApply returns true. Use it with caution.
— re-plays the source events into the target to verify that the migration preserves what it should.
and — the live pipeline: given an incoming AggregateCommit, apply each registered IMigration<AggregateCommit> in order and then invoke IMigrationCustomLogic.OnAggregateCommitAsync. This is how the migrator service processes new events that arrive while a migration is still running.
— a step that produces zero, one or many resulting commits from a single input commit.
— a marker IEventStorePlayer that isolates the migration player from the rest of the system, so the live application is not accidentally driven by the historical replay.
IMigration<AggregateEventRaw>Run to confirm the new store is internally consistent.
Flip the production host to read from the new store, retire the old one.
public interface IMigration<in T, out V> : IMigration
where T : class where V : class
{
bool ShouldApply(T current);
V Apply(T current);
}CopyEventStore is the migration runner you use when the goal is "take every event from store A, optionally transform it, write it into store B". It is the workhorse behind every production migration where the event-store schema or contents need to change.
CopyEventStore lives at and extends :
public class CopyEventStore<TSourceEventStorePlayer, TTargetEventStore>
: MigrationRunnerBase<AggregateEventRaw, TSourceEventStorePlayer, TTargetEventStore>
where TSourceEventStorePlayer : IEventStorePlayer
where TTargetEventStore : IEventStore
{
public override async Task RunAsync(IEnumerable<IMigration<AggregateEventRaw>> migrations)
{
PlayerOperator @operator = new PlayerOperator()
{
OnLoadAsync = target.AppendAsync
};
PlayerOptions playerOptions = new PlayerOptions();
await source.EnumerateEventStore(@operator, playerOptions);
}
}There are two open generics — the source player and the target event store. The player drives enumeration of the old store; the target receives each event. The runner itself is a thin loop: for every AggregateEventRaw the player yields, it calls target.AppendAsync on the destination. Each migration you pass is expected to have already inspected the raw event and returned the replacement — the runner treats the result as opaque bytes and writes them.
The current implementation applies migrations via the
IMigration<AggregateEventRaw>pipeline your host wires into the player operator chain. Expect to see a small migrator service that sets this up — see for the overall topology.
A migration we ran in production: an event contained a struct Cycle with a TimeZoneInfo member. Serialising TimeZoneInfo produced roughly 6000 lines of JSON per event, because every Windows time-zone record is serialised in full. The fix was to replace the TimeZoneInfo with a string timezone id. Because the old, bloated shape was already in production, changing only the code was not enough — the persisted bytes had to be rewritten.
The struct definition kept its DataContract name (it is the identity of the contract, not of the field) but its last member changed:
Implement and mutate the serialized payload so that every occurrence of the old Cycle shape is rewritten with the new _timezoneId:
The migrator runs in its own process with Cronus:MigrationsEnabled = true (see ). Two things happen concurrently:
The migrator subscribes to the live event stream of the old service. Every incoming AggregateCommit goes through , which applies each IMigration<AggregateCommit> in order and then writes the result into the new store. This keeps the new store consistent while the backfill runs.
A background task runs CopyEventStore.RunAsync(new[] { new TimeZoneInfoToIdMigration() }) against the historical data of the old store. The source player enumerates every event; the runner appends each one (rewritten or not) to the target.
Once both parts have finished — the live-subscription and the historical copy — run to confirm the old and new stores agree, flip production traffic over to the new store, and retire the old one. Keep the old store around for at least one billing cycle in case you need to reopen the investigation.
The source and target of CopyEventStore are open generics, so you can copy across backends. A typical cross-backend migration is Cassandra → new Cassandra keyspace; you can also move from one store implementation to another (Cronus.Persistence.MSSQL → Cronus.Persistence.Cassandra).
CopyEventStore is idempotent at the event level only if the target event store treats re-appends as a no-op. In practice Cassandra's primary-key deduplication does the right thing, but if you re-run a copy you should expect the runner to rewrite already-migrated events.
The pagination token and retries are the responsibility of the source IEventStorePlayer; see EventStore Player for the hooks you will wire up.
[DataContract(Namespace = BC.ContextName, Name = "dce741fb-8671-42b8-af59-d30aaae27bad")]
public struct Cycle
{
[DataMember(Order = 1)] private DateTimeOffset _start;
[DataMember(Order = 2)] private DateTimeOffset _end;
[DataMember(Order = 3)] private TimeSpan _duration;
[DataMember(Order = 4)] private readonly string _timezoneId;
}public class TimeZoneInfoToIdMigration : IMigration<AggregateEventRaw>
{
public bool ShouldApply(AggregateEventRaw current)
{
// Cheap test on the raw bytes: only apply when the old shape marker is present.
// Keep this fast — it runs for every event in the source store.
return BytesContain(current.Data, OldCycleMarker);
}
public AggregateEventRaw Apply(AggregateEventRaw current)
{
byte[] rewritten = RewriteCyclePayload(current.Data);
return new AggregateEventRaw(
current.AggregateRootId,
rewritten,
current.Revision,
current.Position,
current.Timestamp);
}
}A projection is a read model derived from the events in the Event Store. It subscribes to the event types it cares about, maintains its own state in its own store, and exposes query-shaped answers to the rest of the service through IProjectionReader. The tactical reference — how to write a projection, the ProjectionDefinition<TState, TId> base class, the Subscribe() pattern — lives under Domain Modeling / Handlers / Projections.
This section covers the two topics that are orthogonal to how you write the handler, and that Cronus owns on your behalf once your projection is deployed:
VersioningHow a projection evolves over its lifetime. The shape of the handler changes across releases — the signature of HandleAsync, the fields on the state, the set of events subscribed to. Cronus detects that the shape has changed, issues a new version of the projection, replays the events through the updated handler into a new storage slot, and promotes the new slot to live once the replay completes.
How a projection avoids re-reading its entire history every time it is queried. Snapshots are periodic captures of a projection instance's state written to an ISnapshotStore; when the projection is next loaded, Cronus restores from the most recent snapshot and only replays the events that landed after it.
Most projections should participate in versioning and replay — that is the point of ProjectionVersionManager. A few should not. The framework expresses that with two marker interfaces declared in CronusAssembly.cs: INonVersionableProjection and INonRebuildableProjection. Both are opt-out signals — you implement them on your projection class, and the framework changes how it treats the projection at boot and during replay.
This page covers when to reach for either marker, what changes when you do, and how the markers interact with the rest of the versioning subsystem.
The default versioning policy (MarkupInterfaceProjectionVersioningPolicy) treats every projection as versionable. Whenever the projection's hash changes, the version manager requests a new version, the builder replays every event the projection handles, and once the new version is live, reads switch over. That is the right thing for almost every projection.
It is the wrong thing for two narrow cases:
System projections that the versioning subsystem itself depends on. is the projection that records what versions exist. If it tried to participate in versioning, the system would have to be running before it could be running.
Projections that should not be wiped and rebuilt at all — typically because their state cannot be reconstructed from events alone, or because rebuilding them would be ruinously expensive and the on-disk shape is stable enough that no rebuild is ever needed.
INonVersionableProjection covers the first; INonRebuildableProjection covers the second.
The full interface:
When a projection implements this marker, two things change:
does not derive a hash from the set of IEventHandler<T> interfaces. It instantiates the projection with the parameterless constructor and returns whatever GetHash() says — "ver" by default. Adding or removing event handlers does not change this value.
MarkupInterfaceProjectionVersioningPolicy.IsVersionable(projectionName) returns false. The version manager — which reads the policy on every Replay, NotifyHash
The combined effect is that the projection has exactly one version for its lifetime. Reads always come from that version's storage slot; replays do not happen automatically when you change the code.
The framework uses this on ProjectionVersionsHandler because that projection has to be available before the version manager can do anything; there is no chicken-and-egg way to version it. Your code uses it when you have a projection whose state must survive deploys verbatim — for example, a projection that records the latest known offset of an external feed, where re-deriving the value from the event log is not the right behaviour even if you change the handler shape.
A worked example:
If you later add IEventHandler<FeedReset>, the hash stays "ver", no replay is requested, and the new handler simply starts running for new events from the moment it is deployed. State accumulated under the previous handler set is preserved.
The interface is empty — it is a pure marker:
The semantics are "this projection's state must not be wiped and reconstructed from the event log". A projection that cannot survive a rebuild is one where, for example, the state was bootstrapped from a one-off import that the event log does not contain, or where part of the state is computed from an external source at write time and the source is no longer available.
The framework declares the marker but does not currently apply it inside the versioning policy classes that ship in . The Cronus tests () cover the type so satellite packages — particularly the projection store implementations such as — can detect it and refuse to drop or recreate the projection's tables during a rebuild.
The recommended treatment, until the marker has more first-class support in core, is to use it as a declaration of intent in your domain code so that operators reading the projection know not to issue a rebuild against it. Wire a real refusal — failing the call, or branching at the projection-store level — in whatever component manages your rebuild operations.
The version manager runs the same flow regardless of markers — it requests versions, it tracks timeboxes, it transitions a Building version to Live. The markers change two of its inputs:
returns the marker-overridden hash for INonVersionableProjection, so the manager never sees a hash change for those projections in normal operation.
returns false for INonVersionableProjection, which is what the manager uses to decide whether Replay is allowed. The first-time-ever case is special-cased: even a non-versionable projection gets a single creation pass when no live version exists yet.
The disaster-recovery branch in bypasses the policy entirely — it is reserved for the system projection that owns versioning state.
The full versioning lifecycle, including how the policy is consulted and how the timebox is set, lives next to this page:
Rebuildpublic interface INonVersionableProjection
{
string GetHash() => "ver";
int GetRevision() => 1;
}[DataContract(Name = "8a4f06d3-3f9b-46ab-8f5c-6f6b6e64a3f5")]
public class FeedOffsetProjection : ProjectionDefinition<FeedOffsetState, FeedId>,
INonVersionableProjection,
IEventHandler<FeedAdvanced>
{
public FeedOffsetProjection()
{
Subscribe<FeedAdvanced>(x => new FeedId(x.Id.Tenant, x.Id.Id));
}
public Task HandleAsync(FeedAdvanced @event)
{
// ...
return Task.CompletedTask;
}
}public interface INonRebuildableProjection { }[DataContract(Name = "1d6f2c79-1e8b-4a07-9db6-2e0c8b0f2d3f")]
public class ImportedCustomerLedgerProjection : ProjectionDefinition<ImportedCustomerLedgerState, CustomerId>,
INonRebuildableProjection,
IEventHandler<LedgerEntryRecorded>
{
// ...
}A projection's shape is not stable across the lifetime of a service. You add a new field to the state; you subscribe to a new event; you change the way an existing event updates the state. The projection code is different after the change — but the rows already in the projection store were written by the old code, against the old shape, and querying them from the new code would return wrong data. Projection versioning is Cronus's answer to that: every shape change is a new version, old versions keep serving reads until the new one is ready, and the switchover is atomic.
The projection hash is the fingerprint of the projection's handler type. It is computed by ProjectionHasher from the type's contract id plus the set of IEventHandler<T> interfaces it implements. When the hash changes, Cronus considers the projection's shape to have changed.
A ProjectionVersion bundles three pieces of information: the projection's contract id (ProjectionName), a numeric Revision and the current Hash. It also carries a Status — New, Building, Fixing, Live, Canceled, Timedout, NotPresent or Unknown — which drives the lifecycle.
Four system components cooperate to turn a shape change into a safe replay:
— the aggregate. One instance per projection contract id per tenant, it owns the list of known versions and decides whether a new version should be requested.
— the default policy. A projection is versionable unless it implements the marker INonVersionableProjection (system projections opt out this way).
— the time window within which a version request is expected to complete. If the request has not finished by FinishRequestUntil, the manager cancels it.
The end-to-end flow:
Detect change. When the host starts, each projection's hash is computed. If it differs from the hash currently marked Live for that projection, ProjectionVersionManager.NotifyHash(hash, policy, replayOptions) is called.
Request a version. The manager checks that no replay is already in progress, then calls Replay(hash, policy, replayOptions), which applies a ProjectionVersionRequested event with a fresh that starts immediately and expires effectively "never" (the default is int.MaxValue milliseconds into the future).
Timeout. If the timebox expires before the replay completes, ProjectionVersionManager.VersionRequestTimedout fires a ProjectionVersionRequestTimedout event and the version's status becomes Timedout. A new request can then be issued.
Cancelled replay. Operators can pause a replay through ProjectionVersionRequestPaused; the builder responds by calling jobRunner.JobManager.CancelAsync(job.Name).
You rarely call versioning APIs yourself — the framework orchestrates the whole dance. What you do is add the new field to the state, change the HandleAsync, deploy, and let Cronus notice:
Next deploy, Cronus hashes the new handler, notices the hash has changed, requests a new ProjectionVersion and kicks off a replay. The old version keeps answering reads until the new one is Live.
— how to write the handler itself.
— INonVersionableProjection and INonRebuildableProjection, the opt-out markers referenced above.
— the job runner the replay sits on top of.
— the system saga that turns a ProjectionVersionRequested event into actual work. It decides whether the projection is "fast" (IAmEventSourcedProjectionFast or IProjectionDefinition) or sequential, builds the appropriate job, and hands it to the cluster.
Start the builder. The ProjectionBuilder saga handles ProjectionVersionRequested and schedules a CreateNewProjectionVersion timeout at the requested start. When it fires, the saga calls GetJob(version, options, timebox) to choose between (fast — for event-sourced projections) and RebuildProjectionSequentially_Job (sequential — for projections that require strict event ordering), then runs the job through the .
Replay events. Inside the job, seeds the counter from the so you can display "X of Y events processed". The job walks each event type the projection handles, calls with the type id, deserialises each raw event and writes a projection commit into the new version's storage slot. Progress is pinged to the cluster on every page.
Announce milestones. A signal goes out when the replay starts; is published once a second for monitoring; marks the end.
Promote to live. When the job returns JobExecutionStatus.Completed, the saga publishes FinalizeProjectionVersionRequest. The manager handles it by applying NewProjectionVersionIsNowLive, which moves the new version's Status from Building to Live. From that moment IProjectionReader answers queries from the new version.
Retain old versions. The previous live version is not dropped. The Cassandra projection store keeps old versions around according to the Cronus:Projections:Cassandra:TableRetention:* options (see ). By default DeleteOldProjectionTables is false, so nothing is deleted; when enabled, NumberOfOldProjectionTablesToRetain keeps that many historical versions around before garbage-collecting the oldest.
Outdated building version. If a newer version of the projection is already Live by the time a Building one catches up, CancelVersionRequest retires the stale one.
Disaster recovery. For system projections like , Rebuild(hash, policy, options) bypasses the usual checks and forces a fresh rebuild, because the versioning subsystem itself depends on this projection being correct.
EventToAggregateRootId index, which the rebuild depends on to locate events efficiently.— note: snapshots are not currently shipped; that page documents the situation.
[DataContract(Name = "c94513d1-e5ee-4aae-8c0f-6e85b63a4e03")]
public class TaskProjection : ProjectionDefinition<TaskProjectionData, TaskId>,
IEventHandler<TaskCreated>,
IEventHandler<TaskCompleted> // new — previously only TaskCreated
{
public TaskProjection()
{
Subscribe<TaskCreated>(x => new TaskId(x.Id.Tenant, x.Id.Id));
Subscribe<TaskCompleted>(x => new TaskId(x.Id.Tenant, x.Id.Id));
}
public Task HandleAsync(TaskCreated @event) { /* ... */ return Task.CompletedTask; }
public Task HandleAsync(TaskCompleted @event) { /* updated state shape */ return Task.CompletedTask; }
}The event-store player is the read counterpart of IEventStore used by rebuilds. Where IEventStore.LoadAsync(IBlobId) loads the stream of a single aggregate, the player enumerates the whole store — scoped by event type, timestamp window and pagination token — without caring about aggregate boundaries. It is the mechanism that powers projection rebuilds, index rebuilds and public-event replay.
The player contract lives at Cronus/src/Elders.Cronus/EventStore/IEventStorePlayer.cs:
public interface IEventStorePlayer
{
Task EnumerateEventStore(PlayerOperator @operator, PlayerOptions replayOptions, CancellationToken cancellationToken = default);
}A caller hands the player two values:
A PlayerOperator — a bag of callbacks the player invokes per raw event, per aggregate stream and on pagination checkpoints. The two you will set most often are OnLoadAsync (one raw event at a time) and NotifyProgressAsync (checkpoint signal, used to persist a pagination token between batches).
A — EventTypeId, PaginationToken, BatchSize (defaults to 1000), After and Before bounds and MaxDegreeOfParallelism (defaults to 2).
Raw events travel as so a player can move bytes without deserialising them. The typed variant groups raw events of one aggregate into AggregateCommitRaw by revision — useful when you want to see a single aggregate's commits together.
The player is the workhorse under several framework jobs:
iterates once per event type that the projection handles and calls EnumerateEventStore for each. The OnLoadAsync callback deserialises the raw bytes, writes a projection commit and updates the . NotifyProgressAsync persists the pagination token into job data so the work survives a process restart.
republishes the stream of one public-event type so a newly-added subscriber can catch up.
The typed sibling IEventStorePlayer<TSettings> exists so that different backends can register their own players into DI without clashing.
Reach for the player when you need to walk every event of a given type (or a time slice of them) and do something idempotent with each — rebuild a projection, backfill a new index, republish a stream to a new subscriber. The player already knows how to paginate, resume from a token and report progress.
Reach for a one-off instead when you need to transform the contents of the store — copy it into a new keyspace with some events rewritten, or remove events that were persisted in error. Migrations give you a destination store; the player only reads.
Workflows are Cronus's message-processing pipeline. When a command, event, signal or scheduled message arrives from the transport, a workflow is responsible for resolving the right handler, invoking it inside the right scope, and surfacing any failures. The design mirrors the ASP.NET Core middleware pipeline: each workflow wraps an inner workflow and can add cross-cutting behaviour — logging, activity tracing, retries — before or after the inner RunAsync call.
Every workflow inherits Workflow<TContext>:
public abstract class Workflow<TContext> : WorkflowBase<TContext> where TContext : class
{
protected abstract Task RunAsync(Execution<TContext> execution);
}The context is the envelope that carries state down the pipeline. For message handling the context is HandleContext, which holds the CronusMessage, the handler type, and (once the scope has been created) the scoped IServiceProvider.
public class HandleContext : IWorkflowContextWithServiceProvider
{
public CronusMessage Message { get; }
public Type HandlerType { get; }
public IServiceProvider ServiceProvider { get; set; }
}When Cronus starts a subscriber (application services, projections, sagas, ports, triggers, gateways, indices) it composes a pipeline like this, from outer to inner:
ExceptionEaterWorkflow<HandleContext> — last line of defence; swallows and logs exceptions that escape the rest of the pipeline so the consumer does not crash.
DiagnosticsWorkflow<HandleContext> — starts a System.Diagnostics.Activity, emits a structured log scope containing the tenant and aggregate root id, and writes a handled in Xms info log on success.
You can see the composition in ; each subscriber kind has its own factory that assembles a pipeline tailored to its needs.
Each workflow is parameterised by its context type so the compiler can enforce that a DiagnosticsWorkflow<HandleContext> only wraps another Workflow<HandleContext>. The generic parameter is the context, not the message — there is one DiagnosticsWorkflow<T> type that works for any context that derives from HandleContext.
MessageHandleWorkflow exposes five extension points — each is itself a Workflow that defaults to a no-op lambda:
Use OnHandle(...) to inject your own Workflow<HandlerContext> around the actual handler call:
DiagnosticsWorkflow writes Activities to the Elders.Cronus ActivitySource and a DiagnosticListener named "cronus". If you configure OpenTelemetry (or any other APM) to listen for that source, you get end-to-end traces of every message handled in your host.
Log scopes include:
cronus_messageHandler — the handler type name
cronus_messageType — the message payload type name
cronus_tenant — the tenant extracted from the message headers
InMemoryRetryWorkflow<HandleContext> — retries transient failures in-process before giving up.ScopedMessageWorkflow — creates a fresh IServiceScope for the message, initialises a CronusContext with the tenant resolved from the headers, and attaches a logger scope. The inner workflow receives the scoped ServiceProvider via HandleContext.ServiceProvider.
MessageHandleWorkflow — resolves the handler via CreateHandler, invokes BeginHandle → ActualHandle → EndHandle, and routes exceptions into the Error sub-workflow. The default ActualHandle calls DynamicMessageHandle which dispatches to HandleAsync(message) on the resolved handler.
EndHandle
After a successful handler call
Post-commit hooks, metrics
Error
When BeginHandle / ActualHandle / EndHandle throws
Error enrichment, dead-letter routing
Finalize
Always, at the end (after success or after Error)
Cleanup
cronus_arid — the aggregate root id if the message carries one
BeginHandle
Before the actual handler invocation
Instrumentation, authorisation checks
ActualHandle
The handler call itself (default: DynamicMessageHandle)
Replace or wrap dispatch logic
public sealed class DiagnosticsWorkflow<TContext> : Workflow<TContext>
where TContext : HandleContext
{
public DiagnosticsWorkflow(
Workflow<TContext> workflow,
DiagnosticListener diagnosticListener,
ActivitySource activitySource) { ... }
}messageHandleWorkflow.OnHandle(inner =>
WorkflowExtensions.Lamda<HandlerContext>()
.Use(async ctx =>
{
// pre-handle
await inner.RunAsync(ctx.Context).ConfigureAwait(false);
// post-handle
}));In the current codebase a projection's state is rebuilt by replaying every event of every type the projection handles — see Versioning and Handlers / Projections. There is no ISnapshotStore, no IAmNotSnapshotable marker, no EventsCountSnapshotStrategy / TimeOffsetSnapshotStrategy and no Cronus:Projections:Cassandra:Snapshot… configuration family in either Elders/Cronus or Elders/Cronus.Projections.Cassandra at the time of writing.
If a projection's event volume per id grows large enough that full replay becomes expensive, the practical lever today is the projection-versioning machinery — bumping a projection's hash forces a fresh rebuild, after which the new version serves all reads. See Versioning for that flow.
— how a projection's shape change kicks off a replay.
— how to write the projection.
A Cronus job is a long-running, idempotent piece of work coordinated across the cluster. Think "rebuild this projection", "rebuild this index", "replay these public events to a new subscriber" — work that cannot finish inside the timeout of a single message handler and must survive a process restart. Jobs are how those tasks are modelled.
Job code lives under Cronus/src/Elders.Cronus/Cluster/Job/. The core types are:
public interface ICronusJob<out TData> : ICronusJobb
where TData : class
{
public TData Data { get; }
Task SyncInitialStateAsync(IClusterOperations cluster, CancellationToken cancellationToken = default);
Task<JobExecutionStatus> RunAsync(IClusterOperations cluster, CancellationToken cancellationToken = default);
}
public enum JobExecutionStatus
{
Completed,
Canceled,
Failed,
Running
}See ICronusJob.cs and JobExecutionStatus.cs. A job pairs a strongly-typed TData (its durable state, implementing IJobData) with a RunAsync method that pushes the work forward one increment at a time.
The entry point is ICronusJobRunner:
public interface ICronusJobRunner : IDisposable
{
Task<JobExecutionStatus> ExecuteAsync(ICronusJob<object> job, CancellationToken cancellationToken = default);
JobManager JobManager { get; }
}Callers build a job (typically via a factory, not by hand), hand it to the runner, and receive a JobExecutionStatus. The runner registers the job under its Name in the JobManager so it can be cancelled by id.
The default runner is the single-process, no-cluster , which runs against a . For the multi-host story the Consul-backed runner in coordinates which node runs which job — see .
Most jobs do not implement ICronusJob directly; they extend :
The base class handles the two things every job cares about:
SyncInitialStateAsync — ping the cluster for the last-known TData so a new host picks up the work where a previous one stopped.
Override(fromCluster, fromLocal) — merge the cluster state with the local initial data. The default implementation prefers fromCluster unless the cluster state is older than the local one and complete.
From inside RunJobAsync the job reads and writes Data, and periodically calls cluster.PingAsync(Data) to publish its progress. See for the contract.
Jobs are wired up through . It walks the assemblies, registers every implementation of ICronusJob<object> as a transient service, wires ICronusJobRunner to InMemoryCronusJobRunner by default, and builds a TypeContainer<ICronusJob<object>> so the system knows how to resolve them.
The same discovery also registers factory types for the framework-owned jobs:
RebuildIndex_EventToAggregateRootId_JobFactory, RebuildIndex_MessageCounter_JobFactory (see )
ReplayPublicEvents_JobFactory (see )
RebuildProjection_JobFactory, RebuildProjectionSequentially_JobFactory
Reach for a job when the work is all of:
Idempotent — re-running the same increment against the same TData must be safe.
Too long for a message handler timeout, or requires pagination through large data sets.
Cluster-coordinated — only one host should advance it at a time.
Stateful — progress should survive process restarts.
If the work is short, stateless and tenant-scoped, write a signal handler or a saga instead.
A sketch of a minimal job — the only interesting thing it does is increment a counter until it reaches some target and ping the cluster each iteration:
You execute it through the runner — never by calling RunAsync directly, because the runner is what registers the cancellation and tracks the job name:
public abstract class CronusJob<TData> : ICronusJob<TData>
where TData : class, IJobData, new()
{
public abstract string Name { get; set; }
public TData Data { get; protected set; }
protected abstract Task<JobExecutionStatus> RunJobAsync(IClusterOperations cluster, CancellationToken cancellationToken = default);
}public sealed class CountToTenJob : CronusJob<CountToTenJobData>
{
public CountToTenJob(ILogger<CountToTenJob> logger) : base(logger) { }
public override string Name { get; set; } = "count-to-ten";
protected override async Task<JobExecutionStatus> RunJobAsync(IClusterOperations cluster, CancellationToken cancellationToken = default)
{
while (Data.Counter < 10)
{
Data.Counter++;
Data.Timestamp = DateTimeOffset.UtcNow;
Data = await cluster.PingAsync(Data, cancellationToken).ConfigureAwait(false);
if (cancellationToken.IsCancellationRequested)
return JobExecutionStatus.Canceled;
}
Data.IsCompleted = true;
Data = await cluster.PingAsync(Data, cancellationToken).ConfigureAwait(false);
return JobExecutionStatus.Completed;
}
}
public sealed class CountToTenJobData : IJobData
{
public bool IsCompleted { get; set; }
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;
public int Counter { get; set; }
}var status = await jobRunner.ExecuteAsync(countToTenJob);The event store is partitioned by aggregate id, which is exactly what you want for the write side — loading every commit of an aggregate by id is a single hot read. It is not what you want for read queries that are shaped differently ("every event of type X", "every commit whose payload references aggregate Y"). Cronus maintains a set of secondary indices to answer those queries without scanning the whole store.
The index subsystem lives under Cronus/src/Elders.Cronus/EventStore/Index/. Three indices ship with the framework.
An IEventStoreIndex is a write-time hook: every time a message is processed, Cronus dispatches it through the list of registered indices and each index records what it needs to.
public interface IEventStoreIndex : IMessageHandler
{
Task IndexAsync(CronusMessage message);
}
public interface ICronusEventStoreIndex : IEventStoreIndex, ISystemHandler
{ }See IEventStoreIndex. Implementations are system handlers — Cronus owns them; you do not write your own indices in application code.
The payloads each index writes live in an IIndexStore (and an IIndexStatusStore tracks the lifecycle — NotPresent / Building / Present, expressed by ).
File: . Contract id 3d59f948-870f-4b12-ada6-9603627aaab6.
This index records "event of type X was written, here is the aggregate root id and the revision". It answers the question "give me every event of type X" — which is the primary access pattern of projection rebuilds: a projection interested in events of a given type walks this index to find the aggregates it should load events from. Public events are indexed too, but only when the event originates in the host's own bounded context (so a subscriber is not indexed for a foreign public event passing through).
File: . Contract id f8c532eb-57ad-469f-9002-6c286bdd88f2.
A counter of "how many events of this type does the store contain". Used to display progress of a rebuild (counter / total) — the reads the counter to compute the total it needs to chew through. The counter is updated through IMessageCounter.IncrementAsync.
File: . Contract id 37336a18-573a-4e9e-b4a2-085033b74353.
This one bridges events and projections. When a CronusMessage arrives, the index looks up every registered projection type, checks whether any of its IEventHandler<T> interfaces would accept the message, and — if so — routes the message to IProjectionWriter.SaveAsync(projectionType, event). It is the mechanism behind the live projection updates.
An index transitions through three states:
NotPresent — the index has never been built (new tenant, fresh deploy, or it has been dropped).
Building — an index-rebuild job is running.
Present — the index is up to date.
The state lives in — a system projection whose contract id is 1bcdb806-dbd0-45e7-b781-e3d2fd0589c1. Its State.Status moves from NotPresent to Building (on EventStoreIndexRequested) to Present (on EventStoreIndexIsNowPresent).
Rebuilding an index is orchestrated by — a system saga that reacts to EventStoreIndexRequested and schedules the appropriate rebuild job:
for the EventToAggregateRootId index.
for the message-counter index.
The saga routes between them based on the requested index's contract id. The rebuild jobs reuse the framework-wide machinery, so they survive process restarts, coordinate across cluster nodes and report progress via IClusterOperations.PingAsync.
Each job enumerates the event store through an , checkpoints its pagination token into IJobData and keeps the index status at Building until the last page has been read — at which point the saga finalises the request and publishes EventStoreIndexIsNowPresent, taking the index to Present.
The saga reacts to a RebuildIndexCommand (see ). From the administration-side tooling you publish it against the EventStoreIndexManager aggregate for the target tenant, wait for the saga to run its course, and monitor EventStoreIndexStatus for the state change to Present.
Messaging is the transport layer that ties the pieces of a Cronus service — and of a federation of Cronus services — together. Commands flow from the edge to the application services; events flow out of the aggregates to the projections, ports, sagas and triggers that react to them; signals coordinate across tenants and across hosts. All of that travels through a small, transport-agnostic contract.
Every message that crosses a boundary is wrapped in a CronusMessage. It carries the payload (a typed IMessage or the serialized PayloadRaw bytes plus a contract id) and a Dictionary<string, string> of headers — tenant, bounded context, causation id, traceparent, and the usual routing metadata. Every subscriber operates on CronusMessage and inspects those headers to decide how to handle the payload.
On the publishing side the entry point is IPublisher<TMessage>, with a specialisation per message kind (IPublisher<ICommand>, IPublisher<IEvent>, IPublisher<IPublicEvent>, IPublisher<IScheduledMessage>, IPublisher<ISystemSignal>). Publishers are implemented by transport packages; the framework ships the shared pipeline types and (which adds a linear retry policy on publish failure).
On the subscription side the entry point is :
A subscriber declares the message types it is interested in and knows how to dispatch a CronusMessage into its handler. The abstract wires the handler type's DataContract id as the subscriber's id — that is the stable value the transport uses to route messages and that appears in headers like RecipientHandlers.
Both sides meet in the consumer, :
Each handler kind (application services, projections, sagas, ports, triggers, gateways) has its own consumer, which is started or stopped by the corresponding Cronus:*Enabled flag (see ).
A message is processed through a pipeline of steps. The standard pipeline creates a scoped service provider and a per-message CronusContext (), dispatches the message to the handler () and wraps everything in a diagnostic envelope (). On the publishing side the pipeline sets tenant and bounded-context headers (), emits structured log lines () and propagates the OpenTelemetry traceparent ().
The transport abstraction sits behind IPublisher<T> and the consumer contracts, so the framework itself does not care which broker is under it. Cronus ships with two implementations:
— the canonical transport, marked olympus. One private RabbitMQ broker per trust boundary, with optional federation and an independent public broker for cross-boundary public events. Configuration lives under Cronus:Transport:RabbitMQ:* and Cronus:Transport:PublicRabbitMQ:* — see .
— a secondary transport used where Azure Service Bus is the operational default.
RabbitMQ is the recommended transport for new services. It is the transport we have exercised in production hardest, the federation story maps naturally to multi-tenant and multi-bounded-context deployments, and the Cronus:Transport:RabbitMQ:Consumer:FanoutMode switch lets you flip the semantics from competing consumers to every node sees every message without changing the application code.
Messages need to become bytes before they go over the wire, and bytes again when a subscriber picks them up. That responsibility lives behind ISerializer. See for the shipped implementations, the contract-id convention and the rules for keeping DataContract attributes stable.
public interface ISubscriber
{
string Id { get; }
IEnumerable<Type> GetInvolvedMessageTypes();
Type HandlerType { get; }
Task ProcessAsync(CronusMessage message);
}public interface IConsumer<out T> where T : IMessageHandler
{
Task StartAsync();
Task StopAsync();
}The generic Jobs page describes what a CronusJob<TData> is and how you write one. This page is about the part that matters only once more than one host is running: how the cluster picks who advances a job, how progress is shared, and what that means for the jobs Cronus ships with.
The foundational guarantee the cluster makes is "at most one host is actively advancing the job with this Name". This is the property that lets a rebuild run across many machines without double-writing projection rows, and that lets a migration copy events without duplicating them at the destination.
Implementations achieve it with different primitives. The in-memory InMemoryCronusJobRunner trivially satisfies the rule because there is only one host. The Consul-backed Cronus.Cluster.Consul runner relies on Consul session locks: acquiring the session for a given job name is what gives a host the right to advance it. If the session expires — because the host died or stopped pinging — Consul releases the lock and another host can take over.
The fact that jobs are keyed by Name has consequences. Cronus tries hard to produce deterministic, tenant-scoped names; see, for example, how the message-counter rebuild factory builds its name:
— from . Every job factory you meet constructs the name from the bounded context, the tenant and a stable suffix, so the cluster can correlate runs across restarts and across hosts.
A job's progress is an instance of IJobData (see for the minimal contract — IsCompleted and Timestamp). Real jobs carry more: pagination tokens, counters, the projection version being rebuilt, the event-type id being replayed.
is the sync point. When a host is about to run a job, it first calls:
If the cluster has state for this job, the host uses that state. If it does not, the host falls back to the initial data built by the factory — which typically seeds things like the timestamp when the work was first requested.
After that, every meaningful step of the work is followed by a Data = await cluster.PingAsync(Data). That call does two things: it publishes the current Data as the cluster's authoritative state, and it returns whatever the cluster has (which may be the same or may have been overwritten concurrently, though in practice only the singleton lock-holder writes).
Leader election is not exposed as a first-class API — it is implicit in ExecuteAsync. If the runner decides this host is not currently the leader for a given job name, ExecuteAsync will either refuse to advance the work or it will wait for the leader to release the lock. The concrete behaviour is backend-specific (Consul returns fast if the session is taken; the in-memory runner always proceeds).
If you need to cancel the work from another host, the cluster's mechanism for that is the JobManager — call JobManager.CancelAsync(jobName) on any host, and the cancellation token propagates through to the runner that currently holds the lock.
The jobs Cronus ships use the cluster in subtly different ways:
— a singleton sweep over every registered event type. It pings the cluster every 5 seconds (in a background loop) so the cluster sees the host is alive while the sweep runs.
— a singleton per projection version. Progress tokens per event type live in Data.EventTypePaging and are pinged to the cluster through NotifyProgressAsync inside the PlayerOperator.
— a singleton per
All three share the same pattern: a short SyncInitialStateAsync, a loop that does one unit of work at a time, a PingAsync(Data) after each unit, and a IsCompleted = true; PingAsync(Data) at the end.
{recipient bounded context, recipient handlers, source event type}job.Name = $"urn:{boundedContext.Name}:{contextAccessor.CronusContext.Tenant}:{job.Name}";Data = await cluster.PingAsync<TData>(cancellationToken).ConfigureAwait(false);
if (Data is null) Data = BuildInitialData();
Data = DataOverride(Data);Every message that lives longer than a single process call — an event in the store, a command on the wire, a public event crossing a bounded context — goes through a serializer on the way out and through the same serializer on the way in. Serialization is the contract between "now" and "every version of the service that will ever run".
The interface in Cronus/src/Elders.Cronus/ISerializer.cs is deliberately minimal:
public interface ISerializer
{
byte[] SerializeToBytes<T>(T message);
string SerializeToString<T>(T message);
T DeserializeFromBytes<T>(byte[] bytes);
}The implementation you wire into DI is the one the entire framework uses — there is no per-message switch. You should not change it once a service is in production without doing a full event-store migration, because every byte stored before the switch was produced by the previous serializer.
Every IMessage (every ICommand, IEvent, IPublicEvent, ISignal) and every value-typed record/class that persists bytes is annotated with a DataContractAttribute whose Name is a GUID:
That guid is the contract id — the stable identifier the serializer writes alongside the payload. It is the id the system uses to look up the type when deserialising:
GetContractId() on a Type returns the guid.
GetTypeByContract(string contractId) goes the other way.
Serialized messages carry their contract id so the runtime can pick the right type to materialise into.
Because the contract id is just a guid in an attribute, you can freely rename the C# class, the namespace, the fields (as long as [DataMember(Order = N)] is stable), or move it between assemblies, and the persisted bytes keep working. That is the property that makes long-lived event stores maintainable — the wire shape and the code shape are allowed to diverge.
The rules that govern contract evolution are explained in full under ; the hint block at the bottom of this page summarises them.
Two serializers live in the ecosystem:
— the canonical serializer, marked olympus. JSON, driven by [DataContract] and [DataMember] attributes. Human-readable bytes in the store (very helpful for debugging), broad compatibility, and zero warm-up cost. This is the one you should use.
(legacy) — the protobuf-based serializer, marked styx. Faster once warm, more compact on disk — but it pays a significant warm-up penalty on large projects (the type graph is walked once on first use) and the implementation has a small protocol deviation from stock protobuf. The recommendation today is the JSON serializer.
The pattern that keeps serialization safe long-term is exactly the pattern DataContract encodes:
Each message type gets a DataContractAttribute with Name set to a new GUID. Never reuse guids.
Each persisted field gets [DataMember(Order = N)] where N is unique within the type and never changes.
Every type you persist has a private parameterless constructor (the serializer needs to build the instance before it fills it).
The best-practices block below is the full can / should / must form.
Collection fields are initialised in the constructor (otherwise a freshly-deserialised instance may expose a null list).
[DataContract(Name = "f69daa12-171c-43a1-b049-be8a93ff137f")]
public class AggregateCommit : IMessage { ... }Cronus and its satellite packages are configured through the standard ASP.NET Core configuration system. Every options class binds to a well-known section of IConfiguration under the root key cronus:, which means you can supply values from appsettings.json, environment variables, Consul/Vault providers, command-line arguments, or any other IConfigurationProvider you register.
Defaults are chosen so that a local development environment works out of the box. In production you almost always override at least:
cronus:boundedcontext — the name of your service
cronus:tenants — the list of tenants the service serves
The connection strings for Cassandra, RabbitMQ and Redis
Configuration keys are case-insensitive. The headings below use the exact casing emitted by the option providers as a reference.
The core Cronus package exposes two required settings (the bounded context name and the tenant list) plus a family of boolean feature flags controlled through . The flags decide which consumers and background services Cronus starts inside the host process, so you can split a monolith into several specialised processes by toggling them.
The logical name of the service, bound from . It is marked [Required] and validated against the regular expression ^\b([\w\d_]+$), so it may contain only alphanumeric characters and underscores. The value is lower-cased and trimmed by BoundedContextProvider.
Cronus uses this value to personalise the infrastructure it creates on your behalf. It is used when naming:
RabbitMQ exchanges and queues
The Cassandra event-store keyspace and tables
The Cassandra projections-store keyspace and tables
The list of tenants the service is allowed to serve, bound from . The array is marked [Required]; every element is validated against ^\b([\w\d_]+$) (alphanumeric plus underscore), lower-cased, trimmed and deduplicated by TenantsOptionsProvider before being used.
Cronus is multitenant by design and will refuse to start if the list is empty. Many components of the framework are tenant-aware, including:
Every CronusMessage is tagged with the tenant it belongs to
RabbitMQ exchanges and queues are partitioned per tenant
The Cassandra event store keeps a dedicated keyspace per tenant
The Cassandra projection store keeps a dedicated keyspace per tenant
Once set, the TenantsOptions instance is available via dependency injection wherever you need to enumerate the tenants at runtime. See for the full model.
The interval, in seconds, between the signal messages Cronus emits to prove the host is alive. Bound from . Validated with [Range(5, 3600)].
Starts the consumer that dispatches commands to application services (command handlers). Turn it off on processes that are not supposed to handle commands — for example a read-only projections host.
Starts the consumer that writes events into the projection store. Turn it off on processes that only dispatch commands or act as API gateways, or when you want to pause projections while running a migration.
Starts the consumer that dispatches events to ports (outbound integrations such as sending e-mail or calling a third-party API). Disable it where you do not want outbound side effects, typically on read-only replicas.
Starts the consumer that dispatches events to sagas (long-running processes). Disable it on hosts that must not advance saga state.
Starts the consumer that dispatches events to gateways (components that publish to a second transport, typically the public RabbitMQ bus). Disable it on hosts that should not re-publish events outside the service.
Starts the consumer that dispatches events to triggers (hooks that react to specific events). Disable it on hosts where triggers should not fire.
Starts Cronus-internal system services such as the heartbeat publisher. Leave it on unless you are deliberately running a worker that must emit no system signals.
Starts the replay/migration consumer. Defaults to false because running migrations in the same process that serves production traffic is usually undesirable; enable it on a dedicated migrator host while a migration is in progress.
Starts the RPC API host that exposes request/response endpoints over RabbitMQ (see ). Defaults to false; enable it on the process that should answer RPC calls.
Cronus.Api hosts an ASP.NET Core endpoint with Kestrel. Two configuration sections are read directly by the host at startup:
The API is hosted with Kestrel on port 7477 by default. The whole section is forwarded to the , so any endpoint, certificate or HTTP-protocol setting Kestrel understands is valid here.
The API can be protected with JWT bearer authentication. If this section exists in configuration, Cronus.Api.Startup enables authentication and forwards the section to . If the section is absent, the API runs without authentication.
Additional remarks: .
Configuration for the Cassandra-backed event store, bound from .
The connection string to the Cassandra cluster that holds the event store keyspaces.
The replication strategy used when Cronus creates the event-store keyspace for a new tenant. This setting only has an effect when a keyspace is first provisioned.
Valid values:
simple
network_topology — when used you must also provide ReplicationFactor and Datacenters.
The replication factor used together with ReplicationStrategy. Only used at keyspace creation.
The datacenter names used with the network_topology strategy. Unused for simple.
Configuration for the Cassandra-backed projection store. The main connection settings come from ; a dedicated TableRetention sub-section controls how Cronus prunes old projection tables during a replay.
The connection string to the Cassandra cluster that holds the projection keyspaces.
The replication strategy used when Cronus creates the projections keyspace for a new tenant. Only effective at keyspace creation.
Valid values:
simple
network_topology — when used you must also provide ReplicationFactor and Datacenters.
The replication factor used with ReplicationStrategy. Only used at keyspace creation.
The datacenter names used with the network_topology strategy. Unused for simple.
When a projection is rebuilt (replayed), Cronus creates a fresh table and, once the replay finishes, can optionally drop the older versions. This flag toggles that behaviour. The default is false — old tables are kept forever unless you opt in. The feature is experimental and was originally introduced for Cosmos DB, where every table has a direct cost.
When DeleteOldProjectionTables is enabled, this value controls how many previous versions of a projection table are kept around before older ones are deleted. The current (live) table is never counted against this limit; a value of 2 therefore means "the live table plus the two most recent historical tables".
Configuration for the primary (private) RabbitMQ connection, bound from under the cronus:transport:rabbitmq section. This is the bus Cronus uses to route commands, events and signals between the services that share the same trust boundary.
DNS name or IP address of the RabbitMQ broker. Multiple endpoints can be provided as a comma-separated list; they are parsed with AmqpTcpEndpoint.ParseMultiple.
The AMQP port of the RabbitMQ broker.
The virtual host to use. It is good practice not to use the default / vhost; see the . Cronus does not use vhosts to implement multitenancy.
Username used by the AMQP connection.
Password used by the AMQP connection.
The HTTP port of the RabbitMQ management API. Cronus uses this port together with ApiAddress to create and delete exchanges, queues, policies and federation links.
Base URL of the RabbitMQ management API (for example http://rabbitmq:15672). When not set Cronus falls back to Server and AdminPort.
The bounded context this particular RabbitMQ connection belongs to. Defaults to implicit, which means "this service's own bounded context". You only need to override it when declaring an entry inside ExternalServices that points at another bounded context.
Whether the RabbitMQ client uses the async dispatcher. Leave it at the default (true) unless you have a specific reason to fall back to the synchronous dispatcher.
Settings for a RabbitMQ federated exchange. Currently only one property is supported:
MaxHops (int, default 1) — the maximum hops a federated message is allowed to travel.
An optional array of additional RabbitMQ connections, one per external bounded context that this service needs to reach over its own transport. Each item has the same shape as the root RabbitMqOptions (including BoundedContext). If a property is left at its default value on an entry, Cronus fills it in with the value from the root section. ExternalServices is bound with BindNonPublicProperties = true because the property itself is internal to the options class.
A second, independent RabbitMQ connection used by the public-event fan-out. It is bound from under the cronus:transport:publicrabbitmq section. Use it when you want to publish a subset of events onto a broker that lives outside your trust boundary, leaving the private broker untouched.
The semantics of each key match the corresponding one on the private connection; the only differences are:
UseAsyncDispatcher defaults to false.
FederatedExchange is always initialised (to { MaxHops = 1 }) rather than left null, because public events are typically federated across brokers.
PublicRabbitMqOptions.GetUpstreamUris()
Consumer-side settings, bound from under the cronus:transport:rabbitmq:consumer section. This replaces the legacy flat Cronus:Transport:RabbitMq:ConsumerWorkersCount setting that earlier versions used.
The number of worker threads each Cronus consumer (application services, projections, ports, sagas, gateways, triggers) spins up for processing messages pulled from RabbitMQ. Validated with [Range(1, int.MaxValue)].
The timeout, in seconds, Cronus waits for a response when dispatching an RPC call over RabbitMQ.
Drastically changes the infrastructure layout. When enabled, each node creates its own queue and every message is delivered to every node, i.e. the bus behaves as a fan-out rather than a worker pool. Use with care; the default false gives you classic competing-consumers semantics.
The Redis-backed implementation of Cronus.AtomicAction uses distributed locks following the . The cronus:atomicaction:redis configuration section is bound by two option providers in this package:
— the atomic-action TTLs and connection string.
(from the Elders.RedLock library) — the Redlock-specific retry and drift settings.
Both classes read from the same section, so every key below goes into the same cronus:atomicaction:redis object in appsettings.json.
Connection string to the Redis instance (or cluster) that holds the locks. Marked [Required].
The TTL applied at the start of an atomic action. While this lock is held no other node may execute an action against the same aggregate root + revision. Defaults to 1 second.
A second, longer TTL applied after the atomic action has succeeded. It prevents a late-arriving node from overwriting the last action on the same aggregate + revision. Defaults to 5 seconds and does not interfere with the normal action flow.
How many times the Redlock client retries acquiring a contended lock before giving up. Defaults to 1.
How long the client waits between retries. Defaults to 10 ms.
Safety margin used by Redlock to compensate for clock drift between Redis nodes. See the for the full derivation. Defaults to 0.01.
ServerAmqpTcpEndpoint.ParseMultipleVHoststring
yes
{
"cronus": {
"boundedcontext": "billing"
}
}{
"cronus": {
"tenants": [ "tenant1", "tenant2", "tenant3" ]
}
}{
"cronus": {
"heartbeat": {
"intervalinseconds": 5
}
}
}configuration section
no
{
"Cronus": {
"Api": {
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://*:7477",
"Certificate": {
"Subject": "*.example.com",
"Store": "My",
"Location": "CurrentUser",
"AllowInvalid": "true"
}
}
}
}
}
}
}{
"Cronus": {
"Api": {
"JwtAuthentication": {
"Authority": "https://example.com",
"Audience": "https://example.com/resources"
}
}
}
}string
yes
{
"cronus": {
"persistence": {
"cassandra": {
"connectionstring": "Contact Points=cassandra;Default Keyspace=billing",
"replicationstrategy": "network_topology",
"replicationfactor": 3,
"datacenters": [ "dc1", "dc2" ]
}
}
}
}string
yes
{
"cronus": {
"projections": {
"cassandra": {
"connectionstring": "Contact Points=cassandra;Default Keyspace=billing_projections",
"replicationstrategy": "simple",
"replicationfactor": 1,
"tableretention": {
"deleteoldprojectiontables": false,
"numberofoldprojectiontablestoretain": 2
}
}
}
}
}string
no
127.0.0.1
{
"cronus": {
"transport": {
"rabbitmq": {
"federatedexchange": {
"maxhops": 1
}
}
}
}
}{
"cronus": {
"transport": {
"rabbitmq": {
"server": "rabbitmq.internal",
"username": "service",
"password": "secret",
"externalservices": [
{
"boundedcontext": "identity",
"server": "rabbitmq.identity.internal"
}
]
}
}
}
}string
no
127.0.0.1
{
"cronus": {
"transport": {
"publicrabbitmq": {
"server": "rabbitmq.public.example.com",
"port": 5672,
"vhost": "/public",
"username": "public-service",
"password": "secret",
"apiaddress": "https://rabbitmq.public.example.com:15672",
"federatedexchange": {
"maxhops": 1
}
}
}
}
}int
no
10
{
"cronus": {
"transport": {
"rabbitmq": {
"consumer": {
"workerscount": 10,
"rpctimeout": 10,
"fanoutmode": false
}
}
}
}
}string
yes
string
yes
{
"cronus": {
"atomicaction": {
"redis": {
"connectionstring": "redis:6379",
"lockttl": "00:00:01.000",
"longttl": "00:00:05.000",
"lockretrycount": 1,
"lockretrydelay": "00:00:00.010",
"clockdrivefactor": 0.01
}
}
}
}string[]
yes
uint
no
5
bool
no
true
bool
no
true
bool
no
true
bool
no
true
bool
no
true
bool
no
true
bool
no
true
bool
no
false
bool
no
false
configuration section
no
string
no
simple
int
no
1
string[]
no
[]
string
no
simple
int
no
1
string[]
no
[]
bool
no
false
uint
no
2
int
no
5672
string
no
/
string
no
guest
string
no
guest
int
no
5672
string
no
string
no
implicit
bool
no
true
configuration section
no
object[]
no
[]
int
no
5672
string
no
/
string
no
guest
string
no
guest
int
no
5672
string
no
bool
no
false
configuration section
no
{ MaxHops = 1 }
int
no
10
bool
no
false
TimeSpan
no
00:00:01.000
TimeSpan
no
00:00:05.000
ushort
no
1
TimeSpan
no
00:00:00.010
double
no
0.01
.NET Aspire is the canonical way to compose a Cronus solution today. The AppHost owns the topology — Cassandra, RabbitMQ, Redis, Consul, Elasticsearch, plus every Cronus process that consumes them — and it injects connection strings into each process as environment variables that map directly onto the Cronus:* configuration keys. The Cronus side stays standard ASP.NET Core hosting; the only thing the AppHost replaces is "how does the worker find the broker?".
This page is the wiring contract: what the AppHost must do, what the worker must do, and how the two halves talk through IConfiguration.
The AppHost is a DistributedApplication project. It does three things for Cronus:
Stand up infrastructure resources — Cassandra, RabbitMQ, Redis, optionally Consul, Elasticsearch — using the standard Aspire builder API (builder.AddRedis, builder.AddRabbitMQ, builder.AddContainer for things without a first-party integration).
Reference each resource from each Cronus process with WithReference(...), so Aspire knows the dependency graph and provides the connection strings.
Map every connection string onto the Cronus:* configuration key the worker expects, using WithEnvironment(...) and the double-underscore convention that ASP.NET Core configuration interprets as Cronus:Foo:Bar.
The third step is the only Cronus-specific bit. The worker process expects (for example) Cronus:Persistence:Cassandra:ConnectionString. Aspire only knows it has a Cassandra container at some endpoint. The AppHost is the place where you say "the contact points the worker should use are this Aspire endpoint":
Pulled from the Locus AppHost — see for the whole file.
The worker stays a vanilla ASP.NET Core or worker-service host. It calls AddServiceDefaults() (the Aspire defaults extension method), AddCronus(configuration), and that is the wiring done. Nothing in the Cronus call chain knows about Aspire — the configuration providers built into IConfiguration already turn Cronus__Persistence__Cassandra__ConnectionString (env var) into Cronus:Persistence:Cassandra:ConnectionString (configuration key), and AddCronus reads from there.
The Locus API's Program.cs shows the canonical ordering — service defaults first, then AddCronus:
Source: .
takes the IConfiguration from the host, runs the discovery scan () over every assembly that AssemblyLoader found in the output directory, and registers everything the satellites declare. There is no Aspire-specific call.
A worker host that needs Cassandra, RabbitMQ and Redis looks like this:
The pattern repeats for every Cronus process the AppHost owns.
For the full key reference see:
The AppHost is also where you decide how many Cronus processes there are and what each one does. A typical Aspire-era Cronus solution has at least two — an API host that dispatches commands and a worker host that runs projections, sagas, ports and gateways. The split is controlled by Cronus:*Enabled flags injected per process. That story has its own page:
redis.GetEndpoint("tcp")
consul.GetEndpoint("http")
Cronus:Cluster:Consul:Address (where used)
.WithEnvironment("Cronus__Persistence__Cassandra__ConnectionString",
() => $"Contact Points={cassandra.GetEndpoint("cql").Host};Port={cassandra.GetEndpoint("cql").Port};Default Keyspace=billing")var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// ...
builder.Services.AddCronusAspNetCore();
builder.Services.AddCronus(configuration);var builder = DistributedApplication.CreateBuilder(args);
var redis = builder.AddRedis("redis")
.WithLifetime(ContainerLifetime.Persistent);
var cassandra = builder.AddContainer("cassandra", "cassandra", "latest")
.WithLifetime(ContainerLifetime.Persistent)
.WithEndpoint(port: 9042, targetPort: 9042, name: "cql")
.WithEnvironment("CASSANDRA_CLUSTER_NAME", "BillingCluster");
var rabbitmq = builder.AddRabbitMQ("rabbitmq")
.WithLifetime(ContainerLifetime.Persistent)
.WithManagementPlugin(15672);
builder.AddProject<Projects.Billing_Worker>("billing-worker")
.WithReference(redis)
.WithReference(rabbitmq)
.WithEnvironment("Cronus__Persistence__Cassandra__ConnectionString",
() => $"Contact Points={cassandra.GetEndpoint("cql").Host};Port={cassandra.GetEndpoint("cql").Port};Default Keyspace=billing")
.WithEnvironment("Cronus__Projections__Cassandra__ConnectionString",
() => $"Contact Points={cassandra.GetEndpoint("cql").Host};Port={cassandra.GetEndpoint("cql").Port};Default Keyspace=billing_projections")
.WithEnvironment("Cronus__Transport__RabbitMQ__Server",
() => rabbitmq.GetEndpoint("tcp").Host)
.WithEnvironment("Cronus__Transport__RabbitMQ__Port",
() => rabbitmq.GetEndpoint("tcp").Port.ToString())
.WithEnvironment("Cronus__Transport__RabbitMQ__Username", rabbitmq.Resource.UserNameReference)
.WithEnvironment("Cronus__Transport__RabbitMQ__Password", rabbitmq.Resource.PasswordParameter)
.WithEnvironment("Cronus__AtomicAction__Redis__ConnectionString",
() => $"{redis.GetEndpoint("tcp").Host}:{redis.GetEndpoint("tcp").Port}")
.WaitFor(redis)
.WaitFor(cassandra)
.WaitFor(rabbitmq);
builder.Build().Run();cassandra.GetEndpoint("cql")
rabbitmq.GetEndpoint("tcp")
rabbitmq.GetEndpoint("management")
When Cronus loads an aggregate from the event store, it does not blindly fold every event it finds into the state. The stream is first run through an integrity policy that checks the events make a coherent history — no duplicates, no out-of-order revisions, no holes. If the policy reports a violation that no resolver could fix, the load fails. This page covers the policy extension point, the default rules, and the (deliberate) absence of a "skip the policy" bypass in the shipped framework.
Every load goes through AggregateRepository.LoadAsync:
public async Task<ReadResult<AR>> LoadAsync<AR>(AggregateRootId id) where AR : IAggregateRoot
{
EventStream eventStream = await eventStore.LoadAsync(id).ConfigureAwait(false);
var integrityResult = integrityPolicy.Apply(eventStream);
if (integrityResult.IsIntegrityViolated)
throw new EventStreamIntegrityViolationException($"AR integrity is violated for ID={id.Value}");
eventStream = integrityResult.Output;
// ... fold into aggregate state ...
}The repository asks the registered IIntegrityPolicy<EventStream> to inspect the freshly loaded EventStream. If the policy returns IsIntegrityViolated = true, the load throws and the application service does not see a half-broken aggregate. If the policy is satisfied, the (possibly rewritten) stream is folded into the state.
The policy in core is EventStreamIntegrityPolicy. It is composed of three rules applied in order:
DuplicateRevisionsValidator — fails if two commits in the stream share a revision number.
OrderedRevisionsValidator paired with UnorderedRevisionsResolver — flags out-of-order revisions and lets the resolver attempt to put them back in order.
MissingRevisionsValidator — fails if a revision number is missing from the sequence.
Each rule is an — a pair of an (does the stream look right?) and an (if it does not, can we recover?). The default resolver is , which always reports the stream as violated — no recovery, raise the exception.
The interface is small:
A policy returns a sealed :
Output is the (possibly rewritten) candidate. IsIntegrityViolated is the verdict.
Validators implement :
carries IsValid, an ErrorType discriminator and an Errors collection. The default concrete result is — a list of error strings plus the type tag.
Resolvers implement :
A resolver is given the offending stream and the validator's report. It returns either a recovered IntegrityResult<T> with IsIntegrityViolated = false and a corrected Output, or it gives up and returns IsIntegrityViolated = true.
The repository takes its IIntegrityPolicy<EventStream> from the container, so a satellite — or your own discovery — can replace it with a stricter or more permissive variant. Use a with CanOverrideDefaults = true:
The same shape lets you write a permissive policy that knows how to repair a specific corruption pattern that turned up once in production, by adding a custom resolver to the list. Keep that policy package gated to the bounded context that needs it.
Suppose a Cassandra topology change once produced streams where revisions are correct but the rows came back in the wrong order. The default OrderedRevisionsValidator flags this; the default resolver (in core, before wires UnorderedRevisionsResolver) would refuse the load. A resolver that sorts the commits in place and returns a healthy result fixes the case without dropping data:
Pair it with the validator, register the rule on a custom policy, override the default through a discovery, and the next load that hits the bad pattern recovers without a manual repair.
Some Cronus discussions refer to a DangerZone — a deliberate, ugly bypass of the integrity policy for one-off corrective actions. There is no DangerZone namespace, no IDangerZone interface, and no Cronus:DangerZone:* configuration key in the shipped framework today. Every load goes through the registered IIntegrityPolicy<EventStream>, and the only way to "bypass" the standard checks is to register a more permissive policy via a discovery — which is itself the first-class extension point.
If you find code or scripts that mention DangerZone, treat them as out-of-band repair tooling that lives outside the Cronus host, not as a documented framework feature. The expected pattern for a corrective action is:
Stop the host (or the relevant consumers).
Apply the repair directly against the event store, in a one-shot tool that you can audit.
Restart the host.
Do not try to encode the bypass as a permanent flag.
public interface IIntegrityPolicy<T>
{
IEnumerable<IntegrityRule<T>> Rules { get; }
IntegrityResult<T> Apply(T candidate);
}public sealed class IntegrityResult<T>
{
public IntegrityResult(T output, bool isIntegrityViolated) { /* ... */ }
public bool IsIntegrityViolated { get; }
public T Output { get; }
}public interface IValidator<T> : IComparable<IValidator<T>>
{
IValidatorResult Validate(T candidate);
uint PriorityLevel { get; }
}public interface IResolver<T> : IComparable<IResolver<T>>
{
IntegrityResult<T> Resolve(T eventStream, IValidatorResult validatorResult);
uint PriorityLevel { get; }
}public class CustomIntegrityPolicyDiscovery : DiscoveryBase<IIntegrityPolicy<EventStream>>
{
protected override DiscoveryResult<IIntegrityPolicy<EventStream>> DiscoverFromAssemblies(DiscoveryContext context)
{
var policyModel = new DiscoveredModel(
typeof(IIntegrityPolicy<EventStream>),
typeof(MyStrictEventStreamIntegrityPolicy),
ServiceLifetime.Singleton);
policyModel.CanOverrideDefaults = true;
return new DiscoveryResult<IIntegrityPolicy<EventStream>>([policyModel]);
}
}public sealed class SortByRevisionResolver : IResolver<EventStream>
{
public uint PriorityLevel => 100;
public int CompareTo(IResolver<EventStream> other) => PriorityLevel.CompareTo(other.PriorityLevel);
public IntegrityResult<EventStream> Resolve(EventStream eventStream, IValidatorResult validatorResult)
{
var sorted = eventStream.Commits.OrderBy(c => c.Revision).ToList();
var fixedStream = new EventStream(sorted);
return new IntegrityResult<EventStream>(fixedStream, isIntegrityViolated: false);
}
}A discovery is a class that Cronus runs at boot to decide which services should be added to the host's IServiceCollection. Every satellite package ships one or more discoveries; your own code can ship them too. This is how Cronus is assembled out of a shared core plus pluggable satellites without any of them having a compile-time reference to the host's Program.cs.
The moving parts live under Elders.Cronus.Discoveries. They are, in rough dependency order:
DiscoveryBase<TCronusService> — abstract base that implements IDiscovery<TCronusService> and defines the one method you override, DiscoverFromAssemblies(DiscoveryContext). Only the generic form exists; there is no non-generic DiscoveryBase.
— exposes a Name and a Discover(DiscoveryContext) method that returns IDiscoveryResult<TCronusService>.
— the read-only state the scanner hands to every discovery. It holds Assemblies (the set of assemblies Cronus has loaded), Configuration (the IConfiguration passed to AddCronus), a derived Types enumeration, and the helper FindService<TService>() which scans the assemblies for concrete implementations of TService. It does not hold IServiceCollection — that is the job of CronusServicesProvider
— the result a discovery returns. It wraps an IEnumerable<DiscoveredModel> plus an optional Action<IServiceCollection> for registrations that don't fit the DiscoveredModel shape (typically AddOptions calls). Both are generic only; there is no non-generic form.
— extends the standard Microsoft.Extensions.DependencyInjection.ServiceDescriptor with two flags: CanOverrideDefaults (use Services.Replace) and CanAddMultiple (use Services.Add). If neither flag is set the model is applied with Services.TryAdd, i.e. it registers the service only if nothing else has claimed it. The class is non-generic; there is no DiscoveredModel<T>.
— the reflection-based invoker. Its only public member is IEnumerable<IDiscoveryResult<object>> Scan(DiscoveryContext context), which returns one result per discovery type it finds.
— the object that applies a discovery result to the IServiceCollection. It calls the AddServices action first, then walks Models and dispatches each one to Services.Add, Services.Replace, or Services.TryAdd depending on the two flags above.
DiscoveryScanner.Scan looks like this:
Two details are worth calling out:
The filter t.BaseType == candidate removes any discovery that has a subclass elsewhere in the assemblies. That is how the EventStoreDiscovery in Cronus core is replaced by CassandraEventStoreDiscovery in the satellite — only the subclass runs.
Discoveries are instantiated by FastActivator.CreateInstance with no constructor parameters. A discovery class must have a public parameterless constructor.
Here is the RabbitMQ publisher discovery in full. It registers the three publisher lanes Cronus has (generic IPublisher<>, public-event publisher, signal publisher) and marks each as overriding the in-memory defaults:
Source: .
What to notice:
The type parameter on DiscoveryBase<IPublisher<IMessage>> is the conceptual service the discovery owns — it is informational, not a constraint. The scanner does not act on it.
AddServices is where the AddOptions calls live, because AddOptions does not fit the DiscoveredModel shape (it registers three services — IConfigureOptions, IOptionsChangeTokenSource
A discovery can inherit from another discovery and extend its model set. The Cassandra event store does exactly that:
Source: .
Because CassandraEventStoreDiscovery inherits from EventStoreDiscovery, the scanner's "filter out base discoveries" rule kicks in: EventStoreDiscovery will not run on its own — only the subclass runs — which is exactly what you want.
The assemblies fed to every DiscoveryContext come from . Its static constructor runs once, on first access, and does the following:
Takes Assembly.GetEntryAssembly().Location and derives the directory the host was launched from.
Enumerates every *.dll and *.exe file in that directory.
Skips a hard-coded list of unmanaged runtime files (sni.dll, coreclr.dll
DiscoveryContext.Assemblies is this dictionary. If the directory the host runs from does not contain your satellite's DLL, the discovery will not run. This is why the recommended hosting pattern is to deploy every satellite package into the host process's output directory.
A discovery is the right extension point whenever you want to add, replace, or reconfigure services that Cronus will consume at runtime — a custom publisher, a custom event store, a custom handler factory, and so on. The checklist:
Create a class that derives from DiscoveryBase<TCronusService>, where TCronusService is the conceptual service the discovery owns. It is informational; pick whatever type documents intent.
Give the class a public parameterless constructor so FastActivator.CreateInstance can build it.
Override DiscoverFromAssemblies(DiscoveryContext context)
IOptionsFactoryThe publisher models set CanOverrideDefaults = true because they are meant to replace the in-memory publishers registered by InMemoryPublisherDiscovery.
clrjit.dllmicrosoftapi-mssos_mscordaccoremscorFor every remaining file, checks if the assembly is already loaded in AppDomain.CurrentDomain.GetAssemblies(); if not, loads it with AssemblyLoadContext.Default.LoadFromAssemblyPath.
Adds the loaded assemblies to the static Assemblies dictionary keyed by full name.
DiscoveryResult<TCronusService>An IEnumerable<DiscoveredModel> — one entry per service you want to register.
An optional Action<IServiceCollection> for registrations that do not fit the DiscoveredModel shape (typically AddOptions<TOptions, TOptionsProvider>).
On each DiscoveredModel, set CanOverrideDefaults = true if you want to replace a default registration, or CanAddMultiple = true if you want the service to be registered alongside others. Leave both false if you only want to fill in when nothing else has.
Make sure the assembly containing your discovery class is deployed into the host process's directory so AssemblyLoader picks it up.
Cronus.Persistence.Cassandra/src/Elders.Cronus.Persistence.Cassandra/CassandraEventStoreDiscovery.cspublic IEnumerable<IDiscoveryResult<object>> Scan(DiscoveryContext context)
{
IEnumerable<Type> allTypes = context.Assemblies
.SelectMany(asm => asm
.GetLoadableTypes()
.Where(type => type.IsAbstract == false && type.IsClass && typeof(IDiscovery<object>).IsAssignableFrom(type)));
IEnumerable<IDiscovery<object>> discoveries = allTypes
.Where(candidate => allTypes.Where(t => t.BaseType == candidate).Any() == false) // filter out discoveries which inherit from each other. We remove the base discoveries
.Select(dt => (IDiscovery<object>)FastActivator.CreateInstance(dt));
foreach (var discovery in discoveries)
{
logger.LogInformation("Discovered {name}.", discovery.Name);
yield return discovery.Discover(context);
}
}public class RabbitMqPublisherDiscovery : DiscoveryBase<IPublisher<IMessage>>
{
protected override DiscoveryResult<IPublisher<IMessage>> DiscoverFromAssemblies(DiscoveryContext context)
{
return new DiscoveryResult<IPublisher<IMessage>>(GetModels(), services => services
.AddOptions<RabbitMqOptions, RabbitMqOptionsProvider>()
.AddOptions<PublicRabbitMqOptions, PublicRabbitMqOptionsProvider>());
}
IEnumerable<DiscoveredModel> GetModels()
{
yield return new DiscoveredModel(typeof(BoundedContextRabbitMqNamer), typeof(BoundedContextRabbitMqNamer), ServiceLifetime.Singleton);
yield return new DiscoveredModel(typeof(PublicMessagesRabbitMqNamer), typeof(PublicMessagesRabbitMqNamer), ServiceLifetime.Singleton);
yield return new DiscoveredModel(typeof(SignalMessagesRabbitMqNamer), typeof(SignalMessagesRabbitMqNamer), ServiceLifetime.Singleton);
yield return new DiscoveredModel(typeof(PrivateRabbitMqPublisher<>), typeof(PrivateRabbitMqPublisher<>), ServiceLifetime.Singleton);
yield return new DiscoveredModel(typeof(PublicRabbitMqPublisher), typeof(PublicRabbitMqPublisher), ServiceLifetime.Singleton);
yield return new DiscoveredModel(typeof(SignalRabbitMqPublisher), typeof(SignalRabbitMqPublisher), ServiceLifetime.Singleton);
var publisherModel = new DiscoveredModel(typeof(IPublisher<>), typeof(PrivateRabbitMqPublisher<>), ServiceLifetime.Singleton);
publisherModel.CanOverrideDefaults = true;
yield return publisherModel;
var publicPublisherModel = new DiscoveredModel(typeof(IPublisher<IPublicEvent>), typeof(PublicRabbitMqPublisher), ServiceLifetime.Singleton);
publicPublisherModel.CanOverrideDefaults = true;
yield return publicPublisherModel;
var signalPublisherModel = new DiscoveredModel(typeof(IPublisher<ISignal>), typeof(SignalRabbitMqPublisher), ServiceLifetime.Singleton);
signalPublisherModel.CanOverrideDefaults = true;
yield return signalPublisherModel;
yield return new DiscoveredModel(typeof(RabbitMqInfrastructure), typeof(RabbitMqInfrastructure), ServiceLifetime.Singleton);
yield return new DiscoveredModel(typeof(ConnectionResolver), typeof(ConnectionResolver), ServiceLifetime.Singleton);
yield return new DiscoveredModel(typeof(PublisherChannelResolver), typeof(PublisherChannelResolver), ServiceLifetime.Singleton);
}
}public class CassandraEventStoreDiscovery : EventStoreDiscovery
{
protected override DiscoveryResult<IEventStore> DiscoverFromAssemblies(DiscoveryContext context)
{
IEnumerable<DiscoveredModel> models = base.DiscoverFromAssemblies(context).Models
.Concat(GetModels(context))
.Concat(DiscoverCassandraTableNameStrategy(context));
return new DiscoveryResult<IEventStore>(models, services => services.AddOptions<CassandraProviderOptions, CassandraProviderOptionsProvider>());
}
IEnumerable<DiscoveredModel> GetModels(DiscoveryContext context)
{
yield return new DiscoveredModel(typeof(IEventStore<>), typeof(CassandraEventStore<>), ServiceLifetime.Transient) { CanOverrideDefaults = true };
yield return new DiscoveredModel(typeof(IEventStore), typeof(CassandraEventStore), ServiceLifetime.Transient) { CanOverrideDefaults = true };
// ...
}
}Cronus is built on a reflection-based extensibility seam called discoveries. Every satellite package — Cronus.Transport.RabbitMQ, Cronus.Persistence.Cassandra, Cronus.Projections.Cassandra, and so on — ships one or more discovery classes. When services.AddCronus(configuration) is called at boot, Cronus scans the loaded assemblies, invokes every discovery it finds, and applies the resulting ServiceDescriptor entries to the host's DI container. Your own code can ship discoveries too; that is how you inject or replace services in a way that composes cleanly with the framework defaults.
On top of discoveries sit three more extensibility points, covered in the pages below:
[CronusStartup] — a way to schedule ICronusStartup code to run at a particular phase of the boot sequence.
Fault handling — the retry policies Cronus ships with, and how to replace them.
Observability — the DiagnosticListener, ActivitySource and heartbeat subsystem Cronus wires up by default, and how to bolt on your own exporter.
A Cronus solution rarely runs as a single process in production. Once traffic grows, the workload splits: an API host has to answer HTTP fast, a worker host has to chew through events at its own pace, an offline scraper has to pour data in without holding up either, and a console may need to run a one-off bulk operation against the same event store. All four are the same Cronus solution — same bounded context, same configuration, same event store and broker — but each does only the part of the work it is meant to do.
The split is controlled by a small family of boolean flags. This page explains the pattern, the toggles, and the topologies that fall out of them.
You split a Cronus host when one of these things stops being true:
The HTTP-facing process is fast enough to keep the user waiting only for the command write, not for projections, sagas and gateways to settle.
A long replay (a versioning rebuild, a migration, a backfill) does not steal capacity from production traffic.
External-facing side effects — emails, push notifications, third-party calls — happen on a host that can be restarted, throttled or paused without taking the API down.
Read-only workloads can scale horizontally without each replica trying to advance write-side state.
Each of those is a Cronus:*Enabled flag flipped on a different host.
Cronus core ships nine flags, all on and bound under cronus::
The flags are read by at start. Each flag gates a consumer.StartAsync() call — if the flag is false, the consumer is built (the container still resolves it) but never started, so it never pulls from RabbitMQ. The flags are also re-evaluated when the options are reloaded, which means you can toggle a host between modes by changing configuration.
For the full reference of these keys see:
Two consumers — MigrationsEnabled and RpcApiEnabled — default to false. Every other consumer defaults to true. The default Cronus host is therefore an "all-in-one" worker; you turn things off as you split it.
Every flag at default. One process does everything. Simple, fine until traffic forces you to split.
Two processes share the same configuration except for the flags:
The API host accepts commands over HTTP, dispatches them to application services and returns; nothing else runs in-process. The worker picks up the resulting events and turns them into projections, command-publishing sagas, side-effect ports and external-system gateways. Both processes call IAggregateRepository.SaveAsync (the API on every command, the worker through sagas), so both must have a real IAggregateRootAtomicAction configured — see .
Add a third process that does no writes at all:
It serves reads from the projection store and never advances any write-side state. You can run as many of these as the load needs, behind a load balancer.
A short-lived process that runs a replay or a backfill without disturbing production:
Production hosts keep MigrationsEnabled = false so they never compete for the migration queue. When the replay finishes, the migration host shuts down.
A scraper is a long-running process that pulls data from an external source and publishes commands; a console is a short one-off. Both typically run with most consumers off — they do not need to dispatch commands or run projections — and only the message-publishing infrastructure on. The Locus solution's and are the reference shape.
Every process in the topology sees the same:
Event store () — appends from any host go into the same keyspaces; loads from any host hit the same data.
Projection store () — the worker writes; everyone reads.
Transport () — the API publishes commands and events; the worker consumes them. Each consumer type has its own queue, so flipping a flag on one host does not starve another.
The bounded-context name and tenant list — and — must match across every process, because they are part of how queues, exchanges and keyspaces are named.
The natural place to define a topology is the AppHost. Each process is a builder.AddProject<...>() with its own WithEnvironment("Cronus__ProjectionsEnabled", "false") overrides, while sharing one cassandra, rabbitmq and redis resource. See:
Cronus:PortsEnabled
true
The ports consumer (events → outbound side effects with no persistent state)
Cronus:SagasEnabled
true
The sagas consumer (events → new commands and timeouts)
Cronus:GatewaysEnabled
true
The gateways consumer (events → external systems with persistent infrastructure state)
Cronus:TriggersEnabled
true
The triggers consumer (event hooks)
Cronus:SystemServicesEnabled
true
Cronus-internal system app services, sagas, ports, triggers, projections, system indices
Cronus:MigrationsEnabled
false
The replay/migration consumer
Cronus:RpcApiEnabled
false
The RPC API host (request/response over RabbitMQ)
PortsEnabled
false
true
SagasEnabled
false
true
GatewaysEnabled
false
true
TriggersEnabled
false
true
SystemServicesEnabled
true
true
SagasEnabled
false
GatewaysEnabled
false
TriggersEnabled
false
SystemServicesEnabled
false
Cronus:AtomicAction:Redis:*) — every process that writes must reach the same Redis. See Atomic Actions.Cronus:ApplicationServicesEnabled
true
The application-services consumer (commands → aggregates) and the event-store indices consumer
Cronus:ProjectionsEnabled
true
ApplicationServicesEnabled
true
true
ProjectionsEnabled
false
ApplicationServicesEnabled
false
ProjectionsEnabled
false
PortsEnabled
ApplicationServicesEnabled
false
ProjectionsEnabled
false
MigrationsEnabled
The projections consumer (events → projection store)
true
false
true
Cronus retries on its own whenever a transient failure is worth one more attempt. There are two separate retry stacks in the codebase — knowing which one kicks in for your scenario is half of the useful knowledge on this page.
RetryableOperationEvery publisher that derives from Publisher<TMessage> wraps its Publish call in a retry loop built from RetryableOperation:
public abstract class Publisher<TMessage> : PublisherBase<TMessage> where TMessage : IMessage
{
private RetryPolicy retryPolicy;
public Publisher(IEnumerable<DelegatingPublishHandler> handlers) : base(handlers)
{
retryPolicy = new RetryPolicy(RetryableOperation.RetryPolicyFactory.CreateLinearRetryPolicy(5, TimeSpan.FromMilliseconds(300)));
}
public override bool Publish(TMessage message, Dictionary<string, string> messageHeaders)
{
bool isPublished = RetryableOperation.TryExecute(() => base.Publish(message, messageHeaders), retryPolicy);
return isPublished;
}
}The default is 5 attempts with a fixed 300 ms delay between them. The RetryableOperation.RetryPolicyFactory ships three delegate factories: CreateLinearRetryPolicy, CreateExponentialRetryPolicy, and CreateInfiniteLinearRetryPolicy. The RetryPolicy type this loop uses is public delegate ShouldRetry RetryPolicy() — a delegate, not a class. It returns a bool from Publish; there is no exception thrown when all retries are exhausted, the method simply returns false.
Note on the synchronous overload. The snippet above is the in-repo
Publisher<TMessage>base, which exposes a synchronousPublish(...)for the framework's own retry loop. The user-facingIPublisher<T>contract that ships in the NuGet (11.0.x) is async-only — the methods you call from your code arePublishAsync(...). The synchronous form is a framework-internal extension point, not part of the published surface.
Subscriber-side retries are implemented by , which wraps an inner workflow in a class-based RetryPolicy from :
This uses the richer class combined with an and a . The defaults for RetryStrategy are:
DefaultClientRetryCount — 10
DefaultRetryInterval — 1 s
DefaultClientBackoff — 10 s
The three built-in strategies live in :
FixedInterval — constant delay between retries
Incremental — linearly increasing delay
ExponentialBackoff — randomised exponential delay
and the two built-in transient-error detection strategies are TransientErrorCatchAllStrategy (everything is transient) and TransientErrorIgnoreStrategy (nothing is transient — never retries).
Both stacks can be replaced; the mechanism is the same as for any other Cronus default — register your replacement with a discovery marked CanOverrideDefaults = true.
For the subscriber workflow, swap the concrete InMemoryRetryWorkflow<> registration for your own:
For the publisher retry loop, either subclass Publisher<TMessage> with your own retry configuration or, in a transport, replace the publisher type altogether with a discovery — the RabbitMQ and CosmosDb satellites both do this.
A retry strategy that only treats network-level exceptions as transient and backs off exponentially:
Pass the resulting RetryPolicy into your replacement workflow. The built-in InMemoryRetryWorkflow<T> hard-codes TransientErrorCatchAllStrategy plus Incremental — if you want anything else, you replace the whole workflow.
For more advanced fault tolerance — circuit breakers, bulkheads, fallbacks — there is a legacy satellite called . It predates the current extensibility model and is documented separately; mention it here only because some older services still depend on it.
DefaultMaxBackoff — 30 s
DefaultMinBackoff — 1 s
DefaultRetryIncrement — 1 s
public class InMemoryRetryWorkflow<TContext> : Workflow<TContext> where TContext : class
{
private RetryPolicy retryPolicy;
readonly Workflow<TContext> workflow;
public InMemoryRetryWorkflow(Workflow<TContext> workflow, ILogger logger)
{
this.workflow = workflow;
var retryStrategy = new Incremental(5, TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500));//Total 3 etries
retryPolicy = new RetryPolicy(new TransientErrorCatchAllStrategy(), retryStrategy, logger);
}
protected override async Task RunAsync(Execution<TContext> execution)
{
if (execution is null) throw new ArgumentNullException(nameof(execution));
await retryPolicy.ExecuteActionAsync(() => workflow.RunAsync(execution.Context));
}
}public class CustomRetryWorkflowDiscovery : DiscoveryBase<Workflow<HandleContext>>
{
protected override DiscoveryResult<Workflow<HandleContext>> DiscoverFromAssemblies(DiscoveryContext context)
{
var model = new DiscoveredModel(
typeof(InMemoryRetryWorkflow<HandleContext>),
typeof(CustomRetryWorkflow<HandleContext>),
ServiceLifetime.Transient)
{
CanOverrideDefaults = true
};
return new DiscoveryResult<Workflow<HandleContext>>(new[] { model });
}
}public sealed class NetworkOnlyTransientStrategy : ITransientErrorDetectionStrategy
{
public bool IsTransient(Exception ex)
=> ex is HttpRequestException or SocketException or TimeoutException;
}
public static class CustomRetryPolicies
{
public static RetryPolicy NetworkExponential(ILogger logger)
{
var strategy = new ExponentialBackoff(
retryCount: 5,
minBackoff: TimeSpan.FromMilliseconds(250),
maxBackoff: TimeSpan.FromSeconds(10),
deltaBackoff: TimeSpan.FromMilliseconds(500));
return new RetryPolicy(new NetworkOnlyTransientStrategy(), strategy, logger);
}
}Cronus ships with three built-in pieces you can tap into:
A DiagnosticListener and an ActivitySource for distributed tracing.
Structured log scopes carrying the tenant, aggregate id, message id and handler name.
A heartbeat signal emitted on a configurable interval so monitors can tell the host is alive.
Everything below is wired up for you the moment you call services.AddCronus(configuration).
AddCronus calls which registers the two low-level primitives that every distributed-tracing stack on .NET builds on:
Two singletons are added — but only if no DiagnosticListener has been registered yet. On an ASP.NET Core host, ASP.NET Core registers its own listener first; Cronus piggy-backs on it. On a worker-service host, Cronus registers a new one named cronus. The ActivitySource is always named Elders.Cronus with version 11.0.0.
Two places start and stop Activity objects:
Publish path — in the publisher pipeline. It starts an activity named Publish {messageTypeName} for every outgoing message, propagates telemetry_traceparent through the message headers, and writes the completed activity to the diagnostic listener under the name Elders.Cronus.Hosting.Workflow.
Handle path — in every subscriber. It starts an activity named {HandlerType}__{MessageType} for every handler invocation, reads the incoming telemetry_traceparent so the span joins the upstream trace, and writes to the listener under the same activity name
Every activity carries at least one tag:
cronus_messageId — the ID of the CronusMessage the activity tracks.
DiagnosticsWorkflow also enriches every log scope with:
cronus_tenant — the tenant the message belongs to, via Message.GetTenant().
cronus_arid — the aggregate root id, when the message payload exposes one.
These constants are defined in :
Because these are plain string constants used as log-scope keys and activity-tag keys, they flow through any structured logger and any OpenTelemetry exporter with no further configuration on Cronus's side. Your tenant and bounded-context dimensions are already there; all you have to do is scrape them.
The built-in DiagnosticListener is named cronus. To observe everything Cronus emits, subscribe to it from your host:
For most production setups you will not write this yourself — you will let the OpenTelemetry SDK subscribe for you via AddSource("Elders.Cronus").
A minimal OTLP wiring in a host that already calls services.AddCronus(...):
AddSource("Elders.Cronus") is the one line that matters — it tells the OpenTelemetry SDK to listen to the ActivitySource Cronus creates. Every handle and publish activity then goes out over OTLP, with the cronus_messageId, cronus_tenant and cronus_arid tags Cronus set.
Cronus also registers a background that publishes an on a timer, carrying the current bounded-context name and the full tenant list. This is useful as a liveness probe from outside the host: downstream services that consume signals will either see the heartbeat on schedule, or infer that the host is dead.
The interval is configured by Cronus:Heartbeat:IntervalInSeconds — an unsigned integer between 5 and 3600 seconds, defaulting to 5. Validated by a [Range] attribute on .
Elders.Cronus.Hosting.Workflowinternal static IServiceCollection AddOpenTelemetry(this IServiceCollection services)
{
// https://github.com/dotnet/aspnetcore/blob/f3f9a1cdbcd06b298035b523732b9f45b1408461/src/Hosting/Hosting/src/WebHostBuilder.cs#L334
// By default aspnet core registers a DiagnosticListener and if we add our own you will loose the http insights
// However, for worker services we need to register our own Listener.
if (services.Any(x => x.ServiceType == typeof(DiagnosticListener)) == false)
{
services.AddSingleton<DiagnosticListener>(new DiagnosticListener("cronus"));
services.AddSingleton<ActivitySource>(new ActivitySource("Elders.Cronus", "11.0.0"));
}
return services;
}public static class Log
{
public const string Tenant = "cronus_tenant";
public const string AggregateId = "cronus_arid";
public const string AggregateName = "cronus_arname";
public const string MessageId = "cronus_messageId";
public const string MessageData = "cronus_messageData";
public const string MessageType = "cronus_messageType";
public const string MessageHandler = "cronus_messageHandler";
// ... plus job and projection keys
}DiagnosticListener.AllListeners.Subscribe(new AllListenersObserver());
class AllListenersObserver : IObserver<DiagnosticListener>
{
public void OnCompleted() { }
public void OnError(Exception error) { }
public void OnNext(DiagnosticListener listener)
{
if (listener.Name == "cronus")
{
listener.Subscribe(new CronusEventsObserver());
}
}
}
class CronusEventsObserver : IObserver<KeyValuePair<string, object>>
{
public void OnCompleted() { }
public void OnError(Exception error) { }
public void OnNext(KeyValuePair<string, object> kv)
{
if (kv.Key == "Elders.Cronus.Hosting.Workflow" && kv.Value is Activity activity)
{
// record duration, tags, parent span id, etc.
}
}
}using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("my-bounded-context"))
.WithTracing(tracing => tracing
.AddSource("Elders.Cronus")
.AddOtlpExporter(otlp =>
{
otlp.Endpoint = new Uri("http://otel-collector:4317");
}));internal static IServiceCollection AddCronusHeartbeat(this IServiceCollection services)
{
services.AddOptions<HeartbeatOptions, HeartbeaOptionsProvider>();
services.AddSingleton<IHeartbeat, CronusHeartbeat>();
services.AddHostedService<CronusHeartbeatService>();
return services;
}A Cronus service is normally deployed as multiple identical hosts behind a load balancer: the same binary runs on several machines for throughput and fault tolerance. For the message-handling path this works trivially — RabbitMQ delivers each message to exactly one of the workers and there is nothing else to coordinate. For the long-running, singleton work (projection rebuilds, index rebuilds, migration sweeps, replays) the cluster has to agree on one host doing the work at a time, and it has to agree on where that work is up to if the host dies in the middle of it. The cluster subsystem exists to provide that coordination.
Concretely, the cluster gives you:
Leader election / singleton execution. A job declares a Name and the cluster guarantees that only one host is actively advancing the job with that name. If that host dies, another host picks up from where the cluster last saw progress.
Shared job state. A job's is synced to the cluster through . When a new host starts running a job, it pings the cluster first and uses whatever state comes back.
Cancellation. Each running job is registered under its name in a JobManager (see ). Jobs can be cancelled by name from any host.
Health signals. Every host emits a HeartbeatSignal on a configurable interval so the cluster can see which hosts are live. The interval is Cronus:Heartbeat:IntervalInSeconds (default 5, validated 5..3600) — see .
The cluster exposes two related contracts under :
IClusterOperations is the "talk to the cluster" side: jobs call PingAsync(Data) to publish progress and PingAsync<TData>() to fetch the last-known state. ICronusJobRunner is the "execute work on this host" side: it registers the cancellation source under the job name and invokes the job's RunAsync.
Most hosts extend rather than implementing the runner from scratch; the abstract base class does the cancellation book-keeping for you.
Out of the box Cronus wires the cluster contracts to their no-op in-memory implementations:
— executes the job on the current host with no external coordination.
— PingAsync<T>() returns default; PingAsync<T>(data) returns the data it was given.
This is what you want during local development and single-host deployments. It is also what wires by default.
The production runner is the satellite package . It replaces the in-memory runner and operations with a Consul-backed pair: job state lives in the Consul key-value store, leader election uses Consul session locks, and the heartbeat writes into Consul's health checks.
The Consul package is configured through the standard Cronus configuration surface — see for the cronus:cluster:* options (connection string, acl token, namespace). When the Consul runner is registered it replaces the in-memory one in DI so existing jobs run unchanged.
public interface IClusterOperations
{
Task<TData> PingAsync<TData>(CancellationToken cancellationToken = default) where TData : class, new();
Task<TData> PingAsync<TData>(TData data, CancellationToken cancellationToken = default) where TData : class, new();
}
public interface ICronusJobRunner : IDisposable
{
Task<JobExecutionStatus> ExecuteAsync(ICronusJob<object> job, CancellationToken cancellationToken = default);
JobManager JobManager { get; }
}Cronus aggregates are a joy to test. An aggregate is a pure state machine — given a history of events, applying a command produces zero or more new events. No databases, no message brokers, no mocks: just a sequence of inputs and a sequence of outputs. The Elders.Cronus.Testing helpers shipped in Cronus.DomainModeling lean into that, and the pattern you end up using is always the same.
Aggregate<T>.FromHistory(...) patternThe shipped helper is Aggregate<T> under the Elders.Cronus.Testing namespace. It hands you a fluent stream builder, replays the events through the aggregate's When handlers, and returns an instance that is ready to have a command executed against it.
A representative test from the Cronus test suite — When_projection_version_with_status_building_is_outside_of_the_timebox - Copy.cs — reads:
using Elders.Cronus.Testing;
ar = Aggregate<ProjectionVersionManager>
.FromHistory(stream => stream
.AddEvent(new ProjectionVersionRequested(id, new ProjectionVersion(...), ...))
.AddEvent(new ProjectionVersionRequested(id, new ProjectionVersion(...), ...))
.AddEvent(new ProjectionVersionRequestTimedout(id, new ProjectionVersion(...), ...)));
// When: execute a method on the aggregate root
ar.Replay(hash, new MarkupInterfaceProjectionVersioningPolicy(), new ReplayEventsOptions());
// Then: assert on the uncommitted events produced by the command
ar.PublishedEvents<ProjectionVersionRequestTimedout>().Count().ShouldEqual(2);The three-step shape — Given the history, When the command, Then the published events — is the shape of every aggregate test. The PublishedEvents<TEvent>() helper filters the uncommitted events on the aggregate by type so you can assert on exactly the facts you expect the command to have produced.
The reason the test is so compact is structural: AggregateRoot<TState> is a reducer. Its state changes only through When(TEvent) handlers, and the aggregate methods never do I/O — they compute the next event and call Apply, which runs through When and appends to UncommittedEvents. Aggregate<T>.FromHistory is doing in a few lines what AggregateRepository.LoadAsync does at runtime: it creates a fresh instance of the aggregate and feeds the history through ReplayEvents.
The same property makes aggregate tests uniquely valuable. Writing a failing test for a bug is usually one copy-paste away from the production event log that revealed it: the same events, in the same order, and the assertion that the next command produces the event that should have been produced.
Suppose you are modelling a Concert aggregate with an Announce method and a RegisterPerformer method. The test that a performer cannot be registered after the concert has already started:
Five lines of arrange; one line of act; two lines of assert. No fixtures, no setup, no async, no mocks — because the aggregate is a pure reducer.
When you want to test the path through the repository — integrity checks, atomic-action retries, the aggregate-commit interceptor — use the in-memory event store that Cronus wires by default in tests:
This is slower than Aggregate<T>.FromHistory and you should still prefer the pure-reducer test as the first-class unit test. Reach for the integration test when the question you are answering is about the repository, not about the aggregate.
using Elders.Cronus.Testing;
using Machine.Specifications;
[Subject("Concert")]
public class When_registering_a_performer_after_the_concert_has_started
{
Establish context = () =>
{
concertId = new ConcertId("summer-festival", "eldersoss");
concert = Aggregate<Concert>
.FromHistory(stream => stream
.AddEvent(new ConcertAnnounced(concertId, "Summer Festival", venue, startTime, duration))
.AddEvent(new ConcertStarted(concertId, startTime)));
};
Because of = () => registerResult = Catch.Exception(
() => concert.RegisterPerformer(new Performer("Some Band")));
It should_throw = () => registerResult.ShouldBeOfExactType<InvalidOperationException>();
It should_not_publish_a_performer_registered_event =
() => concert.PublishedEvents<PerformerRegistered>().ShouldBeEmpty();
static ConcertId concertId;
static Concert concert;
static Exception registerResult;
}var services = new ServiceCollection()
.AddLogging()
.AddCronus(...) // uses InMemory everything
.BuildServiceProvider();
var repository = services.GetRequiredService<IAggregateRepository>();
await repository.SaveAsync(concert); // exercises the real IEventStore, with the in-memory backend
var roundtripped = await repository.LoadAsync<Concert>(concertId);Once AddCronus has run — and every discovery has registered its services — Cronus still has to invoke one-time startup code (create Cassandra keyspaces, provision RabbitMQ exchanges, register event-store indices for each tenant, and so on). That one-time code lives in classes that implement ICronusStartup:
public interface ICronusStartup
{
void Bootstrap();
}
public interface ICronusTenantStartup
{
void Bootstrap();
}ICronusStartup runs once per host. ICronusTenantStartup runs once per tenant per host — Cronus creates a scoped ICronusContext for each tenant before calling it.
Startup classes are discovered by CronusStartupScanner during CronusBooter.BootstrapCronus(). The scanner finds every concrete class that implements the interface, orders them by the phase declared on [CronusStartup], resolves each from the service provider, and calls Bootstrap().
The phase is declared with :
The enum is small and stable. Each value is literally the numeric rank used by the scanner's OrderBy — smaller runs earlier.
The values with a 0, 10, 20 and 1000 ordering leave room for satellites to slot in between phases without ever colliding with the framework's own numbers. That is deliberate — you can use (Bootstraps)15 if you really need to run between ExternalResource and Configuration, because the enum is just an int under the hood.
The [CronusStartup] attribute is only for ICronusStartup and ICronusTenantStartup implementations. It does not affect discoveries. Discoveries are found by DiscoveryScanner, not by CronusStartupScanner, and the scanner does not look at this attribute at all. A discovery runs when AddCronus is called; a startup runs later, when CronusBooter.BootstrapCronus() is called. Do not put [CronusStartup(Bootstraps.X)] on a discovery expecting the phase to apply — it will be silently ignored.
If you need a discovery to run in a particular order relative to others, the right tool is the CanOverrideDefaults flag on DiscoveredModel, not a bootstrap phase.
Example — a startup that provisions a third-party resource during the ExternalResource phase:
For the class to be resolvable, it must be registered in the container. The idiomatic way is to ship it as part of a discovery in the same assembly:
Configuration
20
Finalise configuration and options
Aggregates
30
One-time work for aggregates
Ports
40
One-time work for ports
Sagas
50
One-time work for sagas
EventStoreIndices
55
Register per-tenant event-store indices (see )
Projections
60
One-time work for projections
Gateways
70
One-time work for gateways
Runtime
1000
Anything else; this is the default when the attribute is omitted
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class CronusStartupAttribute : Attribute
{
public CronusStartupAttribute() : this(Bootstraps.Runtime) { }
public CronusStartupAttribute(Bootstraps bootstraps)
{
Bootstraps = bootstraps;
}
public Bootstraps Bootstraps { get; }
}Environment
0
Prepare the environment for Cronus (set process-wide switches, configure loggers)
ExternalResource
10
[CronusStartup(Bootstraps.ExternalResource)]
public class SearchIndexStartup : ICronusStartup
{
private readonly ISearchIndexProvisioner provisioner;
private readonly ILogger<SearchIndexStartup> logger;
public SearchIndexStartup(ISearchIndexProvisioner provisioner, ILogger<SearchIndexStartup> logger)
{
this.provisioner = provisioner;
this.logger = logger;
}
public void Bootstrap()
{
logger.LogInformation("Provisioning search index...");
provisioner.EnsureIndexExists();
}
}public class SearchIndexDiscovery : DiscoveryBase<ICronusStartup>
{
protected override DiscoveryResult<ICronusStartup> DiscoverFromAssemblies(DiscoveryContext context)
{
return new DiscoveryResult<ICronusStartup>(new[]
{
new DiscoveredModel(typeof(SearchIndexStartup), typeof(SearchIndexStartup), ServiceLifetime.Singleton),
new DiscoveredModel(typeof(ISearchIndexProvisioner), typeof(SearchIndexProvisioner), ServiceLifetime.Singleton)
});
}
}Provision external resources such as a database keyspace or a message broker exchange
An atomic action is the cross-process lock Cronus takes around the "load aggregate, append events" critical section. It is the safety net that turns optimistic event-store concurrency into something safe to run on multiple hosts at once: at most one node, anywhere in the cluster, may append the next revision of a given aggregate at any given moment.
You will see two interfaces under Elders.Cronus.AtomicAction — IAggregateRootAtomicAction and ILock. The first is what AggregateRepository.SaveAsync uses; the second is the primitive lock that the atomic action is built on. Most projects only ever wire up an implementation; very few write a new one.
Read paths do not need an atomic action — they replay the stream and produce a state. Write paths do, because two hosts that load the same aggregate at the same revision and both try to append revision N+1 would otherwise race. The Cronus event store catches a duplicate revision and rejects the second writer with AggregateStateFirstLevelConcurrencyException — but the lost work has already happened. The atomic action stops the second writer earlier, before any side effect runs.
You need a real implementation as soon as more than one process can write to the same aggregate. A single-process worker can run on the missing implementation; an Aspire-era Cronus solution where API hosts and worker hosts both call SaveAsync cannot.
IAggregateRootAtomicAction is the contract AggregateRepository calls. The full surface is one method:
ExecuteAsync is given the aggregate id, the revision the caller intends to append, and the work that should run while the lock is held. The implementation acquires a lock keyed on arId.Value, runs action, releases the lock and returns a — IsSuccessful = true if both the lock and the action succeeded.
ILock is the lower-level primitive the atomic action sits on:
A lock is named (resource) and has a time-to-live. LockAsync returns true when the lock was acquired. UnlockAsync releases it explicitly; the TTL releases it implicitly if the holder dies. The same ILock instance is reused across many atomic actions.
The Redis-backed implementation binds two option classes against the cronus:atomicaction:redis section. The full reference, including defaults and validation, lives next to the rest of the framework configuration:
Specifically: , , , , and .
LockTtl is the short TTL applied while the action runs; LongTtl is the longer TTL applied after the action succeeds, to prevent a late-arriving node from re-running the same revision. The retry count and delay are passed through to RedLock; the clock-drive factor compensates for clock drift between Redis nodes.
Three implementations exist. The first is the only one you should default to.
— the production choice. wraps a (an ILock over Elders.RedLock 9.0.2 — the ) plus an that remembers the last revision committed for each aggregate. Together they reject a stale aggregateRootRevision even if the lock acquisition itself was successful. The discovery — — registers both IAggregateRootAtomicAction and ILock with CanOverrideDefaults = true, so adding the package is enough to replace the in-memory default.
Lock not acquired. LockAsync returned false. RedisAggregateRootAtomicAction.ExecuteAsync returns Result<bool>(false).WithError("Failed to lock and execute atomic action."). AggregateRepository.SaveInternalAsync raises AggregateStateFirstLevelConcurrencyException from the result errors. The caller — typically an application service — sees the exception and is expected to either retry or abort the command.
IAggregateRootId interface; it does not currently compile against Cronus 11.x and is not currently shipped as a working option. If you need a non-Redis implementation, use it as a sketch rather than as something you can drop in.MissingAggregateRootAtomicAction — the in-process default. It implements both IAggregateRootAtomicAction and ILock by throwing NotImplementedException with a clear message: "The AggregateRootAtomicAction is not configured. Please install a nuget package which provides aggregate sync capabilities such as IAggregateRootAtomicAction. ex.: Cronus.AtomicAction.Redis." It is registered by Cronus core so the container resolves; the first save call is what fails. Treat it as "atomic actions are not wired up yet", not as "atomic actions are off".
LongTtlAction threw. The action ran but threw. The atomic action captures it (Result.Error(ex)), releases the lock, and the exception surfaces back through SaveInternalAsync.
Lock holder died. Whoever held the lock crashed before calling UnlockAsync. The TTL releases it; the next caller acquires cleanly. This is why LockTtl defaults to one second — long enough for the work, short enough that nobody waits long for a dead holder.
public interface IAggregateRootAtomicAction : IDisposable
{
Task<Result<bool>> ExecuteAsync(AggregateRootId arId, int aggregateRootRevision, Func<Task> action);
}public interface ILock
{
Task<bool> IsLockedAsync(string resource);
Task<bool> LockAsync(string resource, TimeSpan ttl);
Task UnlockAsync(string resource);
}