Skip to content

Commit fc3b5c6

Browse files
feat(persistence): expose options in journal and snapshot builders (#691)
Added Options property to AkkaPersistenceJournalBuilder and AkkaPersistenceSnapshotBuilder, allowing extension methods to access configuration details without requiring options as explicit parameters. This resolves the API design issue where connectivity check extensions and other plugin-specific methods needed options passed redundantly. Changes: - Added JournalOptions? Options property to AkkaPersistenceJournalBuilder - Added SnapshotOptions? Options property to AkkaPersistenceSnapshotBuilder - Added new constructors accepting options parameters - Updated WithJournal() and WithSnapshot() to pass options to builders - Maintained backward compatibility via nullable Options and legacy constructors - Added comprehensive test suite (BuilderOptionsAccessSpec) with 6 passing tests - Updated API approval baseline Resolves #690
1 parent 86f5e70 commit fc3b5c6

File tree

5 files changed

+269
-2
lines changed

5 files changed

+269
-2
lines changed

src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApprovePersistence.verified.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ namespace Akka.Persistence.Hosting
2222
public sealed class AkkaPersistenceJournalBuilder
2323
{
2424
public AkkaPersistenceJournalBuilder(string journalId, Akka.Hosting.AkkaConfigurationBuilder builder) { }
25+
public AkkaPersistenceJournalBuilder(string journalId, Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.JournalOptions options) { }
26+
public Akka.Persistence.Hosting.JournalOptions? Options { get; }
2527
public Akka.Persistence.Hosting.AkkaPersistenceJournalBuilder AddEventAdapter<TAdapter>(string eventAdapterName, System.Collections.Generic.IEnumerable<System.Type> boundTypes)
2628
where TAdapter : Akka.Persistence.Journal.IEventAdapter { }
2729
public Akka.Persistence.Hosting.AkkaPersistenceJournalBuilder AddReadEventAdapter<TAdapter>(string eventAdapterName, System.Collections.Generic.IEnumerable<System.Type> boundTypes)
@@ -34,6 +36,8 @@ namespace Akka.Persistence.Hosting
3436
public sealed class AkkaPersistenceSnapshotBuilder
3537
{
3638
public AkkaPersistenceSnapshotBuilder(string snapshotStoreId, Akka.Hosting.AkkaConfigurationBuilder builder) { }
39+
public AkkaPersistenceSnapshotBuilder(string snapshotStoreId, Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.SnapshotOptions options) { }
40+
public Akka.Persistence.Hosting.SnapshotOptions? Options { get; }
3741
public Akka.Persistence.Hosting.AkkaPersistenceSnapshotBuilder WithCustomHealthCheck(Akka.Hosting.AkkaHealthCheckRegistration registration) { }
3842
public Akka.Persistence.Hosting.AkkaPersistenceSnapshotBuilder WithHealthCheck(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus unHealthyStatus = 1, string? name = null, System.Collections.Generic.IEnumerable<string>? tags = null) { }
3943
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
using System;
2+
using Akka.Configuration;
3+
using Akka.Hosting;
4+
using FluentAssertions;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Hosting;
7+
using Xunit;
8+
using Xunit.Abstractions;
9+
using static Akka.Persistence.Hosting.Tests.UnifiedApiTestResources;
10+
11+
namespace Akka.Persistence.Hosting.Tests;
12+
13+
/// <summary>
14+
/// Tests that verify builders expose their options, eliminating the need
15+
/// to pass options explicitly to extension methods like WithConnectivityCheck().
16+
///
17+
/// This resolves the API design issue identified in https://github.com/akkadotnet/Akka.Hosting/issues/690
18+
/// </summary>
19+
public sealed class BuilderOptionsAccessSpec
20+
{
21+
private readonly ITestOutputHelper _output;
22+
23+
public BuilderOptionsAccessSpec(ITestOutputHelper output)
24+
{
25+
_output = output;
26+
}
27+
28+
[Fact(DisplayName = "Journal builder should expose JournalOptions via Options property")]
29+
public void JournalBuilderShouldExposeOptions()
30+
{
31+
// Arrange
32+
var services = new ServiceCollection();
33+
var builder = new AkkaConfigurationBuilder(services, "test-system");
34+
var journalOptions = new TestJournalOptions(isDefault: true)
35+
{
36+
Identifier = "test-journal"
37+
};
38+
39+
AkkaPersistenceJournalBuilder? capturedBuilder = null;
40+
41+
// Act
42+
builder.WithJournal(journalOptions, journal =>
43+
{
44+
capturedBuilder = journal;
45+
});
46+
47+
// Assert
48+
capturedBuilder.Should().NotBeNull("builder should be passed to callback");
49+
capturedBuilder!.Options.Should().NotBeNull("Options should be set");
50+
capturedBuilder.Options!.Should().BeSameAs(journalOptions, "Options should reference the original options instance");
51+
capturedBuilder.Options!.Identifier.Should().Be("test-journal");
52+
53+
_output.WriteLine($"✓ Journal builder correctly exposes options with identifier: {capturedBuilder.Options!.Identifier}");
54+
}
55+
56+
[Fact(DisplayName = "Snapshot builder should expose SnapshotOptions via Options property")]
57+
public void SnapshotBuilderShouldExposeOptions()
58+
{
59+
// Arrange
60+
var services = new ServiceCollection();
61+
var builder = new AkkaConfigurationBuilder(services, "test-system");
62+
var snapshotOptions = new TestSnapshotOptions(isDefault: true)
63+
{
64+
Identifier = "test-snapshot"
65+
};
66+
67+
AkkaPersistenceSnapshotBuilder? capturedBuilder = null;
68+
69+
// Act
70+
builder.WithSnapshot(snapshotOptions, snapshot =>
71+
{
72+
capturedBuilder = snapshot;
73+
});
74+
75+
// Assert
76+
capturedBuilder.Should().NotBeNull("builder should be passed to callback");
77+
capturedBuilder!.Options.Should().NotBeNull("Options should be set");
78+
capturedBuilder.Options!.Should().BeSameAs(snapshotOptions, "Options should reference the original options instance");
79+
capturedBuilder.Options!.Identifier.Should().Be("test-snapshot");
80+
81+
_output.WriteLine($"✓ Snapshot builder correctly exposes options with identifier: {capturedBuilder.Options!.Identifier}");
82+
}
83+
84+
[Fact(DisplayName = "WithJournalAndSnapshot should expose both journal and snapshot options")]
85+
public void WithJournalAndSnapshotShouldExposeBothOptions()
86+
{
87+
// Arrange
88+
var services = new ServiceCollection();
89+
var builder = new AkkaConfigurationBuilder(services, "test-system");
90+
var journalOptions = new TestJournalOptions(isDefault: true)
91+
{
92+
Identifier = "test-journal"
93+
};
94+
var snapshotOptions = new TestSnapshotOptions(isDefault: true)
95+
{
96+
Identifier = "test-snapshot"
97+
};
98+
99+
AkkaPersistenceJournalBuilder? capturedJournalBuilder = null;
100+
AkkaPersistenceSnapshotBuilder? capturedSnapshotBuilder = null;
101+
102+
// Act
103+
builder.WithJournalAndSnapshot(
104+
journalOptions,
105+
snapshotOptions,
106+
journal => { capturedJournalBuilder = journal; },
107+
snapshot => { capturedSnapshotBuilder = snapshot; });
108+
109+
// Assert
110+
capturedJournalBuilder.Should().NotBeNull("journal builder should be passed to callback");
111+
capturedJournalBuilder!.Options.Should().NotBeNull("journal Options should be set");
112+
capturedJournalBuilder.Options.Should().BeSameAs(journalOptions);
113+
114+
capturedSnapshotBuilder.Should().NotBeNull("snapshot builder should be passed to callback");
115+
capturedSnapshotBuilder!.Options.Should().NotBeNull("snapshot Options should be set");
116+
capturedSnapshotBuilder.Options.Should().BeSameAs(snapshotOptions);
117+
118+
_output.WriteLine($"✓ Both builders correctly expose their options");
119+
}
120+
121+
[Fact(DisplayName = "Legacy parameterless constructor should have null Options (backward compatibility)")]
122+
public void LegacyParameterlessConstructorShouldHaveNullOptions()
123+
{
124+
// Arrange
125+
var services = new ServiceCollection();
126+
var akkaBuilder = new AkkaConfigurationBuilder(services, "test-system");
127+
128+
// Act - using the parameterless constructor directly (legacy usage)
129+
var journalBuilder = new AkkaPersistenceJournalBuilder("legacy-journal", akkaBuilder);
130+
var snapshotBuilder = new AkkaPersistenceSnapshotBuilder("legacy-snapshot", akkaBuilder);
131+
132+
// Assert
133+
journalBuilder.Options.Should().BeNull("parameterless constructor should result in null Options for backward compatibility");
134+
snapshotBuilder.Options.Should().BeNull("parameterless constructor should result in null Options for backward compatibility");
135+
136+
_output.WriteLine($"✓ Legacy constructors correctly maintain backward compatibility with null Options");
137+
}
138+
139+
[Fact(DisplayName = "WithInMemoryJournal should work without options (backward compatibility)")]
140+
public void WithInMemoryJournalShouldWorkWithoutOptions()
141+
{
142+
// Arrange
143+
var services = new ServiceCollection();
144+
var builder = new AkkaConfigurationBuilder(services, "test-system");
145+
146+
AkkaPersistenceJournalBuilder? capturedBuilder = null;
147+
148+
// Act
149+
builder.WithInMemoryJournal(journal =>
150+
{
151+
capturedBuilder = journal;
152+
});
153+
154+
// Assert
155+
capturedBuilder.Should().NotBeNull("builder should be passed to callback");
156+
capturedBuilder!.Options.Should().BeNull("WithInMemoryJournal doesn't have options, so Options should be null");
157+
capturedBuilder.JournalId.Should().Be("inmem");
158+
159+
_output.WriteLine($"✓ WithInMemoryJournal correctly works without options (backward compatibility maintained)");
160+
}
161+
162+
/// <summary>
163+
/// Demonstrates the improved API ergonomics - extension methods can now access options
164+
/// from the builder without requiring them as explicit parameters.
165+
/// </summary>
166+
[Fact(DisplayName = "Extension methods can access connection details from builder options")]
167+
public void ExtensionMethodsCanAccessConnectionDetailsFromBuilderOptions()
168+
{
169+
// Arrange
170+
var services = new ServiceCollection();
171+
var builder = new AkkaConfigurationBuilder(services, "test-system");
172+
173+
// Simulate a concrete options class with connection details
174+
var journalOptions = new TestJournalOptionsWithConnectionString(isDefault: true)
175+
{
176+
Identifier = "test-journal",
177+
ConnectionString = "Server=localhost;Database=test;User=sa;Password=pass123"
178+
};
179+
180+
string? capturedConnectionString = null;
181+
182+
// Act
183+
builder.WithJournal(journalOptions, journal =>
184+
{
185+
// Extension method can now access connection string from builder.Options
186+
// WITHOUT requiring it as an explicit parameter!
187+
if (journal.Options is TestJournalOptionsWithConnectionString options)
188+
{
189+
capturedConnectionString = options.ConnectionString;
190+
}
191+
});
192+
193+
// Assert
194+
capturedConnectionString.Should().NotBeNull("extension method should be able to access connection string from builder options");
195+
capturedConnectionString.Should().Be("Server=localhost;Database=test;User=sa;Password=pass123");
196+
197+
_output.WriteLine($"✓ Extension methods can access connection details from builder without explicit parameters");
198+
_output.WriteLine($" Connection string: {capturedConnectionString}");
199+
}
200+
201+
/// <summary>
202+
/// Test helper class simulating a real-world options class with connection string
203+
/// </summary>
204+
private sealed class TestJournalOptionsWithConnectionString : JournalOptions
205+
{
206+
public TestJournalOptionsWithConnectionString(bool isDefault = true) : base(isDefault)
207+
{
208+
Identifier = "test-journal-with-conn";
209+
}
210+
211+
public override string Identifier { get; set; }
212+
213+
protected override Config InternalDefaultConfig =>
214+
ConfigurationFactory.ParseString("""
215+
class = "Akka.Persistence.Journal.MemoryJournal, Akka.Persistence"
216+
plugin-dispatcher = "akka.actor.default-dispatcher"
217+
""");
218+
219+
public string? ConnectionString { get; set; }
220+
}
221+
}

src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ public static AkkaConfigurationBuilder WithJournal(
135135
// Apply the builder configuration (adapters + health checks) if provided
136136
if (configureBuilder != null)
137137
{
138-
var jBuilder = new AkkaPersistenceJournalBuilder(journalOptions.Identifier, builder);
138+
var jBuilder = new AkkaPersistenceJournalBuilder(journalOptions.Identifier, builder, journalOptions);
139139
configureBuilder(jBuilder);
140140
jBuilder.Build();
141141
}
@@ -190,7 +190,7 @@ public static AkkaConfigurationBuilder WithSnapshot(
190190
// Apply the builder configuration (health checks) if provided
191191
if (configureBuilder != null)
192192
{
193-
var sBuilder = new AkkaPersistenceSnapshotBuilder(snapshotOptions.Identifier, builder);
193+
var sBuilder = new AkkaPersistenceSnapshotBuilder(snapshotOptions.Identifier, builder, snapshotOptions);
194194
configureBuilder(sBuilder);
195195
sBuilder.Build();
196196
}

src/Akka.Persistence.Hosting/AkkaPersistenceJournalBuilder.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,31 @@ public sealed class AkkaPersistenceJournalBuilder
2121
internal readonly Dictionary<string, Type> Adapters = new Dictionary<string, Type>();
2222
internal readonly HashSet<AkkaHealthCheckRegistration> HealthCheckRegistrations = [];
2323

24+
/// <summary>
25+
/// The <see cref="JournalOptions"/> instance used to configure this journal.
26+
/// This property allows extension methods to access journal configuration details
27+
/// (such as connection strings) without requiring them as explicit parameters.
28+
/// </summary>
29+
public JournalOptions? Options { get; }
30+
2431
public AkkaPersistenceJournalBuilder(string journalId, AkkaConfigurationBuilder builder)
2532
{
2633
JournalId = journalId;
2734
Builder = builder;
35+
Options = null;
36+
}
37+
38+
/// <summary>
39+
/// Constructor that accepts journal options for improved extension method ergonomics.
40+
/// </summary>
41+
/// <param name="journalId">The journal identifier</param>
42+
/// <param name="builder">The Akka configuration builder</param>
43+
/// <param name="options">The journal options instance</param>
44+
public AkkaPersistenceJournalBuilder(string journalId, AkkaConfigurationBuilder builder, JournalOptions options)
45+
{
46+
JournalId = journalId;
47+
Builder = builder;
48+
Options = options;
2849
}
2950

3051
/// <summary>

src/Akka.Persistence.Hosting/AkkaPersistenceSnapshotBuilder.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,31 @@ public sealed class AkkaPersistenceSnapshotBuilder
1414
internal readonly AkkaConfigurationBuilder Builder;
1515
internal readonly HashSet<AkkaHealthCheckRegistration> HealthCheckRegistrations = [];
1616

17+
/// <summary>
18+
/// The <see cref="SnapshotOptions"/> instance used to configure this snapshot store.
19+
/// This property allows extension methods to access snapshot store configuration details
20+
/// (such as connection strings) without requiring them as explicit parameters.
21+
/// </summary>
22+
public SnapshotOptions? Options { get; }
23+
1724
public AkkaPersistenceSnapshotBuilder(string snapshotStoreId, AkkaConfigurationBuilder builder)
1825
{
1926
SnapshotStoreId = snapshotStoreId;
2027
Builder = builder;
28+
Options = null;
29+
}
30+
31+
/// <summary>
32+
/// Constructor that accepts snapshot options for improved extension method ergonomics.
33+
/// </summary>
34+
/// <param name="snapshotStoreId">The snapshot store identifier</param>
35+
/// <param name="builder">The Akka configuration builder</param>
36+
/// <param name="options">The snapshot options instance</param>
37+
public AkkaPersistenceSnapshotBuilder(string snapshotStoreId, AkkaConfigurationBuilder builder, SnapshotOptions options)
38+
{
39+
SnapshotStoreId = snapshotStoreId;
40+
Builder = builder;
41+
Options = options;
2142
}
2243

2344
/// <summary>

0 commit comments

Comments
 (0)