diff --git a/csharp-architectural-patterns/OnionArchitecture/.gitignore b/csharp-architectural-patterns/OnionArchitecture/.gitignore
new file mode 100644
index 000000000..3102f3a35
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/.gitignore
@@ -0,0 +1,4 @@
+# SQLite database the API creates on first run (EnsureCreated)
+*.db
+*.db-shm
+*.db-wal
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Api/AccountEndpoints.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/AccountEndpoints.cs
new file mode 100644
index 000000000..9fa29ab07
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/AccountEndpoints.cs
@@ -0,0 +1,35 @@
+using System.ComponentModel.DataAnnotations;
+using Banking.Services;
+
+namespace Banking.Api;
+
+public sealed record WithdrawRequest([property: Range(typeof(decimal), "1", "1000")] decimal Amount);
+
+public static class AccountEndpoints
+{
+ public static void MapAccountEndpoints(this IEndpointRouteBuilder app)
+ {
+ var accounts = app.MapGroup("/api/accounts");
+
+ accounts.MapGet("/{accountId:int}", async (
+ int accountId,
+ IAccountService service,
+ CancellationToken cancellationToken) =>
+ {
+ var result = await service.GetByIdAsync(accountId, cancellationToken);
+
+ return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
+ });
+
+ accounts.MapPost("/{accountId:int}/withdrawals", async (
+ int accountId,
+ WithdrawRequest request,
+ IAccountService service,
+ CancellationToken cancellationToken) =>
+ {
+ var result = await service.WithdrawAsync(accountId, request.Amount, cancellationToken);
+
+ return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
+ });
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Banking.Api.csproj b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Banking.Api.csproj
new file mode 100644
index 000000000..1f5c1652a
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Banking.Api.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Banking.Api.http b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Banking.Api.http
new file mode 100644
index 000000000..9670e9a9d
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Banking.Api.http
@@ -0,0 +1,26 @@
+GET http://localhost:5000/api/accounts/1
+
+###
+
+POST http://localhost:5000/api/accounts/1/withdrawals
+Content-Type: application/json
+
+{ "amount": 30 }
+
+###
+
+POST http://localhost:5000/api/accounts/2/withdrawals
+Content-Type: application/json
+
+{ "amount": 50 }
+
+###
+
+POST http://localhost:5000/api/accounts/1/withdrawals
+Content-Type: application/json
+
+{ "amount": 0 }
+
+###
+
+GET http://localhost:5000/api/accounts/99
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Program.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Program.cs
new file mode 100644
index 000000000..7c1de899d
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Program.cs
@@ -0,0 +1,25 @@
+using Banking.Api;
+using Banking.Infrastructure;
+using Banking.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddServices();
+builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Banking")!);
+
+builder.Services.AddProblemDetails();
+builder.Services.AddValidation();
+
+var app = builder.Build();
+
+app.UseExceptionHandler();
+
+using (var scope = app.Services.CreateScope())
+{
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ dbContext.Database.EnsureCreated();
+}
+
+app.MapAccountEndpoints();
+
+app.Run();
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Properties/launchSettings.json b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Properties/launchSettings.json
new file mode 100644
index 000000000..304f4337a
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7254;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Api/ResultExtensions.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/ResultExtensions.cs
new file mode 100644
index 000000000..1059a2e4e
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/ResultExtensions.cs
@@ -0,0 +1,21 @@
+using Banking.Domain;
+
+namespace Banking.Api;
+
+public static class ResultExtensions
+{
+ public static IResult ToProblem(this Result result)
+ {
+ var error = result.Error
+ ?? throw new InvalidOperationException("A successful result is not a problem.");
+
+ var statusCode = error.Type switch
+ {
+ ErrorType.NotFound => StatusCodes.Status404NotFound,
+ ErrorType.Conflict => StatusCodes.Status409Conflict,
+ _ => StatusCodes.Status500InternalServerError
+ };
+
+ return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Api/appsettings.Development.json b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Api/appsettings.json b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/appsettings.json
new file mode 100644
index 000000000..c74c0dff2
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Api/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "ConnectionStrings": {
+ "Banking": "Data Source=banking.db"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.ArchitectureTests/Banking.ArchitectureTests.csproj b/csharp-architectural-patterns/OnionArchitecture/Banking.ArchitectureTests/Banking.ArchitectureTests.csproj
new file mode 100644
index 000000000..96cb7866a
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.ArchitectureTests/Banking.ArchitectureTests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.ArchitectureTests/LayerTests.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.ArchitectureTests/LayerTests.cs
new file mode 100644
index 000000000..02e92bdf9
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.ArchitectureTests/LayerTests.cs
@@ -0,0 +1,45 @@
+using Banking.Api;
+using Banking.Domain;
+using Banking.Services;
+using NetArchTest.Rules;
+
+namespace Banking.ArchitectureTests;
+
+public class LayerTests
+{
+ [Fact]
+ public void Domain_DependsOnNoOtherLayer()
+ {
+ var result = Types.InAssembly(typeof(Account).Assembly)
+ .ShouldNot()
+ .HaveDependencyOnAny("Banking.Services", "Banking.Infrastructure", "Banking.Api")
+ .GetResult();
+
+ Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
+ }
+
+ [Fact]
+ public void Services_DoNotDependOnTheOuterRing()
+ {
+ var result = Types.InAssembly(typeof(IAccountService).Assembly)
+ .ShouldNot()
+ .HaveDependencyOnAny("Banking.Infrastructure", "Banking.Api",
+ "Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
+ .GetResult();
+
+ Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
+ }
+
+ [Fact]
+ public void Endpoints_GoThroughTheServices()
+ {
+ var result = Types.InAssembly(typeof(AccountEndpoints).Assembly)
+ .That()
+ .ResideInNamespace("Banking.Api")
+ .ShouldNot()
+ .HaveDependencyOnAny("Banking.Infrastructure", "Banking.Domain.IAccountRepository")
+ .GetResult();
+
+ Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Account.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Account.cs
new file mode 100644
index 000000000..ab82a1d79
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Account.cs
@@ -0,0 +1,34 @@
+namespace Banking.Domain;
+
+public sealed class Account
+{
+ private Account(string owner, decimal balance)
+ {
+ Owner = owner;
+ Balance = balance;
+ }
+
+ public int Id { get; private set; }
+ public string Owner { get; private set; }
+ public decimal Balance { get; private set; }
+
+ public static Account Open(string owner, decimal initialDeposit)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(owner);
+ ArgumentOutOfRangeException.ThrowIfNegative(initialDeposit);
+
+ return new Account(owner, initialDeposit);
+ }
+
+ public Result Withdraw(decimal amount)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
+
+ if (amount > Balance)
+ return AccountErrors.InsufficientFunds(Id);
+
+ Balance -= amount;
+
+ return Result.Success();
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/AccountErrors.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/AccountErrors.cs
new file mode 100644
index 000000000..fb6dedf99
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/AccountErrors.cs
@@ -0,0 +1,10 @@
+namespace Banking.Domain;
+
+public static class AccountErrors
+{
+ public static Error NotFound(int accountId) =>
+ new("Account.NotFound", $"Account {accountId} was not found.", ErrorType.NotFound);
+
+ public static Error InsufficientFunds(int accountId) =>
+ new("Account.InsufficientFunds", $"Account {accountId} doesn't have enough money for this withdrawal.", ErrorType.Conflict);
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Banking.Domain.csproj b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Banking.Domain.csproj
new file mode 100644
index 000000000..b76014470
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Banking.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/IAccountRepository.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/IAccountRepository.cs
new file mode 100644
index 000000000..5587568c8
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/IAccountRepository.cs
@@ -0,0 +1,8 @@
+namespace Banking.Domain;
+
+public interface IAccountRepository
+{
+ Task GetByIdAsync(int accountId, CancellationToken cancellationToken = default);
+
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Result.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Result.cs
new file mode 100644
index 000000000..597112ba3
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Domain/Result.cs
@@ -0,0 +1,39 @@
+namespace Banking.Domain;
+
+public enum ErrorType
+{
+ NotFound,
+ Conflict
+}
+
+public sealed record Error(string Code, string Description, ErrorType Type);
+
+public class Result
+{
+ protected Result(Error? error) => Error = error;
+
+ public Error? Error { get; }
+ public bool IsSuccess => Error is null;
+
+ public static Result Success() => new(null);
+
+ public static implicit operator Result(Error error) => new(error);
+}
+
+public sealed class Result : Result
+{
+ private readonly TValue? _value;
+
+ private Result(TValue? value, Error? error)
+ : base(error) => _value = value;
+
+ public TValue Value => IsSuccess
+ ? _value!
+ : throw new InvalidOperationException("A failed result has no value.");
+
+ public static implicit operator Result(TValue value) =>
+ new(value, null);
+
+ public static implicit operator Result(Error error) =>
+ new(default, error);
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/AccountRepository.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/AccountRepository.cs
new file mode 100644
index 000000000..dace4285f
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/AccountRepository.cs
@@ -0,0 +1,13 @@
+using Banking.Domain;
+using Microsoft.EntityFrameworkCore;
+
+namespace Banking.Infrastructure;
+
+internal sealed class AccountRepository(BankingDbContext dbContext) : IAccountRepository
+{
+ public Task GetByIdAsync(int accountId, CancellationToken cancellationToken = default) =>
+ dbContext.Accounts.FirstOrDefaultAsync(a => a.Id == accountId, cancellationToken);
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
+ dbContext.SaveChangesAsync(cancellationToken);
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/Banking.Infrastructure.csproj b/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/Banking.Infrastructure.csproj
new file mode 100644
index 000000000..fb5f147fd
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/Banking.Infrastructure.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/BankingDbContext.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/BankingDbContext.cs
new file mode 100644
index 000000000..2d89b5b60
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/BankingDbContext.cs
@@ -0,0 +1,22 @@
+using Banking.Domain;
+using Microsoft.EntityFrameworkCore;
+
+namespace Banking.Infrastructure;
+
+public sealed class BankingDbContext(DbContextOptions options)
+ : DbContext(options)
+{
+ public DbSet Accounts => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(builder =>
+ {
+ builder.Property(a => a.Owner).HasMaxLength(100);
+
+ builder.HasData(
+ new { Id = 1, Owner = "Anna Smith", Balance = 100m },
+ new { Id = 2, Owner = "Mark Jones", Balance = 20m });
+ });
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/DependencyInjection.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/DependencyInjection.cs
new file mode 100644
index 000000000..329085a15
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,17 @@
+using Banking.Domain;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Banking.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddInfrastructure(
+ this IServiceCollection services, string connectionString)
+ {
+ services.AddDbContext(options => options.UseSqlite(connectionString));
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Services/AccountService.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Services/AccountService.cs
new file mode 100644
index 000000000..036ecbb7a
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Services/AccountService.cs
@@ -0,0 +1,35 @@
+using Banking.Domain;
+
+namespace Banking.Services;
+
+internal sealed class AccountService(IAccountRepository accounts) : IAccountService
+{
+ public async Task> GetByIdAsync(
+ int accountId, CancellationToken cancellationToken = default)
+ {
+ var account = await accounts.GetByIdAsync(accountId, cancellationToken);
+ if (account is null)
+ return AccountErrors.NotFound(accountId);
+
+ return ToDto(account);
+ }
+
+ public async Task> WithdrawAsync(
+ int accountId, decimal amount, CancellationToken cancellationToken = default)
+ {
+ var account = await accounts.GetByIdAsync(accountId, cancellationToken);
+ if (account is null)
+ return AccountErrors.NotFound(accountId);
+
+ var withdrawal = account.Withdraw(amount);
+ if (!withdrawal.IsSuccess)
+ return withdrawal.Error!;
+
+ await accounts.SaveChangesAsync(cancellationToken);
+
+ return ToDto(account);
+ }
+
+ private static AccountDto ToDto(Account account) =>
+ new(account.Id, account.Owner, account.Balance);
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Services/Banking.Services.csproj b/csharp-architectural-patterns/OnionArchitecture/Banking.Services/Banking.Services.csproj
new file mode 100644
index 000000000..4396d1e70
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Services/Banking.Services.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Services/DependencyInjection.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Services/DependencyInjection.cs
new file mode 100644
index 000000000..972e1d99f
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Services/DependencyInjection.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Banking.Services;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddServices(this IServiceCollection services)
+ {
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.Services/IAccountService.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.Services/IAccountService.cs
new file mode 100644
index 000000000..da8c10b64
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.Services/IAccountService.cs
@@ -0,0 +1,12 @@
+using Banking.Domain;
+
+namespace Banking.Services;
+
+public sealed record AccountDto(int Id, string Owner, decimal Balance);
+
+public interface IAccountService
+{
+ Task> GetByIdAsync(int accountId, CancellationToken cancellationToken = default);
+
+ Task> WithdrawAsync(int accountId, decimal amount, CancellationToken cancellationToken = default);
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/AccountServiceTests.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/AccountServiceTests.cs
new file mode 100644
index 000000000..50b55ab88
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/AccountServiceTests.cs
@@ -0,0 +1,49 @@
+using Banking.Domain;
+using Banking.Services;
+
+namespace Banking.UnitTests;
+
+public class AccountServiceTests
+{
+ [Fact]
+ public async Task WithdrawAsync_WhenTheBalanceCoversIt_SavesOnce()
+ {
+ var accounts = new FakeAccountRepository(Account.Open("Anna Smith", initialDeposit: 100m));
+ var service = new AccountService(accounts);
+
+ var result = await service.WithdrawAsync(
+ accountId: 1, amount: 30m, TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(70m, result.Value.Balance);
+ Assert.Equal(1, accounts.SaveCount);
+ }
+
+ [Fact]
+ public async Task WithdrawAsync_WhenTheBalanceIsTooLow_SavesNothing()
+ {
+ var accounts = new FakeAccountRepository(Account.Open("Mark Jones", initialDeposit: 20m));
+ var service = new AccountService(accounts);
+
+ var result = await service.WithdrawAsync(
+ accountId: 2, amount: 50m, TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal("Account.InsufficientFunds", result.Error!.Code);
+ Assert.Equal(0, accounts.SaveCount);
+ }
+}
+
+internal sealed class FakeAccountRepository(Account? account) : IAccountRepository
+{
+ public int SaveCount { get; private set; }
+
+ public Task GetByIdAsync(int accountId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(account);
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ SaveCount++;
+ return Task.CompletedTask;
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/AccountTests.cs b/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/AccountTests.cs
new file mode 100644
index 000000000..57a5fff58
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/AccountTests.cs
@@ -0,0 +1,29 @@
+using Banking.Domain;
+
+namespace Banking.UnitTests;
+
+public class AccountTests
+{
+ [Fact]
+ public void Withdraw_WhenTheBalanceCoversIt_TakesTheMoney()
+ {
+ var account = Account.Open("Anna Smith", initialDeposit: 100m);
+
+ var result = account.Withdraw(30m);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(70m, account.Balance);
+ }
+
+ [Fact]
+ public void Withdraw_WhenTheBalanceIsTooLow_ReturnsAnErrorAndKeepsTheMoney()
+ {
+ var account = Account.Open("Mark Jones", initialDeposit: 20m);
+
+ var result = account.Withdraw(50m);
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal("Account.InsufficientFunds", result.Error!.Code);
+ Assert.Equal(20m, account.Balance);
+ }
+}
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/Banking.UnitTests.csproj b/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/Banking.UnitTests.csproj
new file mode 100644
index 000000000..7fb25bc03
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.UnitTests/Banking.UnitTests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/csharp-architectural-patterns/OnionArchitecture/Banking.sln b/csharp-architectural-patterns/OnionArchitecture/Banking.sln
new file mode 100644
index 000000000..5c1f8c4ef
--- /dev/null
+++ b/csharp-architectural-patterns/OnionArchitecture/Banking.sln
@@ -0,0 +1,104 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Banking.Domain", "Banking.Domain\Banking.Domain.csproj", "{06F01CD1-449D-486C-ABF8-03D3DB1EED4E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Banking.Services", "Banking.Services\Banking.Services.csproj", "{F416B60F-6BC7-47FC-8316-110B48BD64EE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Banking.Infrastructure", "Banking.Infrastructure\Banking.Infrastructure.csproj", "{45B57D8B-8530-4679-94E8-7DF1D3793EB2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Banking.Api", "Banking.Api\Banking.Api.csproj", "{C78040F3-EE19-4DC7-93F1-E086D8896D02}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Banking.UnitTests", "Banking.UnitTests\Banking.UnitTests.csproj", "{A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Banking.ArchitectureTests", "Banking.ArchitectureTests\Banking.ArchitectureTests.csproj", "{A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Debug|x64.Build.0 = Debug|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Debug|x86.Build.0 = Debug|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Release|x64.ActiveCfg = Release|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Release|x64.Build.0 = Release|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Release|x86.ActiveCfg = Release|Any CPU
+ {06F01CD1-449D-486C-ABF8-03D3DB1EED4E}.Release|x86.Build.0 = Release|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Debug|x64.Build.0 = Debug|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Debug|x86.Build.0 = Debug|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Release|x64.ActiveCfg = Release|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Release|x64.Build.0 = Release|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Release|x86.ActiveCfg = Release|Any CPU
+ {F416B60F-6BC7-47FC-8316-110B48BD64EE}.Release|x86.Build.0 = Release|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Debug|x64.Build.0 = Debug|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Debug|x86.Build.0 = Debug|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Release|x64.ActiveCfg = Release|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Release|x64.Build.0 = Release|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Release|x86.ActiveCfg = Release|Any CPU
+ {45B57D8B-8530-4679-94E8-7DF1D3793EB2}.Release|x86.Build.0 = Release|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Debug|x64.Build.0 = Debug|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Debug|x86.Build.0 = Debug|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Release|x64.ActiveCfg = Release|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Release|x64.Build.0 = Release|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Release|x86.ActiveCfg = Release|Any CPU
+ {C78040F3-EE19-4DC7-93F1-E086D8896D02}.Release|x86.Build.0 = Release|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Debug|x64.Build.0 = Debug|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Debug|x86.Build.0 = Debug|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Release|x64.ActiveCfg = Release|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Release|x64.Build.0 = Release|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Release|x86.ActiveCfg = Release|Any CPU
+ {A100026C-6EDF-443A-9BE5-5DB3FC4F7EC6}.Release|x86.Build.0 = Release|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Debug|x64.Build.0 = Debug|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Debug|x86.Build.0 = Debug|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Release|x64.ActiveCfg = Release|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Release|x64.Build.0 = Release|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Release|x86.ActiveCfg = Release|Any CPU
+ {A7E49AE1-CCB6-44C9-9CD4-0CA4AF9A18C9}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal