Skip to content

Commit 50a3e17

Browse files
committed
Initial commit: companion code for all 32 chapters (Parts I-VII, 152 tests passing)
0 parents  commit 50a3e17

243 files changed

Lines changed: 15329 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main, master ]
6+
paths:
7+
- 'Code/**'
8+
- 'References/**'
9+
- '.github/workflows/ci.yml'
10+
pull_request:
11+
branches: [ main, master ]
12+
paths:
13+
- 'Code/**'
14+
- 'References/**'
15+
- '.github/workflows/ci.yml'
16+
17+
jobs:
18+
build-and-test:
19+
name: Build & Test — All Parts
20+
runs-on: ubuntu-latest
21+
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Setup .NET 9
27+
uses: actions/setup-dotnet@v4
28+
with:
29+
dotnet-version: '9.0.x'
30+
31+
- name: Restore dependencies
32+
run: dotnet restore Code/BankAccount.sln
33+
34+
- name: Build
35+
run: dotnet build Code/BankAccount.sln --no-restore --configuration Release
36+
37+
- name: Test — Part I (Thinking in Domains, Ch 1–6)
38+
run: dotnet test Code/Part-I/Part-I.sln --no-build --configuration Release --logger "trx;LogFileName=part-i-results.trx" --results-directory ./test-results
39+
continue-on-error: false
40+
41+
- name: Test — Part II (Modelling with Events, Ch 7–11)
42+
run: dotnet test Code/Part-II/Part-II.sln --no-build --configuration Release --logger "trx;LogFileName=part-ii-results.trx" --results-directory ./test-results
43+
continue-on-error: false
44+
45+
- name: Test — Part III (Commanding Change, Ch 12–16)
46+
run: dotnet test Code/Part-III/Part-III.sln --no-build --configuration Release --logger "trx;LogFileName=part-iii-results.trx" --results-directory ./test-results
47+
continue-on-error: false
48+
49+
- name: Test — Part IV (Reading the World, Ch 17–22)
50+
run: dotnet test Code/Part-IV/Part-IV.sln --no-build --configuration Release --logger "trx;LogFileName=part-iv-results.trx" --results-directory ./test-results
51+
continue-on-error: false
52+
53+
- name: Test — Part V (Infrastructure & Persistence, Ch 23–26)
54+
run: dotnet test Code/Part-V/Part-V.sln --no-build --configuration Release --logger "trx;LogFileName=part-v-results.trx" --results-directory ./test-results
55+
continue-on-error: false
56+
57+
- name: Test — Part VI (Production Readiness, Ch 27–29)
58+
run: dotnet test Code/Part-VI/Part-VI.sln --no-build --configuration Release --logger "trx;LogFileName=part-vi-results.trx" --results-directory ./test-results
59+
continue-on-error: false
60+
61+
- name: Test — Part VII (Inside the Framework, Ch 30–32)
62+
run: dotnet test Code/Part-VII/Part-VII.sln --no-build --configuration Release --logger "trx;LogFileName=part-vii-results.trx" --results-directory ./test-results
63+
continue-on-error: false
64+
65+
- name: Upload test results
66+
uses: actions/upload-artifact@v4
67+
if: always()
68+
with:
69+
name: test-results
70+
path: ./test-results/*.trx
71+
retention-days: 30
72+
73+
build-summary:
74+
name: Summary
75+
runs-on: ubuntu-latest
76+
needs: build-and-test
77+
if: always()
78+
steps:
79+
- name: Report status
80+
run: |
81+
if [ "${{ needs.build-and-test.result }}" == "success" ]; then
82+
echo "✅ All 7 parts built and 152 tests passed."
83+
else
84+
echo "❌ Build or tests failed. Check the build-and-test job for details."
85+
exit 1
86+
fi

.gitignore

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# .gitignore for DDD Event Sourcing companion code repository
2+
3+
# Build outputs
4+
bin/
5+
obj/
6+
7+
# Visual Studio
8+
.vs/
9+
*.user
10+
*.suo
11+
*.userosscache
12+
*.sln.docstates
13+
14+
# Rider
15+
.idea/
16+
17+
# Test results
18+
TestResults/
19+
*.trx
20+
*.coverage
21+
*.coveragexml
22+
23+
# NuGet
24+
*.nupkg
25+
*.snupkg
26+
packages/
27+
project.lock.json
28+
project.fragment.lock.json
29+
30+
# OS
31+
.DS_Store
32+
Thumbs.db
33+
34+
# Docker
35+
docker-compose.override.yml

BankAccount.sln

Lines changed: 266 additions & 0 deletions
Large diffs are not rendered by default.

Part-I/Part-I.sln

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
Microsoft Visual Studio Solution File, Format Version 12.00
2+
# Visual Studio Version 17
3+
VisualStudioVersion = 17.0.31903.59
4+
MinimumVisualStudioVersion = 10.0.40219.1
5+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9EA39149-7870-4C27-A7CD-DC130BF09635}"
6+
EndProject
7+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{5C75626C-2652-4AF3-B133-E12D4EDDB5FE}"
8+
EndProject
9+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BankAccount.Domain", "src\BankAccount.Domain\BankAccount.Domain.csproj", "{9B1729DB-4FBB-4A56-96DE-3290BBE5017B}"
10+
EndProject
11+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BankAccount.Domain.Tests", "tests\BankAccount.Domain.Tests\BankAccount.Domain.Tests.csproj", "{AD39EDD9-8D14-4316-B6B8-A0955CDBD7FE}"
12+
EndProject
13+
Global
14+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
15+
Debug|Any CPU = Debug|Any CPU
16+
Release|Any CPU = Release|Any CPU
17+
EndGlobalSection
18+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
19+
{9B1729DB-4FBB-4A56-96DE-3290BBE5017B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
20+
{9B1729DB-4FBB-4A56-96DE-3290BBE5017B}.Debug|Any CPU.Build.0 = Debug|Any CPU
21+
{9B1729DB-4FBB-4A56-96DE-3290BBE5017B}.Release|Any CPU.ActiveCfg = Release|Any CPU
22+
{9B1729DB-4FBB-4A56-96DE-3290BBE5017B}.Release|Any CPU.Build.0 = Release|Any CPU
23+
{AD39EDD9-8D14-4316-B6B8-A0955CDBD7FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
24+
{AD39EDD9-8D14-4316-B6B8-A0955CDBD7FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
25+
{AD39EDD9-8D14-4316-B6B8-A0955CDBD7FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
26+
{AD39EDD9-8D14-4316-B6B8-A0955CDBD7FE}.Release|Any CPU.Build.0 = Release|Any CPU
27+
EndGlobalSection
28+
GlobalSection(NestedProjects) = preSolution
29+
{9B1729DB-4FBB-4A56-96DE-3290BBE5017B} = {9EA39149-7870-4C27-A7CD-DC130BF09635}
30+
{AD39EDD9-8D14-4316-B6B8-A0955CDBD7FE} = {5C75626C-2652-4AF3-B133-E12D4EDDB5FE}
31+
EndGlobalSection
32+
EndGlobal
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// File: BankAccount.Domain/AccountId.cs
2+
#nullable enable
3+
4+
namespace BankAccount.Domain;
5+
6+
/// <summary>
7+
/// The identity of a bank account.
8+
/// An AccountId is a Value Object — two AccountIds with the same Value are equal.
9+
/// The distinction between a transient (not yet persisted) account and a persisted
10+
/// account is captured by <see cref="IsTransient"/> rather than by checking for zero
11+
/// throughout the codebase.
12+
/// </summary>
13+
/// <param name="Value">The underlying integer identifier. Zero represents a transient account.</param>
14+
public record AccountId(int Value)
15+
{
16+
/// <summary>
17+
/// Returns true if this AccountId has not yet been assigned by the persistence layer.
18+
/// A transient account has not been saved and does not have a database-generated identifier.
19+
/// </summary>
20+
public bool IsTransient => Value <= 0;
21+
22+
/// <summary>
23+
/// Creates an AccountId representing a new, not-yet-persisted account.
24+
/// </summary>
25+
public static AccountId Transient() => new(0);
26+
27+
/// <summary>
28+
/// Creates an AccountId from a known persisted value.
29+
/// </summary>
30+
/// <param name="value">The persisted identifier. Must be positive.</param>
31+
/// <exception cref="ArgumentOutOfRangeException">Thrown if value is not positive.</exception>
32+
public static AccountId From(int value)
33+
{
34+
if (value <= 0)
35+
throw new ArgumentOutOfRangeException(nameof(value),
36+
$"A persisted AccountId must be positive. Received: {value}");
37+
return new(value);
38+
}
39+
40+
/// <summary>Returns a string representation, e.g. "AccountId(42)".</summary>
41+
public override string ToString() => IsTransient ? "AccountId(transient)" : $"AccountId({Value})";
42+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// File: BankAccount.Domain/AccountNumber.cs
2+
#nullable enable
3+
4+
using System.Text.RegularExpressions;
5+
6+
namespace BankAccount.Domain;
7+
8+
/// <summary>
9+
/// A bank account number. This is a Value Object that enforces the account number
10+
/// format rule: all account numbers in our system start with "ACC-" followed by
11+
/// one or more digits. An AccountNumber that violates this rule cannot be constructed.
12+
/// </summary>
13+
public record AccountNumber
14+
{
15+
private static readonly Regex ValidPattern = new(@"^ACC-\d+$", RegexOptions.Compiled);
16+
17+
/// <summary>The formatted account number string.</summary>
18+
public string Value { get; }
19+
20+
/// <summary>
21+
/// Constructs an AccountNumber, validating the format.
22+
/// This is a non-positional record with a single constructor that
23+
/// validates its argument before assigning the property — the standard
24+
/// C# pattern for value objects that require invariant checking at creation time.
25+
/// </summary>
26+
/// <param name="value">The account number string to validate and wrap.</param>
27+
/// <exception cref="ArgumentException">Thrown if the format is invalid.</exception>
28+
public AccountNumber(string value)
29+
{
30+
if (string.IsNullOrWhiteSpace(value))
31+
throw new ArgumentException(
32+
"Account number cannot be empty or whitespace.", nameof(value));
33+
34+
if (!ValidPattern.IsMatch(value))
35+
throw new ArgumentException(
36+
$"Account number '{value}' does not match the required format 'ACC-<digits>'.",
37+
nameof(value));
38+
39+
Value = value;
40+
}
41+
42+
/// <summary>Returns the account number string.</summary>
43+
public override string ToString() => Value;
44+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// File: BankAccount.Domain/AccountsContext/AccountCreditApplicationService.cs
2+
#nullable enable
3+
4+
using BankAccount.Domain;
5+
using BankAccount.Domain.NotificationsContext;
6+
7+
namespace BankAccount.Domain.AccountsContext;
8+
9+
/// <summary>
10+
/// An application service that orchestrates the credit operation and the resulting
11+
/// notification. This is the only place where both contexts meet — and they meet
12+
/// at the boundary, not inside either context's domain model.
13+
/// </summary>
14+
public sealed class AccountCreditApplicationService
15+
{
16+
private readonly BankAccountService _accountService;
17+
private readonly AccountNotificationAdapter _adapter;
18+
private readonly INotificationSender _notifications;
19+
20+
/// <summary>Initialises the application service with its dependencies.</summary>
21+
public AccountCreditApplicationService(
22+
BankAccountService accountService,
23+
AccountNotificationAdapter adapter,
24+
INotificationSender notifications)
25+
{
26+
_accountService = accountService;
27+
_adapter = adapter;
28+
_notifications = notifications;
29+
}
30+
31+
/// <summary>
32+
/// Credits an account and dispatches a notification to the account holder.
33+
/// The credit operation is a pure Accounts concern.
34+
/// The notification is translated at the boundary before dispatch.
35+
/// </summary>
36+
/// <param name="accountId">The account to credit.</param>
37+
/// <param name="amount">The amount to credit.</param>
38+
/// <param name="holder">The account holder, used to construct the notification target.</param>
39+
public async Task CreditAndNotify(int accountId, Money amount, AccountHolder holder)
40+
{
41+
await _accountService.CreditAccount(accountId, amount);
42+
43+
var target = _adapter.ToNotificationTarget(holder);
44+
var notification = new AccountCreditedNotification(
45+
Target: target,
46+
CreditedAmount: amount.ToString(),
47+
// Note: CreditAndNotify cannot easily provide the updated balance without
48+
// re-querying the repository or changing CreditAccount's return type.
49+
// This is a limitation of the current design that the domain event pattern
50+
// in Chapter 6 resolves: the AccountCredited event carries NewBalance directly
51+
// from the aggregate, eliminating the need for a second query.
52+
NewBalance: "See account statement",
53+
OccurredOn: DateTime.UtcNow);
54+
55+
await _notifications.SendCreditNotification(notification);
56+
}
57+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// File: BankAccount.Domain/AccountsContext/AccountHolder.cs
2+
#nullable enable
3+
4+
namespace BankAccount.Domain.AccountsContext;
5+
6+
/// <summary>
7+
/// The Accounts context's model of an account holder.
8+
/// This is NOT a customer in the CRM sense — it is specifically the person
9+
/// who holds a bank account in our Accounts bounded context.
10+
/// It contains only the information the Accounts context needs.
11+
/// </summary>
12+
/// <param name="FullName">The account holder's full legal name.</param>
13+
/// <param name="AccountNumber">The account number associated with this holder.</param>
14+
public record AccountHolder(string FullName, string AccountNumber);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net9.0</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
</Project>

0 commit comments

Comments
 (0)