EF Core Projections
Marten provides first-class support for projecting events into Entity Framework Core DbContext entities. This lets you use EF Core's model configuration, change tracking, and migration tooling while still benefiting from Marten's event sourcing infrastructure.
The Marten.EntityFrameworkCore NuGet package provides three projection base classes:
| Base Class | Use Case |
|---|---|
EfCoreSingleStreamProjection<TDoc, TId, TDbContext> | Aggregate a single event stream into one EF Core entity |
EfCoreMultiStreamProjection<TDoc, TId, TDbContext> | Aggregate events across multiple streams into one EF Core entity |
EfCoreEventProjection<TDbContext> | React to individual events, writing to both EF Core and Marten |
All three types support Inline, Async, and Live projection lifecycles.
Installation
Add the Marten.EntityFrameworkCore NuGet package to your project:
dotnet add package Marten.EntityFrameworkCoreDefining a DbContext
EF Core projections require a DbContext with entity mappings. Use OnModelCreating to configure table names and column mappings:
public class OrderDbContext : DbContext
{
public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderSummary> OrderSummaries => Set<OrderSummary>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(entity =>
{
entity.ToTable("ef_orders");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.CustomerName).HasColumnName("customer_name");
entity.Property(e => e.TotalAmount).HasColumnName("total_amount");
entity.Property(e => e.ItemCount).HasColumnName("item_count");
entity.Property(e => e.IsShipped).HasColumnName("is_shipped");
});
}
}TIP
Entity tables defined in the DbContext are automatically migrated alongside Marten's own schema objects through Weasel. You do not need to run dotnet ef database update separately.
Marten emits no document table for the aggregate
The DbContext-mapped table is the only table for an EF Core-backed aggregate. Marten does not generate a mt_doc_<tdoc> table for it, and none of the full-schema operations -- ApplyAllConfiguredChangesToDatabaseAsync(), AssertDatabaseMatchesConfigurationAsync(), CreateMigrationAsync(), or the db-apply / db-patch commands -- create, assert or script one.
Two things follow:
- A schema built by a migration pass that does not register the projection still passes
AssertDatabaseMatchesConfigurationAsync()in the application that does, which is the usualAutoCreate.Nonedeployment shape. - The aggregate does not need an
Idmember. EF Core is happy with any primary key --[Key] public Guid OrderNumberand friends -- and because the type never reaches Marten's document conventions, neither is Marten. Identity is read from the EF Core model's primary key.
modelBuilder.Entity<Shipment>(entity =>
{
entity.ToTable("shipments");
// No "Id" anywhere -- fine for an EF Core projection aggregate
entity.HasKey(e => e.TrackingNumber);
entity.Property(e => e.TrackingNumber).HasColumnName("tracking_number");
});Single Stream Projections
Use EfCoreSingleStreamProjection<TDoc, TId, TDbContext> to build an aggregate from a single event stream and persist it through EF Core.
Entity and Events
// Events
public record OrderPlaced(Guid OrderId, string CustomerName, decimal Amount, int Items);
public record OrderShipped(Guid OrderId);
public record OrderCancelled(Guid OrderId);
// EF Core entity (the aggregate)
public class Order
{
public Guid Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
public int ItemCount { get; set; }
public bool IsShipped { get; set; }
public bool IsCancelled { get; set; }
}Projection Class
Override ApplyEvent to handle each event. The DbContext is available for querying or writing side effects:
public class OrderAggregate
: EfCoreSingleStreamProjection<Order, Guid, OrderDbContext>
{
public override Order? ApplyEvent(
Order? snapshot, Guid identity, IEvent @event,
OrderDbContext dbContext, IQuerySession session)
{
switch (@event.Data)
{
case OrderPlaced placed:
return new Order
{
Id = placed.OrderId,
CustomerName = placed.CustomerName,
TotalAmount = placed.Amount,
ItemCount = placed.Items
};
case OrderShipped:
if (snapshot != null) snapshot.IsShipped = true;
return snapshot;
case OrderCancelled:
if (snapshot != null) snapshot.IsCancelled = true;
return snapshot;
}
return snapshot;
}
}Registration
Use the StoreOptions.Add() extension method to register the projection. This sets up EF Core storage, Weasel schema migration, and the projection lifecycle in one call:
var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
opts.Add(new OrderAggregate(), ProjectionLifecycle.Inline);
});Multi Stream Projections
Use EfCoreMultiStreamProjection<TDoc, TId, TDbContext> to aggregate events from multiple streams into a single EF Core entity.
Entity and Events
public record CustomerOrderPlaced(Guid OrderId, string CustomerName, decimal Amount);
public record CustomerOrderCompleted(Guid OrderId, string CustomerName);
public class CustomerOrderHistory
{
public string Id { get; set; } = string.Empty;
public int TotalOrders { get; set; }
public decimal TotalSpent { get; set; }
}Projection Class
Use the constructor to configure event-to-aggregate identity mapping, then override ApplyEvent:
public class CustomerOrderHistoryProjection
: EfCoreMultiStreamProjection<CustomerOrderHistory, string, OrderDbContext>
{
public CustomerOrderHistoryProjection()
{
// Map events to the aggregate identity (customer name in this case)
Identity<CustomerOrderPlaced>(e => e.CustomerName);
Identity<CustomerOrderCompleted>(e => e.CustomerName);
}
public override CustomerOrderHistory? ApplyEvent(
CustomerOrderHistory? snapshot, string identity,
IEvent @event, OrderDbContext dbContext)
{
snapshot ??= new CustomerOrderHistory { Id = identity };
switch (@event.Data)
{
case CustomerOrderPlaced placed:
snapshot.TotalOrders++;
snapshot.TotalSpent += placed.Amount;
break;
}
return snapshot;
}
}Registration
var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
opts.Events.StreamIdentity = StreamIdentity.AsString;
opts.Add(new CustomerOrderHistoryProjection(), ProjectionLifecycle.Async);
});Event Projections
Use EfCoreEventProjection<TDbContext> when you need to react to individual events and write to both EF Core entities and Marten documents in the same transaction:
Projection Class
public class OrderSummaryProjection : EfCoreEventProjection<OrderDbContext>
{
protected override async Task ProjectAsync(
IEvent @event, OrderDbContext dbContext,
IDocumentOperations operations, CancellationToken token)
{
switch (@event.Data)
{
case OrderPlaced placed:
// Write to EF Core
dbContext.OrderSummaries.Add(new OrderSummary
{
Id = placed.OrderId,
CustomerName = placed.CustomerName,
TotalAmount = placed.Amount,
ItemCount = placed.Items,
Status = "Placed"
});
// Also write to Marten
operations.Store(new Order
{
Id = placed.OrderId,
CustomerName = placed.CustomerName,
TotalAmount = placed.Amount,
ItemCount = placed.Items
});
break;
case OrderShipped shipped:
var summary = await dbContext.OrderSummaries
.FindAsync(new object[] { shipped.OrderId }, token);
if (summary != null)
{
summary.Status = "Shipped";
}
break;
}
}
}Registration
EfCoreEventProjection uses the standard Projections.Add() method with a separate call to register entity tables:
var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
opts.Projections.Add(new OrderSummaryProjection(), ProjectionLifecycle.Inline);
opts.AddEntityTablesFromDbContext<OrderDbContext>();
});Conjoined Multi-Tenancy
EF Core single-stream and multi-stream projections support Marten's conjoined multi-tenancy. When the event store uses TenancyStyle.Conjoined, the projection infrastructure automatically writes the tenant ID to each projected entity.
Requirements
Your aggregate entity must implement ITenanted from Marten.Metadata. This interface adds a TenantId property that the projection infrastructure uses to write the tenant identifier:
using Marten.Metadata;
public class TenantedOrder : ITenanted
{
public Guid Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
public int ItemCount { get; set; }
public bool IsShipped { get; set; }
public string? TenantId { get; set; } // Required by ITenanted
}The DbContext must also map the TenantId property to a column:
public class TenantedOrderDbContext : DbContext
{
public TenantedOrderDbContext(DbContextOptions<TenantedOrderDbContext> options)
: base(options) { }
public DbSet<TenantedOrder> TenantedOrders => Set<TenantedOrder>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TenantedOrder>(entity =>
{
entity.ToTable("ef_tenanted_orders");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.CustomerName).HasColumnName("customer_name");
entity.Property(e => e.TotalAmount).HasColumnName("total_amount");
entity.Property(e => e.ItemCount).HasColumnName("item_count");
entity.Property(e => e.IsShipped).HasColumnName("is_shipped");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
});
}
}Projection Class
The projection class itself does not need any special tenancy logic. The base infrastructure sets TenantId automatically:
public class TenantedOrderAggregate
: EfCoreSingleStreamProjection<TenantedOrder, Guid, TenantedOrderDbContext>
{
public override TenantedOrder? ApplyEvent(
TenantedOrder? snapshot, Guid identity, IEvent @event,
TenantedOrderDbContext dbContext, IQuerySession session)
{
switch (@event.Data)
{
case OrderPlaced placed:
return new TenantedOrder
{
Id = placed.OrderId,
CustomerName = placed.CustomerName,
TotalAmount = placed.Amount,
ItemCount = placed.Items
};
case OrderShipped:
if (snapshot != null) snapshot.IsShipped = true;
return snapshot;
}
return snapshot;
}
}Registration
var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
opts.Events.TenancyStyle = TenancyStyle.Conjoined;
opts.Add(new TenantedOrderAggregate(), ProjectionLifecycle.Inline);
});Appending Events with a Tenant
Use ForTenant() when opening a session to associate events with a specific tenant:
await using var session = store.LightweightSession("tenant-alpha");
session.Events.StartStream(orderId, new OrderPlaced(orderId, "Alice", 100m, 3));
await session.SaveChangesAsync();
// The projected row in ef_tenanted_orders will have tenant_id = 'tenant-alpha'Validation
Marten validates your configuration at startup. If the event store uses conjoined tenancy but your aggregate type does not implement ITenanted, Marten throws an InvalidProjectionException with a descriptive error message.
Limitations
EfCoreEventProjectiondoes not support conjoined tenancy validation. The event projection base class (EfCoreEventProjection<TDbContext>) is a lower-levelIProjectionimplementation that does not participate in the aggregate tenancy validation. If you need multi-tenant event projections, you are responsible for reading the tenant ID from@event.TenantIdand writing it yourself.Multi-stream projections with non-unique keys across tenants. When using
EfCoreMultiStreamProjectionwith conjoined tenancy, be aware thatDbContext.FindAsynclooks up entities by primary key only, not by a composite of primary key + tenant ID. If two tenants can produce the same aggregate key (e.g., a customer name), you must ensure globally unique aggregate IDs (such as GUIDs) or configure a composite primary key in EF Core that includes the tenant ID column.
Composite Projections
EF Core projections can participate in composite projections for multi-stage processing:
var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
opts.Projections.Composite(composite =>
{
composite.Add(opts, new OrderAggregate(), stageNumber: 1);
composite.Add(opts, new CustomerOrderHistoryProjection(), stageNumber: 2);
}, ProjectionLifecycle.Async);
});DbContext Configuration
All EF Core projection types expose a ConfigureDbContext method you can override to customize the DbContextOptionsBuilder. The Npgsql provider is already configured before this method is called:
public class MyProjection
: EfCoreSingleStreamProjection<Order, Guid, OrderDbContext>
{
public override void ConfigureDbContext(
DbContextOptionsBuilder<OrderDbContext> builder)
{
builder.EnableSensitiveDataLogging();
}
}Rebuilding
IProjectionDaemon.RebuildProjectionAsync works normally for EF Core projections. Because the projection's data lives in the DbContext-mapped table rather than in a Marten document table, the rebuild's teardown step clears that table -- Marten's conventional mt_doc_<tdoc> table is neither created nor truncated for an EF Core-backed aggregate.
This matters when you build your schema with a separate migration and run with AutoCreateSchemaObjects = AutoCreate.None: there is no mt_doc_<tdoc> table to find, and the rebuild does not go looking for one.
If you need to suppress the teardown entirely -- for instance when the entity table is shared with data Marten did not write -- set Options.TeardownDataOnRebuild = false in your projection's constructor and clear the table yourself.
WARNING
A per-tenant rebuild (RebuildProjectionAsync scoped to one tenant) scopes the wipe with delete from <entity table> where tenant_id = ?. That column name is not read from your EF Core model, so it only matches a DbContext that maps the tenant property to a snake_case tenant_id column -- as the conjoined multi-tenancy example above does. Under EF Core's default naming the column is TenantId and the rebuild fails with 42703: column "tenant_id" does not exist. Map the column explicitly:
entity.Property(e => e.TenantId).HasColumnName("tenant_id");See #5351. A store-wide rebuild is unaffected.
How It Works
Under the hood, EF Core projections:
- Create a per-slice DbContext using the same PostgreSQL connection as the Marten session
- Register a transaction participant so the DbContext's
SaveChangesAsyncis called within Marten's transaction, ensuring atomicity - Migrate entity tables through Weasel alongside Marten's own schema objects, so
dotnet efmigrations are not needed - Use EF Core change tracking for insert vs. update detection (detached entities are added; unchanged entities are marked as modified)
- Register the entity table as the rebuild teardown target, so a rebuild clears the rows EF Core actually wrote

