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
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@ jobs:
run: ./build.sh test-event-sourcing
shell: bash

- name: test-modular-config
if: ${{ success() || failure() }}
run: ./build.sh test-modular-config
shell: bash

5 changes: 5 additions & 0 deletions .github/workflows/on-push-do-ci-build-pg15-jsonnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ jobs:
run: ./build.sh test-document-db
shell: bash

- name: test-modular-config
if: ${{ success() || failure() }}
run: ./build.sh test-modular-config
shell: bash

- name: test-cli
if: ${{ success() || failure() }}
run: ./build.sh test-cli
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@ jobs:
run: ./build.sh test-event-sourcing
shell: bash

- name: test-modular-config
if: ${{ success() || failure() }}
run: ./build.sh test-modular-config
shell: bash

Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ jobs:
run: ./build.sh test-document-db
shell: bash

- name: test-modular-config
if: ${{ success() || failure() }}
run: ./build.sh test-modular-config
shell: bash

- name: test-cli
if: ${{ success() || failure() }}
run: ./build.sh test-cli
Expand Down
2 changes: 1 addition & 1 deletion .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@
"TestAspnetcore",
"TestBaseLib",
"TestCli",
"TestCodeGen",
"TestCore",
"TestDocumentDb",
"TestEventSourcing",
"TestExtensions",
"TestLinq",
"TestModularConfig",
"TestMultiTenancy",
"TestNodaTime",
"TestPatching",
Expand Down
13 changes: 13 additions & 0 deletions build/build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Build : NukeBuild
.DependsOn(TestCore)
.DependsOn(TestDocumentDb)
.DependsOn(TestEventSourcing)
.DependsOn(TestModularConfig)
.DependsOn(TestCli)
.DependsOn(TestLinq)
.DependsOn(TestMultiTenancy)
Expand Down Expand Up @@ -183,6 +184,18 @@ class Build : NukeBuild
.SetFramework(Framework));
});

Target TestModularConfig => _ => _
.ProceedAfterFailure()
.Executes(() =>
{
DotNetTest(c => c
.SetProjectFile("src/ModularConfigTests")
.SetConfiguration(Configuration)
.EnableNoBuild()
.EnableNoRestore()
.SetFramework(Framework));
});

Target TestLinq => _ => _
.ProceedAfterFailure()
.Executes(() =>
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ const config: UserConfig<DefaultTheme.Config> = {
collapsed: true,
items: [
{ text: 'Bootstrapping Marten', link: '/configuration/hostbuilder' },
{ text: 'Composite Configuration Across Assemblies', link: '/configuration/composite-configuration' },
{ text: 'Configuring Document Storage', link: '/configuration/storeoptions' },
{ text: 'Json Serialization', link: '/configuration/json' },
{ text: 'Resiliency Policies', link: '/configuration/retries' },
Expand Down
114 changes: 114 additions & 0 deletions docs/configuration/composite-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Composite Configuration Across Satellite Assemblies

Marten supports a "modular monolith" deployment shape where projection types, event types, and `StoreOptions` tweaks live in **satellite assemblies** owned by individual feature teams, and a main host composes them together via dependency injection. Each satellite contributes its own `IConfigureMarten` (sync) or `IAsyncConfigureMarten` (async) implementation; the main host's `AddMarten(...)` call carries only shared infrastructure (connection string, default serializer, etc.).

This page documents the contracts that compose those satellites into a single `DocumentStore`.

## The pattern

Each satellite assembly:

1. Carries `[assembly: JasperFx.JasperFxAssembly]` in an `AssemblyInfo.cs` file.
2. Declares its projection classes as `partial`.
3. References `JasperFx.Events.SourceGenerator` as an analyzer-only `PackageReference` so `[GeneratedEvolver]` attributes are emitted at compile time for the satellite's own projection types:

```xml
<PackageReference Include="JasperFx.Events.SourceGenerator"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
```

4. Exposes one or more `IConfigureMarten` / `IAsyncConfigureMarten` implementations that register the satellite's projections, event types, or option tweaks.

The main host:

```csharp
var builder = Host.CreateApplicationBuilder();

// Each satellite's IConfigureMarten gets wired into DI.
builder.Services.AddSingleton<IConfigureMarten, OrdersConfig>(); // SatelliteA
// For IAsyncConfigureMarten, use ConfigureMartenWithServices<T>() so the
// hosted service that drains async configs gets registered. A raw
// AddSingleton<IAsyncConfigureMarten>() would add the type to DI but
// never invoke its Configure method.
builder.Services.ConfigureMartenWithServices<ReportingConfig>(); // SatelliteB

// Main host carries only shared infrastructure.
builder.Services.AddMarten(opts =>
{
opts.Connection(connectionString);
opts.DatabaseSchemaName = "modular_monolith";
});

using var host = builder.Build();
await host.StartAsync();
```

The canonical worked example lives under `src/ModularConfigTests/` in the Marten repo — that's the regression-gate fixture the rest of this page links back to.

## `[assembly: JasperFxAssembly]`

The marker isn't required for Marten's `DiscoverGeneratedEvolvers` to find a satellite's `[GeneratedEvolver]` attributes — that scan walks every loaded assembly in `AppDomain.CurrentDomain.GetAssemblies()` regardless. It IS required for other Critter Stack scanning surfaces (`CommandFactory`, extension discovery). Mark every satellite that participates in modular Marten composition with it for forward-compat with those surfaces.

## Locked-in design contracts

These four behaviors are pinned by the regression-gate fixture in `src/ModularConfigTests/`. Any change that breaks them surfaces in CI.

### 1. Registration order = invocation order

`IEnumerable<IConfigureMarten>` is resolved from DI; `Configure` is invoked in DI registration order. If two satellites both write to the same `StoreOptions` scalar property, the **later-registered** call wins.

```csharp
builder.Services.AddSingleton<IConfigureMarten>(new SetNameLength(100));
builder.Services.AddSingleton<IConfigureMarten>(new SetNameLength(250));
// → final NameDataLength == 250
```

Pin test: `src/ModularConfigTests/OrderingTests.cs`.

### 2. Last-wins on scalar setter conflicts; idempotent on event-type registration

Two satellites registering the same scalar `StoreOptions` setter (`NameDataLength`, `DatabaseSchemaName`, etc.) end up with the last-registered value. Two satellites registering the same event type via `options.Events.AddEventType(typeof(SomeEvent))` is **idempotent** — no exception, the event is registered once.

Projection registration is the exception: two satellites registering the same projection class throws `DuplicateSubscriptionNamesException` at host build. The error message points to the `Name` property to disambiguate; set it explicitly on each satellite's projection class to coexist.

Pin test: `src/ModularConfigTests/LastWinsTests.cs`.

### 3. `AddMarten` timing is order-independent

`IConfigureMarten` registered **after** `services.AddMarten(...)` still applies. The `StoreOptions` factory resolves `IEnumerable<IConfigureMarten>` at store-build time from the final DI snapshot — not at `AddMarten` time. Teams can register their satellite contributions in any order relative to the main `AddMarten` call.

Pin test: `src/ModularConfigTests/AddMartenTimingTests.cs`.

### 4. `IConfigureMarten` and `IAsyncConfigureMarten` compose

A host can mix both. Sync contributions apply during the `StoreOptions` factory's resolution (synchronous, on first `IDocumentStore` resolution). Async contributions apply inside the `AsyncConfigureMartenApplication` hosted service, which is inserted ahead of `MartenActivator` in the `IHostedService` chain — so async configs are visible by the time anything else consumes the store.

The registration APIs are asymmetric:

| Contract | Sync | Async |
| --- | --- | --- |
| Bare `AddSingleton<...>` works | ✅ | ❌ (hosted service not registered) |
| Extension API | `services.AddSingleton<IConfigureMarten, T>()` or `services.ConfigureMarten(...)` | `services.ConfigureMartenWithServices<T>()` |

Pin test: `src/ModularConfigTests/AsyncComposeTests.cs`.

## Required satellite setup checklist

| Step | Why |
| --- | --- |
| `[assembly: JasperFx.JasperFxAssembly]` in an `AssemblyInfo.cs` | Forward-compat with Critter Stack scanning surfaces |
| Projection classes marked `partial` | Post-#276, the SG-emitted dispatcher merges into the projection class via partial; non-partial silently skips SG emission and the runtime fail-fast at `AssembleAndAssertValidity` throws |
| `JasperFx.Events.SourceGenerator` as analyzer-only `PackageReference` | Marten's own csproj sets `PrivateAssets="all"` on the SG so the analyzer doesn't flow transitively. Each satellite that declares its own projection types needs the analyzer wired locally |
| Satellite ProjectReference'd from the main host (or referenced via type) | `AppDomain.CurrentDomain.GetAssemblies()` only returns LOADED assemblies. A `typeof(SatelliteType)` reference or an `IConfigureMarten` singleton registration is enough to force the load |

## Out of scope

* NuGet-package distribution scenarios (satellite as a `.nupkg` consumed by downstream apps) are tracked separately. The contracts above hold for ProjectReference-composed assemblies.
* The order of `IConfigureMarten` execution relative to `IAsyncConfigureMarten` execution is not part of the locked contracts — sync configs apply at store-build time, async configs apply during host start. Don't write code that depends on the relative order.

## See also

* The regression fixture: [`src/ModularConfigTests/SmokeTest.cs`](https://github.com/JasperFx/marten/blob/master/src/ModularConfigTests/SmokeTest.cs) (end-to-end)
* The four pin tests: `OrderingTests.cs`, `LastWinsTests.cs`, `AddMartenTimingTests.cs`, `AsyncComposeTests.cs` in the same directory
* [Bootstrapping Marten](./hostbuilder.md) for the basic `AddMarten` shape this page builds on top of
3 changes: 3 additions & 0 deletions src/Marten.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
<Project Path="Marten.Testing.OtherAssembly/Marten.Testing.OtherAssembly.csproj" />
<Project Path="Marten.Testing.ThirdAssembly/Marten.Testing.ThirdAssembly.csproj" />
<Project Path="Marten.Testing/Marten.Testing.csproj" />
<Project Path="ModularConfigTests.SatelliteA/ModularConfigTests.SatelliteA.csproj" />
<Project Path="ModularConfigTests.SatelliteB/ModularConfigTests.SatelliteB.csproj" />
<Project Path="ModularConfigTests/ModularConfigTests.csproj" />
<Project Path="MultiTenancyTests/MultiTenancyTests.csproj" />
<Project Path="PatchingTests/PatchingTests.csproj" />
<Project Path="StressTests/StressTests.csproj" />
Expand Down
9 changes: 9 additions & 0 deletions src/ModularConfigTests.SatelliteA/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using JasperFx;

// Marker so the Critter Stack scans this assembly for command-line extensions
// and other discovery surfaces. Note: Marten's DiscoverGeneratedEvolvers does
// NOT filter by this attribute (it walks AppDomain.CurrentDomain.GetAssemblies()),
// but the chip-prescribed pattern is to mark every satellite that participates
// in modular Marten composition with it for forward-compat with the broader
// Critter Stack scanning model.
[assembly: JasperFxAssembly]
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>Satellite assembly for Marten#4472 modular composite configuration tests. Carries [assembly: JasperFxAssembly] and its own IConfigureMarten implementation that registers a partial SingleStreamProjection. Wired with the JasperFx.Events.SourceGenerator analyzer so [GeneratedEvolver] is emitted at compile time for the satellite's own projections.</Description>
<GenerateAssemblyTitleAttribute>false</GenerateAssemblyTitleAttribute>
<GenerateAssemblyDescriptionAttribute>false</GenerateAssemblyDescriptionAttribute>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<GenerateAssemblyCopyrightAttribute>false</GenerateAssemblyCopyrightAttribute>
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Marten\Marten.csproj" />
<!-- Marten.csproj sets PrivateAssets="all" on the SG analyzer so it
doesn't flow transitively. This satellite declares its own
SingleStreamProjection<,> subclass and needs the SG to emit a
[GeneratedEvolver] dispatcher for it. Same wiring CoreTests /
DocumentDbTests / ValueTypeTests / Marten.AotSmoke use. -->
<PackageReference Include="JasperFx.Events.SourceGenerator" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>
10 changes: 10 additions & 0 deletions src/ModularConfigTests.SatelliteA/Order.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System;

namespace ModularConfigTests.SatelliteA;

public class Order
{
public Guid Id { get; set; }
public decimal Amount { get; set; }
public bool IsShipped { get; set; }
}
7 changes: 7 additions & 0 deletions src/ModularConfigTests.SatelliteA/OrderEvents.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System;

namespace ModularConfigTests.SatelliteA;

public record OrderPlaced(Guid OrderId, decimal Amount);

public record OrderShipped(Guid OrderId);
22 changes: 22 additions & 0 deletions src/ModularConfigTests.SatelliteA/OrderProjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;
using Marten.Events.Aggregation;

namespace ModularConfigTests.SatelliteA;

// `partial` is required so the JasperFx.Events.SourceGenerator can emit
// the dispatcher's `Evolve` override into this class declaration. Post-#276
// the SG-emitted [GeneratedEvolver] is the only apply-dispatch path; the
// SG silently skips emission for non-partial classes, after which the
// runtime fail-fast at AssembleAndAssertValidity throws.
public partial class OrderProjection : SingleStreamProjection<Order, Guid>
{
public void Apply(OrderPlaced @event, Order snapshot)
{
snapshot.Amount = @event.Amount;
}

public void Apply(OrderShipped @event, Order snapshot)
{
snapshot.IsShipped = true;
}
}
21 changes: 21 additions & 0 deletions src/ModularConfigTests.SatelliteA/OrdersConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using JasperFx.Events.Projections;
using Marten;

namespace ModularConfigTests.SatelliteA;

/// <summary>
/// Satellite-owned <see cref="IConfigureMarten"/> that registers this
/// assembly's projection with the host's <see cref="Marten.StoreOptions"/>.
/// Composes via DI: the main host wires this class as a singleton, then
/// Marten's <c>AsyncConfigureMartenApplication</c> calls
/// <see cref="Configure"/> after <c>AddMarten()</c> and before the store
/// is built.
/// </summary>
public class OrdersConfig : IConfigureMarten
{
public void Configure(IServiceProvider services, StoreOptions options)
{
options.Projections.Add<OrderProjection>(ProjectionLifecycle.Inline);
}
}
4 changes: 4 additions & 0 deletions src/ModularConfigTests.SatelliteB/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using JasperFx;

// See ModularConfigTests.SatelliteA.AssemblyInfo for rationale.
[assembly: JasperFxAssembly]
8 changes: 8 additions & 0 deletions src/ModularConfigTests.SatelliteB/Daily.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ModularConfigTests.SatelliteB;

public class Daily
{
public string Id { get; set; } = "";
public int OpenCount { get; set; }
public int CloseCount { get; set; }
}
5 changes: 5 additions & 0 deletions src/ModularConfigTests.SatelliteB/DailyEvents.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace ModularConfigTests.SatelliteB;

public record DailyOpened(string Day);

public record DailyClosed(string Day);
28 changes: 28 additions & 0 deletions src/ModularConfigTests.SatelliteB/DailyProjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Marten.Events.Projections;

namespace ModularConfigTests.SatelliteB;

// `partial` required for the JasperFx.Events.SourceGenerator to emit the
// dispatcher partial-class merge. See OrderProjection in SatelliteA for
// the post-#276 SG-only contract.
public partial class DailyProjection : MultiStreamProjection<Daily, string>
{
public DailyProjection()
{
// Multi-stream keyer: both event types route to the Daily whose
// Id matches the event's Day string. Lets events from many source
// streams contribute to one shared daily counter.
Identity<DailyOpened>(x => x.Day);
Identity<DailyClosed>(x => x.Day);
}

public void Apply(DailyOpened @event, Daily snapshot)
{
snapshot.OpenCount++;
}

public void Apply(DailyClosed @event, Daily snapshot)
{
snapshot.CloseCount++;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>Satellite assembly for Marten#4472 modular composite configuration tests. Carries [assembly: JasperFxAssembly] and its own IAsyncConfigureMarten implementation that registers a partial MultiStreamProjection. Wired with the JasperFx.Events.SourceGenerator analyzer so [GeneratedEvolver] is emitted at compile time.</Description>
<GenerateAssemblyTitleAttribute>false</GenerateAssemblyTitleAttribute>
<GenerateAssemblyDescriptionAttribute>false</GenerateAssemblyDescriptionAttribute>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<GenerateAssemblyCopyrightAttribute>false</GenerateAssemblyCopyrightAttribute>
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Marten\Marten.csproj" />
<!-- See ModularConfigTests.SatelliteA.csproj for why the SG analyzer
needs to be wired locally per-satellite (Marten's PrivateAssets). -->
<PackageReference Include="JasperFx.Events.SourceGenerator" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>
Loading
Loading