From 0376b58a333efc6c176eacf95494b225c9bf3d5e Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Wed, 15 Jul 2026 13:36:37 +0200 Subject: [PATCH] Add Domain project and shifted handlers to application. --- .../ApplyFeeHandler.cs | 35 +++--- .../FinancialApi.Application.csproj | 10 ++ .../TransferFundsHandler.cs | 20 +-- .../Account.cs | 18 +-- .../BalancedJournal.cs | 2 +- .../EntryType.cs | 2 +- .../FinancialApi.Domain.csproj | 9 ++ .../JournalEntry.cs | 6 +- .../JournalLine.cs | 2 +- .../AccountDbContext.cs | 2 +- .../AccountQuery.cs | 13 ++ .../FinancialApi.Infrastructure.csproj | 1 + ...15100428_UpdateAccountBalances.Designer.cs | 115 ++++++++++++++++++ .../FinancialApi/DomainExceptionHandler.cs | 32 +++++ .../FinancialApi/Program.cs | 8 ++ .../a-frame-architecture.sln | 6 + 16 files changed, 229 insertions(+), 52 deletions(-) rename wolverine/a-frame-architecture/{FinancialApi.Infrastructure => FinancialApi.Application}/ApplyFeeHandler.cs (51%) rename wolverine/a-frame-architecture/{FinancialApi.Infrastructure => FinancialApi.Application}/TransferFundsHandler.cs (73%) rename wolverine/a-frame-architecture/{FinancialApi.Application => FinancialApi.Domain}/Account.cs (77%) rename wolverine/a-frame-architecture/{FinancialApi.Application => FinancialApi.Domain}/BalancedJournal.cs (95%) rename wolverine/a-frame-architecture/{FinancialApi.Application => FinancialApi.Domain}/EntryType.cs (57%) create mode 100644 wolverine/a-frame-architecture/FinancialApi.Domain/FinancialApi.Domain.csproj rename wolverine/a-frame-architecture/{FinancialApi.Application => FinancialApi.Domain}/JournalEntry.cs (85%) rename wolverine/a-frame-architecture/{FinancialApi.Application => FinancialApi.Domain}/JournalLine.cs (67%) create mode 100644 wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountQuery.cs create mode 100644 wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.Designer.cs create mode 100644 wolverine/a-frame-architecture/FinancialApi/DomainExceptionHandler.cs diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ApplyFeeHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/ApplyFeeHandler.cs similarity index 51% rename from wolverine/a-frame-architecture/FinancialApi.Infrastructure/ApplyFeeHandler.cs rename to wolverine/a-frame-architecture/FinancialApi.Application/ApplyFeeHandler.cs index 14c55c9..d787a34 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/ApplyFeeHandler.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Application/ApplyFeeHandler.cs @@ -1,28 +1,28 @@ -using FinancialApi.Application; -using Microsoft.EntityFrameworkCore; +using FinancialApi.Domain; using Wolverine.Attributes; using Wolverine.Persistence; -namespace FinancialApi.Infrastructure; +namespace FinancialApi.Application; public record ApplyFeeCommand(int AccountId, decimal Amount); -public record FeePair(Account CustomerAccount, Account RevenueAccount); - public record FeeAppliedEvent(int AccountId, decimal Amount); public static class ApplyFeeHandler { - public static async Task LoadAsync( + public static async Task<(Account, Account)> LoadAsync( ApplyFeeCommand cmd, - AccountDbContext db) + IAccountQuery query) { - var customer = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == cmd.AccountId); - var revenue = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == 99999); - return (customer == null || revenue == null) ? null : new FeePair(customer, revenue); + var source = await query.FindByIdAsync(cmd.AccountId); + var dest = await query.FindByIdAsync(99999); + if (source == null || dest == null) + { + throw new InvalidOperationException($"Cannot process transfer. Account(s) not found."); + } + return (source, dest); } - [Transactional] public static ( IStorageAction CustomerWrite, IStorageAction RevenueWrite, @@ -31,15 +31,16 @@ public static class ApplyFeeHandler ) Handle( ApplyFeeCommand cmd, - FeePair pair + Account source, + Account dest ) { - var updatedCustomer = pair.CustomerAccount.ApplyPosting(cmd.Amount, EntryType.Debit); - var updatedRevenue = pair.RevenueAccount.ApplyPosting(cmd.Amount, EntryType.Credit); + var updatedSource = source.ApplyPosting(cmd.Amount, EntryType.Debit); + var updatedRevenue = dest.ApplyPosting(cmd.Amount, EntryType.Credit); var lines = new List { - new(pair.CustomerAccount.Id, cmd.Amount, EntryType.Debit), - new(9999, cmd.Amount, EntryType.Credit) + new(updatedSource.Id, cmd.Amount, EntryType.Debit), + new(updatedRevenue.Id, cmd.Amount, EntryType.Credit) }; var journalEntry = BalancedJournal.Create(Guid.NewGuid(), $"Service Fee Applied: {cmd.Amount} to Account {cmd.AccountId}", @@ -48,7 +49,7 @@ public static class ApplyFeeHandler var @event = new FeeAppliedEvent(cmd.AccountId, cmd.Amount); return ( - Storage.Update(updatedCustomer), + Storage.Update(updatedSource), Storage.Update(updatedRevenue), Storage.Insert(journalEntry.Entry), @event diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj b/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj index 237d661..cc8c37d 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj +++ b/wolverine/a-frame-architecture/FinancialApi.Application/FinancialApi.Application.csproj @@ -6,4 +6,14 @@ enable + + + + + + + ..\..\..\..\..\..\.nuget\packages\wolverinefx\6.18.0\lib\net10.0\Wolverine.dll + + + diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/TransferFundsHandler.cs b/wolverine/a-frame-architecture/FinancialApi.Application/TransferFundsHandler.cs similarity index 73% rename from wolverine/a-frame-architecture/FinancialApi.Infrastructure/TransferFundsHandler.cs rename to wolverine/a-frame-architecture/FinancialApi.Application/TransferFundsHandler.cs index 448aba2..f1d333c 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/TransferFundsHandler.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Application/TransferFundsHandler.cs @@ -1,30 +1,32 @@ -using FinancialApi.Application; -using Microsoft.EntityFrameworkCore; +using FinancialApi.Domain; using Wolverine.Attributes; using Wolverine.Persistence; -namespace FinancialApi.Infrastructure; +namespace FinancialApi.Application; + +public interface IAccountQuery +{ + Task FindByIdAsync(int id); +} public record TransferFundsCommand(int SourceAccountId, int DestinationAccountId, decimal Amount); public record FundsTransferredEvent(int SourceAccountId, int DestinationAccountId, decimal Amount); -// A simple container to pass our loaded entities into the pure function public record TransferPair(Account Source, Account Destination); public static class TransferFundsHandler { - public static async Task LoadAsync(TransferFundsCommand cmd, AccountDbContext db) + public static async Task LoadAsync(TransferFundsCommand cmd, IAccountQuery query) { - var source = await db.Accounts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cmd.SourceAccountId); - var dest = await db.Accounts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cmd.DestinationAccountId); + var source = await query.FindByIdAsync(cmd.SourceAccountId); + var dest = await query.FindByIdAsync(cmd.DestinationAccountId); - if (source == null || dest == null) return null; // Wolverine drops into a 404/Problem if null + if (source == null || dest == null) return null; return new TransferPair(source, dest); } - [Transactional] public static ( IStorageAction SourceWrite, IStorageAction DestWrite, diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/Account.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/Account.cs similarity index 77% rename from wolverine/a-frame-architecture/FinancialApi.Application/Account.cs rename to wolverine/a-frame-architecture/FinancialApi.Domain/Account.cs index b8e0c6e..0d4d592 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/Account.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/Account.cs @@ -1,4 +1,4 @@ -namespace FinancialApi.Application; +namespace FinancialApi.Domain; public record Account(int Id, decimal Balance, string AccountType) { @@ -18,7 +18,6 @@ public record Account(int Id, decimal Balance, string AccountType) { newBalance = entryType == EntryType.Debit ? newBalance - amount : newBalance + amount; } - if (newBalance < 0 && AccountType == "Liability") { @@ -28,21 +27,6 @@ public record Account(int Id, decimal Balance, string AccountType) return this with { Balance = newBalance }; } - public Account ApplyFee(decimal amount) - { - if (amount <= 0) - { - throw new ArgumentException("Fee must be positive."); - } - - if (Balance - amount < 0) - { - throw new InvalidOperationException("Insufficient funds."); - } - - return this with { Balance = Balance - amount }; - } - public Account Debit(decimal amount) { if (amount <= 0) diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/BalancedJournal.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/BalancedJournal.cs similarity index 95% rename from wolverine/a-frame-architecture/FinancialApi.Application/BalancedJournal.cs rename to wolverine/a-frame-architecture/FinancialApi.Domain/BalancedJournal.cs index 1592967..6a1d154 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/BalancedJournal.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/BalancedJournal.cs @@ -1,4 +1,4 @@ -namespace FinancialApi.Application; +namespace FinancialApi.Domain; public record BalancedJournal { diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/EntryType.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/EntryType.cs similarity index 57% rename from wolverine/a-frame-architecture/FinancialApi.Application/EntryType.cs rename to wolverine/a-frame-architecture/FinancialApi.Domain/EntryType.cs index 8d485c4..4211801 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/EntryType.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/EntryType.cs @@ -1,4 +1,4 @@ -namespace FinancialApi.Application; +namespace FinancialApi.Domain; public enum EntryType { diff --git a/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialApi.Domain.csproj b/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialApi.Domain.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/FinancialApi.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/JournalEntry.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/JournalEntry.cs similarity index 85% rename from wolverine/a-frame-architecture/FinancialApi.Application/JournalEntry.cs rename to wolverine/a-frame-architecture/FinancialApi.Domain/JournalEntry.cs index 0bb571c..e1ce7eb 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/JournalEntry.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/JournalEntry.cs @@ -1,4 +1,4 @@ -namespace FinancialApi.Application; +namespace FinancialApi.Domain; public record JournalEntry { @@ -7,10 +7,6 @@ public record JournalEntry public DateTimeOffset CreatedAt { get; init; } public List Lines { get; init; } = []; - private JournalEntry() - { - } - internal JournalEntry(Guid id, string description, DateTimeOffset createdAt, List lines) { Lines = lines; diff --git a/wolverine/a-frame-architecture/FinancialApi.Application/JournalLine.cs b/wolverine/a-frame-architecture/FinancialApi.Domain/JournalLine.cs similarity index 67% rename from wolverine/a-frame-architecture/FinancialApi.Application/JournalLine.cs rename to wolverine/a-frame-architecture/FinancialApi.Domain/JournalLine.cs index 3ba41f8..41802c9 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Application/JournalLine.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Domain/JournalLine.cs @@ -1,3 +1,3 @@ -namespace FinancialApi.Application; +namespace FinancialApi.Domain; public record JournalLine(int AccountId, decimal Amount, EntryType Type); \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs index c2d911b..935af0f 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountDbContext.cs @@ -1,4 +1,4 @@ -using FinancialApi.Application; +using FinancialApi.Domain; using Microsoft.EntityFrameworkCore; namespace FinancialApi.Infrastructure; diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountQuery.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountQuery.cs new file mode 100644 index 0000000..273c109 --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/AccountQuery.cs @@ -0,0 +1,13 @@ +using FinancialApi.Application; +using FinancialApi.Domain; +using Microsoft.EntityFrameworkCore; + +namespace FinancialApi.Infrastructure; + +public class AccountQuery(AccountDbContext db) : IAccountQuery +{ + public Task FindByIdAsync(int id) + { + return db.Accounts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj index 965b782..ccd540f 100644 --- a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/FinancialApi.Infrastructure.csproj @@ -8,6 +8,7 @@ + diff --git a/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.Designer.cs b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.Designer.cs new file mode 100644 index 0000000..00ec55f --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi.Infrastructure/Migrations/20260715100428_UpdateAccountBalances.Designer.cs @@ -0,0 +1,115 @@ +// +using System; +using FinancialApi.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FinancialApi.Infrastructure.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20260715100428_UpdateAccountBalances")] + partial class UpdateAccountBalances + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("FinancialApi.Application.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + + b.HasData( + new + { + Id = 1, + AccountType = "Credit", + Balance = 10000000.00m + }, + new + { + Id = 2, + AccountType = "Liability", + Balance = 50000000.00m + }, + new + { + Id = 99999, + AccountType = "Revenue", + Balance = 0.00m + }); + }); + + modelBuilder.Entity("FinancialApi.Application.JournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("JournalEntries"); + }); + + modelBuilder.Entity("FinancialApi.Application.JournalEntry", b => + { + b.OwnsMany("FinancialApi.Application.JournalLine", "Lines", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("AccountId") + .HasColumnType("INTEGER"); + + b1.Property("Amount") + .HasColumnType("TEXT"); + + b1.Property("JournalEntryId") + .HasColumnType("TEXT"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("Id"); + + b1.HasIndex("JournalEntryId"); + + b1.ToTable("JournalLine"); + + b1.WithOwner() + .HasForeignKey("JournalEntryId"); + }); + + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/wolverine/a-frame-architecture/FinancialApi/DomainExceptionHandler.cs b/wolverine/a-frame-architecture/FinancialApi/DomainExceptionHandler.cs new file mode 100644 index 0000000..305b82c --- /dev/null +++ b/wolverine/a-frame-architecture/FinancialApi/DomainExceptionHandler.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace FinancialApi; + +public class DomainExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + // Only intercept our specific domain/validation exceptions + if (exception is InvalidOperationException or ArgumentException) + { + var problemDetails = new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Domain Validation Error", + Detail = exception.Message, + Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1" + }; + + httpContext.Response.StatusCode = problemDetails.Status.Value; + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/wolverine/a-frame-architecture/FinancialApi/Program.cs b/wolverine/a-frame-architecture/FinancialApi/Program.cs index d6e5348..607851e 100644 --- a/wolverine/a-frame-architecture/FinancialApi/Program.cs +++ b/wolverine/a-frame-architecture/FinancialApi/Program.cs @@ -1,4 +1,5 @@ using FinancialApi; +using FinancialApi.Application; using FinancialApi.Infrastructure; using FinancialApi.ServiceDefaults; using JasperFx.CodeGeneration; @@ -11,11 +12,16 @@ using Wolverine.Sqlite; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); + var connectionString = "Data Source=demo.db"; builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); +builder.Services.AddScoped(); + // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); @@ -33,6 +39,8 @@ builder.Host.UseResourceSetupOnStartup(); var app = builder.Build(); +app.UseExceptionHandler(); + using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); diff --git a/wolverine/a-frame-architecture/a-frame-architecture.sln b/wolverine/a-frame-architecture/a-frame-architecture.sln index ab94823..7ac1c44 100644 --- a/wolverine/a-frame-architecture/a-frame-architecture.sln +++ b/wolverine/a-frame-architecture/a-frame-architecture.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Application", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Infrastructure", "FinancialApi.Infrastructure\FinancialApi.Infrastructure.csproj", "{6AB36AB5-05BD-41C8-9498-63EBDFAF8114}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Domain", "FinancialApi.Domain\FinancialApi.Domain.csproj", "{2DB493D2-1C96-4F7C-9AB6-2846A92860B9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,5 +32,9 @@ Global {6AB36AB5-05BD-41C8-9498-63EBDFAF8114}.Debug|Any CPU.Build.0 = Debug|Any CPU {6AB36AB5-05BD-41C8-9498-63EBDFAF8114}.Release|Any CPU.ActiveCfg = Release|Any CPU {6AB36AB5-05BD-41C8-9498-63EBDFAF8114}.Release|Any CPU.Build.0 = Release|Any CPU + {2DB493D2-1C96-4F7C-9AB6-2846A92860B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DB493D2-1C96-4F7C-9AB6-2846A92860B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DB493D2-1C96-4F7C-9AB6-2846A92860B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DB493D2-1C96-4F7C-9AB6-2846A92860B9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal