@malamute/ai-rules 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/bin/cli.js +5 -0
- package/configs/_shared/.claude/commands/fix-issue.md +38 -0
- package/configs/_shared/.claude/commands/generate-tests.md +49 -0
- package/configs/_shared/.claude/commands/review-pr.md +77 -0
- package/configs/_shared/.claude/rules/accessibility.md +270 -0
- package/configs/_shared/.claude/rules/performance.md +226 -0
- package/configs/_shared/.claude/rules/security.md +188 -0
- package/configs/_shared/.claude/skills/debug/SKILL.md +118 -0
- package/configs/_shared/.claude/skills/learning/SKILL.md +224 -0
- package/configs/_shared/.claude/skills/review/SKILL.md +86 -0
- package/configs/_shared/.claude/skills/spec/SKILL.md +112 -0
- package/configs/_shared/CLAUDE.md +174 -0
- package/configs/angular/.claude/rules/components.md +257 -0
- package/configs/angular/.claude/rules/state.md +250 -0
- package/configs/angular/.claude/rules/testing.md +422 -0
- package/configs/angular/.claude/settings.json +31 -0
- package/configs/angular/CLAUDE.md +251 -0
- package/configs/dotnet/.claude/rules/api.md +370 -0
- package/configs/dotnet/.claude/rules/architecture.md +199 -0
- package/configs/dotnet/.claude/rules/database/efcore.md +408 -0
- package/configs/dotnet/.claude/rules/testing.md +389 -0
- package/configs/dotnet/.claude/settings.json +9 -0
- package/configs/dotnet/CLAUDE.md +319 -0
- package/configs/nestjs/.claude/rules/auth.md +321 -0
- package/configs/nestjs/.claude/rules/database/prisma.md +305 -0
- package/configs/nestjs/.claude/rules/database/typeorm.md +379 -0
- package/configs/nestjs/.claude/rules/modules.md +215 -0
- package/configs/nestjs/.claude/rules/testing.md +315 -0
- package/configs/nestjs/.claude/rules/validation.md +279 -0
- package/configs/nestjs/.claude/settings.json +15 -0
- package/configs/nestjs/CLAUDE.md +263 -0
- package/configs/nextjs/.claude/rules/components.md +211 -0
- package/configs/nextjs/.claude/rules/state/redux-toolkit.md +429 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +299 -0
- package/configs/nextjs/.claude/rules/testing.md +315 -0
- package/configs/nextjs/.claude/settings.json +29 -0
- package/configs/nextjs/CLAUDE.md +376 -0
- package/configs/python/.claude/rules/database/sqlalchemy.md +355 -0
- package/configs/python/.claude/rules/fastapi.md +272 -0
- package/configs/python/.claude/rules/flask.md +332 -0
- package/configs/python/.claude/rules/testing.md +374 -0
- package/configs/python/.claude/settings.json +18 -0
- package/configs/python/CLAUDE.md +273 -0
- package/package.json +41 -0
- package/src/install.js +315 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "src/WebApi/**/*.cs"
|
|
4
|
+
- "src/**/Controllers/**/*.cs"
|
|
5
|
+
- "src/**/Endpoints/**/*.cs"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ASP.NET Core API Rules
|
|
9
|
+
|
|
10
|
+
## Minimal APIs vs Controllers
|
|
11
|
+
|
|
12
|
+
### Minimal APIs (Recommended for .NET 8+)
|
|
13
|
+
|
|
14
|
+
```csharp
|
|
15
|
+
// Organized by feature in extension methods
|
|
16
|
+
public static class UserEndpoints
|
|
17
|
+
{
|
|
18
|
+
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
|
|
19
|
+
{
|
|
20
|
+
var group = app.MapGroup("/api/users")
|
|
21
|
+
.WithTags("Users")
|
|
22
|
+
.RequireAuthorization();
|
|
23
|
+
|
|
24
|
+
group.MapGet("/", GetUsers);
|
|
25
|
+
group.MapGet("/{id:guid}", GetUser).WithName("GetUser");
|
|
26
|
+
group.MapPost("/", CreateUser);
|
|
27
|
+
group.MapPut("/{id:guid}", UpdateUser);
|
|
28
|
+
group.MapDelete("/{id:guid}", DeleteUser);
|
|
29
|
+
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private static async Task<IResult> GetUsers(
|
|
34
|
+
[AsParameters] GetUsersQuery query,
|
|
35
|
+
ISender sender)
|
|
36
|
+
{
|
|
37
|
+
var result = await sender.Send(query);
|
|
38
|
+
return Results.Ok(result);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private static async Task<IResult> GetUser(
|
|
42
|
+
Guid id,
|
|
43
|
+
ISender sender)
|
|
44
|
+
{
|
|
45
|
+
var result = await sender.Send(new GetUserQuery(id));
|
|
46
|
+
return result is not null ? Results.Ok(result) : Results.NotFound();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static async Task<IResult> CreateUser(
|
|
50
|
+
CreateUserCommand command,
|
|
51
|
+
ISender sender)
|
|
52
|
+
{
|
|
53
|
+
var id = await sender.Send(command);
|
|
54
|
+
return Results.CreatedAtRoute("GetUser", new { id }, new { id });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private static async Task<IResult> UpdateUser(
|
|
58
|
+
Guid id,
|
|
59
|
+
UpdateUserCommand command,
|
|
60
|
+
ISender sender)
|
|
61
|
+
{
|
|
62
|
+
if (id != command.Id) return Results.BadRequest();
|
|
63
|
+
await sender.Send(command);
|
|
64
|
+
return Results.NoContent();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private static async Task<IResult> DeleteUser(
|
|
68
|
+
Guid id,
|
|
69
|
+
ISender sender)
|
|
70
|
+
{
|
|
71
|
+
await sender.Send(new DeleteUserCommand(id));
|
|
72
|
+
return Results.NoContent();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Program.cs
|
|
77
|
+
app.MapUserEndpoints();
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Controllers (When More Control Needed)
|
|
81
|
+
|
|
82
|
+
```csharp
|
|
83
|
+
[ApiController]
|
|
84
|
+
[Route("api/[controller]")]
|
|
85
|
+
[Produces("application/json")]
|
|
86
|
+
public class UsersController(ISender sender) : ControllerBase
|
|
87
|
+
{
|
|
88
|
+
/// <summary>
|
|
89
|
+
/// Get all users with pagination
|
|
90
|
+
/// </summary>
|
|
91
|
+
[HttpGet]
|
|
92
|
+
[ProducesResponseType<PaginatedList<UserDto>>(StatusCodes.Status200OK)]
|
|
93
|
+
public async Task<IActionResult> GetAll([FromQuery] GetUsersQuery query)
|
|
94
|
+
{
|
|
95
|
+
return Ok(await sender.Send(query));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// <summary>
|
|
99
|
+
/// Get a user by ID
|
|
100
|
+
/// </summary>
|
|
101
|
+
[HttpGet("{id:guid}")]
|
|
102
|
+
[ProducesResponseType<UserDto>(StatusCodes.Status200OK)]
|
|
103
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
104
|
+
public async Task<IActionResult> Get(Guid id)
|
|
105
|
+
{
|
|
106
|
+
var user = await sender.Send(new GetUserQuery(id));
|
|
107
|
+
return user is not null ? Ok(user) : NotFound();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// <summary>
|
|
111
|
+
/// Create a new user
|
|
112
|
+
/// </summary>
|
|
113
|
+
[HttpPost]
|
|
114
|
+
[ProducesResponseType<Guid>(StatusCodes.Status201Created)]
|
|
115
|
+
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
|
116
|
+
public async Task<IActionResult> Create(CreateUserCommand command)
|
|
117
|
+
{
|
|
118
|
+
var id = await sender.Send(command);
|
|
119
|
+
return CreatedAtAction(nameof(Get), new { id }, new { id });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// <summary>
|
|
123
|
+
/// Update an existing user
|
|
124
|
+
/// </summary>
|
|
125
|
+
[HttpPut("{id:guid}")]
|
|
126
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
127
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
128
|
+
public async Task<IActionResult> Update(Guid id, UpdateUserCommand command)
|
|
129
|
+
{
|
|
130
|
+
if (id != command.Id) return BadRequest();
|
|
131
|
+
await sender.Send(command);
|
|
132
|
+
return NoContent();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// <summary>
|
|
136
|
+
/// Delete a user
|
|
137
|
+
/// </summary>
|
|
138
|
+
[HttpDelete("{id:guid}")]
|
|
139
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
140
|
+
public async Task<IActionResult> Delete(Guid id)
|
|
141
|
+
{
|
|
142
|
+
await sender.Send(new DeleteUserCommand(id));
|
|
143
|
+
return NoContent();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Request/Response Patterns
|
|
149
|
+
|
|
150
|
+
### Pagination
|
|
151
|
+
|
|
152
|
+
```csharp
|
|
153
|
+
// Query with pagination
|
|
154
|
+
public record GetUsersQuery(int Page = 1, int PageSize = 10) : IRequest<PaginatedList<UserDto>>;
|
|
155
|
+
|
|
156
|
+
// Paginated response
|
|
157
|
+
public class PaginatedList<T>
|
|
158
|
+
{
|
|
159
|
+
public IReadOnlyList<T> Items { get; }
|
|
160
|
+
public int Page { get; }
|
|
161
|
+
public int PageSize { get; }
|
|
162
|
+
public int TotalCount { get; }
|
|
163
|
+
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
|
164
|
+
public bool HasPreviousPage => Page > 1;
|
|
165
|
+
public bool HasNextPage => Page < TotalPages;
|
|
166
|
+
|
|
167
|
+
public PaginatedList(IReadOnlyList<T> items, int count, int page, int pageSize)
|
|
168
|
+
{
|
|
169
|
+
Items = items;
|
|
170
|
+
TotalCount = count;
|
|
171
|
+
Page = page;
|
|
172
|
+
PageSize = pageSize;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Error Responses (Problem Details)
|
|
178
|
+
|
|
179
|
+
```csharp
|
|
180
|
+
// Always use ProblemDetails for errors (RFC 7807)
|
|
181
|
+
app.UseExceptionHandler(exceptionApp =>
|
|
182
|
+
{
|
|
183
|
+
exceptionApp.Run(async context =>
|
|
184
|
+
{
|
|
185
|
+
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
|
|
186
|
+
|
|
187
|
+
var problemDetails = exception switch
|
|
188
|
+
{
|
|
189
|
+
ValidationException ex => new ValidationProblemDetails(
|
|
190
|
+
ex.Errors.GroupBy(e => e.PropertyName)
|
|
191
|
+
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()))
|
|
192
|
+
{
|
|
193
|
+
Status = StatusCodes.Status400BadRequest,
|
|
194
|
+
Title = "Validation Error"
|
|
195
|
+
},
|
|
196
|
+
NotFoundException ex => new ProblemDetails
|
|
197
|
+
{
|
|
198
|
+
Status = StatusCodes.Status404NotFound,
|
|
199
|
+
Title = "Not Found",
|
|
200
|
+
Detail = ex.Message
|
|
201
|
+
},
|
|
202
|
+
UnauthorizedAccessException => new ProblemDetails
|
|
203
|
+
{
|
|
204
|
+
Status = StatusCodes.Status401Unauthorized,
|
|
205
|
+
Title = "Unauthorized"
|
|
206
|
+
},
|
|
207
|
+
_ => new ProblemDetails
|
|
208
|
+
{
|
|
209
|
+
Status = StatusCodes.Status500InternalServerError,
|
|
210
|
+
Title = "Server Error"
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
context.Response.StatusCode = problemDetails.Status ?? 500;
|
|
215
|
+
await context.Response.WriteAsJsonAsync(problemDetails);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## OpenAPI / Swagger
|
|
221
|
+
|
|
222
|
+
```csharp
|
|
223
|
+
// Program.cs
|
|
224
|
+
builder.Services.AddEndpointsApiExplorer();
|
|
225
|
+
builder.Services.AddSwaggerGen(options =>
|
|
226
|
+
{
|
|
227
|
+
options.SwaggerDoc("v1", new OpenApiInfo
|
|
228
|
+
{
|
|
229
|
+
Title = "My API",
|
|
230
|
+
Version = "v1",
|
|
231
|
+
Description = "API Description"
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
|
235
|
+
{
|
|
236
|
+
Description = "JWT Authorization header using the Bearer scheme",
|
|
237
|
+
Name = "Authorization",
|
|
238
|
+
In = ParameterLocation.Header,
|
|
239
|
+
Type = SecuritySchemeType.Http,
|
|
240
|
+
Scheme = "bearer"
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
|
244
|
+
{
|
|
245
|
+
{
|
|
246
|
+
new OpenApiSecurityScheme
|
|
247
|
+
{
|
|
248
|
+
Reference = new OpenApiReference
|
|
249
|
+
{
|
|
250
|
+
Type = ReferenceType.SecurityScheme,
|
|
251
|
+
Id = "Bearer"
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
Array.Empty<string>()
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Include XML comments
|
|
259
|
+
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
|
260
|
+
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Enable only in development
|
|
264
|
+
if (app.Environment.IsDevelopment())
|
|
265
|
+
{
|
|
266
|
+
app.UseSwagger();
|
|
267
|
+
app.UseSwaggerUI();
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Authentication
|
|
272
|
+
|
|
273
|
+
### JWT Bearer
|
|
274
|
+
|
|
275
|
+
```csharp
|
|
276
|
+
// Program.cs
|
|
277
|
+
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
278
|
+
.AddJwtBearer(options =>
|
|
279
|
+
{
|
|
280
|
+
options.TokenValidationParameters = new TokenValidationParameters
|
|
281
|
+
{
|
|
282
|
+
ValidateIssuer = true,
|
|
283
|
+
ValidateAudience = true,
|
|
284
|
+
ValidateLifetime = true,
|
|
285
|
+
ValidateIssuerSigningKey = true,
|
|
286
|
+
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
|
287
|
+
ValidAudience = builder.Configuration["Jwt:Audience"],
|
|
288
|
+
IssuerSigningKey = new SymmetricSecurityKey(
|
|
289
|
+
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
builder.Services.AddAuthorization();
|
|
294
|
+
|
|
295
|
+
// Middleware order matters!
|
|
296
|
+
app.UseAuthentication();
|
|
297
|
+
app.UseAuthorization();
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Policy-Based Authorization
|
|
301
|
+
|
|
302
|
+
```csharp
|
|
303
|
+
// Define policies
|
|
304
|
+
builder.Services.AddAuthorization(options =>
|
|
305
|
+
{
|
|
306
|
+
options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
|
|
307
|
+
options.AddPolicy("CanManageUsers", policy =>
|
|
308
|
+
policy.RequireClaim("permission", "users:manage"));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Use on endpoints
|
|
312
|
+
app.MapDelete("/api/users/{id}", DeleteUser)
|
|
313
|
+
.RequireAuthorization("AdminOnly");
|
|
314
|
+
|
|
315
|
+
// Or on controllers
|
|
316
|
+
[Authorize(Policy = "AdminOnly")]
|
|
317
|
+
public class AdminController : ControllerBase { }
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Versioning
|
|
321
|
+
|
|
322
|
+
```csharp
|
|
323
|
+
builder.Services.AddApiVersioning(options =>
|
|
324
|
+
{
|
|
325
|
+
options.DefaultApiVersion = new ApiVersion(1, 0);
|
|
326
|
+
options.AssumeDefaultVersionWhenUnspecified = true;
|
|
327
|
+
options.ReportApiVersions = true;
|
|
328
|
+
options.ApiVersionReader = ApiVersionReader.Combine(
|
|
329
|
+
new UrlSegmentApiVersionReader(),
|
|
330
|
+
new HeaderApiVersionReader("X-Api-Version"));
|
|
331
|
+
}).AddApiExplorer(options =>
|
|
332
|
+
{
|
|
333
|
+
options.GroupNameFormat = "'v'VVV";
|
|
334
|
+
options.SubstituteApiVersionInUrl = true;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Usage
|
|
338
|
+
app.MapGroup("/api/v{version:apiVersion}/users")
|
|
339
|
+
.MapUserEndpoints()
|
|
340
|
+
.HasApiVersion(1.0)
|
|
341
|
+
.HasApiVersion(2.0);
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
## Rate Limiting
|
|
345
|
+
|
|
346
|
+
```csharp
|
|
347
|
+
builder.Services.AddRateLimiter(options =>
|
|
348
|
+
{
|
|
349
|
+
options.AddFixedWindowLimiter("fixed", config =>
|
|
350
|
+
{
|
|
351
|
+
config.Window = TimeSpan.FromMinutes(1);
|
|
352
|
+
config.PermitLimit = 100;
|
|
353
|
+
config.QueueLimit = 0;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
options.AddTokenBucketLimiter("token", config =>
|
|
357
|
+
{
|
|
358
|
+
config.TokenLimit = 100;
|
|
359
|
+
config.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
|
|
360
|
+
config.TokensPerPeriod = 10;
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
app.UseRateLimiter();
|
|
367
|
+
|
|
368
|
+
// Apply to endpoints
|
|
369
|
+
app.MapGet("/api/users", GetUsers).RequireRateLimiting("fixed");
|
|
370
|
+
```
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "src/**/*.cs"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Clean Architecture Rules
|
|
7
|
+
|
|
8
|
+
## Layer Dependencies
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
WebApi → Application → Domain
|
|
12
|
+
WebApi → Infrastructure → Application → Domain
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Domain Layer (src/Domain/)
|
|
16
|
+
|
|
17
|
+
- **ZERO external dependencies** (no NuGet packages except primitives)
|
|
18
|
+
- Contains: Entities, Value Objects, Enums, Domain Events, Interfaces
|
|
19
|
+
- No references to other projects
|
|
20
|
+
|
|
21
|
+
```csharp
|
|
22
|
+
// Good - Domain entity
|
|
23
|
+
namespace MyApp.Domain.Entities;
|
|
24
|
+
|
|
25
|
+
public class User
|
|
26
|
+
{
|
|
27
|
+
public Guid Id { get; private set; }
|
|
28
|
+
public string Email { get; private set; } = default!;
|
|
29
|
+
public string PasswordHash { get; private set; } = default!;
|
|
30
|
+
|
|
31
|
+
private User() { } // EF Core
|
|
32
|
+
|
|
33
|
+
public static User Create(string email, string passwordHash)
|
|
34
|
+
{
|
|
35
|
+
return new User
|
|
36
|
+
{
|
|
37
|
+
Id = Guid.NewGuid(),
|
|
38
|
+
Email = email,
|
|
39
|
+
PasswordHash = passwordHash
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Application Layer (src/Application/)
|
|
46
|
+
|
|
47
|
+
- References: Domain only
|
|
48
|
+
- Contains: Commands, Queries, DTOs, Interfaces, Validators, Mappings
|
|
49
|
+
- Allowed packages: MediatR, FluentValidation, AutoMapper
|
|
50
|
+
|
|
51
|
+
```csharp
|
|
52
|
+
// Command + Handler in same folder
|
|
53
|
+
namespace MyApp.Application.Users.Commands.CreateUser;
|
|
54
|
+
|
|
55
|
+
public record CreateUserCommand(string Email, string Password) : IRequest<Guid>;
|
|
56
|
+
|
|
57
|
+
public class CreateUserCommandHandler(
|
|
58
|
+
IUserRepository userRepository,
|
|
59
|
+
IPasswordHasher passwordHasher
|
|
60
|
+
) : IRequestHandler<CreateUserCommand, Guid>
|
|
61
|
+
{
|
|
62
|
+
public async Task<Guid> Handle(CreateUserCommand request, CancellationToken cancellationToken)
|
|
63
|
+
{
|
|
64
|
+
var hashedPassword = passwordHasher.Hash(request.Password);
|
|
65
|
+
var user = User.Create(request.Email, hashedPassword);
|
|
66
|
+
await userRepository.AddAsync(user, cancellationToken);
|
|
67
|
+
return user.Id;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Infrastructure Layer (src/Infrastructure/)
|
|
73
|
+
|
|
74
|
+
- References: Application, Domain
|
|
75
|
+
- Contains: DbContext, Repositories, External Services, Configurations
|
|
76
|
+
- Implements interfaces defined in Application/Domain
|
|
77
|
+
|
|
78
|
+
```csharp
|
|
79
|
+
// Repository implementation
|
|
80
|
+
namespace MyApp.Infrastructure.Repositories;
|
|
81
|
+
|
|
82
|
+
public class UserRepository(ApplicationDbContext context) : IUserRepository
|
|
83
|
+
{
|
|
84
|
+
public async Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
|
85
|
+
{
|
|
86
|
+
return await context.Users.FindAsync([id], cancellationToken);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public async Task AddAsync(User user, CancellationToken cancellationToken = default)
|
|
90
|
+
{
|
|
91
|
+
await context.Users.AddAsync(user, cancellationToken);
|
|
92
|
+
await context.SaveChangesAsync(cancellationToken);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Presentation Layer (src/WebApi/)
|
|
98
|
+
|
|
99
|
+
- References: Application, Infrastructure
|
|
100
|
+
- Contains: Controllers/Endpoints, Middleware, Filters
|
|
101
|
+
- Only layer that knows about HTTP
|
|
102
|
+
|
|
103
|
+
## CQRS Pattern
|
|
104
|
+
|
|
105
|
+
### Commands (Write Operations)
|
|
106
|
+
|
|
107
|
+
```csharp
|
|
108
|
+
// Commands modify state, return minimal data (ID or void)
|
|
109
|
+
public record CreateUserCommand(string Email, string Password) : IRequest<Guid>;
|
|
110
|
+
public record UpdateUserCommand(Guid Id, string Name) : IRequest;
|
|
111
|
+
public record DeleteUserCommand(Guid Id) : IRequest;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Queries (Read Operations)
|
|
115
|
+
|
|
116
|
+
```csharp
|
|
117
|
+
// Queries return data, never modify state
|
|
118
|
+
public record GetUserQuery(Guid Id) : IRequest<UserDto?>;
|
|
119
|
+
public record GetUsersQuery(int Page, int PageSize) : IRequest<PaginatedList<UserDto>>;
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Validation
|
|
123
|
+
|
|
124
|
+
```csharp
|
|
125
|
+
// Validator in same folder as command
|
|
126
|
+
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
|
127
|
+
{
|
|
128
|
+
public CreateUserCommandValidator(IUserRepository userRepository)
|
|
129
|
+
{
|
|
130
|
+
RuleFor(x => x.Email)
|
|
131
|
+
.NotEmpty()
|
|
132
|
+
.EmailAddress()
|
|
133
|
+
.MustAsync(async (email, ct) => !await userRepository.ExistsAsync(email, ct))
|
|
134
|
+
.WithMessage("Email already exists");
|
|
135
|
+
|
|
136
|
+
RuleFor(x => x.Password)
|
|
137
|
+
.NotEmpty()
|
|
138
|
+
.MinimumLength(8)
|
|
139
|
+
.Matches("[A-Z]").WithMessage("Must contain uppercase")
|
|
140
|
+
.Matches("[a-z]").WithMessage("Must contain lowercase")
|
|
141
|
+
.Matches("[0-9]").WithMessage("Must contain digit");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Value Objects
|
|
147
|
+
|
|
148
|
+
```csharp
|
|
149
|
+
// Immutable, equality by value
|
|
150
|
+
public record Email
|
|
151
|
+
{
|
|
152
|
+
public string Value { get; }
|
|
153
|
+
|
|
154
|
+
public Email(string value)
|
|
155
|
+
{
|
|
156
|
+
if (string.IsNullOrWhiteSpace(value))
|
|
157
|
+
throw new ArgumentException("Email cannot be empty");
|
|
158
|
+
|
|
159
|
+
if (!value.Contains('@'))
|
|
160
|
+
throw new ArgumentException("Invalid email format");
|
|
161
|
+
|
|
162
|
+
Value = value.ToLowerInvariant();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public static implicit operator string(Email email) => email.Value;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Domain Events
|
|
170
|
+
|
|
171
|
+
```csharp
|
|
172
|
+
// Domain event
|
|
173
|
+
public record UserCreatedEvent(Guid UserId, string Email) : INotification;
|
|
174
|
+
|
|
175
|
+
// In entity
|
|
176
|
+
public class User
|
|
177
|
+
{
|
|
178
|
+
private readonly List<INotification> _domainEvents = [];
|
|
179
|
+
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents.AsReadOnly();
|
|
180
|
+
|
|
181
|
+
public static User Create(string email, string passwordHash)
|
|
182
|
+
{
|
|
183
|
+
var user = new User { Id = Guid.NewGuid(), Email = email, PasswordHash = passwordHash };
|
|
184
|
+
user._domainEvents.Add(new UserCreatedEvent(user.Id, user.Email));
|
|
185
|
+
return user;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
public void ClearDomainEvents() => _domainEvents.Clear();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Handler
|
|
192
|
+
public class UserCreatedEventHandler(IEmailService emailService) : INotificationHandler<UserCreatedEvent>
|
|
193
|
+
{
|
|
194
|
+
public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
|
|
195
|
+
{
|
|
196
|
+
await emailService.SendWelcomeEmailAsync(notification.Email, cancellationToken);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|