Projection code is usually simple. Projection plumbing is not.
Kuna.Projections is a high-throughput .NET projection pipeline that turns event streams into read models aka materialized views aka projections. It separates projection behavior from event ingestion, persistence, checkpointing, batching, and failure tracking so application code can stay focused on read-model logic.
The library is aimed at that event-sourcing use case first, but it is not limited to it. The runtime is source-driven rather than hard-wired to one storage model, so it can also be used in non-event-sourced systems by implementing a different source that emits envelopes from some other upstream change feed or event stream.
In event-sourced, projections are a standard way to turn append-only domain event streams into queryable read models. The write side preserves the event history, while the read side usually needs a materialized view shaped for API queries, reporting, dashboards, search, or operational workflows. That pattern is common, but teams often end up rebuilding the same projection runtime concerns around it in each service.
The architecture is built around independent seams with explicit bounds. Event ingestion, projection execution, and persistence do not have to run as one synchronous loop. Each part can move independently within configured limits for buffering, batching, cache size, and checkpoint advancement.
This library moves the repetitive infrastructure concerns out of the application:
- event reading and deserialization
- model id resolution
- projection instance lifecycle
- replay and catch-up handling
- batching and flush control
- checkpoint persistence
- projection failure recording
That leaves projection authors with a smaller, clearer programming model: define a state type, define events, implement Apply(...) methods, wire the source and sink, and run the pipeline.
In other words, the library gives you a reusable projection engine: it reads envelopes from a source, routes them to projection instances, tracks replay progress with checkpoints, batches persistence work, records failures, and keeps the projection code itself focused on state transitions instead of pipeline mechanics.
It also gives consumers a better throughput model:
- bounded buffering instead of unbounded memory growth
- separate catch-up and live-processing persistence strategies
- decoupled source, runtime, and sink responsibilities
- checkpoint-driven recovery instead of ad hoc replay loops
Today, the repository provides:
Kuna.Projections.Abstractionsfor shared contracts and model typesKuna.Projections.Corefor the projection runtime and pipelineKuna.Projections.Source.KurrentDBfor KurrentDB-backed event ingestionKuna.Projections.Source.Kafkafor Kafka-backed event ingestionKuna.Projections.Source.EventSourcingDBfor EventSourcingDB-backed event ingestionKuna.Projections.Sink.EFfor the shared EF Core-backed relational sinkKuna.Projections.Sink.EF.Npgsql,Kuna.Projections.Sink.EF.SqlServer, andKuna.Projections.Sink.EF.MySqlfor provider-specific relational registration and duplicate-key handlingKuna.Projections.Sink.MongoDBfor MongoDB-backed persistence, checkpoints, and failure storageexamples/Kuna.Projections.Worker.Kurrent_MongoDB.Exampleas the runnable KurrentDB to MongoDB reference workerexamples/Kuna.Projections.Worker.Kurrent_EF.Exampleas the runnable KurrentDB to Postgres reference workerexamples/Kuna.Projections.Worker.Kafka_MongoDB.Exampleas the runnable Kafka to MongoDB reference workerexamples/Kuna.Projections.Worker.EventSourcingDB_Postgres.Exampleas the runnable EventSourcingDB to Postgres reference worker
If you want the shortest route to a running worker, start with docs/quickstart.md. If you want Mongo-specific sink registration details, see docs/mongodb-sink.md. If you want EventSourcingDB-specific source registration details, see docs/eventsourcingdb-source.md. If you want the full architecture and API map, start with docs/overview.md.
The smallest credible setup is:
- Add the projection packages.
- Define a read model.
- Define domain events.
- Implement a projection with
Apply(...)methods. - Register the source, sink, and projection runtime.
- Run
IProjectionPipeline.
Every worker needs:
dotnet add package Kuna.Projections.CoreThen add one source package:
KurrentDB source:
dotnet add package Kuna.Projections.Source.KurrentDBKafka source:
dotnet add package Kuna.Projections.Source.KafkaEventSourcingDB source:
dotnet add package Kuna.Projections.Source.EventSourcingDBMongoDB-backed workers use:
dotnet add package Kuna.Projections.Sink.MongoDBRelational workers use a provider adapter package. PostgreSQL is the example here:
dotnet add package Kuna.Projections.Sink.EF.NpgsqlOther relational providers use:
Kuna.Projections.Sink.EF.SqlServerKuna.Projections.Sink.EF.MySql
The relational adapter package brings in Kuna.Projections.Sink.EF transitively. Kuna.Projections.Abstractions is brought in transitively as well.
using Kuna.Projections.Abstractions.Models;
public sealed class Account : Model
{
public string Email { get; set; } = string.Empty;
public bool Verified { get; set; }
}using Kuna.Projections.Abstractions.Attributes;
using Kuna.Projections.Abstractions.Models;
public sealed class AccountCreated : Event
{
public Guid AccountId { get; init; }
public required string Email { get; init; }
}
public sealed class EmailVerified : Event
{
[ModelId]
public Guid AcctId { get; init; }
}using Kuna.Projections.Core;
public sealed class AccountProjection : Projection<Account>
{
public AccountProjection(Guid modelId)
: base(modelId)
{
}
public void Apply(AccountCreated @event) => this.My.Email = @event.Email;
public void Apply(EmailVerified @event) => this.My.Verified = true;
}Delete semantics are terminal. If your domain has a delete event, handle it in the projection and call DeleteModel():
public sealed class AccountDeleted : Event
{
public Guid AccountId { get; init; }
}
public void Apply(AccountDeleted @event)
{
this.DeleteModel();
}That marks the projection row for physical deletion on the next flush. The runtime does not soft-delete the model and does not keep deleted state in the in-memory handoff cache. The operating assumption is that, after a delete event for a model, later events for that same model are invalid and should fail because the model no longer exists.
MongoDB-backed registration:
using Kuna.Projections.Core;
using Kuna.Projections.Sink.MongoDB;
using Kuna.Projections.Source.KurrentDB;
services.AddProjectionHost(typeof(Program).Assembly);
services.AddProjection<Account>(configuration, settingsSectionName: "AccountProjection")
.UseKurrentDbSource(loggerFactory)
.UseMongoDataStore(
"mongodb://localhost:27017",
"account_projection",
options =>
{
})
.WithInitialEvent<AccountCreated>();Relational registration with PostgreSQL:
using Kuna.Projections.Core;
using Kuna.Projections.Sink.EF.Npgsql;
using Kuna.Projections.Source.KurrentDB;
using Microsoft.EntityFrameworkCore;
services.AddProjectionHost(typeof(Program).Assembly);
services.AddDbContext<AccountProjectionDbContext>(
options => options.UseNpgsql(configuration.GetConnectionString("PostgreSql")));
services.AddProjection<Account>(configuration, "AccountProjection")
.UseKurrentDbSource(loggerFactory)
.UseNpgsqlDataStore<Account, AccountProjectionDbContext>(schema: "dbo");The projection type declares its own creation event:
using Kuna.Projections.Abstractions.Attributes;
[InitialEvent<AccountCreated>]
public sealed class AccountProjection : Projection<Account>
{
}If you are not using PostgreSQL, swap the provider adapter and registration method:
- SQL Server:
Kuna.Projections.Sink.EF.SqlServerwithAddSqlServerProjectionsDataStore<TState, TDataContext>(schema: ...) - MySQL:
Kuna.Projections.Sink.EF.MySqlwithAddMySqlProjectionsDataStore<TState, TDataContext>(schema: ...)
Then run the pipeline from your host:
await pipeline.RunAsync(stoppingToken);For full working examples, see:
- examples/Kuna.Projections.Worker.Kurrent_MongoDB.Example
- examples/Kuna.Projections.Worker.Kurrent_EF.Example
The important seam lines are:
- source: where events come from
- projection: how the read model changes
- sink: where the read model and operational metadata are stored
- pipeline: how replay, batching, flushes, and recovery are coordinated
Those seam lines are also throughput boundaries. The source can continue reading, the runtime can continue transforming, and the sink can continue flushing within configured operational bounds rather than forcing a per-event end-to-end write cycle.
Current flow:
- The Kurrent source reads events and emits envelopes.
- The core pipeline resolves or loads projection state.
- The projection applies the event to the in-memory model.
- The EF sink persists model changes.
- The checkpoint store records the last durable global position.
- Failures are stored for later inspection and recovery.
That separation is what makes high-throughput projection processing practical: each stage can absorb short-term pressure independently while the pipeline still stays bounded and recoverable.
At minimum, applications typically provide:
- one source connection string, such as
ConnectionStrings:KurrentDBorConnectionStrings:Kafka ConnectionStrings:PostgreSql- one projection settings section per registered projection
Example:
{
"ConnectionStrings": {
"PostgreSql": "Host=localhost;Port=5432;Database=orders_projection;Username=postgres;Password=postgres",
"KurrentDB": "esdb://admin:changeit@localhost:2113?tls=false"
},
"OrdersProjection": {
"InstanceId": "orders-v1",
"ModelIdResolutionStrategy": "UseStreamId",
"KurrentDB": {
"Filter": {
"Kind": "StreamPrefix",
"Prefixes": [ "order" ]
}
}
}
}InstanceId is required. It identifies one deployed projection instance, not the TState type in the abstract.
That distinction matters when the projection logic changes and you want a blue/green rollout:
- keep the current deployment running with
InstanceId = "orders-v1" - deploy the new projection with
InstanceId = "orders-v2" - let
orders-v2replay from the beginning into its own checkpoint and storage boundary - validate the rebuilt
orders-v2read model - switch readers or configuration to
orders-v2 - retire
orders-v1later
The point is to make projection changes replay-safe. A new InstanceId starts a new checkpoint lineage, so the new deployment can rebuild from the start without overwriting the current deployment's checkpoint or failure records.
KurrentDB:Filter maps onto the Kurrent .NET subscription filter model:
Kindselects the filter shape.- Use
Prefixesfor prefix-based kinds. - Use
Regexfor regex-based kinds.
Supported Kind values are StreamPrefix, StreamRegex, EventTypePrefix, and EventTypeRegex.
For the full Kurrent .NET filtering model and semantics, see the official documentation: https://docs.kurrent.io/clients/dotnet/v1.0/subscriptions.html
Full configuration details live in docs/configuration-reference.md.
