Add Domain project and shifted handlers to application.

This commit is contained in:
2026-07-15 16:10:00 +02:00
parent 656f545281
commit 0376b58a33
16 changed files with 229 additions and 52 deletions
@@ -1,28 +1,28 @@
using FinancialApi.Application; using FinancialApi.Domain;
using Microsoft.EntityFrameworkCore;
using Wolverine.Attributes; using Wolverine.Attributes;
using Wolverine.Persistence; using Wolverine.Persistence;
namespace FinancialApi.Infrastructure; namespace FinancialApi.Application;
public record ApplyFeeCommand(int AccountId, decimal Amount); public record ApplyFeeCommand(int AccountId, decimal Amount);
public record FeePair(Account CustomerAccount, Account RevenueAccount);
public record FeeAppliedEvent(int AccountId, decimal Amount); public record FeeAppliedEvent(int AccountId, decimal Amount);
public static class ApplyFeeHandler public static class ApplyFeeHandler
{ {
public static async Task<FeePair?> LoadAsync( public static async Task<(Account, Account)> LoadAsync(
ApplyFeeCommand cmd, ApplyFeeCommand cmd,
AccountDbContext db) IAccountQuery query)
{ {
var customer = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == cmd.AccountId); var source = await query.FindByIdAsync(cmd.AccountId);
var revenue = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == 99999); var dest = await query.FindByIdAsync(99999);
return (customer == null || revenue == null) ? null : new FeePair(customer, revenue); if (source == null || dest == null)
{
throw new InvalidOperationException($"Cannot process transfer. Account(s) not found.");
}
return (source, dest);
} }
[Transactional]
public static ( public static (
IStorageAction<Account> CustomerWrite, IStorageAction<Account> CustomerWrite,
IStorageAction<Account> RevenueWrite, IStorageAction<Account> RevenueWrite,
@@ -31,15 +31,16 @@ public static class ApplyFeeHandler
) )
Handle( Handle(
ApplyFeeCommand cmd, ApplyFeeCommand cmd,
FeePair pair Account source,
Account dest
) )
{ {
var updatedCustomer = pair.CustomerAccount.ApplyPosting(cmd.Amount, EntryType.Debit); var updatedSource = source.ApplyPosting(cmd.Amount, EntryType.Debit);
var updatedRevenue = pair.RevenueAccount.ApplyPosting(cmd.Amount, EntryType.Credit); var updatedRevenue = dest.ApplyPosting(cmd.Amount, EntryType.Credit);
var lines = new List<JournalLine> var lines = new List<JournalLine>
{ {
new(pair.CustomerAccount.Id, cmd.Amount, EntryType.Debit), new(updatedSource.Id, cmd.Amount, EntryType.Debit),
new(9999, cmd.Amount, EntryType.Credit) new(updatedRevenue.Id, cmd.Amount, EntryType.Credit)
}; };
var journalEntry = BalancedJournal.Create(Guid.NewGuid(), var journalEntry = BalancedJournal.Create(Guid.NewGuid(),
$"Service Fee Applied: {cmd.Amount} to Account {cmd.AccountId}", $"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); var @event = new FeeAppliedEvent(cmd.AccountId, cmd.Amount);
return ( return (
Storage.Update(updatedCustomer), Storage.Update(updatedSource),
Storage.Update(updatedRevenue), Storage.Update(updatedRevenue),
Storage.Insert(journalEntry.Entry), Storage.Insert(journalEntry.Entry),
@event @event
@@ -6,4 +6,14 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FinancialApi.Domain\FinancialApi.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Wolverine">
<HintPath>..\..\..\..\..\..\.nuget\packages\wolverinefx\6.18.0\lib\net10.0\Wolverine.dll</HintPath>
</Reference>
</ItemGroup>
</Project> </Project>
@@ -1,30 +1,32 @@
using FinancialApi.Application; using FinancialApi.Domain;
using Microsoft.EntityFrameworkCore;
using Wolverine.Attributes; using Wolverine.Attributes;
using Wolverine.Persistence; using Wolverine.Persistence;
namespace FinancialApi.Infrastructure; namespace FinancialApi.Application;
public interface IAccountQuery
{
Task<Account?> FindByIdAsync(int id);
}
public record TransferFundsCommand(int SourceAccountId, int DestinationAccountId, decimal Amount); public record TransferFundsCommand(int SourceAccountId, int DestinationAccountId, decimal Amount);
public record FundsTransferredEvent(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 record TransferPair(Account Source, Account Destination);
public static class TransferFundsHandler public static class TransferFundsHandler
{ {
public static async Task<TransferPair?> LoadAsync(TransferFundsCommand cmd, AccountDbContext db) public static async Task<TransferPair?> LoadAsync(TransferFundsCommand cmd, IAccountQuery query)
{ {
var source = await db.Accounts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cmd.SourceAccountId); var source = await query.FindByIdAsync(cmd.SourceAccountId);
var dest = await db.Accounts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == cmd.DestinationAccountId); 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); return new TransferPair(source, dest);
} }
[Transactional]
public static ( public static (
IStorageAction<Account> SourceWrite, IStorageAction<Account> SourceWrite,
IStorageAction<Account> DestWrite, IStorageAction<Account> DestWrite,
@@ -1,4 +1,4 @@
namespace FinancialApi.Application; namespace FinancialApi.Domain;
public record Account(int Id, decimal Balance, string AccountType) 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; newBalance = entryType == EntryType.Debit ? newBalance - amount : newBalance + amount;
} }
if (newBalance < 0 && AccountType == "Liability") if (newBalance < 0 && AccountType == "Liability")
{ {
@@ -28,21 +27,6 @@ public record Account(int Id, decimal Balance, string AccountType)
return this with { Balance = newBalance }; 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) public Account Debit(decimal amount)
{ {
if (amount <= 0) if (amount <= 0)
@@ -1,4 +1,4 @@
namespace FinancialApi.Application; namespace FinancialApi.Domain;
public record BalancedJournal public record BalancedJournal
{ {
@@ -1,4 +1,4 @@
namespace FinancialApi.Application; namespace FinancialApi.Domain;
public enum EntryType public enum EntryType
{ {
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,4 +1,4 @@
namespace FinancialApi.Application; namespace FinancialApi.Domain;
public record JournalEntry public record JournalEntry
{ {
@@ -7,10 +7,6 @@ public record JournalEntry
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
public List<JournalLine> Lines { get; init; } = []; public List<JournalLine> Lines { get; init; } = [];
private JournalEntry()
{
}
internal JournalEntry(Guid id, string description, DateTimeOffset createdAt, List<JournalLine> lines) internal JournalEntry(Guid id, string description, DateTimeOffset createdAt, List<JournalLine> lines)
{ {
Lines = lines; Lines = lines;
@@ -1,3 +1,3 @@
namespace FinancialApi.Application; namespace FinancialApi.Domain;
public record JournalLine(int AccountId, decimal Amount, EntryType Type); public record JournalLine(int AccountId, decimal Amount, EntryType Type);
@@ -1,4 +1,4 @@
using FinancialApi.Application; using FinancialApi.Domain;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace FinancialApi.Infrastructure; namespace FinancialApi.Infrastructure;
@@ -0,0 +1,13 @@
using FinancialApi.Application;
using FinancialApi.Domain;
using Microsoft.EntityFrameworkCore;
namespace FinancialApi.Infrastructure;
public class AccountQuery(AccountDbContext db) : IAccountQuery
{
public Task<Account?> FindByIdAsync(int id)
{
return db.Accounts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
}
}
@@ -8,6 +8,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\FinancialApi.Application\FinancialApi.Application.csproj" /> <ProjectReference Include="..\FinancialApi.Application\FinancialApi.Application.csproj" />
<ProjectReference Include="..\FinancialApi.Domain\FinancialApi.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,115 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("AccountId")
.HasColumnType("INTEGER");
b1.Property<decimal>("Amount")
.HasColumnType("TEXT");
b1.Property<Guid>("JournalEntryId")
.HasColumnType("TEXT");
b1.Property<string>("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
}
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace FinancialApi;
public class DomainExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> 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;
}
}
@@ -1,4 +1,5 @@
using FinancialApi; using FinancialApi;
using FinancialApi.Application;
using FinancialApi.Infrastructure; using FinancialApi.Infrastructure;
using FinancialApi.ServiceDefaults; using FinancialApi.ServiceDefaults;
using JasperFx.CodeGeneration; using JasperFx.CodeGeneration;
@@ -11,11 +12,16 @@ using Wolverine.Sqlite;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddProblemDetails();
var connectionString = "Data Source=demo.db"; var connectionString = "Data Source=demo.db";
builder.Services.AddDbContext<AccountDbContext>(options => builder.Services.AddDbContext<AccountDbContext>(options =>
options.UseSqlite(connectionString)); options.UseSqlite(connectionString));
builder.Services.AddScoped<IAccountQuery, AccountQuery>();
// Add services to the container. // Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
@@ -33,6 +39,8 @@ builder.Host.UseResourceSetupOnStartup();
var app = builder.Build(); var app = builder.Build();
app.UseExceptionHandler();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AccountDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AccountDbContext>();
@@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Application",
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Infrastructure", "FinancialApi.Infrastructure\FinancialApi.Infrastructure.csproj", "{6AB36AB5-05BD-41C8-9498-63EBDFAF8114}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Infrastructure", "FinancialApi.Infrastructure\FinancialApi.Infrastructure.csproj", "{6AB36AB5-05BD-41C8-9498-63EBDFAF8114}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinancialApi.Domain", "FinancialApi.Domain\FinancialApi.Domain.csproj", "{2DB493D2-1C96-4F7C-9AB6-2846A92860B9}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{6AB36AB5-05BD-41C8-9498-63EBDFAF8114}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal