Skip to content

Commit 1bc7bef

Browse files
authored
refactor: make the shared inbox registration hub public instead of using cross-assembly internals (#350)
1 parent 3b2a545 commit 1bc7bef

5 files changed

Lines changed: 161 additions & 18 deletions

File tree

docs/articles/inbox-pattern.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,21 @@ builder.Services.AddCosmosInbox<AppDbContext>();
139139

140140
The marker is a self-contained document keyed and partitioned by `MessageId` in its own container, so a duplicate insert conflicts and is treated as already-processed. Because Cosmos cannot commit the marker and the business write atomically, the store writes the marker **after** the consumer's own commit and the guarantee is **effectively-once** — keep your consumer's writes idempotent (deterministic ids / upserts) so a redelivery that races ahead of the marker is harmless.
141141

142+
### Custom stores
143+
144+
Implement `IIdempotencyStore` to back the inbox with a different persistence technology. Register your store, then
145+
call `AddInboxCore` to get the same retention sweep, metrics, and `TimeProvider` wiring that
146+
`AddRelationalInbox`/`AddCosmosInbox` give the first-party stores — there is no first-party-only registration path:
147+
148+
```csharp
149+
builder.Services.TryAddScoped<IIdempotencyStore, MyCustomIdempotencyStore>();
150+
builder.Services.AddInboxCore(o => o.Retention.Enabled = true);
151+
```
152+
153+
Implement `IInboxRetentionStore` too if your store should support the retention sweep. `AddInboxCore` registers the
154+
sweep whenever `Retention.Enabled` is set, and the sweep silently skips (after a one-time warning) if the
155+
registered store doesn't implement it.
156+
142157
## Typical Flow
143158

144159
```

src/Vulthil.Messaging.Inbox/InboxCoreServiceCollectionExtensions.cs

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,32 @@
44
namespace Vulthil.Messaging.Inbox;
55

66
/// <summary>
7-
/// Registration shared by every inbox store package (<c>AddRelationalInbox</c>, <c>AddCosmosInbox</c>). The store
8-
/// extensions differ only in which <see cref="IIdempotencyStore"/> implementation they register; the retention
9-
/// sweep, OpenTelemetry metrics, and <see cref="TimeProvider"/> registration are identical regardless of the store,
10-
/// so they live here once instead of being duplicated per store package. Internal, and visible to the store
11-
/// packages via <c>InternalsVisibleTo</c>.
7+
/// Service-collection extension that registers the inbox infrastructure shared by every idempotency store.
128
/// </summary>
13-
internal static class InboxCoreServiceCollectionExtensions
9+
public static class InboxCoreServiceCollectionExtensions
1410
{
1511
/// <summary>
16-
/// Registers everything an <c>AddXyzInbox</c> store extension needs beyond its own store type: the shared
17-
/// <see cref="TimeProvider"/>, the retention sweep (gated by <see cref="InboxRetentionOptions.Enabled"/>), and
18-
/// OpenTelemetry metrics (gated by <see cref="InboxOptions.EnableMetrics"/>). <paramref name="configure"/> is
19-
/// invoked exactly once here to evaluate both gates from a single materialized <see cref="InboxOptions"/>
20-
/// instance, plus once more when the options system resolves <see cref="Microsoft.Extensions.Options.IOptions{TOptions}"/>
21-
/// for injected consumers.
12+
/// Registers everything an <see cref="IIdempotencyStore"/> needs beyond the store registration itself: the
13+
/// shared <see cref="TimeProvider"/>, the retention sweep (gated by <see cref="InboxRetentionOptions.Enabled"/>),
14+
/// and OpenTelemetry metrics (gated by <see cref="InboxOptions.EnableMetrics"/>).
2215
/// </summary>
23-
internal static IServiceCollection AddInboxCore(this IServiceCollection services, Action<InboxOptions>? configure)
16+
/// <param name="services">The service collection.</param>
17+
/// <param name="configure">
18+
/// An optional action to configure <see cref="InboxOptions"/>. Invoked once eagerly, against a single
19+
/// materialized <see cref="InboxOptions"/> instance, to evaluate both the retention and metrics gates, and once
20+
/// more when the options system materializes <see cref="Microsoft.Extensions.Options.IOptions{TOptions}"/> for
21+
/// injected consumers.
22+
/// </param>
23+
/// <returns>The same service collection, for chaining.</returns>
24+
/// <remarks>
25+
/// The first-party store extensions (<c>AddRelationalInbox</c>, <c>AddCosmosInbox</c>) register their
26+
/// <see cref="IIdempotencyStore"/> implementation and then call this method; a custom store package follows the
27+
/// same pattern — register its own <see cref="IIdempotencyStore"/> (typically with <c>TryAddScoped</c> so an
28+
/// application can still substitute a different implementation), then call <c>AddInboxCore</c> to get the same
29+
/// retention, metrics, and <see cref="TimeProvider"/> wiring the first-party stores get. There is no privileged
30+
/// first-party path: every store, first- or third-party, shares this one entry point.
31+
/// </remarks>
32+
public static IServiceCollection AddInboxCore(this IServiceCollection services, Action<InboxOptions>? configure = null)
2433
{
2534
ArgumentNullException.ThrowIfNull(services);
2635

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
Vulthil.Messaging.Inbox.InboxCoreServiceCollectionExtensions
3+
static Vulthil.Messaging.Inbox.InboxCoreServiceCollectionExtensions.AddInboxCore(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Vulthil.Messaging.Inbox.InboxOptions!>? configure = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!

src/Vulthil.Messaging.Inbox/Vulthil.Messaging.Inbox.csproj

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,4 @@
1515
<ProjectReference Include="..\Vulthil.Messaging\Vulthil.Messaging.csproj" />
1616
</ItemGroup>
1717

18-
<ItemGroup>
19-
<InternalsVisibleTo Include="Vulthil.Messaging.Inbox.Relational" />
20-
<InternalsVisibleTo Include="Vulthil.Messaging.Inbox.Cosmos" />
21-
</ItemGroup>
22-
2318
</Project>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.DependencyInjection.Extensions;
3+
using Microsoft.Extensions.Hosting;
4+
using OpenTelemetry.Metrics;
5+
using Vulthil.Messaging.Abstractions.Consumers;
6+
using Vulthil.xUnit;
7+
8+
namespace Vulthil.Messaging.Inbox.Tests;
9+
10+
public sealed class InboxCoreServiceCollectionExtensionsTests : BaseUnitTestCase
11+
{
12+
[Fact]
13+
public void AThirdPartyStoreRegisteredBeforeAddInboxCoreResolvesUnchanged()
14+
{
15+
// Arrange — a custom store package registers its own IIdempotencyStore first, exactly as
16+
// AddRelationalInbox/AddCosmosInbox register theirs before calling AddInboxCore.
17+
var services = new ServiceCollection();
18+
services.TryAddScoped<IIdempotencyStore, FakeIdempotencyStore>();
19+
20+
// Act
21+
services.AddInboxCore();
22+
using var provider = services.BuildServiceProvider();
23+
24+
// Assert
25+
provider.GetRequiredService<IIdempotencyStore>().ShouldBeOfType<FakeIdempotencyStore>();
26+
}
27+
28+
[Fact]
29+
public void RegistersTheSharedTimeProvider()
30+
{
31+
// Arrange
32+
var services = new ServiceCollection();
33+
34+
// Act
35+
services.AddInboxCore();
36+
37+
// Assert
38+
services.ShouldContain(descriptor => descriptor.ServiceType == typeof(TimeProvider));
39+
}
40+
41+
[Fact]
42+
public void EnableMetricsRegistersTheMeterProviderService()
43+
{
44+
// Arrange
45+
var services = new ServiceCollection();
46+
47+
// Act
48+
services.AddInboxCore(o => o.EnableMetrics = true);
49+
50+
// Assert
51+
services.ShouldContain(descriptor => descriptor.ServiceType == typeof(MeterProvider));
52+
}
53+
54+
[Fact]
55+
public void EnableMetricsDisabledSkipsMeterProviderRegistration()
56+
{
57+
// Arrange
58+
var services = new ServiceCollection();
59+
60+
// Act
61+
services.AddInboxCore(o => o.EnableMetrics = false);
62+
63+
// Assert
64+
services.ShouldNotContain(descriptor => descriptor.ServiceType == typeof(MeterProvider));
65+
}
66+
67+
[Fact]
68+
public void RetentionEnabledRegistersTheRetentionBackgroundService()
69+
{
70+
// Arrange
71+
var services = new ServiceCollection();
72+
73+
// Act
74+
services.AddInboxCore(o => o.Retention.Enabled = true);
75+
76+
// Assert
77+
services.ShouldContain(descriptor => descriptor.ServiceType == typeof(IHostedService) && descriptor.ImplementationType == typeof(InboxRetentionBackgroundService));
78+
}
79+
80+
[Fact]
81+
public void RetentionDisabledDoesNotRegisterTheRetentionBackgroundService()
82+
{
83+
// Arrange
84+
var services = new ServiceCollection();
85+
86+
// Act
87+
services.AddInboxCore(o => o.Retention.Enabled = false);
88+
89+
// Assert
90+
services.ShouldNotContain(descriptor => descriptor.ServiceType == typeof(IHostedService) && descriptor.ImplementationType == typeof(InboxRetentionBackgroundService));
91+
}
92+
93+
[Fact]
94+
public void CanBeCalledWithoutAConfigureDelegate()
95+
{
96+
// Arrange
97+
var services = new ServiceCollection();
98+
99+
// Act
100+
var exception = Record.Exception(() => services.AddInboxCore());
101+
102+
// Assert
103+
exception.ShouldBeNull();
104+
}
105+
106+
[Fact]
107+
public void NullServicesThrows()
108+
{
109+
// Arrange
110+
IServiceCollection services = null!;
111+
112+
// Act & Assert
113+
Should.Throw<ArgumentNullException>(() => services.AddInboxCore());
114+
}
115+
116+
private sealed class FakeIdempotencyStore : IIdempotencyStore, IInboxRetentionStore
117+
{
118+
public Task<bool> ProcessAsync(string idempotencyKey, IMessageContext context, Func<CancellationToken, Task> process, CancellationToken cancellationToken) => Task.FromResult(false);
119+
120+
public Task<int> DeleteProcessedAsync(DateTimeOffset olderThanUtc, int batchSize, CancellationToken cancellationToken) => Task.FromResult(0);
121+
}
122+
}

0 commit comments

Comments
 (0)