Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supporting strong typed identifiers for aggregated projections #3438

Merged
merged 2 commits into from
Sep 24, 2024
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
4 changes: 3 additions & 1 deletion docs/documents/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,9 @@ Vogen or StronglyTypedID for now.
:::

::: info
There is not yet any direct support for strong typed identifiers for the event store
As of Marten 7.29.0, the event sourcing features support strong typed identifiers for the aggregated
document types, but there is still no direct support for supplying strong typed identifiers for event streams yet.
This may change in Marten 8.0.
:::

Marten can now support [strong typed identifiers](https://en.wikipedia.org/wiki/Strongly_typed_identifie) using a couple different strategies.
Expand Down
9 changes: 9 additions & 0 deletions docs/events/projections/aggregate-projections.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ Alternatively, if your aggregate will never be deleted you can use a stream aggr

To create aggregate projections that include events in multiple streams, see [Multi-Stream Projections](/events/projections/multi-stream-projections).

## Strong Typed Identifiers <Badge type="tip" text="7.29" />

Marten supports using strong-typed identifiers as the document identity for aggregated documents. Here's an example:

snippet: sample_using_strong_typed_identifier_for_aggregate_projections

Just note that for single stream aggregations, your strong typed identifier types will need to wrap either a `Guid` or
`string` depending on your application's `StreamIdentity`.

## Aggregate Creation

::: tip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public void aggregate_id_is_wrong_type_1()
});

message.ShouldContain(
$"Id type mismatch. The stream identity type is System.Guid, but the aggregate document {typeof(StringIdentifiedAggregate).FullNameInCode()} id type is string",
$"Id type mismatch",
StringComparisonOption.Default);
}

Expand All @@ -67,7 +67,7 @@ public void aggregate_id_is_wrong_type_2()
x.Events.StreamIdentity = StreamIdentity.AsString;
x.Projections.Snapshot<GuidIdentifiedAggregate>(SnapshotLifecycle.Async);
}).ShouldContain(
$"Id type mismatch. The stream identity type is string, but the aggregate document {typeof(GuidIdentifiedAggregate).FullNameInCode()} id type is Guid",
$"Id type mismatch",
StringComparisonOption.Default
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System;
using System.Text.Json.Serialization;
using Marten.Events;
using Marten.Events.Projections;
using Marten.Exceptions;
using Marten.Testing.Harness;
using Shouldly;
using Xunit;

namespace EventSourcingTests.Aggregation;

public class strong_typed_identifiers_on_aggregates_must_be_nullable : OneOffConfigurationsContext
{
[Fact]
public void should_warn_if_the_id_is_not_nullable()
{
Should.Throw<InvalidProjectionException>(() =>
{
StoreOptions(opts => opts.Projections.Snapshot<Payment3>(SnapshotLifecycle.Inline));
});

}
}

public class Payment3
{
[JsonInclude] public PaymentId Id { get; private set; }

[JsonInclude] public DateTimeOffset CreatedAt { get; private set; }

[JsonInclude] public PaymentState State { get; private set; }

public static Payment3 Create(IEvent<PaymentCreated> @event)
{
return new Payment3
{
Id = new PaymentId(@event.StreamId), CreatedAt = @event.Data.CreatedAt, State = PaymentState.Created
};
}

public void Apply(PaymentCanceled @event)
{
State = PaymentState.Canceled;
}

public void Apply(PaymentVerified @event)
{
State = PaymentState.Verified;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
using System;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using JasperFx.Core;
using JasperFx.Core.Reflection;
using Marten.Events;
using Marten.Events.Projections;
using Marten.Testing.Harness;
using Microsoft.Extensions.Logging;
using Shouldly;
using StronglyTypedIds;
using Xunit;
using Xunit.Abstractions;

namespace EventSourcingTests.Aggregation;

// Sample code taken from https://github.com/JasperFx/marten/issues/3306
public class using_guid_based_strong_typed_id_for_aggregate_identity: OneOffConfigurationsContext
{
private readonly ITestOutputHelper _output;

public using_guid_based_strong_typed_id_for_aggregate_identity(ITestOutputHelper output)
{
_output = output;
}

[Fact]
public async Task can_utilize_strong_typed_id_in_aggregate_stream()
{
StoreOptions(opts =>
{
opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true });
});

var id = theSession.Events.StartStream<Payment>(new PaymentCreated(DateTimeOffset.UtcNow),
new PaymentVerified(DateTimeOffset.UtcNow)).Id;

await theSession.SaveChangesAsync();

var payment = await theSession.Events.AggregateStreamAsync<Payment>(id);

payment.Id.Value.Value.ShouldBe(id);
}

[Fact]
public async Task can_utilize_strong_typed_id_in_with_inline_aggregations()
{
StoreOptions(opts =>
{
opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true });
opts.Projections.Snapshot<Payment>(SnapshotLifecycle.Inline);
});

var id = theSession.Events.StartStream<Payment>(new PaymentCreated(DateTimeOffset.UtcNow),
new PaymentVerified(DateTimeOffset.UtcNow)).Id;

await theSession.SaveChangesAsync();

var payment = await theSession.LoadAsync<Payment>(new PaymentId(id));

payment.State.ShouldBe(PaymentState.Verified);
}

