diff --git a/csharp-fp1/Application/AccountApplication.cs b/csharp-fp1/Application/AccountApplication.cs new file mode 100644 index 0000000..c654996 --- /dev/null +++ b/csharp-fp1/Application/AccountApplication.cs @@ -0,0 +1,37 @@ +using CsharpFp1.Domain; +using CsharpFp1.Infrastructure; + +namespace CsharpFp1.Application; + +// delegate type is optional, but nice to illustrate +public delegate void WithdrawMoney(Guid accountId, decimal amount); + +public static class AccountApplication +{ + public static WithdrawMoney CreateWithdrawMoney( + LoadAccount loadAccount, + SaveAccount saveAccount + ) + { + return (accountId, amount) => + { + var account = + loadAccount(accountId) ?? throw new InvalidOperationException("Account not found."); + + Console.WriteLine( + $"[App] Account loaded. Current balance: {account.Balance.Amount:0.00}" + ); + Console.WriteLine($"[App] Executing withdrawal of {amount:0.00}..."); + + var modifiedAccount = AccountDomain.Withdraw(account, new Money(amount)); + + Console.WriteLine( + $"[App] Withdrawal applied. New balance: {modifiedAccount.Balance.Amount:0.00}" + ); + + saveAccount(modifiedAccount); + + Console.WriteLine("[App] Account persisted."); + }; + } +} diff --git a/csharp-fp1/Domain/Account.cs b/csharp-fp1/Domain/Account.cs new file mode 100644 index 0000000..9429e6f --- /dev/null +++ b/csharp-fp1/Domain/Account.cs @@ -0,0 +1,32 @@ +namespace CsharpFp1.Domain; + +public sealed record Account(Guid Id, Money Balance); + +public static class AccountDomain +{ + // Choosing a "clean" FP approach here of instantiating the Account + // Depending on needs, code to prevent the Account type from + // being instantiated without this helper needs to be added + // to the record type. + public static Account Open(Guid id, Money openingBalance) + { + if (openingBalance.Amount < 0) + throw new ArgumentOutOfRangeException(nameof(openingBalance)); + + return new Account(id, openingBalance); + } + + public static Account Withdraw(Account account, Money amount) + { + if (amount.Amount <= 0) + throw new InvalidOperationException("Withdrawal amount must be positive."); + + if (account.Balance.Amount < amount.Amount) + throw new InsufficientBalanceException(account.Balance, amount); + + return account with + { + Balance = account.Balance.Subtract(amount), + }; + } +} diff --git a/csharp-fp1/Domain/InsufficientBalanceException.cs b/csharp-fp1/Domain/InsufficientBalanceException.cs new file mode 100644 index 0000000..08333ad --- /dev/null +++ b/csharp-fp1/Domain/InsufficientBalanceException.cs @@ -0,0 +1,17 @@ +namespace CsharpFp1.Domain; + +/// Custom domain exception thrown when a withdrawal would cause the balance to go below zero +public sealed class InsufficientBalanceException : InvalidOperationException +{ + public Money CurrentBalance { get; } + public Money RequestedAmount { get; } + + public InsufficientBalanceException(Money currentBalance, Money requestedAmount) + : base( + $"Insufficient balance. Current: {currentBalance.Amount:0.00}, Requested: {requestedAmount.Amount:0.00}" + ) + { + CurrentBalance = currentBalance; + RequestedAmount = requestedAmount; + } +} diff --git a/csharp-fp1/Domain/Money.cs b/csharp-fp1/Domain/Money.cs new file mode 100644 index 0000000..61f1c2f --- /dev/null +++ b/csharp-fp1/Domain/Money.cs @@ -0,0 +1,33 @@ +namespace CsharpFp1.Domain; + +/// Value object used to represent money and enforce simple invariants. +/// Note that this implementation uses immutable patterns for the data +/// by returning a new instance for each modification. This is an early +/// recommendation for DDD with OO, but not necessarily the common practice +/// in many real-world implementations. +public sealed class Money +{ + // Potentially with a setter - see note above + public decimal Amount { get; } + + public Money(decimal amount) + { + Amount = amount; + } + + // In many existing DDD/OO codebases you may actually see the use + // of mutable value types. + // + // public void Add(Money other) + // { + // this.Amount += other.Amount; + // } + + // On the other hand, sometimes these helpers may be left out + // and operations encoded directly "from the outside": + // newBalance = new Money(oldBalance.Amount - charge.Amount) + // + public Money Add(Money other) => new(Amount + other.Amount); + + public Money Subtract(Money other) => new(Amount - other.Amount); +} diff --git a/csharp-fp1/Infrastructure/InMemoryAccountRepository.cs b/csharp-fp1/Infrastructure/InMemoryAccountRepository.cs new file mode 100644 index 0000000..7d371a7 --- /dev/null +++ b/csharp-fp1/Infrastructure/InMemoryAccountRepository.cs @@ -0,0 +1,40 @@ +using CsharpFp1.Domain; + +namespace CsharpFp1.Infrastructure; + +// If we don't like working with generic delegates directly, we can +// create custom named delegate types. +public delegate Account? LoadAccount(Guid id); + +public delegate void SaveAccount(Account accunt); + +// If we don't want to use tuples or really miss the interface idea, we can create a named container +// public sealed record AccountPersistence(LoadAccount Load, SaveAccount Save); + +public static class InMemoryAccount +{ + public static (LoadAccount, SaveAccount) Create() + { + Dictionary store = new(); + + Account? GetById(Guid id) + { + var found = store.TryGetValue(id, out var account); + Console.WriteLine( + found ? $"[Repo] Loaded account {id}" : $"[Repo] Account {id} not found" + ); + + return found ? account : null; + } + + void Save(Account account) + { + store[account.Id] = account; + Console.WriteLine( + $"[Repo] Saved account {account.Id} with balance {account.Balance.Amount:0.00}" + ); + } + + return (GetById, Save); + } +} diff --git a/csharp-fp1/Program.cs b/csharp-fp1/Program.cs new file mode 100644 index 0000000..51949d5 --- /dev/null +++ b/csharp-fp1/Program.cs @@ -0,0 +1,28 @@ +using CsharpFp1.Application; +using CsharpFp1.Domain; +using CsharpFp1.Infrastructure; + +namespace CsharpFp1; + +public class Program +{ + public static void Main() + { + Console.WriteLine("[csharp-fp1] Starting withdraw money demo..."); + + var (loadAccount, saveAccount) = InMemoryAccount.Create(); + var withdrawMoney = AccountApplication.CreateWithdrawMoney(loadAccount, saveAccount); + + var accountId = Guid.NewGuid(); + Console.WriteLine($"[csharp-fp1] Seeding account {accountId} with opening balance 200.00"); + saveAccount(new Account(accountId, new Money(200m))); + + decimal amount = 100m; + Console.WriteLine( + $"[csharp-fp1] Executing withdrawal {amount:0.00} from account {accountId}" + ); + withdrawMoney(accountId, amount); + + Console.WriteLine("[csharp-fp1] Demo completed."); + } +} diff --git a/csharp-fp1/csharp-fp1.csproj b/csharp-fp1/csharp-fp1.csproj new file mode 100644 index 0000000..0fadaa1 --- /dev/null +++ b/csharp-fp1/csharp-fp1.csproj @@ -0,0 +1,9 @@ + + + Exe + net10.0 + CsharpFp1 + enable + enable + + diff --git a/csharp.sln b/csharp.sln index bdbf68d..f30bd40 100644 --- a/csharp.sln +++ b/csharp.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-oop-simplified1", "c EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-oop-simplified2", "csharp-oop-simplified2\csharp-oop-simplified2.csproj", "{7237398A-2E8B-4161-BA15-DB090395A1F2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp1", "csharp-fp1\csharp-fp1.csproj", "{C9D46510-994A-4C43-BA8F-33CA9BED79D0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +75,18 @@ Global {7237398A-2E8B-4161-BA15-DB090395A1F2}.Release|x64.Build.0 = Release|Any CPU {7237398A-2E8B-4161-BA15-DB090395A1F2}.Release|x86.ActiveCfg = Release|Any CPU {7237398A-2E8B-4161-BA15-DB090395A1F2}.Release|x86.Build.0 = Release|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Debug|x64.ActiveCfg = Debug|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Debug|x64.Build.0 = Debug|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Debug|x86.ActiveCfg = Debug|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Debug|x86.Build.0 = Debug|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Release|Any CPU.Build.0 = Release|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Release|x64.ActiveCfg = Release|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Release|x64.Build.0 = Release|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Release|x86.ActiveCfg = Release|Any CPU + {C9D46510-994A-4C43-BA8F-33CA9BED79D0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -82,5 +96,6 @@ Global {9FD0C8F4-A23B-4C68-A365-86E7EF5623D8} = {CEC0EC27-0CEC-90C6-CABA-E58AB278E4DA} {561BCA96-76DF-4A33-B767-4AD7CD3246B4} = {CEC0EC27-0CEC-90C6-CABA-E58AB278E4DA} {7237398A-2E8B-4161-BA15-DB090395A1F2} = {CEC0EC27-0CEC-90C6-CABA-E58AB278E4DA} + {C9D46510-994A-4C43-BA8F-33CA9BED79D0} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} EndGlobalSection EndGlobal