Skip to content

Commit 2d81d17

Browse files
authored
♻️ Refactor specifications for better discovery (#607)
📄 Refactor specifications: consolidate into aggregate classes with factory methods for improved discoverability and reduced file count
1 parent deaef0c commit 2d81d17

14 files changed

Lines changed: 134 additions & 32 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Use Aggregate Specification Classes with Factory Methods
2+
3+
- Status: accepted
4+
- Deciders: Daniel Mackay, Anton Polkanov
5+
- Date: 2026-02-21
6+
- Tags: domain, specifications
7+
8+
## Context and Problem Statement
9+
10+
The previous approach created a separate class file per specification (e.g., `TeamByIdSpec`, `HeroByIdSpec`). As the number of specifications grows, this leads to file proliferation and poor discoverability — developers must know the exact class name to find a specification, and there is no single place to browse all available specifications for a given aggregate.
11+
12+
## Decision Drivers
13+
14+
- Improve discoverability of specifications per aggregate
15+
- Reduce the number of files in the Domain layer
16+
- Keep specifications co-located with their aggregate
17+
18+
## Considered Options
19+
20+
1. One class per specification (previous approach)
21+
2. Single class per aggregate with static factory methods
22+
23+
## Decision Outcome
24+
25+
Chosen option: **Option 2 - Single class per aggregate with static factory methods**, because it groups all specifications for an aggregate in one discoverable location and reduces file count without sacrificing clarity.
26+
27+
The class itself extends `SingleResultSpecification<T>` and static factory methods configure instances via `spec.Query`, following a consistent naming convention (`{Aggregate}Spec`).
28+
29+
```csharp
30+
public sealed class HeroSpec : SingleResultSpecification<Hero>
31+
{
32+
public static HeroSpec ById(HeroId heroId)
33+
{
34+
var spec = new HeroSpec();
35+
spec.Query.Where(h => h.Id == heroId);
36+
return spec;
37+
}
38+
}
39+
```
40+
41+
Usage:
42+
43+
```csharp
44+
dbContext.Heroes.WithSpecification(HeroSpec.ById(heroId)).FirstOrDefault();
45+
```
46+
47+
### Consequences
48+
49+
- ✅ All specifications for an aggregate live in one file (`HeroSpec.cs`, `TeamSpec.cs`)
50+
- ✅ Intention-revealing factory method names (`HeroSpec.ById(...)`)
51+
- ✅ IntelliSense on `HeroSpec.` surfaces all available specifications immediately
52+
- ✅ No inner classes or extra indirection needed
53+
- ❌ Slightly more boilerplate per specification (factory method vs constructor-only class)
54+
55+
## Pros and Cons of the Options
56+
57+
### Option 1 - One class per specification
58+
59+
- ✅ Minimal boilerplate for a single specification
60+
- ❌ File proliferation as aggregate queries grow
61+
- ❌ No single place to discover all specifications for an aggregate
62+
- ❌ Class names must be memorised to find the right specification
63+
64+
### Option 2 - Single class per aggregate with static factory methods
65+
66+
- ✅ All specifications browsable via IntelliSense on the aggregate's spec class
67+
- ✅ Fewer files in the Domain layer
68+
- ✅ Consistent naming convention (`{Aggregate}Spec`)
69+
- ✅ No inner class boilerplate
70+
- ❌ Slightly more verbose per specification entry

src/AGENTS.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ This file covers conventions, migrations, and all coding patterns for the Domain
77
- **No AutoMapper** - Use manual mapping with `Select()` projections
88
- **Strongly-typed IDs** - All entities use Vogen `[ValueObject<Guid>]`
99
- **Factory methods** - Create aggregates via static `Create()` methods, not constructors
10-
- **Specifications** - Query logic in Domain layer (e.g., `TeamByIdSpec`)
10+
- **Specifications** - One class per aggregate in Domain layer (`{Aggregate}Spec`), with static factory methods per query
1111
- **FluentValidation** - Validators in same folder as Command/Query
1212
- **Awesome Assertions** - Use `Should()` syntax in tests
1313
- **Code generation** - Reference existing code in `src/Application/UseCases/Heroes/` as patterns
@@ -136,3 +136,30 @@ Use `ErrorOr<T>` for commands, not exceptions. Handle with `.Match()` at the HTT
136136
```csharp
137137
result.Match(success => TypedResults.Ok(success), CustomResult.Problem);
138138
```
139+
140+
## Specifications
141+
142+
Location: `src/Domain/{Feature}/{Aggregate}Spec.cs`
143+
144+
Each aggregate has a single specification class that extends `SingleResultSpecification<T>`. Static factory methods build and configure instances. This groups all queries for an aggregate in one place for discoverability.
145+
146+
```csharp
147+
// src/Domain/Heroes/HeroSpec.cs
148+
public sealed class HeroSpec : SingleResultSpecification<Hero>
149+
{
150+
public static HeroSpec ById(HeroId heroId)
151+
{
152+
var spec = new HeroSpec();
153+
spec.Query.Where(h => h.Id == heroId);
154+
return spec;
155+
}
156+
157+
// Add further factory methods here as new queries are needed
158+
}
159+
```
160+
161+
Usage in commands:
162+
163+
```csharp
164+
dbContext.Heroes.WithSpecification(HeroSpec.ById(heroId)).FirstOrDefault();
165+
```

src/Application/UseCases/Teams/Commands/AddHeroToTeam/AddHeroToTeamCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ public async Task<ErrorOr<Success>> Handle(AddHeroToTeamCommand request, Cancell
1515
var heroId = HeroId.From(request.HeroId);
1616

1717
var team = dbContext.Teams
18-
.WithSpecification(new TeamByIdSpec(teamId))
18+
.WithSpecification(TeamSpec.ById(teamId))
1919
.FirstOrDefault();
2020

2121
if (team is null)
2222
return TeamErrors.NotFound;
2323

2424
var hero = dbContext.Heroes
25-
.WithSpecification(new HeroByIdSpec(heroId))
25+
.WithSpecification(HeroSpec.ById(heroId))
2626
.FirstOrDefault();
2727

2828
if (hero is null)

src/Application/UseCases/Teams/Commands/CompleteMission/CompleteMissionCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public async Task<ErrorOr<Success>> Handle(CompleteMissionCommand request, Cance
1212
{
1313
var teamId = TeamId.From(request.TeamId);
1414
var team = dbContext.Teams
15-
.WithSpecification(new TeamByIdSpec(teamId))
15+
.WithSpecification(TeamSpec.ById(teamId))
1616
.FirstOrDefault();
1717

1818
if (team is null)

src/Application/UseCases/Teams/Commands/ExecuteMission/ExecuteMissionCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public async Task<ErrorOr<Success>> Handle(ExecuteMissionCommand request, Cancel
1616
{
1717
var teamId = TeamId.From(request.TeamId);
1818
var team = dbContext.Teams
19-
.WithSpecification(new TeamByIdSpec(teamId))
19+
.WithSpecification(TeamSpec.ById(teamId))
2020
.FirstOrDefault();
2121

2222
if (team is null)

src/Application/UseCases/Teams/Events/PowerLevelUpdatedEventHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public async Task Handle(PowerLevelUpdatedEvent notification, CancellationToken
2626
}
2727

2828
var team = dbContext.Teams
29-
.WithSpecification(new TeamByIdSpec(hero.TeamId.Value))
29+
.WithSpecification(TeamSpec.ById(hero.TeamId.Value))
3030
.FirstOrDefault();
3131

3232
if (team is null)

src/Domain/Heroes/HeroSpec.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace SSW.CleanArchitecture.Domain.Heroes;
2+
3+
// For more on the Specification Pattern see: https://www.ssw.com.au/rules/use-specification-pattern/
4+
public sealed class HeroSpec : SingleResultSpecification<Hero>
5+
{
6+
public static HeroSpec ById(HeroId heroId)
7+
{
8+
var spec = new HeroSpec();
9+
spec.Query.Where(h => h.Id == heroId);
10+
return spec;
11+
}
12+
}

src/Domain/Heroes/TeamByIdSpec.cs

Lines changed: 0 additions & 10 deletions
This file was deleted.

src/Domain/Teams/TeamByIdSpec.cs

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/Domain/Teams/TeamSpec.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace SSW.CleanArchitecture.Domain.Teams;
2+
3+
// For more on the Specification Pattern see: https://www.ssw.com.au/rules/use-specification-pattern/
4+
public sealed class TeamSpec : SingleResultSpecification<Team>
5+
{
6+
public static TeamSpec ById(TeamId teamId)
7+
{
8+
var spec = new TeamSpec();
9+
spec.Query
10+
.Where(t => t.Id == teamId)
11+
.Include(t => t.Missions)
12+
.Include(t => t.Heroes);
13+
return spec;
14+
}
15+
}

0 commit comments

Comments
 (0)