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
79 changes: 79 additions & 0 deletions bobcat.sln
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionIt
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{A46A592A-6C62-43CA-BFD4-8415357987BA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CqrsMinimalApi", "samples\CqrsMinimalApi\CqrsMinimalApi.csproj", "{E52F3990-7155-4AC0-9B51-C68EFF6EA848}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitectureTodos", "samples\CleanArchitectureTodos\CleanArchitectureTodos.csproj", "{7FD3D2D2-6184-4A75-B5B0-1F7E803F4A79}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BankAccountES", "samples\BankAccountES\BankAccountES.csproj", "{4D026C36-573B-4C28-9D85-56046553E64D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EcommerceMicroservices", "samples\EcommerceMicroservices\EcommerceMicroservices.csproj", "{17CB5586-FA40-4201-B442-0A38508E8DAB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutboxDemo", "samples\OutboxDemo\OutboxDemo.csproj", "{E0516B33-6E6E-4320-8837-DFCD1684A599}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoreSpeakers", "samples\MoreSpeakers\MoreSpeakers.csproj", "{9E244D87-789D-4581-8658-7D447E42BE16}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BookingMonolith", "samples\BookingMonolith\BookingMonolith.csproj", "{E3ED754F-75BD-4B21-B402-BE03BBE5F3D8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EcommerceModularMonolith", "samples\EcommerceModularMonolith\EcommerceModularMonolith.csproj", "{91DA5934-B536-4362-A439-E7785B7193C9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeetingGroupMonolith", "samples\MeetingGroupMonolith\MeetingGroupMonolith.csproj", "{D6672F90-0BF2-4DD6-8446-16E5A65EA374}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaymentsMonolith", "samples\PaymentsMonolith\PaymentsMonolith.csproj", "{CA2B415A-DB0C-4556-B52F-AE79FB20F179}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectManagement", "samples\ProjectManagement\ProjectManagement.csproj", "{422A1C15-C46B-4420-A32A-CD96A23DA3DC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bobcat.Generators", "src\Bobcat.Generators\Bobcat.Generators.csproj", "{99613FD0-C608-45B7-B138-CEBFB67F485C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bobcat.Alba", "src\Bobcat.Alba\Bobcat.Alba.csproj", "{6C32FE2C-6246-40C5-A6B1-9D908AC6B91B}"
Expand Down Expand Up @@ -132,6 +156,50 @@ Global
{2882FBEE-E70F-4DEA-A408-1106D69289B8}.Release|x64.Build.0 = Release|Any CPU
{2882FBEE-E70F-4DEA-A408-1106D69289B8}.Release|x86.ActiveCfg = Release|Any CPU
{2882FBEE-E70F-4DEA-A408-1106D69289B8}.Release|x86.Build.0 = Release|Any CPU
{E52F3990-7155-4AC0-9B51-C68EFF6EA848}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E52F3990-7155-4AC0-9B51-C68EFF6EA848}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E52F3990-7155-4AC0-9B51-C68EFF6EA848}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E52F3990-7155-4AC0-9B51-C68EFF6EA848}.Release|Any CPU.Build.0 = Release|Any CPU
{7FD3D2D2-6184-4A75-B5B0-1F7E803F4A79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7FD3D2D2-6184-4A75-B5B0-1F7E803F4A79}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7FD3D2D2-6184-4A75-B5B0-1F7E803F4A79}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7FD3D2D2-6184-4A75-B5B0-1F7E803F4A79}.Release|Any CPU.Build.0 = Release|Any CPU
{4D026C36-573B-4C28-9D85-56046553E64D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4D026C36-573B-4C28-9D85-56046553E64D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D026C36-573B-4C28-9D85-56046553E64D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D026C36-573B-4C28-9D85-56046553E64D}.Release|Any CPU.Build.0 = Release|Any CPU
{17CB5586-FA40-4201-B442-0A38508E8DAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17CB5586-FA40-4201-B442-0A38508E8DAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17CB5586-FA40-4201-B442-0A38508E8DAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17CB5586-FA40-4201-B442-0A38508E8DAB}.Release|Any CPU.Build.0 = Release|Any CPU
{E0516B33-6E6E-4320-8837-DFCD1684A599}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E0516B33-6E6E-4320-8837-DFCD1684A599}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E0516B33-6E6E-4320-8837-DFCD1684A599}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E0516B33-6E6E-4320-8837-DFCD1684A599}.Release|Any CPU.Build.0 = Release|Any CPU
{9E244D87-789D-4581-8658-7D447E42BE16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E244D87-789D-4581-8658-7D447E42BE16}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E244D87-789D-4581-8658-7D447E42BE16}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E244D87-789D-4581-8658-7D447E42BE16}.Release|Any CPU.Build.0 = Release|Any CPU
{E3ED754F-75BD-4B21-B402-BE03BBE5F3D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E3ED754F-75BD-4B21-B402-BE03BBE5F3D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E3ED754F-75BD-4B21-B402-BE03BBE5F3D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E3ED754F-75BD-4B21-B402-BE03BBE5F3D8}.Release|Any CPU.Build.0 = Release|Any CPU
{91DA5934-B536-4362-A439-E7785B7193C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91DA5934-B536-4362-A439-E7785B7193C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91DA5934-B536-4362-A439-E7785B7193C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91DA5934-B536-4362-A439-E7785B7193C9}.Release|Any CPU.Build.0 = Release|Any CPU
{D6672F90-0BF2-4DD6-8446-16E5A65EA374}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D6672F90-0BF2-4DD6-8446-16E5A65EA374}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D6672F90-0BF2-4DD6-8446-16E5A65EA374}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D6672F90-0BF2-4DD6-8446-16E5A65EA374}.Release|Any CPU.Build.0 = Release|Any CPU
{CA2B415A-DB0C-4556-B52F-AE79FB20F179}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CA2B415A-DB0C-4556-B52F-AE79FB20F179}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA2B415A-DB0C-4556-B52F-AE79FB20F179}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CA2B415A-DB0C-4556-B52F-AE79FB20F179}.Release|Any CPU.Build.0 = Release|Any CPU
{422A1C15-C46B-4420-A32A-CD96A23DA3DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{422A1C15-C46B-4420-A32A-CD96A23DA3DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{422A1C15-C46B-4420-A32A-CD96A23DA3DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{422A1C15-C46B-4420-A32A-CD96A23DA3DC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -142,5 +210,16 @@ Global
{5DA0C58B-D959-4877-BA46-A61CD63CB298} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{952DC4A7-F0D2-495E-AC71-BB074BB35E58} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2882FBEE-E70F-4DEA-A408-1106D69289B8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E52F3990-7155-4AC0-9B51-C68EFF6EA848} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{7FD3D2D2-6184-4A75-B5B0-1F7E803F4A79} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{4D026C36-573B-4C28-9D85-56046553E64D} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{17CB5586-FA40-4201-B442-0A38508E8DAB} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{E0516B33-6E6E-4320-8837-DFCD1684A599} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{9E244D87-789D-4581-8658-7D447E42BE16} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{E3ED754F-75BD-4B21-B402-BE03BBE5F3D8} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{91DA5934-B536-4362-A439-E7785B7193C9} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{D6672F90-0BF2-4DD6-8446-16E5A65EA374} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{CA2B415A-DB0C-4556-B52F-AE79FB20F179} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
{422A1C15-C46B-4420-A32A-CD96A23DA3DC} = {A46A592A-6C62-43CA-BFD4-8415357987BA}
EndGlobalSection
EndGlobal
152 changes: 152 additions & 0 deletions samples/BankAccountES/AccountFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System.Text.Json;
using Alba;
using Bobcat;
using Bobcat.Runtime;

namespace BankAccountES;

[FixtureTitle("Bank Account Event Sourcing")]
public class AccountFixture : Fixture
{
private IAlbaHost _host = null!;
private int _lastStatusCode;
private AccountView? _lastAccount;
private DepositView? _lastDeposit;
private string _currentAccountId = string.Empty;
private List<TransactionView> _lastTransactions = [];

private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);

public override Task SetUp()
{
_host = Context.GetResource<AlbaResource>().AlbaHost;
return Task.CompletedTask;
}

[When("I open an account for {string} with initial deposit of {int}")]
public async Task OpenAccount(string owner, int initialDeposit)
{
var result = await _host.Scenario(s =>
{
s.Post.Json(new { owner, initialDeposit = (decimal)initialDeposit }).ToUrl("/api/accounts");
s.StatusCodeShouldBe(201);
});
_lastStatusCode = result.Context.Response.StatusCode;
var json = await result.ReadAsTextAsync();
_lastAccount = JsonSerializer.Deserialize<AccountView>(json, JsonOpts);
if (_lastAccount is not null) _currentAccountId = _lastAccount.Id;
}

[Given("an account exists for {string} with initial deposit of {int}")]
public async Task AccountExists(string owner, int initialDeposit)
{
var result = await _host.Scenario(s =>
{
s.Post.Json(new { owner, initialDeposit = (decimal)initialDeposit }).ToUrl("/api/accounts");
s.StatusCodeShouldBe(201);
});
var json = await result.ReadAsTextAsync();
var account = JsonSerializer.Deserialize<AccountView>(json, JsonOpts)!;
_currentAccountId = account.Id;
}

[When("I get the account details")]
public async Task GetAccountDetails()
{
var result = await _host.Scenario(s => s.Get.Url($"/api/accounts/{_currentAccountId}"));
_lastStatusCode = result.Context.Response.StatusCode;
var json = await result.ReadAsTextAsync();
_lastAccount = JsonSerializer.Deserialize<AccountView>(json, JsonOpts);
}

[When("I deposit {int} into the account")]
public async Task DepositMoney(int amount)
{
var result = await _host.Scenario(s =>
{
s.Post.Json(new { amount = (decimal)amount }).ToUrl($"/api/accounts/{_currentAccountId}/deposit");
});
_lastStatusCode = result.Context.Response.StatusCode;
var json = await result.ReadAsTextAsync();
_lastDeposit = JsonSerializer.Deserialize<DepositView>(json, JsonOpts);
if (_lastDeposit is not null)
_lastAccount = new AccountView(_lastDeposit.Id, _lastDeposit.Owner, _lastDeposit.Balance);
}

[When("I withdraw {int} from the account")]
public async Task WithdrawMoney(int amount)
{
var result = await _host.Scenario(s =>
{
s.Post.Json(new { amount = (decimal)amount }).ToUrl($"/api/accounts/{_currentAccountId}/withdraw");
});
_lastStatusCode = result.Context.Response.StatusCode;
var json = await result.ReadAsTextAsync();
if (_lastStatusCode == 200)
_lastAccount = JsonSerializer.Deserialize<AccountView>(json, JsonOpts);
}

[When("I attempt to withdraw {int} from the account")]
public async Task AttemptWithdraw(int amount)
{
var result = await _host.Scenario(s =>
{
s.Post.Json(new { amount = (decimal)amount }).ToUrl($"/api/accounts/{_currentAccountId}/withdraw");
s.StatusCodeShouldBe(400);
});
_lastStatusCode = result.Context.Response.StatusCode;
}

[When("I get the transaction history")]
public async Task GetTransactionHistory()
{
var result = await _host.Scenario(s => s.Get.Url($"/api/accounts/{_currentAccountId}/transactions"));
_lastStatusCode = result.Context.Response.StatusCode;
var json = await result.ReadAsTextAsync();
_lastTransactions = JsonSerializer.Deserialize<List<TransactionView>>(json, JsonOpts) ?? [];
}

[Then("the response is 200 OK")]
public void ResponseIs200() => AssertStatus(200);

[Then("the response is 201 Created")]
public void ResponseIs201() => AssertStatus(201);

[Then("the response is 400 Bad Request")]
public void ResponseIs400() => AssertStatus(400);

[Then("the account owner is {string}")]
public void AccountOwnerIs(string expected)
{
if (_lastAccount?.Owner != expected)
throw new Exception($"Expected owner '{expected}' but got '{_lastAccount?.Owner}'.");
}

[Then("the account balance is {int}")]
public void AccountBalanceIs(int expected)
{
if (_lastAccount?.Balance != (decimal)expected)
throw new Exception($"Expected balance {expected} but got {_lastAccount?.Balance}.");
}

[Then("the transaction history has {int} entries")]
public void TransactionHistoryHasEntries(int count)
{
if (_lastTransactions.Count != count)
throw new Exception($"Expected {count} transactions but got {_lastTransactions.Count}.");
}

[Then("the transaction history contains a deposit of {int}")]
public void TransactionHistoryContainsDeposit(int amount)
{
var found = _lastTransactions.Any(t => t.Type == "Deposit" && t.Amount == (decimal)amount);
if (!found)
throw new Exception($"Expected a deposit of {amount} in transaction history.");
}

private void AssertStatus(int expected)
{
if (_lastStatusCode != expected)
throw new Exception($"Expected HTTP {expected} but got {_lastStatusCode}.");
}
}
113 changes: 113 additions & 0 deletions samples/BankAccountES/AppBootstrap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Collections.Concurrent;

namespace BankAccountES;

public record AccountEvent(string Type, decimal Amount, DateTimeOffset Timestamp);

public record AccountState(string Id, string Owner, decimal Balance, List<AccountEvent> Events);

public record OpenAccountRequest(string Owner, decimal InitialDeposit);
public record DepositRequest(decimal Amount);
public record WithdrawRequest(decimal Amount);

public record AccountView(string Id, string Owner, decimal Balance);
public record DepositView(string Id, string Owner, decimal Balance, decimal Amount);
public record TransactionView(string Type, decimal Amount, DateTimeOffset Timestamp);

public static class AccountStore
{
private static readonly ConcurrentDictionary<string, AccountState> _accounts = new();
private static int _nextId = 1;

public static void Reset()
{
_accounts.Clear();
_nextId = 1;
}

public static AccountView Open(OpenAccountRequest req)
{
var id = $"ACC{_nextId++:D4}";
var events = new List<AccountEvent>
{
new("Opened", req.InitialDeposit, DateTimeOffset.UtcNow)
};
var state = new AccountState(id, req.Owner, req.InitialDeposit, events);
_accounts[id] = state;
return new AccountView(state.Id, state.Owner, state.Balance);
}

public static AccountState? GetById(string id) =>
_accounts.TryGetValue(id, out var a) ? a : null;

public static (AccountState state, decimal amount)? Deposit(string id, DepositRequest req)
{
if (!_accounts.TryGetValue(id, out var existing)) return null;
var evt = new AccountEvent("Deposit", req.Amount, DateTimeOffset.UtcNow);
var updated = existing with
{
Balance = existing.Balance + req.Amount,
Events = [.. existing.Events, evt]
};
_accounts[id] = updated;
return (updated, req.Amount);
}

public static (AccountState state, bool success)? Withdraw(string id, WithdrawRequest req)
{
if (!_accounts.TryGetValue(id, out var existing)) return null;
if (existing.Balance < req.Amount) return (existing, false);
var evt = new AccountEvent("Withdrawal", req.Amount, DateTimeOffset.UtcNow);
var updated = existing with
{
Balance = existing.Balance - req.Amount,
Events = [.. existing.Events, evt]
};
_accounts[id] = updated;
return (updated, true);
}
}

public static class AppBootstrap
{
public static void MapRoutes(WebApplication app)
{
app.MapPost("/api/accounts", (OpenAccountRequest req) =>
{
var view = AccountStore.Open(req);
return Results.Created($"/api/accounts/{view.Id}", view);
});

app.MapGet("/api/accounts/{id}", (string id) =>
{
var account = AccountStore.GetById(id);
if (account is null) return Results.NotFound();
return Results.Ok(new AccountView(account.Id, account.Owner, account.Balance));
});

app.MapPost("/api/accounts/{id}/deposit", (string id, DepositRequest req) =>
{
var result = AccountStore.Deposit(id, req);
if (result is null) return Results.NotFound();
var (state, amount) = result.Value;
return Results.Ok(new DepositView(state.Id, state.Owner, state.Balance, amount));
});

app.MapPost("/api/accounts/{id}/withdraw", (string id, WithdrawRequest req) =>
{
var result = AccountStore.Withdraw(id, req);
if (result is null) return Results.NotFound();
var (state, success) = result.Value;
if (!success) return Results.BadRequest(new { error = "Insufficient funds", balance = state.Balance });
return Results.Ok(new AccountView(state.Id, state.Owner, state.Balance));
});

app.MapGet("/api/accounts/{id}/transactions", (string id) =>
{
var account = AccountStore.GetById(id);
if (account is null) return Results.NotFound();
var transactions = account.Events.Select(e => new TransactionView(e.Type, e.Amount, e.Timestamp));
return Results.Ok(transactions);
});
}
}
23 changes: 23 additions & 0 deletions samples/BankAccountES/BankAccountES.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<RootNamespace>BankAccountES</RootNamespace>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../../src/Bobcat/Bobcat.csproj" />
<ProjectReference Include="../../src/Bobcat.Alba/Bobcat.Alba.csproj" />
<ProjectReference Include="../../src/Bobcat.Generators/Bobcat.Generators.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

<ItemGroup>
<AdditionalFiles Include="Features/**/*.feature" />
</ItemGroup>

</Project>
Loading