diff --git a/csharp-fp1/Application/AccountApplicationService.cs b/csharp-fp1/Application/AccountApplicationService.cs new file mode 100644 index 0000000..aa36a03 --- /dev/null +++ b/csharp-fp1/Application/AccountApplicationService.cs @@ -0,0 +1,17 @@ +using CsharpFp1.Domain; + +namespace CsharpFp1.Application; + +public static class AccountApplicationService +{ + public static void WithdrawMoney(IAccountRepository repository, Guid accountId, decimal amount) + { + var account = + repository.GetById(new AccountId(accountId)) + ?? throw new InvalidOperationException("Account not found."); + + account.Withdraw(new Money(amount)); + + repository.Save(account); + } +} diff --git a/csharp-fp1/Domain/Account.cs b/csharp-fp1/Domain/Account.cs new file mode 100644 index 0000000..2080baa --- /dev/null +++ b/csharp-fp1/Domain/Account.cs @@ -0,0 +1,36 @@ +namespace CsharpFp1.Domain; + +/// Domain entity / aggregate root representing a bank account +public sealed class Account : AggregateRoot +{ + public Money Balance { get; private set; } + + // Often present to satisfy serializers / ORMs. + private Account() + { + Balance = new Money(0); + } + + public Account(AccountId id, Money openingBalance) + { + if (openingBalance.Amount < 0) + throw new ArgumentOutOfRangeException(nameof(openingBalance)); + + Id = id; + + Balance = openingBalance; + } + + // Domain behaviour attached to the entity. + public void Withdraw(Money amount) + { + if (amount.Amount <= 0) + throw new InvalidOperationException("Withdrawal amount must be positive."); + + // Sometimes, validation is modelled with custom exceptions instead + if (Balance.Amount - amount.Amount < 0) + throw new InsufficientBalanceException(Balance, amount); + + Balance = Balance.Subtract(amount); + } +} diff --git a/csharp-fp1/Domain/AccountId.cs b/csharp-fp1/Domain/AccountId.cs new file mode 100644 index 0000000..49346f5 --- /dev/null +++ b/csharp-fp1/Domain/AccountId.cs @@ -0,0 +1,4 @@ +namespace CsharpFp1.Domain; + +/// Value object used to wrap the aggregate identity +public sealed record AccountId(Guid Value); diff --git a/csharp-fp1/Domain/AggregateRoot.cs b/csharp-fp1/Domain/AggregateRoot.cs new file mode 100644 index 0000000..b9979d1 --- /dev/null +++ b/csharp-fp1/Domain/AggregateRoot.cs @@ -0,0 +1,7 @@ +namespace CsharpFp1.Domain; + +/// Conventional DDD base type for aggregates +public abstract class AggregateRoot +{ + public TId Id { get; protected set; } = default!; +} diff --git a/csharp-fp1/Domain/IAccountRepository.cs b/csharp-fp1/Domain/IAccountRepository.cs new file mode 100644 index 0000000..28fda00 --- /dev/null +++ b/csharp-fp1/Domain/IAccountRepository.cs @@ -0,0 +1,8 @@ +namespace CsharpFp1.Domain; + +/// Repository abstraction used to load and save accounts +public interface IAccountRepository +{ + Account? GetById(AccountId id); + void Save(Account account); +} 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..2c9da43 --- /dev/null +++ b/csharp-fp1/Infrastructure/InMemoryAccountRepository.cs @@ -0,0 +1,27 @@ +using CsharpFp1.Domain; + +namespace CsharpFp1.Infrastructure; + +/// Simple in-memory repository for demonstration +public class InMemoryAccountRepository : IAccountRepository +{ + private readonly Dictionary _accounts = new(); + + public Account? GetById(AccountId id) + { + var found = _accounts.TryGetValue(id, out var account); + Console.WriteLine( + found ? $"[Repo] Loaded account {id.Value}" : $"[Repo] Account {id.Value} not found" + ); + + return found ? account : null; + } + + public void Save(Account account) + { + _accounts[account.Id] = account; + Console.WriteLine( + $"[Repo] Saved account {account.Id.Value} with balance {account.Balance.Amount:0.00}" + ); + } +} diff --git a/csharp-fp1/Program.cs b/csharp-fp1/Program.cs new file mode 100644 index 0000000..7724203 --- /dev/null +++ b/csharp-fp1/Program.cs @@ -0,0 +1,25 @@ +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 repository = new InMemoryAccountRepository(); + + var accountId = Guid.NewGuid(); + Console.WriteLine($"[csharp-fp1] Seeding account {accountId} with opening balance 200.00"); + repository.Save(new Account(new AccountId(accountId), new Money(200m))); + + decimal amount = 100m; + Console.WriteLine($"[csharp-fp1] Executing withdrawal {amount:0.00} from account {accountId}"); + AccountApplicationService.WithdrawMoney(repository, 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..05f1c4c --- /dev/null +++ b/csharp-fp1/csharp-fp1.csproj @@ -0,0 +1,9 @@ + + + Exe + net10.0 + csharp_fp1 + enable + enable + + diff --git a/csharp-fp2/Application/AccountApplicationService.cs b/csharp-fp2/Application/AccountApplicationService.cs new file mode 100644 index 0000000..a943b30 --- /dev/null +++ b/csharp-fp2/Application/AccountApplicationService.cs @@ -0,0 +1,17 @@ +using CsharpFp2.Domain; + +namespace CsharpFp2.Application; + +public static class AccountApplicationService +{ + public static void WithdrawMoney(IAccountRepository repository, Guid accountId, decimal amount) + { + var account = + repository.GetById(accountId) + ?? throw new InvalidOperationException("Account not found."); + + account.Withdraw(new Money(amount)); + + repository.Save(account); + } +} diff --git a/csharp-fp2/Domain/Account.cs b/csharp-fp2/Domain/Account.cs new file mode 100644 index 0000000..745f918 --- /dev/null +++ b/csharp-fp2/Domain/Account.cs @@ -0,0 +1,37 @@ +namespace CsharpFp2.Domain; + +/// Domain entity / aggregate root representing a bank account +public sealed class Account +{ + public Guid Id { get; private set; } + public Money Balance { get; private set; } + + // Often present to satisfy serializers / ORMs. + private Account() + { + Balance = new Money(0); + } + + public Account(Guid id, Money openingBalance) + { + if (openingBalance.Amount < 0) + throw new ArgumentOutOfRangeException(nameof(openingBalance)); + + Id = id; + + Balance = openingBalance; + } + + // Domain behaviour attached to the entity. + public void Withdraw(Money amount) + { + if (amount.Amount <= 0) + throw new InvalidOperationException("Withdrawal amount must be positive."); + + // Sometimes, validation is modelled with custom exceptions instead + if (Balance.Amount - amount.Amount < 0) + throw new InsufficientBalanceException(Balance, amount); + + Balance = Balance.Subtract(amount); + } +} diff --git a/csharp-fp2/Domain/IAccountRepository.cs b/csharp-fp2/Domain/IAccountRepository.cs new file mode 100644 index 0000000..da5fc30 --- /dev/null +++ b/csharp-fp2/Domain/IAccountRepository.cs @@ -0,0 +1,8 @@ +namespace CsharpFp2.Domain; + +/// Repository abstraction used to load and save accounts +public interface IAccountRepository +{ + Account? GetById(Guid id); + void Save(Account account); +} diff --git a/csharp-fp2/Domain/InsufficientBalanceException.cs b/csharp-fp2/Domain/InsufficientBalanceException.cs new file mode 100644 index 0000000..13a436a --- /dev/null +++ b/csharp-fp2/Domain/InsufficientBalanceException.cs @@ -0,0 +1,17 @@ +namespace CsharpFp2.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-fp2/Domain/Money.cs b/csharp-fp2/Domain/Money.cs new file mode 100644 index 0000000..7c0daec --- /dev/null +++ b/csharp-fp2/Domain/Money.cs @@ -0,0 +1,33 @@ +namespace CsharpFp2.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-fp2/Infrastructure/InMemoryAccountRepository.cs b/csharp-fp2/Infrastructure/InMemoryAccountRepository.cs new file mode 100644 index 0000000..6638954 --- /dev/null +++ b/csharp-fp2/Infrastructure/InMemoryAccountRepository.cs @@ -0,0 +1,25 @@ +using CsharpFp2.Domain; + +namespace CsharpFp2.Infrastructure; + +/// Simple in-memory repository for demonstration +public class InMemoryAccountRepository : IAccountRepository +{ + private readonly Dictionary _accounts = new(); + + public Account? GetById(Guid id) + { + var found = _accounts.TryGetValue(id, out var account); + Console.WriteLine(found ? $"[Repo] Loaded account {id}" : $"[Repo] Account {id} not found"); + + return found ? account : null; + } + + public void Save(Account account) + { + _accounts[account.Id] = account; + Console.WriteLine( + $"[Repo] Saved account {account.Id} with balance {account.Balance.Amount:0.00}" + ); + } +} diff --git a/csharp-fp2/Program.cs b/csharp-fp2/Program.cs new file mode 100644 index 0000000..0a29595 --- /dev/null +++ b/csharp-fp2/Program.cs @@ -0,0 +1,27 @@ +using CsharpFp2.Application; +using CsharpFp2.Domain; +using CsharpFp2.Infrastructure; + +namespace CsharpFp2; + +public class Program +{ + public static void Main() + { + Console.WriteLine("[csharp-fp2] Starting withdraw money demo..."); + + var repository = new InMemoryAccountRepository(); + + var accountId = Guid.NewGuid(); + Console.WriteLine($"[csharp-fp2] Seeding account {accountId} with opening balance 200.00"); + repository.Save(new Account(accountId, new Money(200m))); + + decimal amount = 100m; + Console.WriteLine( + $"[csharp-fp2] Executing withdrawal {amount:0.00} from account {accountId}" + ); + AccountApplicationService.WithdrawMoney(repository, accountId, amount); + + Console.WriteLine("[csharp-fp2] Demo completed."); + } +} diff --git a/csharp-fp2/csharp-fp2.csproj b/csharp-fp2/csharp-fp2.csproj new file mode 100644 index 0000000..55a99b6 --- /dev/null +++ b/csharp-fp2/csharp-fp2.csproj @@ -0,0 +1,9 @@ + + + Exe + net10.0 + csharp_fp2 + enable + enable + + diff --git a/csharp-oop/Program.cs b/csharp-oop/Program.cs index 39cb2a8..f282ef9 100644 --- a/csharp-oop/Program.cs +++ b/csharp-oop/Program.cs @@ -9,22 +9,22 @@ public class Program { public static void Main() { - Console.WriteLine("[Program] Starting withdraw money demo..."); + Console.WriteLine("[csharp-oop] Starting withdraw money demo..."); var repository = new InMemoryAccountRepository(); var handler = new WithdrawMoneyHandler(repository); var accountId = Guid.NewGuid(); - Console.WriteLine($"[Program] Seeding account {accountId} with opening balance 200.00"); + Console.WriteLine($"[csharp-oop] Seeding account {accountId} with opening balance 200.00"); repository.Save(new Account(new AccountId(accountId), new Money(200m))); var command = new WithdrawMoneyCommand { AccountId = accountId, Amount = 100m }; Console.WriteLine( - $"[Program] Dispatching command: withdraw {command.Amount:0.00} from account {command.AccountId}" + $"[csharp-oop] Dispatching command: withdraw {command.Amount:0.00} from account {command.AccountId}" ); handler.Handle(command); - Console.WriteLine("[Program] Demo completed."); + Console.WriteLine("[csharp-oop] Demo completed."); } } diff --git a/csharp.sln b/csharp.sln index 2b243d8..c9a4104 100644 --- a/csharp.sln +++ b/csharp.sln @@ -9,6 +9,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-oop", "csharp-oop\cs EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-oop.Tests", "csharp-oop.Tests\csharp-oop.Tests.csproj", "{9FD0C8F4-A23B-4C68-A365-86E7EF5623D8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FP", "FP", "{89C53051-77F9-4C1C-ABE5-6D54AB398471}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp1", "csharp-fp1\csharp-fp1.csproj", "{561BCA96-76DF-4A33-B767-4AD7CD3246B4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp2", "csharp-fp2\csharp-fp2.csproj", "{7237398A-2E8B-4161-BA15-DB090395A1F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,6 +49,30 @@ Global {9FD0C8F4-A23B-4C68-A365-86E7EF5623D8}.Release|x64.Build.0 = Release|Any CPU {9FD0C8F4-A23B-4C68-A365-86E7EF5623D8}.Release|x86.ActiveCfg = Release|Any CPU {9FD0C8F4-A23B-4C68-A365-86E7EF5623D8}.Release|x86.Build.0 = Release|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Debug|x64.Build.0 = Debug|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Debug|x86.Build.0 = Debug|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Release|Any CPU.Build.0 = Release|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Release|x64.ActiveCfg = Release|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Release|x64.Build.0 = Release|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Release|x86.ActiveCfg = Release|Any CPU + {561BCA96-76DF-4A33-B767-4AD7CD3246B4}.Release|x86.Build.0 = Release|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Debug|x64.ActiveCfg = Debug|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Debug|x64.Build.0 = Debug|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Debug|x86.Build.0 = Debug|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Release|Any CPU.Build.0 = Release|Any CPU + {7237398A-2E8B-4161-BA15-DB090395A1F2}.Release|x64.ActiveCfg = Release|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -50,5 +80,7 @@ Global GlobalSection(NestedProjects) = preSolution {E00F4E4F-8D52-49CE-8754-ED8378A5278F} = {CEC0EC27-0CEC-90C6-CABA-E58AB278E4DA} {9FD0C8F4-A23B-4C68-A365-86E7EF5623D8} = {CEC0EC27-0CEC-90C6-CABA-E58AB278E4DA} + {561BCA96-76DF-4A33-B767-4AD7CD3246B4} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} + {7237398A-2E8B-4161-BA15-DB090395A1F2} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} EndGlobalSection EndGlobal