Skip to content

Commit 9b92ce2

Browse files
committed
📄 Add AGENTS.md for implementation patterns and testing conventions
1 parent 535552d commit 9b92ce2

3 files changed

Lines changed: 193 additions & 160 deletions

File tree

‎AGENTS.md‎

Lines changed: 5 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -20,173 +20,18 @@ tools/
2020
## Running the Solution
2121

2222
```bash
23-
cd tools/AppHost && dotnet run
23+
aspire run
2424
```
2525

2626
Aspire handles database provisioning, migrations, and seeding automatically. API available at `https://localhost:7255/scalar/v1`.
2727

28-
## Key Patterns
28+
## Further Reading
2929

30-
### Commands (Create/Update/Delete)
31-
Location: `src/Application/UseCases/{Feature}/Commands/{CommandName}/`
32-
33-
```csharp
34-
public sealed record CreateHeroCommand(string Name, string Alias) : IRequest<ErrorOr<Guid>>;
35-
36-
internal sealed class CreateHeroCommandHandler(IApplicationDbContext dbContext)
37-
: IRequestHandler<CreateHeroCommand, ErrorOr<Guid>>
38-
{
39-
public async Task<ErrorOr<Guid>> Handle(CreateHeroCommand request, CancellationToken ct)
40-
{
41-
var hero = Hero.Create(request.Name, request.Alias);
42-
await dbContext.Heroes.AddAsync(hero, ct);
43-
await dbContext.SaveChangesAsync(ct);
44-
return hero.Id.Value;
45-
}
46-
}
47-
48-
internal sealed class CreateHeroCommandValidator : AbstractValidator<CreateHeroCommand>
49-
{
50-
public CreateHeroCommandValidator() => RuleFor(v => v.Name).NotEmpty();
51-
}
52-
```
53-
54-
### Queries (Read)
55-
Location: `src/Application/UseCases/{Feature}/Queries/{QueryName}/`
56-
57-
```csharp
58-
public record GetAllHeroesQuery : IRequest<IReadOnlyList<HeroDto>>;
59-
public record HeroDto(Guid Id, string Name, string Alias, int PowerLevel);
60-
61-
internal sealed class GetAllHeroesQueryHandler(IApplicationDbContext dbContext)
62-
: IRequestHandler<GetAllHeroesQuery, IReadOnlyList<HeroDto>>
63-
{
64-
public async Task<IReadOnlyList<HeroDto>> Handle(GetAllHeroesQuery request, CancellationToken ct)
65-
=> await dbContext.Heroes.Select(h => new HeroDto(h.Id.Value, h.Name, h.Alias, h.PowerLevel)).ToListAsync(ct);
66-
}
67-
```
68-
69-
### Domain Entities with Strongly-Typed IDs (Vogen)
70-
Location: `src/Domain/{Feature}/`
71-
72-
```csharp
73-
[ValueObject<Guid>]
74-
public readonly partial struct HeroId;
75-
76-
public class Hero : AggregateRoot<HeroId>
77-
{
78-
private Hero() { } // EF Core constructor
79-
80-
public static Hero Create(string name, string alias)
81-
=> new() { Id = HeroId.From(Guid.CreateVersion7()), Name = name, Alias = alias };
82-
}
83-
```
84-
85-
### Domain Events
86-
Raise events from aggregates to trigger side effects. Events are dispatched after `SaveChangesAsync()`.
87-
88-
**Define event** in `src/Domain/{Feature}/`:
89-
```csharp
90-
public sealed record PowerLevelUpdatedEvent(Hero Hero) : IDomainEvent;
91-
```
92-
93-
**Raise from aggregate**:
94-
```csharp
95-
public void UpdatePowers(IEnumerable<Power> powers)
96-
{
97-
_powers.Clear();
98-
foreach (var power in powers) AddPower(power);
99-
AddDomainEvent(new PowerLevelUpdatedEvent(this)); // Raise event
100-
}
101-
```
102-
103-
**Handle event** in `src/Application/UseCases/{Feature}/EventHandlers/`:
104-
```csharp
105-
internal sealed class PowerLevelUpdatedEventHandler : INotificationHandler<PowerLevelUpdatedEvent>
106-
{
107-
public Task Handle(PowerLevelUpdatedEvent notification, CancellationToken ct)
108-
{
109-
// Side effects: send emails, update read models, integrate with external systems
110-
return Task.CompletedTask;
111-
}
112-
}
113-
```
114-
115-
### Minimal API Endpoints
116-
Location: `src/WebApi/Endpoints/`
117-
118-
```csharp
119-
public static void MapHeroEndpoints(this WebApplication app)
120-
{
121-
var group = app.MapApiGroup("heroes");
122-
123-
group.MapPost("/", async (ISender sender, CreateHeroCommand command, CancellationToken ct) =>
124-
{
125-
var result = await sender.Send(command, ct);
126-
return result.Match(_ => TypedResults.Created(), CustomResult.Problem);
127-
})
128-
.WithName("CreateHero")
129-
.ProducesPost(); // Use extension methods for consistent status codes
130-
}
131-
```
132-
133-
### Result Pattern (ErrorOr)
134-
Use `ErrorOr<T>` for commands, not exceptions. Handle with `.Match()`:
135-
```csharp
136-
result.Match(success => TypedResults.Ok(success), CustomResult.Problem);
137-
```
138-
139-
## Testing
140-
141-
### Integration Tests
142-
Location: `tests/WebApi.IntegrationTests/Endpoints/{Feature}/`
143-
144-
```csharp
145-
public class CreateHeroCommandTests(TestingDatabaseFixture fixture) : IntegrationTestBase(fixture)
146-
{
147-
[Fact]
148-
public async Task Command_ShouldCreateHero()
149-
{
150-
var cmd = new CreateHeroCommand("Clark Kent", "Superman", []);
151-
var client = GetAnonymousClient();
152-
153-
var result = await client.PostAsJsonAsync("/api/heroes", cmd, CancellationToken);
154-
155-
result.StatusCode.Should().Be(HttpStatusCode.Created);
156-
var hero = await GetQueryable<Hero>().FirstAsync(CancellationToken);
157-
hero.Name.Should().Be(cmd.Name);
158-
}
159-
}
160-
```
161-
162-
Uses TestContainers + Respawn for real database testing.
163-
164-
### Unit Tests
165-
Location: `tests/Domain.UnitTests/` - Test domain logic without EF Core mocking (Specifications pattern).
166-
167-
### Architecture Tests
168-
Location: `tests/Architecture.Tests/` - NetArchTest enforces layer dependencies.
169-
170-
## EF Migrations
171-
172-
```bash
173-
# Add migration
174-
dotnet ef migrations add YourMigration --project ./src/Infrastructure --startup-project ./src/WebApi --output-dir ./Persistence/Migrations
175-
176-
# Migrations apply automatically via Aspire MigrationService
177-
```
178-
179-
## Conventions
180-
181-
- **No AutoMapper** - Use manual mapping with `Select()` projections
182-
- **Strongly-typed IDs** - All entities use Vogen `[ValueObject<Guid>]`
183-
- **Factory methods** - Create aggregates via static `Create()` methods, not constructors
184-
- **Specifications** - Query logic in Domain layer (e.g., `TeamByIdSpec`)
185-
- **FluentValidation** - Validators in same folder as Command/Query
186-
- **Awesome Assertions** - Use `Should()` syntax in tests
187-
- **Code generation** - Reference existing code in `src/Application/UseCases/Heroes/` as patterns
30+
- Implementation patterns (commands, queries, domain entities, endpoints, conventions, migrations) → `src/AGENTS.md`
31+
- Testing patterns (integration, unit, architecture tests) → `tests/AGENTS.md`
18832

