Banking System with DDD
Build a banking system in C# with domain-driven design: aggregates, value objects, domain events and CQRS.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Expert Project — Aggregates, Value Objects, Domain Events, CQRS
🧠 Project Overview
A bank cannot afford a balance that drifts. If one code path lets a savings account fall below its minimum, or a transfer credits the target without debiting the source, no amount of tidy controller code will save you — the numbers are simply wrong, and they stay wrong. This project is about putting the rules in exactly one place, inside the object that owns the data, so that no caller anywhere in the system can break them even by accident.
You are building the domain core of a retail banking system in C#. Current and savings accounts can be opened, deposited into, withdrawn from, transferred between, and frozen. Every one of those operations is a method on the account itself, and every method refuses to run when the rule it guards would be violated. The account also keeps its own transaction history and records domain events describing what happened, so that email, audit logging and fraud screening can react to a transfer without the account knowing any of those things exist.
When it works, you can open a current account for Alice with £1,000, open one for Bob with £500, move £300 across, and read back two balances that agree with each other plus a matching transaction on each side. Ask Alice's £200 account for a £500 withdrawal and you get a DomainException, not a negative balance. Freeze the account and every operation on it is refused from then on — note that this version has Freeze() but nothing that sets the status back to active, which is your first extension. None of that behaviour lives in a service class or an API endpoint — it lives in BankAccount, which is the point.
This is the shape used by real financial software, and by order systems, insurance policy engines, booking platforms and anything else where "the data must never be wrong" outranks "this must be quick to type". Domain-Driven Design is the name for the approach; this project is a small but complete example of its tactical patterns, and the same three layers — domain, application, tests — scale to systems a hundred times this size.
🎯 Requirements
- • Open Current and Savings accounts with minimum deposit rules
- • Deposit, withdraw, and transfer between accounts
- • Transaction history with complete audit trail
- • Account freezing/closing with state validation
- • Domain events for notifications and audit logging
- • Full unit test coverage of domain logic
🧩 Core Concepts
Aggregate roots and invariants
An invariant is a statement that must be true about your data at every moment a caller could observe it — "a savings balance is never below £500", "the sum of an account's transactions equals its balance". An aggregate is the cluster of objects that must change together for those statements to hold, and the aggregate root is the one object outsiders are allowed to touch. Here the root is the account and the transactions are inside it, so nobody can add a transaction without the balance moving to match.
Value objects
A value object has no identity: two of them holding the same values are the same thing. Money is one, and it exists so that an amount always carries its currency and can never be silently added to a different one. A plain decimal lets you add pounds to euros and get a number that looks perfectly reasonable; a Money type refuses, and it refuses at the exact line where the mistake was made.
Domain events
A domain event is a record that something meaningful happened, expressed in the language of the business: an account was opened, a transfer completed. The aggregate appends the event to a private list and then forgets about it. Something outside the domain picks the list up later and decides what to do, which is how you send a text message on a transfer without your account class taking a dependency on an SMS provider.
Repositories and the unit of work
A repository is an interface the domain owns and the infrastructure implements; the domain says "give me the account with this id" and does not care whether that is SQL, a document store or an in-memory dictionary in a test. The unit of work is separate on purpose: it is what turns several staged changes into a single all-or-nothing commit. Without it, two separate saves can leave one account debited and the other not credited.
Application handlers versus domain logic
The application layer coordinates but does not decide. A handler loads the objects it needs, calls one domain method, saves, and publishes events. The moment a business rule appears in a handler, that rule now exists in two places and the copies will diverge. A useful test of the boundary: if you deleted the handler, would any rule be lost? The answer should be no.
Failing loudly with a domain exception
DomainException is your own type, and having a dedicated one matters more than it looks. It lets the API layer map every broken business rule to a 400-family response and every other exception to a 500, without string-matching messages. It also makes tests exact: asserting that a specific exception type came out is a much stronger claim than asserting that something went wrong.
Step 1: Aggregate Root
Read the property list first and notice what is missing: there is not a single public setter. Balance, Status, Type and the two collections are all private set or backed by a private field, so the only way a balance can change is by calling Deposit, Withdraw or TransferTo — and each of those checks the rules before it moves anything. That is the whole mechanism. Encapsulation here is not a style preference; it is what makes the invariant enforceable rather than merely documented.
The static Open factory exists for the same reason. A constructor is awkward at refusing to build an object, and it cannot express the idea that opening an account is an event which produces both an opening transaction and a domain event. A named factory can: it validates the minimum deposit for the account type, builds the account already in a valid state, records the opening deposit in the history, and appends an AccountOpenedEvent. An account that exists is therefore, by construction, an account that was legally opened.
The two collections are handed out as IReadOnlyCollection<T> wrappers over private lists. If the getter returned the underlying List<Transaction> instead, any caller could add a fabricated transaction and your audit trail would be worthless — the invariant would leak straight out through the property.
// Domain/Aggregates/BankAccount.cs
public class BankAccount
{
public Guid Id { get; private set; }
public string OwnerId { get; private set; } = "";
public string OwnerName { get; private set; } = "";
public AccountType Type { get; private set; }
public Money Balance { get; private set; } = new(0, "GBP");
public AccountStatus Status { get; private set; }
private readonly List<Transaction> _transactions = new();
public IReadOnlyCollection<Transaction> Transactions => _transactions.AsReadOnly();
private readonly List<IDomainEvent> _events = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _events.AsReadOnly();
public static BankAccount Open(string ownerId, string name,
AccountType type, Money initialDeposit)
{
var minDeposit = type == AccountType.Savings ? 500m : 100m;
if (initialDeposit.Amount < minDeposit)
throw new DomainException($"Minimum deposit for {type}: {minDeposit}");
var account = new BankAccount
{
Id = Guid.NewGuid(), OwnerId = ownerId,
OwnerName = name, Type = type,
Balance = initialDeposit, Status = AccountStatus.Active
};
account._transactions.Add(Transaction.Create(
TransactionType.Deposit, initialDeposit, "Opening deposit"));
account._events.Add(new AccountOpenedEvent(account.Id, ownerId, type));
return account;
}
public void Deposit(Money amount, string description)
{
EnsureActive();
if (amount.Amount <= 0) throw new DomainException("Deposit must be positive");
Balance = Balance.Add(amount);
_transactions.Add(Transaction.Create(TransactionType.Deposit, amount, description));
}
public void Withdraw(Money amount, string description)
{
EnsureActive();
if (amount.Amount > Balance.Amount)
throw new DomainException("Insufficient funds");
if (Type == AccountType.Savings && Balance.Subtract(amount).Amount < 500)
throw new DomainException("Savings minimum balance: £500");
Balance = Balance.Subtract(amount);
_transactions.Add(Transaction.Create(TransactionType.Withdrawal, amount, description));
}
public void TransferTo(BankAccount target, Money amount)
{
Withdraw(amount, $"Transfer to {target.Id}");
target.Deposit(amount, $"Transfer from {Id}");
_events.Add(new TransferCompletedEvent(Id, target.Id, amount));
}
public void Freeze() { EnsureActive(); Status = AccountStatus.Frozen; }
private void EnsureActive()
{
if (Status != AccountStatus.Active)
throw new DomainException($"Account is {Status}");
}
}
public enum AccountType { Current, Savings }
public enum AccountStatus { Active, Frozen, Closed }The mistake nearly everyone makes here is putting the balance check in the caller instead of the aggregate. The moment a line like "if the balance is at least the amount, then withdraw" appears in a controller, the rule exists twice, and the second caller written six months later will not have it. Keeping the guard inside Withdraw means the rule cannot be bypassed, only refused.
Two things in this version are worth arguing with, and you should. Withdraw has no positivity check even though Deposit does, so a negative amount would slip past the funds test and quietly increase the balance — add the same guard. And the savings minimum of 500 is written twice, once in Open and once in Withdraw; duplicated business numbers always drift, so pull it into a single constant or a small per-type policy object.
There is also a genuine hole in TransferTo: it debits the source before it credits the target, so if the target turns out to be frozen, Deposit throws after the source has already been mutated in memory. Nothing reaches the database, because the exception escapes before the handler saves — but if you go on using that same instance in the same request, its balance is now wrong. Check the target's state before touching the source, so nothing is mutated until both sides are known to be good. Reversing the order instead — credit first, debit second — is worse than the bug: a failed Withdraw would leave the target holding money that never left any account.
Step 2: Transfer Use Case
The handler is the application layer, and its job description is deliberately boring: load, delegate, save, publish. Read it and notice that it contains no banking rules whatsoever. It does not know the minimum balance for a savings account, it never compares two amounts, and it never touches a balance directly. The single line calling TransferTo is where all the thinking happens; everything around it is plumbing.
Two UpdateAsync calls followed by one CommitAsync is the atomicity story in miniature. Update stages a change; the unit of work is what turns both staged changes into a single database transaction, so either both balances move or neither does. This is precisely why the repository and the unit of work are separate interfaces instead of one Save method per entity — per-entity saves cannot be made atomic with respect to each other.
Events are dispatched after the commit rather than before, and that ordering is a considered decision rather than an accident of typing. Publishing first would risk sending a "transfer completed" notification for a transfer that then failed to save, and a customer who receives a confirmation for money that never moved will not be reassured by your architecture diagram.
// Application/UseCases/TransferFundsHandler.cs
public class TransferFundsHandler
{
private readonly IAccountRepository _repo;
private readonly IUnitOfWork _uow;
private readonly IDomainEventDispatcher _events;
public TransferFundsHandler(IAccountRepository repo,
IUnitOfWork uow, IDomainEventDispatcher events)
{
_repo = repo; _uow = uow; _events = events;
}
public async Task HandleAsync(TransferCommand cmd)
{
var source = await _repo.GetByIdAsync(cmd.SourceAccountId)
?? throw new NotFoundException("Source account not found");
var target = await _repo.GetByIdAsync(cmd.TargetAccountId)
?? throw new NotFoundException("Target account not found");
// Domain logic handles validation
source.TransferTo(target, new Money(cmd.Amount, "GBP"));
// Persist both accounts atomically
await _repo.UpdateAsync(source);
await _repo.UpdateAsync(target);
await _uow.CommitAsync();
// Dispatch domain events after successful save
await _events.DispatchAsync(source.DomainEvents);
source.ClearEvents();
}
}
public record TransferCommand(
Guid SourceAccountId, Guid TargetAccountId, decimal Amount);The trade-off this ordering buys you is worth naming out loud, because the two-line version above is not the finished answer. Dispatching after the commit means a dispatch failure loses the notification while the money has already moved. The standard fix is a transactional outbox: write the events into a table inside the same transaction as the balances, then let a separate worker read that table and publish. Understanding why that pattern exists is more valuable than copying it in on day one.
One more thing to catch: this handler calls ClearEvents(), and the aggregate in Step 1 does not define it. Treat that as homework — add a public method that empties the private events list. If you skip it, the account carries its event list forever and the next transfer republishes the previous one, so your customer gets two notifications for one movement.
Step 3: Domain Tests
These tests are cheap to write because the aggregate has no dependencies. Opening an account needs no database, no HTTP context, no configuration and no mock objects — that is the direct payoff for keeping the rules in the domain rather than scattering them through services. A test suite that runs in milliseconds is a test suite people actually run before pushing.
Notice the shape of the set: three of the five tests assert that something is refused. Beginners test the happy path and stop, but the happy path is not where the money goes missing. Each rejection test pins one invariant in place, so that if someone later "simplifies" the minimum balance check away, a named test tells them exactly which business rule they just deleted.
// Tests/BankAccountTests.cs
using Xunit;
public class BankAccountTests
{
[Fact]
public void Open_WithSufficientDeposit_CreatesAccount()
{
var account = BankAccount.Open("user-1", "Alice",
AccountType.Current, new Money(500, "GBP"));
Assert.Equal(500m, account.Balance.Amount);
Assert.Equal(AccountStatus.Active, account.Status);
Assert.Single(account.Transactions);
}
[Fact]
public void Open_SavingsBelowMinimum_Throws()
{
Assert.Throws<DomainException>(() =>
BankAccount.Open("user-1", "Alice",
AccountType.Savings, new Money(100, "GBP")));
}
[Fact]
public void Withdraw_InsufficientFunds_Throws()
{
var account = BankAccount.Open("user-1", "Alice",
AccountType.Current, new Money(200, "GBP"));
Assert.Throws<DomainException>(() =>
account.Withdraw(new Money(500, "GBP"), "Overspend"));
}
[Fact]
public void TransferTo_ValidAmount_UpdatesBothBalances()
{
var source = BankAccount.Open("u1", "Alice",
AccountType.Current, new Money(1000, "GBP"));
var target = BankAccount.Open("u2", "Bob",
AccountType.Current, new Money(500, "GBP"));
source.TransferTo(target, new Money(300, "GBP"));
Assert.Equal(700m, source.Balance.Amount);
Assert.Equal(800m, target.Balance.Amount);
}
[Fact]
public void Freeze_PreventsWithdrawals()
{
var account = BankAccount.Open("u1", "Alice",
AccountType.Current, new Money(1000, "GBP"));
account.Freeze();
Assert.Throws<DomainException>(() =>
account.Withdraw(new Money(100, "GBP"), "Test"));
}
}The mistake here is testing the handler instead of the aggregate. Handler tests need mocked repositories and a mocked dispatcher, they break whenever you rename a persistence method, and they tell you almost nothing about the rules. Test the domain hard and directly; test the handler once, for wiring, and let it stay thin enough that there is little else to check.
Add the tests this set is missing before you move on. Withdrawing a negative amount is the obvious one, since Step 1 leaves that door open. Then: depositing into a frozen account, transferring into a frozen account, transferring more than the source holds, and checking that a completed transfer left exactly one TransferCompletedEvent on the source. Each of those is three lines and each one buys you a night's sleep.
📋 Common Errors
CS0246: The type or namespace name 'Money' could not be found
The aggregate above references Money, Transaction, TransactionType, IDomainEvent, AccountOpenedEvent, TransferCompletedEvent and DomainException, none of which are defined on this page. They are yours to write. Start with DomainException as a class deriving from Exception, then Money as a small immutable type holding an amount and a currency code with Add and Subtract methods that reject a currency mismatch.
CS1061: 'BankAccount' does not contain a definition for 'ClearEvents'
Exactly the gap described in Step 2 — the handler calls it, the aggregate does not have it. Add a public method on BankAccount that clears the private events list. This error is also a good demonstration of why the compiler is your friend here: a missing method on a strongly typed aggregate is caught at build time rather than in production.
CS0272: ...cannot be used in this context because the set accessor is inaccessible
You tried to assign to Balance or Status from outside the class. This is not a bug to work around — it is the design doing its job. Route the change through Deposit, Withdraw or Freeze. If you genuinely need a new kind of change, add a new intention-revealing method rather than opening the setter.
CS1729: 'Money' does not contain a constructor that takes 2 arguments
Your Money type does not yet accept an amount and a currency. Every call on this page uses that two-argument shape, including the target-typed new(0, "GBP") on the Balance property, so give Money a constructor with those two parameters and keep them read-only afterwards.
CS8618: Non-nullable property must contain a non-null value when exiting constructor
A nullable-reference-types warning, and the reason OwnerId, OwnerName and Balance carry initializers in the code above. Because the object is built through an object initializer inside Open rather than through a parameterised constructor, the compiler cannot prove those properties get set. Either keep the defaults, or give the class a private constructor that takes every field.
A test fails on Assert.Throws
xUnit reports the exception type it expected next to the one it actually received. Usually this means a different exception got there first — a NullReferenceException because Money was never constructed, or a plain ArgumentException from your own validation code. The fix is nearly always to throw DomainException for broken business rules and to reserve the framework exception types for programming errors.
InvalidOperationException: Collection was modified; enumeration operation may not execute
Thrown while dispatching, when a handler reacting to one event causes the aggregate to add another to the same list you are iterating. Copy the events to an array before dispatching, then clear the original — which is another reason to give ClearEvents a well-defined place in the sequence.
The balances are right in memory but wrong in the database
Not a compiler error, and the most expensive one on this list. It means the changes were staged but never committed, or that each account was saved in its own transaction and the second save failed. Confirm that both UpdateAsync calls and the CommitAsync run inside one transaction, and write an integration test that kills the second save on purpose to prove the first one rolls back.
🚀 Enhancement Ideas
1. Overdraft policy for current accounts
Give the account an agreed overdraft limit and change the funds check to compare against balance plus limit rather than balance alone. Charge interest by adding a scheduled operation that records a Fee transaction. This is a good exercise in changing one rule without touching any caller, which is the promise encapsulation made you.
2. Replace the type check with a policy per account type
The minimum deposit and minimum balance are currently decided by comparing an enum inside two methods. Move them into a small object per account type that answers "what is the minimum deposit" and "may this withdrawal proceed". Adding a third account type then becomes a new class instead of edits scattered through the aggregate.
3. A transactional outbox for events
Instead of dispatching in the handler, persist each event into an outbox table inside the same transaction as the balances, and have a background worker publish and mark them as sent. This closes the window where money moves but the notification is lost, and it is the pattern you will meet in almost every production message-driven system.
4. Optimistic concurrency
Two transfers out of the same account at the same moment can both read a balance of £1,000 and both succeed. Add a version number to the aggregate, increment it on every change, and have the repository refuse a save whose version no longer matches the stored one. Then decide what your API should do when it loses the race — retry, or tell the caller.
5. Statements and running balances
Add a read model that projects the transaction history into a monthly statement with a running balance per line. Build it from the transactions rather than by writing new numbers, so the statement can never disagree with the account. This is the read side of CQRS in its simplest honest form.
6. Close accounts properly
The Closed status exists in the enum but nothing sets it. Add a Close method that refuses while the balance is non-zero, records a ClosingEvent, and blocks every subsequent operation through the same EnsureActive guard. Then write the test that proves a closed account cannot be reopened by anything except an explicit reopening method.
7. Expose it over an API
Put a minimal HTTP layer in front of the handlers, mapping DomainException to a 400-family response and NotFoundException to a 404. The endpoints should be almost empty: deserialise a command, call a handler, return a result. If an endpoint starts to grow logic, that logic belongs in the domain.
✅ Next Steps
- Write the supporting types first: DomainException, Money, Transaction, TransactionType and the event records. Nothing compiles until they exist.
- Bring in the aggregate from Step 1 and get the five tests from Step 3 passing before you add anything of your own.
- Fix the two gaps this page pointed out: the missing positivity guard in Withdraw and the duplicated savings minimum.
- Add ClearEvents to the aggregate, then wire up an in-memory repository so the handler in Step 2 can be run end to end.
- Swap the in-memory repository for a real database implementation without changing a single line of the domain — if you have to, the boundary was drawn in the wrong place.
- Add the missing tests: frozen deposits, frozen transfer targets, negative amounts, and the event list after a transfer.
- Push it to GitHub with a short README explaining the three layers and why the rules live where they do. Reviewers care far more about that explanation than about the line count.
Finish this and you will have the instinct that matters most in back-end work: when a new rule arrives, you will know without thinking about it whether it belongs in the aggregate, the handler, or neither.
Challenge Complete Checklist
- ☐ All account operations enforce business rules
- ☐ Transfers are atomic (both accounts updated or neither)
- ☐ Domain events trigger notifications
- ☐ Full unit test coverage of aggregate logic
- ☐ Published to GitHub with architecture diagram