Skip to content

Commit 9c746b5

Browse files
authored
docs: add ADR-002 through ADR-010 and update ADR-001 related links (#226)
1 parent f642687 commit 9c746b5

10 files changed

Lines changed: 944 additions & 3 deletions

docs/adr/ADR-001-clean-architecture.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,6 @@ The project adopts **Clean Architecture** (also known as Ports and Adapters / He
128128

129129
## Related ADRs
130130

131-
- [ADR-002 — CQRS in the Application Layer](ADR-002-cqrs.md) *(forthcoming)*
132-
- [ADR-004 — Azure Cosmos DB as the Primary Game Persistence Backend](ADR-004-cosmosdb.md) *(forthcoming)*
133-
- [ADR-005 — In-Memory Repository Scoped to Puzzle Generation](ADR-005-in-memory-puzzle-repository.md) *(forthcoming)*
131+
- [ADR-002 — CQRS in the Application Layer](ADR-002-cqrs.md)
132+
- [ADR-004 — Azure Cosmos DB as the Primary Game Persistence Backend](ADR-004-cosmosdb.md)
133+
- [ADR-005 — In-Memory Repository Scoped to Puzzle Generation](ADR-005-in-memory-puzzle-repository.md)

docs/adr/ADR-002-cqrs.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# ADR-002 — CQRS in the Application Layer
2+
3+
| Field | Value |
4+
|--------------|---------------------|
5+
| **Date** | 2026-04-15 |
6+
| **Status** | Accepted |
7+
| **Deciders** | Project maintainers |
8+
9+
---
10+
11+
## Context
12+
13+
As the Sudoku application grew, a single `IGameApplicationService` interface was accumulating both state-mutating operations (create game, make move, abandon game) and read operations (get game, get player games, validate game). This conflation caused several problems:
14+
15+
- **Concurrency and consistency**: Write paths require domain invariant enforcement and event raising; read paths benefit from lighter-weight execution paths that do not touch the domain model.
16+
- **Testability**: A monolithic service interface forces all handler tests to configure a large dependency surface even when testing a single operation.
17+
- **Extensibility**: Adding a new query or command to a shared service contract requires touching a single growing interface, increasing merge friction.
18+
- **Auditability**: Without separation, it is unclear which operations mutate state and which are safe to cache or replay.
19+
20+
A pattern was needed that would enforce the separation of intent between reads and writes at the application boundary.
21+
22+
---
23+
24+
## Decision
25+
26+
The Application layer (`Sudoku.Application`) adopts **CQRS (Command Query Responsibility Segregation)** implemented via **MediatR** as the in-process mediator.
27+
28+
### Core Abstractions
29+
30+
| Abstraction | Contract | Return Type |
31+
|---|---|---|
32+
| `ICommand` | Extends `IRequest<Result>` | `Result` (success/failure) |
33+
| `ICommandHandler<TCommand>` | Extends `IRequestHandler<TCommand, Result>` | `Result` |
34+
| `IQuery<TResponse>` | Extends `IRequest<Result<TResponse>>` | `Result<TResponse>` |
35+
| `IQueryHandler<TQuery, TResponse>` | Extends `IRequestHandler<TQuery, Result<TResponse>>` | `Result<TResponse>` |
36+
37+
All commands and queries are dispatched through MediatR's `IMediator` interface. Handlers are registered automatically via MediatR's assembly scanning at startup.
38+
39+
### Registered Commands (14)
40+
41+
`AbandonGameCommand`, `AddPossibleValueCommand`, `ClearPossibleValuesCommand`, `CompleteGameCommand`, `CreateGameCommand`, `DeleteGameCommand`, `DeletePlayerGamesCommand`, `MakeMoveCommand`, `PauseGameCommand`, `RemovePossibleValueCommand`, `ResetGameCommand`, `ResumeGameCommand`, `StartGameCommand`, `UndoLastMoveCommand`
42+
43+
### Registered Queries (4)
44+
45+
`GetGameQuery`, `GetPlayerGamesQuery`, `GetPlayerGamesByStatusQuery`, `ValidateGameQuery`
46+
47+
### Interaction Flow
48+
49+
```mermaid
50+
sequenceDiagram
51+
participant API as Sudoku.Api
52+
participant Mediator as MediatR IMediator
53+
participant Handler as CommandHandler / QueryHandler
54+
participant Domain as Sudoku.Domain
55+
participant Repo as IGameRepository
56+
57+
API->>Mediator: Send(command / query)
58+
Mediator->>Handler: Handle(command / query)
59+
Handler->>Domain: Call aggregate method (commands only)
60+
Domain-->>Handler: Raise domain events / return state
61+
Handler->>Repo: SaveAsync / GetByIdAsync
62+
Repo-->>Handler: Result
63+
Handler-->>Mediator: Result / Result<TResponse>
64+
Mediator-->>API: Result / Result<TResponse>
65+
```
66+
67+
### Rules
68+
69+
1. **Commands never return domain objects.** They return `Result` (success/failure with optional error detail). If the caller needs updated state, it issues a subsequent query.
70+
2. **Queries never mutate state.** A query handler must not call any repository `SaveAsync`, `DeleteAsync`, or domain method that raises events.
71+
3. **Handlers are the only consumers of repository interfaces and domain aggregates.** Controllers call `IMediator.Send()`; they do not touch `IGameRepository` directly.
72+
4. **MediatR pipeline behaviors** (e.g., validation, logging) may be registered globally and apply to all commands and queries without modifying individual handlers.
73+
74+
---
75+
76+
## Consequences
77+
78+
### Positive
79+
80+
- **Separation of concerns**: Write and read paths are independently testable and independently evolvable.
81+
- **Handler isolation**: Each handler has a narrow dependency surface. Tests configure only the dependencies relevant to that operation.
82+
- **Pipeline extensibility**: Cross-cutting concerns (validation, logging, timing) are added via MediatR pipeline behaviors without modifying any handler.
83+
- **Explicit intent**: The presence of a command communicates mutation intent; the presence of a query communicates read intent — both at compile time.
84+
- **Scalability path**: The pattern leaves the door open to separate read and write models (e.g., a denormalized read projection backed by a separate Cosmos DB container) without requiring structural refactoring.
85+
86+
### Tradeoffs
87+
88+
- **MediatR coupling**: All application orchestration is coupled to MediatR as an in-process mediator. Replacing MediatR would require changing all handler registrations and call sites in the API layer.
89+
- **Indirection**: A simple operation (e.g., `GetGame`) passes through `IMediator → GetGameQueryHandler → IGameRepository` rather than a direct service call. This is an intentional tradeoff for consistency and testability.
90+
- **No out-of-process messaging**: The current CQRS implementation is in-process only. Distributing commands or queries to a message bus (e.g., Azure Service Bus) would require introducing a separate messaging infrastructure layer.
91+
92+
### Rules Enforced by This Decision
93+
94+
1. **All new application operations must be expressed as a command or query**, not as a method added to `IGameApplicationService` or `IPlayerApplicationService`.
95+
2. **Never inject `IGameRepository` into a controller.** Repository access belongs exclusively in handlers.
96+
3. **Command handlers raise domain events; query handlers do not.**
97+
4. **Never return domain entities from a handler.** Commands return `Result`; queries return `Result<TDto>`.
98+
99+
---
100+
101+
## Related ADRs
102+
103+
- [ADR-001 — Adoption of Clean Architecture](ADR-001-clean-architecture.md)
104+
- [ADR-003 — Specification Pattern for Repository Queries](ADR-003-specification-pattern.md) *(forthcoming)*
105+
- [ADR-004 — Azure Cosmos DB as the Primary Game Persistence Backend](ADR-004-cosmosdb.md) *(forthcoming)*
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# ADR-003 — Specification Pattern for Repository Queries
2+
3+
| Field | Value |
4+
|--------------|---------------------|
5+
| **Date** | 2026-04-15 |
6+
| **Status** | Accepted |
7+
| **Deciders** | Project maintainers |
8+
9+
---
10+
11+
## Context
12+
13+
`IGameRepository` exposes a set of query methods for filtered access to game data. Without a structured approach, every new filtering requirement would add a new method to `IGameRepository` and require implementation in every concrete repository (`CosmosDbGameRepository`, `AzureBlobGameRepository`). This leads to interface proliferation and duplicated filter logic.
14+
15+
Additionally, the Application layer must remain free of persistence-specific query syntax (e.g., SQL, OData, Cosmos DB query API). It must express query intent in a persistence-agnostic way that can be evaluated by any `IGameRepository` implementation.
16+
17+
---
18+
19+
## Decision
20+
21+
The Application layer (`Sudoku.Application`) defines an `ISpecification<T>` interface that encapsulates filter criteria, ordering, and paging as LINQ expressions. Concrete specifications live in `Sudoku.Application.Specifications`. Repositories consume `ISpecification<T>` through the `GetBySpecificationAsync`, `GetSingleBySpecificationAsync`, and `CountBySpecificationAsync` methods on `IGameRepository`.
22+
23+
### ISpecification<T> Contract
24+
25+
```
26+
ISpecification<T>
27+
├── Criteria : Expression<Func<T, bool>>
28+
├── Includes : List<Expression<Func<T, object>>>
29+
├── IncludeStrings : List<string>
30+
├── OrderBy : Expression<Func<T, object>>?
31+
├── OrderByDescending : Expression<Func<T, object>>?
32+
├── Take : int
33+
├── Skip : int
34+
└── IsPagingEnabled : bool
35+
```
36+
37+
### Concrete Specifications (Sudoku.Application.Specifications)
38+
39+
- `GameByPlayerAndStatusSpecification`
40+
- `GameByDifficultySpecification`
41+
- `GameByStatusSpecification`
42+
- `CompletedGamesSpecification`
43+
- `RecentGamesSpecification`
44+
45+
### Current Evaluation Strategy — Known Technical Debt
46+
47+
Both repository implementations currently evaluate specifications **in-memory**:
48+
49+
| Repository | Evaluation strategy | Impact |
50+
|---|---|---|
51+
| `CosmosDbGameRepository.GetBySpecificationAsync` | Fetches `SELECT * FROM c`, then filters in-process via LINQ | Full container scan on every specification-based query |
52+
| `AzureBlobGameRepository.GetBySpecificationAsync` | Calls `GetAllGamesAsync()` (enumerates all blobs), then filters in-process | Full blob enumeration on every specification-based query |
53+
54+
> **Note:** The majority of `CosmosDbGameRepository` query methods (`GetByPlayerAsync`, `GetByPlayerAndStatusAsync`, `GetGamesByDifficultyAsync`, etc.) already use direct SQL parameterized queries against Cosmos DB and do **not** go through the specification path. The in-memory fallback applies only to `GetBySpecificationAsync` and its callers.
55+
56+
```mermaid
57+
flowchart LR
58+
Handler["Application Handler"] -->|"ISpecification<SudokuGame>"| Repo["CosmosDbGameRepository"]
59+
Repo -->|"SELECT * FROM c"| Cosmos["Cosmos DB Container"]
60+
Cosmos -->|"All Documents"| Repo
61+
Repo -->|"LINQ .Where(spec.Criteria)"| Handler
62+
Handler -->|"Filtered Result"| API["Sudoku.Api"]
63+
64+
style Cosmos fill:#f9a,stroke:#c00
65+
style Repo fill:#ffd,stroke:#990
66+
```
67+
68+
This is acceptable at the current scale but is flagged as technical debt for the Cosmos DB path.
69+
70+
---
71+
72+
## Consequences
73+
74+
### Positive
75+
76+
- **Interface stability**: New filter requirements are satisfied by adding a new `ISpecification<T>` implementation rather than adding a new method to `IGameRepository`.
77+
- **Persistence agnosticism**: Handlers express query intent in LINQ — no SQL or Cosmos DB SDK concepts leak into the Application layer.
78+
- **Reusability**: Specifications are composable and reusable across handlers without code duplication.
79+
80+
### Tradeoffs and Technical Debt
81+
82+
- **`GetBySpecificationAsync` performs a full container scan** in `CosmosDbGameRepository`. This is a known performance limitation. At low game volumes, the impact is negligible; at production scale, it will become unacceptable.
83+
- **Resolution path**: `GetBySpecificationAsync` in `CosmosDbGameRepository` should be refactored to translate `ISpecification<SudokuGame>` criteria into a parameterized Cosmos DB SQL query rather than fetching all documents. This may require an expression tree visitor or a dedicated query builder. This work is tracked as technical debt and should be addressed before the Cosmos DB store reaches production load.
84+
- **`AzureBlobGameRepository` is no longer the active `IGameRepository` for games** (see [ADR-004](ADR-004-cosmosdb.md)). Its in-memory specification evaluation is not a production concern.
85+
86+
### Rules Enforced by This Decision
87+
88+
1. **Do not add new bare query methods to `IGameRepository`** for filtering scenarios. Express filtering as a new `ISpecification<SudokuGame>` in `Sudoku.Application.Specifications`.
89+
2. **Do not evaluate `GetBySpecificationAsync` for high-frequency or high-volume queries** until predicate pushdown to Cosmos DB SQL is implemented.
90+
3. **Prefer the direct SQL query methods** (`GetByPlayerAsync`, `GetByPlayerAndStatusAsync`, etc.) over `GetBySpecificationAsync` for common, well-known access patterns until the Cosmos DB specification translator is in place.
91+
92+
---
93+
94+
## Related ADRs
95+
96+
- [ADR-002 — CQRS in the Application Layer](ADR-002-cqrs.md)
97+
- [ADR-004 — Azure Cosmos DB as the Primary Game Persistence Backend](ADR-004-cosmosdb.md)

docs/adr/ADR-004-cosmosdb.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# ADR-004 — Azure Cosmos DB as the Primary Game Persistence Backend
2+
3+
| Field | Value |
4+
|--------------|---------------------|
5+
| **Date** | 2026-04-15 |
6+
| **Status** | Accepted |
7+
| **Deciders** | Project maintainers |
8+
9+
---
10+
11+
## Context
12+
13+
Two concrete implementations of `IGameRepository` exist in `Sudoku.Infrastructure`:
14+
15+
| Implementation | Backend | Status |
16+
|---|---|---|
17+
| `CosmosDbGameRepository` | Azure Cosmos DB (NoSQL) | **Active — production target** |
18+
| `AzureBlobGameRepository` | Azure Blob Storage | **Retired from game persistence** |
19+
20+
The blob storage implementation used a padded numeric revision naming scheme (`{playerAlias}/{gameId}/00001.json`) that provided an implicit audit trail at the cost of:
21+
22+
- **No structured querying**: All filtering required enumerating every blob and applying in-memory predicates — a full-scan on every read.
23+
- **No indexing**: Player-based or status-based lookups required iterating all player blobs.
24+
- **Concurrency hazard**: Revision number generation required a `SemaphoreSlim` to prevent concurrent write races.
25+
- **Operational overhead**: Deletion required identifying and removing all revision blobs for a given game.
26+
27+
Azure Cosmos DB resolves all of these concerns while remaining within the Azure-native ecosystem already used by the project.
28+
29+
A `UseCosmosDb` environment variable was previously used to toggle between the two implementations at runtime. This toggle is now hardcoded to `true` in `Sudoku.AppHost` and is being phased out.
30+
31+
---
32+
33+
## Decision
34+
35+
**Azure Cosmos DB (NoSQL API) is the canonical and exclusive persistence backend for `SudokuGame` aggregates.** `CosmosDbGameRepository` is the active `IGameRepository` implementation registered in the DI container. `AzureBlobGameRepository` is retired from game persistence and must not be re-registered as `IGameRepository`.
36+
37+
### Rationale for Cosmos DB
38+
39+
| Requirement | Cosmos DB | Blob Storage |
40+
|---|---|---|
41+
| Structured querying (by player, status, difficulty) | SQL API with parameterized queries | Full scan required |
42+
| Document model fit | Native JSON document store; maps directly to `SudokuGameDocument` | JSON blobs with manual naming convention |
43+
| Indexing | Automatic indexing on all properties | None |
44+
| Upsert semantics | Native `UpsertItemAsync` | Sequential revision file creation |
45+
| Scalability | Horizontal partitioning, RU-based throughput model | Scales on storage, not on query throughput |
46+
| Azure integration | First-class Aspire support via `AddConnectionString("CosmosDb")` | First-class but no query advantage |
47+
48+
### Persistence Architecture
49+
50+
```mermaid
51+
flowchart TD
52+
API["Sudoku.Api"] -->|"IMediator.Send(command/query)"| Handler["Application Handler"]
53+
Handler -->|"IGameRepository"| Repo["CosmosDbGameRepository"]
54+
Repo -->|"SQL API"| Cosmos[("Azure Cosmos DB\n(sudoku-games container)")]
55+
56+
BlobRepo["AzureBlobGameRepository\n(retired — game state)"] -. no longer registered .-> IRepo["IGameRepository"]
57+
58+
style BlobRepo fill:#eee,stroke:#aaa,color:#aaa
59+
style IRepo fill:#dfd,stroke:#090
60+
```
61+
62+
### Partition Key Strategy
63+
64+
`SudokuGame` documents are partitioned by `GameId` (`id` field). This enables efficient point reads (`GetByIdAsync`) — the most frequent access pattern — without cross-partition fan-out.
65+
66+
> **Open consideration**: If player-scoped queries (e.g., `GetByPlayerAsync`) become the dominant read pattern at scale, re-evaluating the partition key to `playerAlias` may reduce RU consumption by eliminating cross-partition scans. This does not require an ADR change; it is an operational tuning decision.
67+
68+
### `UseCosmosDb` Environment Variable
69+
70+
The `UseCosmosDb` environment variable is currently set to `"true"` in `Sudoku.AppHost` and injected into `sudoku-api`. Once the variable is removed from the conditional DI registration in `Sudoku.Infrastructure`, this environment variable should be removed from `Sudoku.AppHost/Program.cs` entirely. This cleanup is tracked as a follow-up task.
71+
72+
---
73+
74+
## Consequences
75+
76+
### Positive
77+
78+
- **Structured queries**: Player, status, and difficulty filters are expressed as parameterized SQL queries evaluated server-side — no full-scan reads.
79+
- **Upsert semantics**: Saving a game state is a single `UpsertItemAsync` call. No revision numbering, semaphores, or blob enumeration.
80+
- **Operational simplicity**: Deletion is a single `DeleteItemAsync` by `id` and partition key. No multi-blob cleanup.
81+
- **Automatic indexing**: New filter dimensions (e.g., filter by `difficulty` + `status`) require no manual index management.
82+
83+
### Tradeoffs
84+
85+
- **Audit trail loss**: The blob revision strategy provided an implicit append-only game history. Cosmos DB upsert overwrites the document; point-in-time history is not preserved. If audit history is required in the future, it must be added explicitly (e.g., via a separate event log container or Cosmos DB Change Feed to a history container).
86+
- **Azure dependency**: Cosmos DB is an Azure-exclusive managed service. Non-Azure deployments require the Cosmos DB emulator or a compatibility layer.
87+
- **Cost model**: Cosmos DB is billed per Request Unit (RU). Specification-based queries that trigger full container scans (see [ADR-003](ADR-003-specification-pattern.md)) will consume disproportionate RUs until predicate pushdown is implemented.
88+
89+
### Rules Enforced by This Decision
90+
91+
1. **`CosmosDbGameRepository` is the only `IGameRepository` registered in the DI container for game persistence.**
92+
2. **`AzureBlobGameRepository` must not be re-registered as `IGameRepository`.** The class is retained in `Sudoku.Infrastructure` for potential repurposing (see [ADR-006](ADR-006-blob-storage-repurpose.md)).
93+
3. **The `UseCosmosDb` toggle is deprecated.** New code must not branch on this value. It will be removed in a follow-up cleanup.
94+
4. **New query methods added to `IGameRepository` must use parameterized Cosmos DB SQL** rather than in-memory filtering.
95+
96+
---
97+
98+
## Related ADRs
99+
100+
- [ADR-001 — Adoption of Clean Architecture](ADR-001-clean-architecture.md)
101+
- [ADR-003 — Specification Pattern for Repository Queries](ADR-003-specification-pattern.md)
102+
- [ADR-005 — In-Memory Repository Scoped to Puzzle Generation](ADR-005-in-memory-puzzle-repository.md)
103+
- [ADR-006 — Azure Blob Storage Repurposed Away from Game State](ADR-006-blob-storage-repurpose.md)

0 commit comments

Comments
 (0)