18933
## ADRs
34+
19035
Architectural decisions documented in `docs/adr/`. Key decisions:
19136
- Results pattern over exceptions
19237
- Vogen for strongly-typed IDs

‎src/AGENTS.md‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# SSW Clean Architecture - Implementation Patterns
2+
3+
This file covers conventions, migrations, and all coding patterns for the Domain, Application, Infrastructure, and WebApi layers.
4+
5+
## Conventions
6+
7+
- **No AutoMapper** - Use manual mapping with `Select()` projections
8+
- **Strongly-typed IDs** - All entities use Vogen `[ValueObject<Guid>]`
9+
- **Factory methods** - Create aggregates via static `Create()` methods, not constructors
10+
- **Specifications** - Query logic in Domain layer (e.g., `TeamByIdSpec`)
11+
- **FluentValidation** - Validators in same folder as Command/Query
12+
- **Awesome Assertions** - Use `Should()` syntax in tests
13+
- **Code generation** - Reference existing code in `src/Application/UseCases/Heroes/` as patterns
14+
15+
## EF Migrations
16+
17+
```bash
18+
# Add migration
19+
dotnet ef migrations add YourMigration --project ./src/Infrastructure --startup-project ./src/WebApi --output-dir ./Persistence/Migrations
20+
21+
# Migrations apply automatically via Aspire MigrationService
22+
```
23+
24+
## Commands (Create/Update/Delete)
25+
26+
Location: `src/Application/UseCases/{Feature}/Commands/{CommandName}/`
27+
28+
```csharp
29+
public sealed record CreateHeroCommand(string Name, string Alias) : IRequest<ErrorOr<Guid>>;
30+
31+
internal sealed class CreateHeroCommandHandler(IApplicationDbContext dbContext)
32+
: IRequestHandler<CreateHeroCommand, ErrorOr<Guid>>
33+
{
34+
public async Task<ErrorOr<Guid>> Handle(CreateHeroCommand request, CancellationToken ct)
35+
{
36+
var hero = Hero.Create(request.Name, request.Alias);
37+
await dbContext.Heroes.AddAsync(hero, ct);
38+
await dbContext.SaveChangesAsync(ct);
39+
return hero.Id.Value;
40+
}
41+
}
42+
43+
internal sealed class CreateHeroCommandValidator : AbstractValidator<CreateHeroCommand>
44+
{
45+
public CreateHeroCommandValidator() => RuleFor(v => v.Name).NotEmpty();
46+
}
47+
```
48+
49+
## Queries (Read)
50+
51+
Location: `src/Application/UseCases/{Feature}/Queries/{QueryName}/`
52+
53+
```csharp
54+
public record GetAllHeroesQuery : IRequest<IReadOnlyList<HeroDto>>;
55+
public record HeroDto(Guid Id, string Name, string Alias, int PowerLevel);
56+
57+
internal sealed class GetAllHeroesQueryHandler(IApplicationDbContext dbContext)
58+
: IRequestHandler<GetAllHeroesQuery, IReadOnlyList<HeroDto>>
59+
{
60+
public async Task<IReadOnlyList<HeroDto>> Handle(GetAllHeroesQuery request, CancellationToken ct)
61+
=> await dbContext.Heroes.Select(h => new HeroDto(h.Id.Value, h.Name, h.Alias, h.PowerLevel)).ToListAsync(ct);
62+
}
63+
```
64+
65+
## Domain Entities with Strongly-Typed IDs (Vogen)
66+
67+
Location: `src/Domain/{Feature}/`
68+
69+
```csharp
70+
[ValueObject<Guid>]
71+
public readonly partial struct HeroId;
72+
73+
public class Hero : AggregateRoot<HeroId>
74+
{
75+
private Hero() { } // EF Core constructor
76+
77+
public static Hero Create(string name, string alias)
78+
=> new() { Id = HeroId.From(Guid.CreateVersion7()), Name = name, Alias = alias };
79+
}
80+
```
81+
82+
## Domain Events
83+
84+
Raise events from aggregates to trigger side effects. Events are dispatched after `SaveChangesAsync()`.
85+
86+
**Define event** in `src/Domain/{Feature}/`:
87+
```csharp
88+
public sealed record PowerLevelUpdatedEvent(Hero Hero) : IDomainEvent;
89+
```
90+
91+
**Raise from aggregate**:
92+
```csharp
93+
public void UpdatePowers(IEnumerable<Power> powers)
94+
{
95+
_powers.Clear();
96+
foreach (var power in powers) AddPower(power);
97+
AddDomainEvent(new PowerLevelUpdatedEvent(this)); // Raise event
98+
}
99+
```
100+
101+
**Handle event** in `src/Application/UseCases/{Feature}/EventHandlers/`:
102+
```csharp
103+
internal sealed class PowerLevelUpdatedEventHandler : INotificationHandler<PowerLevelUpdatedEvent>
104+
{
105+
public Task Handle(PowerLevelUpdatedEvent notification, CancellationToken ct)
106+
{
107+
// Side effects: send emails, update read models, integrate with external systems
108+
return Task.CompletedTask;
109+
}
110+
}
111+
```
112+
113+
## Minimal API Endpoints
114+
115+
Location: `src/WebApi/Endpoints/`
116+
117+
```csharp
118+
public static void MapHeroEndpoints(this WebApplication app)
119+
{
120+
var group = app.MapApiGroup("heroes");
121+
122+
group.MapPost("/", async (ISender sender, CreateHeroCommand command, CancellationToken ct) =>
123+
{
124+
var result = await sender.Send(command, ct);
125+
return result.Match(_ => TypedResults.Created(), CustomResult.Problem);
126+
})
127+
.WithName("CreateHero")
128+
.ProducesPost(); // Use extension methods for consistent status codes
129+
}
130+
```
131+
132+
## Result Pattern (ErrorOr)
133+
134+
Use `ErrorOr<T>` for commands, not exceptions. Handle with `.Match()` at the HTTP layer:
135+
136+
```csharp
137+
result.Match(success => TypedResults.Ok(success), CustomResult.Problem);
138+
```

