From 8d5e4a6c433044a58827076363bf4e043ba6e0c3 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 5 Dec 2024 12:05:50 +0100 Subject: [PATCH 1/2] docs: add getting started sample project --- .../GettingStarted/Commands/CreateCommands.cs | 31 +++++++++ .../GettingStarted/Commands/DeleteCommand.cs | 44 +++++++++++++ .../Commands/UpdateNameCommands.cs | 44 +++++++++++++ sample/GettingStarted/ConsoleHostedService.cs | 44 +++++++++++++ sample/GettingStarted/Events/AddedEvent.cs | 6 ++ .../Events/AddressChangedEvent.cs | 6 ++ sample/GettingStarted/Events/DeletedEvent.cs | 6 ++ .../GettingStarted/Events/NameChangedEvent.cs | 6 ++ sample/GettingStarted/GettingStarted.csproj | 17 +++++ sample/GettingStarted/Program.cs | 62 +++++++++++++++++ .../Projections/ContainerInitializer.cs | 25 +++++++ .../Projections/SampleProjection.cs | 66 +++++++++++++++++++ .../Projections/SampleReadModel.cs | 21 ++++++ sample/GettingStarted/SampleEventStreamId.cs | 32 +++++++++ 14 files changed, 410 insertions(+) create mode 100644 sample/GettingStarted/Commands/CreateCommands.cs create mode 100644 sample/GettingStarted/Commands/DeleteCommand.cs create mode 100644 sample/GettingStarted/Commands/UpdateNameCommands.cs create mode 100644 sample/GettingStarted/ConsoleHostedService.cs create mode 100644 sample/GettingStarted/Events/AddedEvent.cs create mode 100644 sample/GettingStarted/Events/AddressChangedEvent.cs create mode 100644 sample/GettingStarted/Events/DeletedEvent.cs create mode 100644 sample/GettingStarted/Events/NameChangedEvent.cs create mode 100644 sample/GettingStarted/GettingStarted.csproj create mode 100644 sample/GettingStarted/Program.cs create mode 100644 sample/GettingStarted/Projections/ContainerInitializer.cs create mode 100644 sample/GettingStarted/Projections/SampleProjection.cs create mode 100644 sample/GettingStarted/Projections/SampleReadModel.cs create mode 100644 sample/GettingStarted/SampleEventStreamId.cs diff --git a/sample/GettingStarted/Commands/CreateCommands.cs b/sample/GettingStarted/Commands/CreateCommands.cs new file mode 100644 index 0000000..672e3fb --- /dev/null +++ b/sample/GettingStarted/Commands/CreateCommands.cs @@ -0,0 +1,31 @@ +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted; + +public record CreateCommand(string Id, string Name, string Address) + : CommandBase(new SampleEventStreamId(Id)); + +public class CreateCommandHandler : + ICommandHandler, + IConsumeEvent +{ + private bool created; + + public void Consume(CreateCommand evt, EventMetadata metadata) + { + this.created = true; + } + + public ValueTask ExecuteAsync( + CreateCommand command, + ICommandContext context, + CancellationToken cancellationToken) + { + if (!created) + { + context.AddEvent(new AddedEvent(command.Name, command.Address)); + } + + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/sample/GettingStarted/Commands/DeleteCommand.cs b/sample/GettingStarted/Commands/DeleteCommand.cs new file mode 100644 index 0000000..b812777 --- /dev/null +++ b/sample/GettingStarted/Commands/DeleteCommand.cs @@ -0,0 +1,44 @@ +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted; + +public record DeleteCommand(string Id, string Reason) + : CommandBase(new SampleEventStreamId(Id)); + +public class DeleteCommandHandler : + ICommandHandler, + IConsumeEvent, + IConsumeEvent +{ + private bool created; + private bool deleted; + + public void Consume(AddedEvent evt, EventMetadata metadata) + { + this.created = true; + } + + public void Consume(DeletedEvent evt, EventMetadata metadata) + { + this.deleted = true; + } + + public ValueTask ExecuteAsync( + DeleteCommand command, + ICommandContext context, + CancellationToken cancellationToken) + { + if (!created) + { + throw new InvalidOperationException("Cannot delete non-existing entity."); + } + + if (deleted) + { + throw new InvalidOperationException("Already deleted."); + } + + context.AddEvent(new DeletedEvent(command.Reason)); + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/sample/GettingStarted/Commands/UpdateNameCommands.cs b/sample/GettingStarted/Commands/UpdateNameCommands.cs new file mode 100644 index 0000000..67feac3 --- /dev/null +++ b/sample/GettingStarted/Commands/UpdateNameCommands.cs @@ -0,0 +1,44 @@ +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted; + +public record UpdateNameCommand(string Id, string Name) + : CommandBase(new SampleEventStreamId(Id)); + +public class UpdateNameCommandHandler : + ICommandHandler, + IConsumeEvent, + IConsumeEvent +{ + private bool created; + private string? currentName; + + public void Consume(AddedEvent evt, EventMetadata metadata) + { + created = true; + currentName = evt.Name; + } + + public void Consume(NameChangedEvent evt, EventMetadata metadata) + { + currentName = evt.NewName; + } + + public ValueTask ExecuteAsync( + UpdateNameCommand command, + ICommandContext context, + CancellationToken cancellationToken) + { + if (!created) + { + throw new InvalidOperationException("Cannot change name on non-existing entity."); + } + + if (currentName != command.Name) + { + context.AddEvent(new NameChangedEvent(currentName!, command.Name)); + } + + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/sample/GettingStarted/ConsoleHostedService.cs b/sample/GettingStarted/ConsoleHostedService.cs new file mode 100644 index 0000000..0341bae --- /dev/null +++ b/sample/GettingStarted/ConsoleHostedService.cs @@ -0,0 +1,44 @@ +using Atc.Cosmos.EventStore.Cqrs; +using Microsoft.Extensions.Hosting; + +namespace GettingStarted; + +public class ConsoleHostedService(ICommandProcessorFactory commandProcessorFactory) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var id = Guid.NewGuid().ToString("N"); + + await commandProcessorFactory + .Create() + .ExecuteAsync( + new CreateCommand(id, "First", "Address 1"), + cancellationToken); + + await commandProcessorFactory + .Create() + .ExecuteAsync( + new UpdateNameCommand(id, "Second"), + cancellationToken); + + await commandProcessorFactory + .Create() + .ExecuteAsync( + new UpdateNameCommand(id, "Third"), + cancellationToken); + + await commandProcessorFactory + .Create() + .ExecuteAsync( + new DeleteCommand(id, "Deleted"), + cancellationToken); + + await commandProcessorFactory + .Create() + .ExecuteAsync( + new DeleteCommand(id, "Deleted"), + cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} \ No newline at end of file diff --git a/sample/GettingStarted/Events/AddedEvent.cs b/sample/GettingStarted/Events/AddedEvent.cs new file mode 100644 index 0000000..0f7fdc4 --- /dev/null +++ b/sample/GettingStarted/Events/AddedEvent.cs @@ -0,0 +1,6 @@ +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted; + +[StreamEvent("added-event:v1")] +public record AddedEvent(string Name, string Address); \ No newline at end of file diff --git a/sample/GettingStarted/Events/AddressChangedEvent.cs b/sample/GettingStarted/Events/AddressChangedEvent.cs new file mode 100644 index 0000000..3840e1f --- /dev/null +++ b/sample/GettingStarted/Events/AddressChangedEvent.cs @@ -0,0 +1,6 @@ +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted; + +[StreamEvent("address-changed-event:v1")] +public record AddressChangedEvent(string OldAddress, string NewAddress); \ No newline at end of file diff --git a/sample/GettingStarted/Events/DeletedEvent.cs b/sample/GettingStarted/Events/DeletedEvent.cs new file mode 100644 index 0000000..f765623 --- /dev/null +++ b/sample/GettingStarted/Events/DeletedEvent.cs @@ -0,0 +1,6 @@ +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted; + +[StreamEvent("deleted-event:v1")] +public record DeletedEvent(string Reason); \ No newline at end of file diff --git a/sample/GettingStarted/Events/NameChangedEvent.cs b/sample/GettingStarted/Events/NameChangedEvent.cs new file mode 100644 index 0000000..2ecbddb --- /dev/null +++ b/sample/GettingStarted/Events/NameChangedEvent.cs @@ -0,0 +1,6 @@ +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted; + +[StreamEvent("name-changed-event:v1")] +public record NameChangedEvent(string OldName, string NewName); \ No newline at end of file diff --git a/sample/GettingStarted/GettingStarted.csproj b/sample/GettingStarted/GettingStarted.csproj new file mode 100644 index 0000000..845cc80 --- /dev/null +++ b/sample/GettingStarted/GettingStarted.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + latest + + + + + + + + + diff --git a/sample/GettingStarted/Program.cs b/sample/GettingStarted/Program.cs new file mode 100644 index 0000000..6e7b4d8 --- /dev/null +++ b/sample/GettingStarted/Program.cs @@ -0,0 +1,62 @@ +using Atc.Cosmos; +using Atc.Cosmos.EventStore; +using GettingStarted; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Atc.Cosmos.Internal; +using GettingStarted.Storage; +using Microsoft.Extensions.Options; + +void ConfigureServices(IServiceCollection services) +{ + services.ConfigureOptions(); + services.ConfigureCosmos(builder => + { + builder.AddContainer(ContainerInitializer.Name); + builder.UseHostedService(); + }); + + services.ConfigureOptions(); + services.AddEventStore( + builder => + { + builder.UseCosmosDb(); + builder.UseEvents(catalogBuilder => catalogBuilder.FromAssembly()); + builder.UseCQRS( + c => + { + c.AddInitialization( + 4000, + serviceProvider => serviceProvider + .GetRequiredService() + .InitializeAsync(CancellationToken.None)); + + c.AddCommandsFromAssembly(); + c.AddProjectionJob(nameof(SampleProjection)); + }); + }); + + services.AddHostedService(); +} + +await Host.CreateDefaultBuilder() + .ConfigureServices(ConfigureServices) + .RunConsoleAsync(); + +public class ConfigureCosmosOptions : IConfigureOptions +{ + public void Configure(CosmosOptions options) + { + options.UseCosmosEmulator(); + options.DatabaseName = "CQRS"; + } +} + +public class ConfigureEventStoreOptions : IConfigureOptions +{ + public void Configure(EventStoreClientOptions options) + { + options.UseCosmosEmulator(); + options.EventStoreDatabaseId = "CQRS"; + } +} \ No newline at end of file diff --git a/sample/GettingStarted/Projections/ContainerInitializer.cs b/sample/GettingStarted/Projections/ContainerInitializer.cs new file mode 100644 index 0000000..d68cf43 --- /dev/null +++ b/sample/GettingStarted/Projections/ContainerInitializer.cs @@ -0,0 +1,25 @@ +using Atc.Cosmos; +using Microsoft.Azure.Cosmos; + +namespace GettingStarted.Storage; + +public class ContainerInitializer : ICosmosContainerInitializer +{ + public const string Name = "read-models"; + + public Task InitializeAsync(Database database, CancellationToken cancellationToken) + { + var options = new ContainerProperties + { + IndexingPolicy = new IndexingPolicy + { + Automatic = true, + IndexingMode = IndexingMode.Consistent, + }, + PartitionKeyPath = "/pk", + Id = Name, + }; + + return database.CreateContainerIfNotExistsAsync(options, cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/sample/GettingStarted/Projections/SampleProjection.cs b/sample/GettingStarted/Projections/SampleProjection.cs new file mode 100644 index 0000000..789bbd0 --- /dev/null +++ b/sample/GettingStarted/Projections/SampleProjection.cs @@ -0,0 +1,66 @@ +using Atc.Cosmos; +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted.Storage; + +[ProjectionFilter(SampleEventStreamId.FilterIncludeAllEvents)] +public class SampleProjection( + ICosmosReader reader, + ICosmosWriter writer) : + IProjection, + IConsumeEvent, + IConsumeEvent, + IConsumeEvent, + IConsumeEvent +{ + private SampleReadModel view = null!; + private bool deleted = false; + + public Task FailedAsync( + Exception exception, + CancellationToken cancellationToken) => + Task.FromResult(ProjectionAction.Continue); + + public async Task InitializeAsync( + EventStreamId id, + CancellationToken cancellationToken) + { + var streamId = new SampleEventStreamId(id); + view = await reader.FindAsync( + streamId.Id, + streamId.Id, + cancellationToken) ?? + new SampleReadModel + { + Id = streamId.Id, + }; + } + + public Task CompleteAsync( + CancellationToken cancellationToken) => + deleted + ? writer.TryDeleteAsync(view!.Id, view!.PartitionKey, cancellationToken) + : writer.WriteAsync(view, cancellationToken); + + public void Consume(AddedEvent evt, EventMetadata metadata) + { + view.Name = evt.Name; + view.Address = evt.Address; + deleted = false; + } + + public void Consume(NameChangedEvent evt, EventMetadata metadata) + { + view.Name = evt.NewName; + } + + public void Consume(AddressChangedEvent evt, EventMetadata metadata) + { + view.Address = evt.NewAddress; + } + + public void Consume(DeletedEvent evt, EventMetadata metadata) + { + deleted = true; + } +} \ No newline at end of file diff --git a/sample/GettingStarted/Projections/SampleReadModel.cs b/sample/GettingStarted/Projections/SampleReadModel.cs new file mode 100644 index 0000000..1d8dee4 --- /dev/null +++ b/sample/GettingStarted/Projections/SampleReadModel.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Atc.Cosmos; + +namespace GettingStarted.Storage; + +public class SampleReadModel : CosmosResource +{ + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("pk")] + public string PartitionKey { get; set; } + + public string Name { get; set; } + + public string Address { get; set; } + + protected override string GetDocumentId() => Id; + + protected override string GetPartitionKey() => PartitionKey; +} \ No newline at end of file diff --git a/sample/GettingStarted/SampleEventStreamId.cs b/sample/GettingStarted/SampleEventStreamId.cs new file mode 100644 index 0000000..0ffa25a --- /dev/null +++ b/sample/GettingStarted/SampleEventStreamId.cs @@ -0,0 +1,32 @@ +using Atc.Cosmos.EventStore.Cqrs; + +namespace GettingStarted; + +public sealed class SampleEventStreamId : EventStreamId, IEquatable +{ + private const string TypeName = "sample"; + public const string FilterIncludeAllEvents = TypeName + ".*"; + + public SampleEventStreamId(string id) + : base(TypeName, id) + { + Id = id; + } + + public SampleEventStreamId(EventStreamId id) + : base(id.Parts.ToArray()) + { + Id = id.Parts[1]; + } + + public string Id { get; } + + public override bool Equals(object? obj) + => Equals(obj as SampleEventStreamId); + + public bool Equals(SampleEventStreamId? other) + => other != null && Value == other.Value; + + public override int GetHashCode() + => HashCode.Combine(Value); +} \ No newline at end of file From 22fabdd195c6ebf0bd342a43133f2cb610e609e3 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 5 Dec 2024 12:06:12 +0100 Subject: [PATCH 2/2] chore: remove directory props/target for samples --- sample/.editorconfig | 80 ---------------------------------- sample/directory.build.props | 12 ----- sample/directory.build.targets | 23 ---------- 3 files changed, 115 deletions(-) delete mode 100644 sample/.editorconfig delete mode 100644 sample/directory.build.props delete mode 100644 sample/directory.build.targets diff --git a/sample/.editorconfig b/sample/.editorconfig deleted file mode 100644 index d6c0e20..0000000 --- a/sample/.editorconfig +++ /dev/null @@ -1,80 +0,0 @@ -# ATC coding rules - https://github.com/atc-net/atc-coding-rules -# Version: 1.0.0 -# Updated: 04-12-2020 -# Location: Sample -# Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options - -########################################## -# Code Analyzers Rules -########################################## -[*.{cs,csx,cake}] - -# AsyncFixer -# http://www.asyncfixer.com - - -# Asyncify -# https://github.com/hvanbakel/Asyncify-CSharp - - -# Meziantou -# https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm - -# Microsoft - Code Analysis -# https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ - - -# Microsoft - FxCop -# https://github.com/dotnet/roslyn-analyzers - - -# Microsoft - Threading -# https://github.com/microsoft/vs-threading/blob/master/doc/analyzers/index.md - - -# SecurityCodeScan -# https://security-code-scan.github.io/ - - -# StyleCop -# https://github.com/DotNetAnalyzers/StyleCopAnalyzers - - -########################################## -# Custom - Code Analyzers Rules -########################################## - -# AsyncFixer -# http://www.asyncfixer.com - - -# Asyncify -# https://github.com/hvanbakel/Asyncify-CSharp - - -# Meziantou -# https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm - -# Microsoft - Code Analysis -# https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ - - -# Microsoft - FxCop -# https://github.com/dotnet/roslyn-analyzers - - -# Microsoft - Threading -# https://github.com/microsoft/vs-threading/blob/master/doc/analyzers/index.md - - -# SecurityCodeScan -# https://security-code-scan.github.io/ - - -# StyleCop -# https://github.com/DotNetAnalyzers/StyleCopAnalyzers - - -########################################## -# Custom - Code Analyzers Rules -########################################## \ No newline at end of file diff --git a/sample/directory.build.props b/sample/directory.build.props deleted file mode 100644 index 6f820a0..0000000 --- a/sample/directory.build.props +++ /dev/null @@ -1,12 +0,0 @@ - - - - - false - - - - - - - \ No newline at end of file diff --git a/sample/directory.build.targets b/sample/directory.build.targets deleted file mode 100644 index 467d7af..0000000 --- a/sample/directory.build.targets +++ /dev/null @@ -1,23 +0,0 @@ - - - - - latest - $(NoWarn);1591 - - - true - bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml - - - full - true - - - - - - \ No newline at end of file