Skip to content

Commit 2aba64e

Browse files
Unit of Work: replace sample with EF Core bank transfer on net10.0 (#2243)
Replace the net7.0 transaction-wrapper sample (Dapper, System.Data.SqlClient 4.8.5, NUnit, Moq) with the article's follow-along build: a console app with Domain, Application and Infrastructure folders, two repositories, one IUnitOfWork commit over EF Core and SQLite, and two xUnit v3 handler tests. Classic Banking.sln for CI, folder .gitignore for the runtime bank.db.
1 parent 73145d1 commit 2aba64e

39 files changed

Lines changed: 454 additions & 378 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
bank.db*
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<IsTestProject>true</IsTestProject>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<Using Include="Xunit" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
17+
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
18+
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<ProjectReference Include="..\Banking\Banking.csproj" />
23+
</ItemGroup>
24+
25+
</Project>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using Banking.Application;
2+
using Banking.Domain;
3+
4+
namespace Banking.Tests;
5+
6+
public class TransferMoneyHandlerTests
7+
{
8+
[Fact]
9+
public async Task HandleAsync_WhenFundsAreAvailable_MovesMoneyAndSavesOnce()
10+
{
11+
var alice = Account.Open("Alice", 100m);
12+
var bob = Account.Open("Bob", 50m);
13+
var transfers = new FakeTransferRepository();
14+
var unitOfWork = new FakeUnitOfWork();
15+
var handler = new TransferMoneyHandler(
16+
new FakeAccountRepository(new() { [1] = alice, [2] = bob }), transfers, unitOfWork);
17+
18+
var result = await handler.HandleAsync(
19+
new TransferMoneyCommand(1, 2, 30m, "TR-1"), TestContext.Current.CancellationToken);
20+
21+
Assert.True(result.IsSuccess);
22+
Assert.Equal(70m, alice.Balance);
23+
Assert.Equal(80m, bob.Balance);
24+
Assert.Single(transfers.Added);
25+
Assert.Equal(1, unitOfWork.SaveCount);
26+
}
27+
28+
[Fact]
29+
public async Task HandleAsync_WhenFundsAreShort_SavesNothing()
30+
{
31+
var alice = Account.Open("Alice", 100m);
32+
var bob = Account.Open("Bob", 50m);
33+
var transfers = new FakeTransferRepository();
34+
var unitOfWork = new FakeUnitOfWork();
35+
var handler = new TransferMoneyHandler(
36+
new FakeAccountRepository(new() { [1] = alice, [2] = bob }), transfers, unitOfWork);
37+
38+
var result = await handler.HandleAsync(
39+
new TransferMoneyCommand(1, 2, 500m, "TR-2"), TestContext.Current.CancellationToken);
40+
41+
Assert.False(result.IsSuccess);
42+
Assert.Equal("Account.InsufficientFunds", result.Error!.Code);
43+
Assert.Equal(100m, alice.Balance);
44+
Assert.Empty(transfers.Added);
45+
Assert.Equal(0, unitOfWork.SaveCount);
46+
}
47+
}
48+
49+
internal sealed class FakeAccountRepository(Dictionary<int, Account> accounts) : IAccountRepository
50+
{
51+
public Task<Account?> GetByIdAsync(int accountId, CancellationToken cancellationToken = default) =>
52+
Task.FromResult(accounts.GetValueOrDefault(accountId));
53+
}
54+
55+
internal sealed class FakeTransferRepository : ITransferRepository
56+
{
57+
public List<Transfer> Added { get; } = [];
58+
59+
public void Add(Transfer transfer) => Added.Add(transfer);
60+
}
61+
62+
internal sealed class FakeUnitOfWork : IUnitOfWork
63+
{
64+
public int SaveCount { get; private set; }
65+
66+
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
67+
{
68+
SaveCount++;
69+
return Task.CompletedTask;
70+
}
71+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
2+
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.0.31903.59
5+
MinimumVisualStudioVersion = 10.0.40219.1
6+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Banking", "Banking\Banking.csproj", "{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}"
7+
EndProject
8+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Banking.Tests", "Banking.Tests\Banking.Tests.csproj", "{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}"
9+
EndProject
10+
Global
11+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
12+
Debug|Any CPU = Debug|Any CPU
13+
Debug|x64 = Debug|x64
14+
Debug|x86 = Debug|x86
15+
Release|Any CPU = Release|Any CPU
16+
Release|x64 = Release|x64
17+
Release|x86 = Release|x86
18+
EndGlobalSection
19+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
20+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
21+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Debug|Any CPU.Build.0 = Debug|Any CPU
22+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Debug|x64.ActiveCfg = Debug|Any CPU
23+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Debug|x64.Build.0 = Debug|Any CPU
24+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Debug|x86.ActiveCfg = Debug|Any CPU
25+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Debug|x86.Build.0 = Debug|Any CPU
26+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Release|Any CPU.ActiveCfg = Release|Any CPU
27+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Release|Any CPU.Build.0 = Release|Any CPU
28+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Release|x64.ActiveCfg = Release|Any CPU
29+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Release|x64.Build.0 = Release|Any CPU
30+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Release|x86.ActiveCfg = Release|Any CPU
31+
{79214DA5-E3B0-4F3F-A50A-67ACBFA0213F}.Release|x86.Build.0 = Release|Any CPU
32+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
33+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Debug|Any CPU.Build.0 = Debug|Any CPU
34+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Debug|x64.ActiveCfg = Debug|Any CPU
35+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Debug|x64.Build.0 = Debug|Any CPU
36+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Debug|x86.ActiveCfg = Debug|Any CPU
37+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Debug|x86.Build.0 = Debug|Any CPU
38+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Release|Any CPU.ActiveCfg = Release|Any CPU
39+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Release|Any CPU.Build.0 = Release|Any CPU
40+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Release|x64.ActiveCfg = Release|Any CPU
41+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Release|x64.Build.0 = Release|Any CPU
42+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Release|x86.ActiveCfg = Release|Any CPU
43+
{8D085A03-DE4D-4BB7-ADC2-762F0040A33C}.Release|x86.Build.0 = Release|Any CPU
44+
EndGlobalSection
45+
GlobalSection(SolutionProperties) = preSolution
46+
HideSolutionNode = FALSE
47+
EndGlobalSection
48+
EndGlobal
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using Banking.Domain;
2+
3+
namespace Banking.Application;
4+
5+
public interface IAccountRepository
6+
{
7+
Task<Account?> GetByIdAsync(int accountId, CancellationToken cancellationToken = default);
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using Banking.Domain;
2+
3+
namespace Banking.Application;
4+
5+
public interface ITransferRepository
6+
{
7+
void Add(Transfer transfer);
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Banking.Application;
2+
3+
public interface IUnitOfWork
4+
{
5+
Task SaveChangesAsync(CancellationToken cancellationToken = default);
6+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using Banking.Domain;
2+
3+
namespace Banking.Application;
4+
5+
public sealed record TransferMoneyCommand(int FromAccountId, int ToAccountId, decimal Amount, string Reference);
6+
7+
public sealed record TransferReceipt(int TransferId, decimal FromBalance, decimal ToBalance);
8+
9+
public sealed class TransferMoneyHandler(
10+
IAccountRepository accounts,
11+
ITransferRepository transfers,
12+
IUnitOfWork unitOfWork)
13+
{
14+
public async Task<Result<TransferReceipt>> HandleAsync(
15+
TransferMoneyCommand command, CancellationToken cancellationToken = default)
16+
{
17+
var from = await accounts.GetByIdAsync(command.FromAccountId, cancellationToken);
18+
if (from is null)
19+
return AccountErrors.NotFound(command.FromAccountId);
20+
21+
var to = await accounts.GetByIdAsync(command.ToAccountId, cancellationToken);
22+
if (to is null)
23+
return AccountErrors.NotFound(command.ToAccountId);
24+
25+
var withdrawal = from.Withdraw(command.Amount);
26+
if (!withdrawal.IsSuccess)
27+
return withdrawal.Error!;
28+
29+
to.Deposit(command.Amount);
30+
31+
var transfer = Transfer.Create(from, to, command.Amount, command.Reference);
32+
transfers.Add(transfer);
33+
34+
await unitOfWork.SaveChangesAsync(cancellationToken);
35+
36+
return new TransferReceipt(transfer.Id, from.Balance, to.Balance);
37+
}
38+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net10.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
12+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
13+
</ItemGroup>
14+
15+
</Project>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace Banking.Domain;
2+
3+
public sealed class Account
4+
{
5+
private Account(string owner, decimal balance)
6+
{
7+
Owner = owner;
8+
Balance = balance;
9+
}
10+
11+
public int Id { get; private set; }
12+
public string Owner { get; private set; }
13+
public decimal Balance { get; private set; }
14+
15+
public static Account Open(string owner, decimal balance)
16+
{
17+
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
18+
ArgumentOutOfRangeException.ThrowIfNegative(balance);
19+
20+
return new Account(owner, balance);
21+
}
22+
23+
public Result Withdraw(decimal amount)
24+
{
25+
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
26+
27+
if (amount > Balance)
28+
return AccountErrors.InsufficientFunds(Balance, amount);
29+
30+
Balance -= amount;
31+
32+
return Result.Success();
33+
}
34+
35+
public void Deposit(decimal amount)
36+
{
37+
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
38+
39+
Balance += amount;
40+
}
41+
}

0 commit comments

Comments
 (0)