‎tests/AGENTS.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SSW Clean Architecture - Testing Patterns
2+
3+
This file covers all testing patterns across integration, unit, and architecture test projects.
4+
5+
## Shared Conventions
6+
7+
- **Awesome Assertions** - Use `Should()` syntax for all assertions
8+
9+
## Integration Tests
10+
11+
Location: `tests/WebApi.IntegrationTests/Endpoints/{Feature}/`
12+
13+
Uses TestContainers (real database) + Respawn (database reset between tests).
14+
15+
```csharp
16+
public class CreateHeroCommandTests(TestingDatabaseFixture fixture) : IntegrationTestBase(fixture)
17+
{
18+
[Fact]
19+
public async Task Command_ShouldCreateHero()
20+
{
21+
var cmd = new CreateHeroCommand("Clark Kent", "Superman", []);
22+
var client = GetAnonymousClient();
23+
24+
var result = await client.PostAsJsonAsync("/api/heroes", cmd, CancellationToken);
25+
26+
result.StatusCode.Should().Be(HttpStatusCode.Created);
27+
var hero = await GetQueryable<Hero>().FirstAsync(CancellationToken);
28+
hero.Name.Should().Be(cmd.Name);
29+
}
30+
}
31+
```
32+
33+
Key helpers available from `IntegrationTestBase`:
34+
- `GetAnonymousClient()` - unauthenticated `HttpClient`
35+
- `GetQueryable<T>()` - direct EF Core access to verify database state
36+
37+
## Unit Tests
38+
39+
Location: `tests/Domain.UnitTests/`
40+
41+
Test domain logic directly — no EF Core, no mocking of infrastructure. Focus on:
42+
- Aggregate behaviour and invariants
43+
- Specifications pattern (e.g., `TeamByIdSpec`)
44+
- Domain event raising
45+
46+
## Architecture Tests
47+
48+
Location: `tests/Architecture.Tests/`
49+
50+
Uses NetArchTest to enforce layer dependency rules. Ensures no illegal references between layers (e.g., Domain must not reference Application or Infrastructure).

0 commit comments

Comments
 (0)