Skip to content
Open
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
403 changes: 403 additions & 0 deletions samples/.gitignore

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions samples/BankAccountES/Account.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace BankAccountES;

// --- Domain Events ---

public record AccountOpened(Guid AccountId, Guid ClientId, string Currency);
public record FundsDeposited(Guid AccountId, decimal Amount, decimal NewBalance);
public record FundsWithdrawn(Guid AccountId, decimal Amount, decimal NewBalance);

// --- Aggregate (event-sourced via Marten) ---

/// <summary>
/// Bank account aggregate. Marten rebuilds state by calling Apply methods
/// when loading from the event stream. Business rules are enforced in
/// Wolverine handler methods that return events.
/// </summary>
public class Account
{
public Guid Id { get; set; }
public Guid ClientId { get; set; }
public string Currency { get; set; } = "USD";
public decimal Balance { get; set; }

public void Apply(AccountOpened e)
{
Id = e.AccountId;
ClientId = e.ClientId;
Currency = e.Currency;
}

public void Apply(FundsDeposited e)
{
Balance = e.NewBalance;
}

public void Apply(FundsWithdrawn e)
{
Balance = e.NewBalance;
}
}
27 changes: 27 additions & 0 deletions samples/BankAccountES/BankAccountES.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="BankAccountES.Tests" />
</ItemGroup>

<ItemGroup>
<Compile Remove="Tests\**" />
<Content Remove="Tests\**" />
<None Remove="Tests\**" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="WolverineFx.FluentValidation" Version="5.30.0" />
<PackageReference Include="WolverineFx.Http" Version="5.30.0" />
<PackageReference Include="WolverineFx.Http.FluentValidation" Version="5.30.0" />
<PackageReference Include="WolverineFx.Marten" Version="5.30.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>

</Project>
48 changes: 48 additions & 0 deletions samples/BankAccountES/BankAccountES.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BankAccountES", "BankAccountES.csproj", "{A087CBDE-238F-45A6-B8E1-9A068DF7F951}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Debug|x64.ActiveCfg = Debug|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Debug|x64.Build.0 = Debug|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Debug|x86.ActiveCfg = Debug|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Debug|x86.Build.0 = Debug|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Release|Any CPU.Build.0 = Release|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Release|x64.ActiveCfg = Release|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Release|x64.Build.0 = Release|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Release|x86.ActiveCfg = Release|Any CPU
{A087CBDE-238F-45A6-B8E1-9A068DF7F951}.Release|x86.Build.0 = Release|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Debug|x64.ActiveCfg = Debug|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Debug|x64.Build.0 = Debug|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Debug|x86.ActiveCfg = Debug|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Debug|x86.Build.0 = Debug|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Release|Any CPU.Build.0 = Release|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Release|x64.ActiveCfg = Release|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Release|x64.Build.0 = Release|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Release|x86.ActiveCfg = Release|Any CPU
{9A2A14D6-41F8-4140-B2DA-D76DE0CF16B9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
28 changes: 28 additions & 0 deletions samples/BankAccountES/Client.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace BankAccountES;

// --- Domain Events ---

public record ClientEnrolled(Guid ClientId, string Name, string Email);
public record ClientUpdated(Guid ClientId, string Name, string Email);

// --- Aggregate (event-sourced via Marten) ---

public class Client
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;

public void Apply(ClientEnrolled e)
{
Id = e.ClientId;
Name = e.Name;
Email = e.Email;
}

public void Apply(ClientUpdated e)
{
Name = e.Name;
Email = e.Email;
}
}
28 changes: 28 additions & 0 deletions samples/BankAccountES/DepositFunds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using FluentValidation;
using Wolverine.Http;
using Wolverine.Marten;

namespace BankAccountES;

public record DepositFunds(Guid AccountId, decimal Amount)
{
public class Validator : AbstractValidator<DepositFunds>
{
public Validator()
{
RuleFor(x => x.AccountId).NotEmpty();
RuleFor(x => x.Amount).GreaterThan(0);
}
}
}

public static class DepositFundsEndpoint
{
[WolverinePost("/api/accounts/{accountId}/deposits")]
[AggregateHandler]
public static (IResult, FundsDeposited) Post(DepositFunds command, Account account)
{
var newBalance = account.Balance + command.Amount;
return (Results.NoContent(), new FundsDeposited(command.AccountId, command.Amount, newBalance));
}
}
33 changes: 33 additions & 0 deletions samples/BankAccountES/EnrollClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using FluentValidation;
using Marten;
using Wolverine.Http;

namespace BankAccountES;

public record EnrollClient(string Name, string Email)
{
public class Validator : AbstractValidator<EnrollClient>
{
public Validator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Email).NotEmpty().EmailAddress();
}
}
}

public static class EnrollClientEndpoint
{
[WolverinePost("/api/clients")]
public static Client Post(EnrollClient command, IDocumentSession session)
{
var clientId = Guid.NewGuid();
var evt = new ClientEnrolled(clientId, command.Name, command.Email);

session.Events.StartStream<Client>(clientId, evt);

var client = new Client();
client.Apply(evt);
return client;
}
}
70 changes: 70 additions & 0 deletions samples/BankAccountES/Features/BankAccount.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
Feature: Bank Account ES

Scenario: Enroll a new client
When I enroll a client named "Alice Smith" with email "alice@test.com"
Then the client id should be valid
And the client name should be "Alice Smith"
And the client email should be "alice@test.com"

Scenario: Update a client
Given a client named "Jane Doe" with email "jane@test.com" is enrolled
When I update the client name to "Updated Name" and email to "updated@test.com"
Then the response status should be 204
When I retrieve the client
Then the client name should be "Updated Name"
And the client email should be "updated@test.com"

Scenario: Open an account
Given a client named "Jane Doe" with email "jane@test.com" is enrolled
When I open a "USD" account for the client
Then the account id should be valid
And the account client id should match the client
And the account currency should be "USD"
And the account balance should be 0

Scenario: Open account with invalid client returns 400
When I try to open an account for a non-existent client in "USD"
Then the response status should be 400

Scenario: Deposit funds
Given a client named "Jane Doe" with email "jane@test.com" is enrolled
And a "USD" account is opened for the client
When I deposit 500 into the account
Then the account balance should be 500

Scenario: Withdraw funds
Given a client named "Jane Doe" with email "jane@test.com" is enrolled
And a "USD" account is opened for the client
And 1000 has been deposited into the account
When I withdraw 300 from the account
Then the account balance should be 700

Scenario: Withdraw with insufficient funds returns 400
Given a client named "Jane Doe" with email "jane@test.com" is enrolled
And a "USD" account is opened for the client
And 100 has been deposited into the account
When I try to withdraw 500 from the account
Then the response status should be 400

Scenario: Get transaction history
Given a client named "Jane Doe" with email "jane@test.com" is enrolled
And a "USD" account is opened for the client
And 1000 has been deposited into the account
And 200 has been withdrawn from the account
When I get the transaction history for the account
Then there should be 2 transactions
And transaction 1 should be a "Deposit" of 1000
And transaction 2 should be a "Withdrawal" of 200
And the transaction history balance should be 800

Scenario: Get a client by id
When I enroll a client named "Bob" with email "bob@test.com"
And I retrieve the client
Then the client name should be "Bob"

Scenario: Get client accounts
Given a client named "Jane Doe" with email "jane@test.com" is enrolled
And a "USD" account is opened for the client
And a "EUR" account is opened for the client
When I get the accounts for the client
Then there should be 2 client accounts
37 changes: 37 additions & 0 deletions samples/BankAccountES/OpenAccount.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using FluentValidation;
using Marten;
using Wolverine.Http;
using Wolverine.Persistence;

namespace BankAccountES;

public record OpenAccount(Guid ClientId, string Currency = "USD")
{
public class Validator : AbstractValidator<OpenAccount>
{
public Validator()
{
RuleFor(x => x.ClientId).NotEmpty();
RuleFor(x => x.Currency).NotEmpty().Length(3);
}
}
}

public static class OpenAccountEndpoint
{
[WolverinePost("/api/accounts")]
public static Account Post(
OpenAccount command,
[Entity("ClientId", Required = true, OnMissing = OnMissing.ProblemDetailsWith400)] Client client,
IDocumentSession session)
{
var accountId = Guid.NewGuid();
var evt = new AccountOpened(accountId, command.ClientId, command.Currency);

session.Events.StartStream<Account>(accountId, evt);

var account = new Account();
account.Apply(evt);
return account;
}
}
57 changes: 57 additions & 0 deletions samples/BankAccountES/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using BankAccountES;
using JasperFx.Events.Projections;
using Marten;
using Marten.Events.Projections;
using Wolverine;
using Wolverine.FluentValidation;
using Wolverine.Http;
using Wolverine.Http.FluentValidation;
using Wolverine.Marten;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddWolverineHttp();

builder.Services.AddMarten(opts =>
{
var connectionString = builder.Configuration.GetConnectionString("Marten")
?? "Host=localhost;Port=5433;Database=bank_account;Username=postgres;Password=postgres";

opts.Connection(connectionString);
opts.DatabaseSchemaName = "bank";

// Inline snapshot projections — aggregates are always up-to-date
opts.Projections.Snapshot<Account>(SnapshotLifecycle.Inline);
opts.Projections.Snapshot<Client>(SnapshotLifecycle.Inline);

// Transaction history projection — builds read model from deposit/withdrawal events
opts.Projections.Add<AccountTransactionsProjection>(ProjectionLifecycle.Inline);
})
.IntegrateWithWolverine()
.UseLightweightSessions();

builder.Host.UseWolverine(opts =>
{
opts.Discovery.IncludeAssembly(typeof(Program).Assembly);
opts.Policies.AutoApplyTransactions();
opts.UseFluentValidation();
opts.ServiceName = "BankAccount";
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.MapWolverineEndpoints(opts =>
{
opts.UseFluentValidationProblemDetailMiddleware();
});

await app.RunAsync();
Loading