[Fact]
public async Task can_utilize_strong_typed_id_with_async_aggregation()
{
var testLogger = new TestLogger<IProjection>(_output);
StoreOptions(opts =>
{
opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true });
opts.Projections.Snapshot<Payment>(SnapshotLifecycle.Async);
opts.DotNetLogger = testLogger;
});

var id = theSession.Events.StartStream<Payment>(new PaymentCreated(DateTimeOffset.UtcNow),
new PaymentVerified(DateTimeOffset.UtcNow)).Id;

await theSession.SaveChangesAsync();


using var daemon = await theStore.BuildProjectionDaemonAsync(logger: testLogger);
await daemon.StartAllAsync();
await daemon.WaitForNonStaleData(1.Minutes());

var payment = await theSession.LoadAsync<Payment>(new PaymentId(id));

payment.State.ShouldBe(PaymentState.Verified);


// Do it again so you catch existing aggregates
theSession.Events.Append(id, new PaymentCanceled(DateTimeOffset.UtcNow));
await theSession.SaveChangesAsync();

await daemon.WaitForNonStaleData(1.Minutes());

payment = await theSession.LoadAsync<Payment>(new PaymentId(id));

payment.State.ShouldBe(PaymentState.Canceled);
}
}

#region sample_using_strong_typed_identifier_for_aggregate_projections

[StronglyTypedId(Template.Guid)]
public readonly partial struct PaymentId;

public class Payment
{
[JsonInclude] public PaymentId? Id { get; private set; }

[JsonInclude] public DateTimeOffset CreatedAt { get; private set; }

[JsonInclude] public PaymentState State { get; private set; }

public static Payment Create(IEvent<PaymentCreated> @event)
{
return new Payment
{
Id = new PaymentId(@event.StreamId), CreatedAt = @event.Data.CreatedAt, State = PaymentState.Created
};
}

public void Apply(PaymentCanceled @event)
{
State = PaymentState.Canceled;
}

public void Apply(PaymentVerified @event)
{
State = PaymentState.Verified;
}
}

#endregion

public enum PaymentState
{
Created,
Initialized,
Canceled,
Verified
}

public record PaymentCreated(
DateTimeOffset CreatedAt
);

public record PaymentCanceled(
DateTimeOffset CanceledAt
);

public record PaymentVerified(
DateTimeOffset VerifiedAt
);

public class TestLogger<T>: ILogger<T>, IDisposable
{
private readonly ITestOutputHelper _output;

public TestLogger(ITestOutputHelper output)
{
_output = output;
}


public void Dispose()
{
// Nothing
}

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
Func<TState, Exception, string> formatter)
{
var message = $"{typeof(T).NameInCode()}/{logLevel}: {formatter(state, exception)}";
Debug.WriteLine(message);
_output.WriteLine(message);

if (exception != null)
{
Debug.WriteLine(exception);
_output.WriteLine(exception.ToString());
}
}

public bool IsEnabled(LogLevel logLevel)
{
return true;
}

public IDisposable BeginScope<TState>(TState state)
{
return this;
}
}
Loading
Loading