Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ const config: UserConfig<DefaultTheme.Config> = {
{ text: 'Rebuilding Projections', link: '/events/projections/rebuilding' },
{ text: 'EF Core Projections', link: '/events/projections/efcore' },
{ text: 'Projections and IoC Services', link: '/events/projections/ioc' },
{ text: 'Ancillary Stores in Projections', link: '/events/projections/ancillary-stores' },
{ text: 'Async Daemon HealthChecks', link: '/events/projections/healthchecks' },]
},
{
Expand Down
125 changes: 125 additions & 0 deletions docs/events/projections/ancillary-stores.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Using Ancillary Stores in Projections <Badge type="tip" text="8.x" />

## The Problem

When building systems with multiple Marten stores (using `AddMartenStore<T>()`), it's common to
need projections in one store that reference data from another. For example, a billing projection
in your primary store might need to look up tariff data from a separate `ITarievenStore`.

The natural approach — constructor injection — **does not work reliably** and can cause your
application to freeze at startup:

```csharp
// DO NOT DO THIS - can cause startup deadlock
public class InvoiceProjection(
ITarievenStore tarievenStore
) : SingleStreamProjection<Invoice, Guid>
{
// ...
}
```

### Why It Freezes

When you register a projection with `AddProjectionWithServices<T>()`, Marten resolves the
projection instance during store construction. If the projection's constructor depends on an
ancillary `IDocumentStore`, the DI container may attempt to resolve that store while the primary
store is still being built — creating a circular dependency that deadlocks silently.

## Solution: Inject `Lazy<T>`

Starting in Marten 8.x, `AddMartenStore<T>()` automatically registers `Lazy<T>` in the DI
container alongside the store itself. This lets you inject a lazy reference that defers
resolution until the store is actually needed — safely past the startup phase:

```csharp
public interface ITarievenStore : IDocumentStore;

public class InvoiceProjection : SingleStreamProjection<Invoice, Guid>
{
private readonly Lazy<ITarievenStore> _tarievenStore;

public InvoiceProjection(Lazy<ITarievenStore> tarievenStore)
{
_tarievenStore = tarievenStore;
}

public override async Task EnrichEventsAsync(
SliceGroup<Invoice, Guid> group,
IQuerySession querySession,
CancellationToken cancellation)
{
// Safe - the store is fully constructed by the time
// EnrichEventsAsync runs
await using var session = _tarievenStore.Value.QuerySession();

var ids = group.Slices
.SelectMany(s => s.Events().OfType<IEvent<ServicePerformed>>())
.Select(e => e.Data.TariefId)
.Distinct().ToArray();

var tarieven = await session.LoadManyAsync<Tarief>(cancellation, ids);

foreach (var slice in group.Slices)
{
foreach (var e in slice.Events().OfType<IEvent<ServicePerformed>>())
{
if (tarieven.TryGetValue(e.Data.TariefId, out var tarief))
{
e.Data.ResolvedPrice = tarief.Price;
}
}
}
}
}
```

Register the projection using `AddProjectionWithServices<T>()` with a **scoped** lifetime
to ensure it's resolved per-batch rather than during store construction:

```csharp
services.AddMarten(opts =>
{
opts.Connection("primary connection string");
})
.AddProjectionWithServices<InvoiceProjection>(
ProjectionLifecycle.Async,
ServiceLifetime.Scoped);

services.AddMartenStore<ITarievenStore>(opts =>
{
opts.Connection("tarieven connection string");
});
```

### Why `Lazy<T>` Works

The `Lazy<T>` wrapper is constructed immediately (it's just a thin wrapper), but the inner
`IDocumentStore` isn't resolved until `.Value` is accessed. By the time your projection's
`Apply`, `Create`, or `EnrichEventsAsync` methods execute, all stores are fully constructed
and the lazy resolution succeeds without deadlock.

### Multiple Ancillary Stores

You can inject multiple lazy store references:

```csharp
public class CrossStoreProjection : SingleStreamProjection<Summary, Guid>
{
private readonly Lazy<ITarievenStore> _tarieven;
private readonly Lazy<IDebtorsStore> _debtors;

public CrossStoreProjection(
Lazy<ITarievenStore> tarieven,
Lazy<IDebtorsStore> debtors)
{
_tarieven = tarieven;
_debtors = debtors;
}

// Use _tarieven.Value and _debtors.Value in your projection methods
}
```

Each `AddMartenStore<T>()` call automatically registers its own `Lazy<T>`, so no
additional configuration is needed.
92 changes: 92 additions & 0 deletions src/CoreTests/lazy_ancillary_store_registration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System;
using System.Threading.Tasks;
using Marten;
using Marten.Testing.Harness;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;

namespace CoreTests;

public interface ILazyTestStore : IDocumentStore;

public class lazy_ancillary_store_registration
{
[Fact]
public async Task lazy_of_ancillary_store_is_registered_as_singleton()
{
using var host = await Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddMarten(opts =>
{
opts.Connection(ConnectionSource.ConnectionString);
opts.DatabaseSchemaName = "lazy_test_primary";
});

services.AddMartenStore<ILazyTestStore>(opts =>
{
opts.Connection(ConnectionSource.ConnectionString);
opts.DatabaseSchemaName = "lazy_test_ancillary";
});
})
.StartAsync();

// Lazy<T> should be resolvable
var lazy = host.Services.GetService<Lazy<ILazyTestStore>>();
lazy.ShouldNotBeNull();

// Should not be created yet
lazy.IsValueCreated.ShouldBeFalse();

// Resolving the value should return the same instance as direct resolution
var fromLazy = lazy.Value;
var direct = host.Services.GetRequiredService<ILazyTestStore>();

fromLazy.ShouldBeSameAs(direct);
}

[Fact]
public async Task lazy_of_ancillary_store_can_be_injected_into_a_service()
{
using var host = await Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddMarten(opts =>
{
opts.Connection(ConnectionSource.ConnectionString);
opts.DatabaseSchemaName = "lazy_test2_primary";
});

services.AddMartenStore<ILazyTestStore>(opts =>
{
opts.Connection(ConnectionSource.ConnectionString);
opts.DatabaseSchemaName = "lazy_test2_ancillary";
});

services.AddSingleton<ServiceThatUsesLazyStore>();
})
.StartAsync();

var service = host.Services.GetRequiredService<ServiceThatUsesLazyStore>();
service.ShouldNotBeNull();

// The store should be accessible through the lazy wrapper
var store = service.GetStore();
store.ShouldNotBeNull();
store.ShouldBeAssignableTo<ILazyTestStore>();
}
}

public class ServiceThatUsesLazyStore
{
private readonly Lazy<ILazyTestStore> _store;

public ServiceThatUsesLazyStore(Lazy<ILazyTestStore> store)
{
_store = store;
}

public IDocumentStore GetStore() => _store.Value;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public Bug_4246_integer_out_of_range_in_quick_append_function()
StoreOptions(opts =>
{
opts.Events.AppendMode = EventAppendMode.Quick;
opts.Events.EnableBigIntEvents = true;
opts.Connection(ConnectionSource.ConnectionString);
});
}
Expand Down
1 change: 1 addition & 0 deletions src/Marten/MartenServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ public static MartenStoreExpression<T> AddMartenStore<T>(this IServiceCollection
stores.Add(config);

services.AddSingleton<T>(s => config.Build(s));
services.AddSingleton<Lazy<T>>(s => new Lazy<T>(() => s.GetRequiredService<T>()));

// Default keyed session factory for the ancillary store
services.AddKeyedSingleton<ISessionFactory>(typeof(T), (sp, _) =>
Expand Down
Loading