@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,389 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "tests/**/*.cs"
|
|
4
|
+
- "**/*.Tests/**/*.cs"
|
|
5
|
+
- "**/*Tests.cs"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# .NET Testing Rules
|
|
9
|
+
|
|
10
|
+
## Test Project Structure
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
tests/
|
|
14
|
+
├── Domain.UnitTests/
|
|
15
|
+
│ └── Entities/
|
|
16
|
+
│ └── UserTests.cs
|
|
17
|
+
├── Application.UnitTests/
|
|
18
|
+
│ └── Users/
|
|
19
|
+
│ └── Commands/
|
|
20
|
+
│ └── CreateUserCommandHandlerTests.cs
|
|
21
|
+
├── Infrastructure.IntegrationTests/
|
|
22
|
+
│ └── Repositories/
|
|
23
|
+
│ └── UserRepositoryTests.cs
|
|
24
|
+
└── WebApi.IntegrationTests/
|
|
25
|
+
└── Endpoints/
|
|
26
|
+
└── UsersEndpointsTests.cs
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Unit Tests (xUnit + NSubstitute + FluentAssertions)
|
|
30
|
+
|
|
31
|
+
### Naming Convention
|
|
32
|
+
|
|
33
|
+
```csharp
|
|
34
|
+
// Method_Scenario_ExpectedResult
|
|
35
|
+
public class UserTests
|
|
36
|
+
{
|
|
37
|
+
[Fact]
|
|
38
|
+
public void Create_WithValidEmail_ReturnsUser()
|
|
39
|
+
{
|
|
40
|
+
// Arrange
|
|
41
|
+
var email = "test@example.com";
|
|
42
|
+
var passwordHash = "hashedPassword";
|
|
43
|
+
|
|
44
|
+
// Act
|
|
45
|
+
var user = User.Create(email, passwordHash);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
user.Should().NotBeNull();
|
|
49
|
+
user.Email.Should().Be(email);
|
|
50
|
+
user.Id.Should().NotBeEmpty();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
[Fact]
|
|
54
|
+
public void Create_WithEmptyEmail_ThrowsArgumentException()
|
|
55
|
+
{
|
|
56
|
+
// Arrange
|
|
57
|
+
var email = "";
|
|
58
|
+
var passwordHash = "hashedPassword";
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
var act = () => User.Create(email, passwordHash);
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
act.Should().Throw<ArgumentException>()
|
|
65
|
+
.WithMessage("*email*");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Testing Handlers with Mocks
|
|
71
|
+
|
|
72
|
+
```csharp
|
|
73
|
+
public class CreateUserCommandHandlerTests
|
|
74
|
+
{
|
|
75
|
+
private readonly IUserRepository _userRepository;
|
|
76
|
+
private readonly IPasswordHasher _passwordHasher;
|
|
77
|
+
private readonly CreateUserCommandHandler _sut;
|
|
78
|
+
|
|
79
|
+
public CreateUserCommandHandlerTests()
|
|
80
|
+
{
|
|
81
|
+
_userRepository = Substitute.For<IUserRepository>();
|
|
82
|
+
_passwordHasher = Substitute.For<IPasswordHasher>();
|
|
83
|
+
_sut = new CreateUserCommandHandler(_userRepository, _passwordHasher);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[Fact]
|
|
87
|
+
public async Task Handle_ValidCommand_CreatesUserAndReturnsId()
|
|
88
|
+
{
|
|
89
|
+
// Arrange
|
|
90
|
+
var command = new CreateUserCommand("test@example.com", "Password123!");
|
|
91
|
+
_passwordHasher.Hash(command.Password).Returns("hashedPassword");
|
|
92
|
+
|
|
93
|
+
// Act
|
|
94
|
+
var result = await _sut.Handle(command, CancellationToken.None);
|
|
95
|
+
|
|
96
|
+
// Assert
|
|
97
|
+
result.Should().NotBeEmpty();
|
|
98
|
+
await _userRepository.Received(1).AddAsync(
|
|
99
|
+
Arg.Is<User>(u => u.Email == command.Email),
|
|
100
|
+
Arg.Any<CancellationToken>());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
[Fact]
|
|
104
|
+
public async Task Handle_ExistingEmail_ThrowsValidationException()
|
|
105
|
+
{
|
|
106
|
+
// Arrange
|
|
107
|
+
var command = new CreateUserCommand("existing@example.com", "Password123!");
|
|
108
|
+
_userRepository.ExistsAsync(command.Email, Arg.Any<CancellationToken>())
|
|
109
|
+
.Returns(true);
|
|
110
|
+
|
|
111
|
+
// Act
|
|
112
|
+
var act = () => _sut.Handle(command, CancellationToken.None);
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
115
|
+
await act.Should().ThrowAsync<ValidationException>();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Testing Validators
|
|
121
|
+
|
|
122
|
+
```csharp
|
|
123
|
+
public class CreateUserCommandValidatorTests
|
|
124
|
+
{
|
|
125
|
+
private readonly CreateUserCommandValidator _sut;
|
|
126
|
+
private readonly IUserRepository _userRepository;
|
|
127
|
+
|
|
128
|
+
public CreateUserCommandValidatorTests()
|
|
129
|
+
{
|
|
130
|
+
_userRepository = Substitute.For<IUserRepository>();
|
|
131
|
+
_sut = new CreateUserCommandValidator(_userRepository);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
[Theory]
|
|
135
|
+
[InlineData("")]
|
|
136
|
+
[InlineData("invalid")]
|
|
137
|
+
[InlineData("missing@")]
|
|
138
|
+
public async Task Validate_InvalidEmail_ReturnsError(string email)
|
|
139
|
+
{
|
|
140
|
+
// Arrange
|
|
141
|
+
var command = new CreateUserCommand(email, "Password123!");
|
|
142
|
+
|
|
143
|
+
// Act
|
|
144
|
+
var result = await _sut.ValidateAsync(command);
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
147
|
+
result.IsValid.Should().BeFalse();
|
|
148
|
+
result.Errors.Should().Contain(e => e.PropertyName == "Email");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
[Theory]
|
|
152
|
+
[InlineData("short")]
|
|
153
|
+
[InlineData("nouppercase1")]
|
|
154
|
+
[InlineData("NOLOWERCASE1")]
|
|
155
|
+
[InlineData("NoDigitsHere")]
|
|
156
|
+
public async Task Validate_WeakPassword_ReturnsError(string password)
|
|
157
|
+
{
|
|
158
|
+
// Arrange
|
|
159
|
+
var command = new CreateUserCommand("test@example.com", password);
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
var result = await _sut.ValidateAsync(command);
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
result.IsValid.Should().BeFalse();
|
|
166
|
+
result.Errors.Should().Contain(e => e.PropertyName == "Password");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Integration Tests
|
|
172
|
+
|
|
173
|
+
### Database Tests with Test Containers
|
|
174
|
+
|
|
175
|
+
```csharp
|
|
176
|
+
public class UserRepositoryTests : IAsyncLifetime
|
|
177
|
+
{
|
|
178
|
+
private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder()
|
|
179
|
+
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
|
|
180
|
+
.Build();
|
|
181
|
+
|
|
182
|
+
private ApplicationDbContext _context = null!;
|
|
183
|
+
private UserRepository _sut = null!;
|
|
184
|
+
|
|
185
|
+
public async Task InitializeAsync()
|
|
186
|
+
{
|
|
187
|
+
await _sqlContainer.StartAsync();
|
|
188
|
+
|
|
189
|
+
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
190
|
+
.UseSqlServer(_sqlContainer.GetConnectionString())
|
|
191
|
+
.Options;
|
|
192
|
+
|
|
193
|
+
_context = new ApplicationDbContext(options);
|
|
194
|
+
await _context.Database.MigrateAsync();
|
|
195
|
+
|
|
196
|
+
_sut = new UserRepository(_context);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
public async Task DisposeAsync()
|
|
200
|
+
{
|
|
201
|
+
await _context.DisposeAsync();
|
|
202
|
+
await _sqlContainer.DisposeAsync();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
[Fact]
|
|
206
|
+
public async Task AddAsync_ValidUser_PersistsToDatabase()
|
|
207
|
+
{
|
|
208
|
+
// Arrange
|
|
209
|
+
var user = User.Create("test@example.com", "hashedPassword");
|
|
210
|
+
|
|
211
|
+
// Act
|
|
212
|
+
await _sut.AddAsync(user);
|
|
213
|
+
|
|
214
|
+
// Assert
|
|
215
|
+
var savedUser = await _context.Users.FindAsync(user.Id);
|
|
216
|
+
savedUser.Should().NotBeNull();
|
|
217
|
+
savedUser!.Email.Should().Be(user.Email);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### API Integration Tests with WebApplicationFactory
|
|
223
|
+
|
|
224
|
+
```csharp
|
|
225
|
+
public class UsersEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
|
226
|
+
{
|
|
227
|
+
private readonly HttpClient _client;
|
|
228
|
+
private readonly WebApplicationFactory<Program> _factory;
|
|
229
|
+
|
|
230
|
+
public UsersEndpointsTests(WebApplicationFactory<Program> factory)
|
|
231
|
+
{
|
|
232
|
+
_factory = factory.WithWebHostBuilder(builder =>
|
|
233
|
+
{
|
|
234
|
+
builder.ConfigureServices(services =>
|
|
235
|
+
{
|
|
236
|
+
// Replace real DB with in-memory
|
|
237
|
+
var descriptor = services.SingleOrDefault(
|
|
238
|
+
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
|
|
239
|
+
if (descriptor != null) services.Remove(descriptor);
|
|
240
|
+
|
|
241
|
+
services.AddDbContext<ApplicationDbContext>(options =>
|
|
242
|
+
options.UseInMemoryDatabase("TestDb"));
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
_client = _factory.CreateClient();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
[Fact]
|
|
250
|
+
public async Task CreateUser_ValidRequest_Returns201()
|
|
251
|
+
{
|
|
252
|
+
// Arrange
|
|
253
|
+
var request = new { Email = "test@example.com", Password = "Password123!" };
|
|
254
|
+
|
|
255
|
+
// Act
|
|
256
|
+
var response = await _client.PostAsJsonAsync("/api/users", request);
|
|
257
|
+
|
|
258
|
+
// Assert
|
|
259
|
+
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
260
|
+
response.Headers.Location.Should().NotBeNull();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
[Fact]
|
|
264
|
+
public async Task CreateUser_InvalidEmail_Returns400WithProblemDetails()
|
|
265
|
+
{
|
|
266
|
+
// Arrange
|
|
267
|
+
var request = new { Email = "invalid", Password = "Password123!" };
|
|
268
|
+
|
|
269
|
+
// Act
|
|
270
|
+
var response = await _client.PostAsJsonAsync("/api/users", request);
|
|
271
|
+
|
|
272
|
+
// Assert
|
|
273
|
+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
274
|
+
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
|
|
275
|
+
problemDetails!.Errors.Should().ContainKey("Email");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
[Fact]
|
|
279
|
+
public async Task GetUser_NonExistent_Returns404()
|
|
280
|
+
{
|
|
281
|
+
// Act
|
|
282
|
+
var response = await _client.GetAsync($"/api/users/{Guid.NewGuid()}");
|
|
283
|
+
|
|
284
|
+
// Assert
|
|
285
|
+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Authenticated Tests
|
|
291
|
+
|
|
292
|
+
```csharp
|
|
293
|
+
public class AuthenticatedEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
|
294
|
+
{
|
|
295
|
+
private readonly HttpClient _client;
|
|
296
|
+
|
|
297
|
+
public AuthenticatedEndpointsTests(WebApplicationFactory<Program> factory)
|
|
298
|
+
{
|
|
299
|
+
_client = factory.WithWebHostBuilder(builder =>
|
|
300
|
+
{
|
|
301
|
+
builder.ConfigureServices(services =>
|
|
302
|
+
{
|
|
303
|
+
// Add test authentication
|
|
304
|
+
services.AddAuthentication("Test")
|
|
305
|
+
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", null);
|
|
306
|
+
});
|
|
307
|
+
}).CreateClient();
|
|
308
|
+
|
|
309
|
+
_client.DefaultRequestHeaders.Authorization =
|
|
310
|
+
new AuthenticationHeaderValue("Test");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
[Fact]
|
|
314
|
+
public async Task GetProfile_Authenticated_Returns200()
|
|
315
|
+
{
|
|
316
|
+
var response = await _client.GetAsync("/api/users/me");
|
|
317
|
+
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Test auth handler
|
|
322
|
+
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
|
323
|
+
{
|
|
324
|
+
public TestAuthHandler(
|
|
325
|
+
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
326
|
+
ILoggerFactory logger,
|
|
327
|
+
UrlEncoder encoder)
|
|
328
|
+
: base(options, logger, encoder) { }
|
|
329
|
+
|
|
330
|
+
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
331
|
+
{
|
|
332
|
+
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) };
|
|
333
|
+
var identity = new ClaimsIdentity(claims, "Test");
|
|
334
|
+
var principal = new ClaimsPrincipal(identity);
|
|
335
|
+
var ticket = new AuthenticationTicket(principal, "Test");
|
|
336
|
+
return Task.FromResult(AuthenticateResult.Success(ticket));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## Test Data Builders
|
|
342
|
+
|
|
343
|
+
```csharp
|
|
344
|
+
public class UserBuilder
|
|
345
|
+
{
|
|
346
|
+
private Guid _id = Guid.NewGuid();
|
|
347
|
+
private string _email = "default@example.com";
|
|
348
|
+
private string _passwordHash = "hashedPassword";
|
|
349
|
+
private string _role = "User";
|
|
350
|
+
|
|
351
|
+
public UserBuilder WithId(Guid id) { _id = id; return this; }
|
|
352
|
+
public UserBuilder WithEmail(string email) { _email = email; return this; }
|
|
353
|
+
public UserBuilder WithRole(string role) { _role = role; return this; }
|
|
354
|
+
public UserBuilder AsAdmin() => WithRole("Admin");
|
|
355
|
+
|
|
356
|
+
public User Build()
|
|
357
|
+
{
|
|
358
|
+
// Use reflection or internal setters for testing
|
|
359
|
+
return new User
|
|
360
|
+
{
|
|
361
|
+
Id = _id,
|
|
362
|
+
Email = _email,
|
|
363
|
+
PasswordHash = _passwordHash,
|
|
364
|
+
Role = _role
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Usage
|
|
370
|
+
var user = new UserBuilder()
|
|
371
|
+
.WithEmail("admin@example.com")
|
|
372
|
+
.AsAdmin()
|
|
373
|
+
.Build();
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Code Coverage
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
# Run with coverage
|
|
380
|
+
dotnet test --collect:"XPlat Code Coverage"
|
|
381
|
+
|
|
382
|
+
# Generate HTML report (requires reportgenerator tool)
|
|
383
|
+
dotnet tool install -g dotnet-reportgenerator-globaltool
|
|
384
|
+
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Coverage Thresholds
|
|
388
|
+
|
|
389
|
+
Target: **80%+ coverage** on Application and Domain layers.
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# .NET Project Guidelines
|
|
2
|
+
|
|
3
|
+
@../_shared/CLAUDE.md
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
|
|
7
|
+
- .NET 8+ (latest LTS)
|
|
8
|
+
- ASP.NET Core Web API
|
|
9
|
+
- Entity Framework Core
|
|
10
|
+
- C# 12+ features
|
|
11
|
+
- xUnit for testing
|
|
12
|
+
|
|
13
|
+
## Architecture
|
|
14
|
+
|
|
15
|
+
### Clean Architecture (Recommended)
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/
|
|
19
|
+
├── Domain/ # Core business logic (no dependencies)
|
|
20
|
+
│ ├── Entities/
|
|
21
|
+
│ │ └── User.cs
|
|
22
|
+
│ ├── ValueObjects/
|
|
23
|
+
│ │ └── Email.cs
|
|
24
|
+
│ ├── Enums/
|
|
25
|
+
│ ├── Exceptions/
|
|
26
|
+
│ │ └── DomainException.cs
|
|
27
|
+
│ └── Interfaces/
|
|
28
|
+
│ └── IUserRepository.cs
|
|
29
|
+
│
|
|
30
|
+
├── Application/ # Use cases, CQRS, DTOs
|
|
31
|
+
│ ├── Common/
|
|
32
|
+
│ │ ├── Behaviors/
|
|
33
|
+
│ │ │ ├── ValidationBehavior.cs
|
|
34
|
+
│ │ │ └── LoggingBehavior.cs
|
|
35
|
+
│ │ ├── Interfaces/
|
|
36
|
+
│ │ │ └── IApplicationDbContext.cs
|
|
37
|
+
│ │ └── Mappings/
|
|
38
|
+
│ │ └── MappingProfile.cs
|
|
39
|
+
│ ├── Users/
|
|
40
|
+
│ │ ├── Commands/
|
|
41
|
+
│ │ │ ├── CreateUser/
|
|
42
|
+
│ │ │ │ ├── CreateUserCommand.cs
|
|
43
|
+
│ │ │ │ ├── CreateUserCommandHandler.cs
|
|
44
|
+
│ │ │ │ └── CreateUserCommandValidator.cs
|
|
45
|
+
│ │ │ └── UpdateUser/
|
|
46
|
+
│ │ └── Queries/
|
|
47
|
+
│ │ ├── GetUser/
|
|
48
|
+
│ │ │ ├── GetUserQuery.cs
|
|
49
|
+
│ │ │ ├── GetUserQueryHandler.cs
|
|
50
|
+
│ │ │ └── UserDto.cs
|
|
51
|
+
│ │ └── GetUsers/
|
|
52
|
+
│ └── DependencyInjection.cs
|
|
53
|
+
│
|
|
54
|
+
├── Infrastructure/ # External concerns
|
|
55
|
+
│ ├── Data/
|
|
56
|
+
│ │ ├── ApplicationDbContext.cs
|
|
57
|
+
│ │ ├── Configurations/
|
|
58
|
+
│ │ │ └── UserConfiguration.cs
|
|
59
|
+
│ │ └── Migrations/
|
|
60
|
+
│ ├── Repositories/
|
|
61
|
+
│ │ └── UserRepository.cs
|
|
62
|
+
│ ├── Services/
|
|
63
|
+
│ │ └── DateTimeService.cs
|
|
64
|
+
│ └── DependencyInjection.cs
|
|
65
|
+
│
|
|
66
|
+
└── WebApi/ # Presentation layer
|
|
67
|
+
├── Controllers/
|
|
68
|
+
│ └── UsersController.cs
|
|
69
|
+
├── Filters/
|
|
70
|
+
│ └── ApiExceptionFilterAttribute.cs
|
|
71
|
+
├── Middleware/
|
|
72
|
+
│ └── ExceptionHandlingMiddleware.cs
|
|
73
|
+
├── Program.cs
|
|
74
|
+
└── appsettings.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Project References
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
WebApi → Application → Domain
|
|
81
|
+
WebApi → Infrastructure → Application → Domain
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Key rule**: Domain has ZERO external dependencies.
|
|
85
|
+
|
|
86
|
+
## Code Style
|
|
87
|
+
|
|
88
|
+
### Naming Conventions
|
|
89
|
+
|
|
90
|
+
| Element | Convention | Example |
|
|
91
|
+
|---------|------------|---------|
|
|
92
|
+
| Classes | PascalCase | `UserService` |
|
|
93
|
+
| Interfaces | IPascalCase | `IUserRepository` |
|
|
94
|
+
| Methods | PascalCase | `GetUserById` |
|
|
95
|
+
| Properties | PascalCase | `FirstName` |
|
|
96
|
+
| Private fields | _camelCase | `_userRepository` |
|
|
97
|
+
| Parameters | camelCase | `userId` |
|
|
98
|
+
| Constants | PascalCase | `MaxRetryCount` |
|
|
99
|
+
| Async methods | Suffix Async | `GetUserAsync` |
|
|
100
|
+
|
|
101
|
+
### File-Scoped Namespaces
|
|
102
|
+
|
|
103
|
+
```csharp
|
|
104
|
+
// Good - C# 10+
|
|
105
|
+
namespace MyApp.Domain.Entities;
|
|
106
|
+
|
|
107
|
+
public class User { }
|
|
108
|
+
|
|
109
|
+
// Avoid
|
|
110
|
+
namespace MyApp.Domain.Entities
|
|
111
|
+
{
|
|
112
|
+
public class User { }
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Primary Constructors (C# 12)
|
|
117
|
+
|
|
118
|
+
```csharp
|
|
119
|
+
// Good - for simple DI
|
|
120
|
+
public class UserService(IUserRepository userRepository, ILogger<UserService> logger)
|
|
121
|
+
{
|
|
122
|
+
public async Task<User?> GetByIdAsync(Guid id)
|
|
123
|
+
{
|
|
124
|
+
logger.LogInformation("Getting user {UserId}", id);
|
|
125
|
+
return await userRepository.GetByIdAsync(id);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Use traditional constructors when you need field assignment or validation
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Records for DTOs
|
|
133
|
+
|
|
134
|
+
```csharp
|
|
135
|
+
// Immutable DTOs
|
|
136
|
+
public record UserDto(Guid Id, string Email, string Name);
|
|
137
|
+
|
|
138
|
+
public record CreateUserRequest(string Email, string Password, string Name);
|
|
139
|
+
|
|
140
|
+
// With validation attributes
|
|
141
|
+
public record CreateUserCommand(
|
|
142
|
+
[Required][EmailAddress] string Email,
|
|
143
|
+
[Required][MinLength(8)] string Password,
|
|
144
|
+
[Required] string Name
|
|
145
|
+
) : IRequest<Guid>;
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Nullable Reference Types
|
|
149
|
+
|
|
150
|
+
```csharp
|
|
151
|
+
// Enable in .csproj
|
|
152
|
+
<Nullable>enable</Nullable>
|
|
153
|
+
|
|
154
|
+
// Be explicit about nullability
|
|
155
|
+
public async Task<User?> GetByIdAsync(Guid id); // Can return null
|
|
156
|
+
public async Task<User> GetByIdOrThrowAsync(Guid id); // Never null
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Commands
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Development
|
|
163
|
+
dotnet run --project src/WebApi
|
|
164
|
+
|
|
165
|
+
# Build
|
|
166
|
+
dotnet build
|
|
167
|
+
dotnet publish -c Release
|
|
168
|
+
|
|
169
|
+
# Tests
|
|
170
|
+
dotnet test
|
|
171
|
+
dotnet test --filter "Category=Unit"
|
|
172
|
+
dotnet test --collect:"XPlat Code Coverage"
|
|
173
|
+
|
|
174
|
+
# EF Core migrations
|
|
175
|
+
dotnet ef migrations add InitialCreate -p src/Infrastructure -s src/WebApi
|
|
176
|
+
dotnet ef database update -p src/Infrastructure -s src/WebApi
|
|
177
|
+
|
|
178
|
+
# Format
|
|
179
|
+
dotnet format
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Common Patterns
|
|
183
|
+
|
|
184
|
+
### Minimal API Endpoints
|
|
185
|
+
|
|
186
|
+
```csharp
|
|
187
|
+
// Program.cs or endpoint extension
|
|
188
|
+
app.MapGet("/users/{id:guid}", async (Guid id, ISender sender) =>
|
|
189
|
+
{
|
|
190
|
+
var user = await sender.Send(new GetUserQuery(id));
|
|
191
|
+
return user is not null ? Results.Ok(user) : Results.NotFound();
|
|
192
|
+
})
|
|
193
|
+
.WithName("GetUser")
|
|
194
|
+
.WithOpenApi()
|
|
195
|
+
.RequireAuthorization();
|
|
196
|
+
|
|
197
|
+
app.MapPost("/users", async (CreateUserCommand command, ISender sender) =>
|
|
198
|
+
{
|
|
199
|
+
var id = await sender.Send(command);
|
|
200
|
+
return Results.CreatedAtRoute("GetUser", new { id }, id);
|
|
201
|
+
})
|
|
202
|
+
.WithName("CreateUser")
|
|
203
|
+
.WithOpenApi();
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Controller-Based API
|
|
207
|
+
|
|
208
|
+
```csharp
|
|
209
|
+
[ApiController]
|
|
210
|
+
[Route("api/[controller]")]
|
|
211
|
+
public class UsersController(ISender sender) : ControllerBase
|
|
212
|
+
{
|
|
213
|
+
[HttpGet("{id:guid}")]
|
|
214
|
+
[ProducesResponseType<UserDto>(StatusCodes.Status200OK)]
|
|
215
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
216
|
+
public async Task<IActionResult> Get(Guid id)
|
|
217
|
+
{
|
|
218
|
+
var user = await sender.Send(new GetUserQuery(id));
|
|
219
|
+
return user is not null ? Ok(user) : NotFound();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
[HttpPost]
|
|
223
|
+
[ProducesResponseType<Guid>(StatusCodes.Status201Created)]
|
|
224
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
225
|
+
public async Task<IActionResult> Create(CreateUserCommand command)
|
|
226
|
+
{
|
|
227
|
+
var id = await sender.Send(command);
|
|
228
|
+
return CreatedAtAction(nameof(Get), new { id }, id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Global Exception Handling
|
|
234
|
+
|
|
235
|
+
```csharp
|
|
236
|
+
public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
|
|
237
|
+
{
|
|
238
|
+
public async Task InvokeAsync(HttpContext context)
|
|
239
|
+
{
|
|
240
|
+
try
|
|
241
|
+
{
|
|
242
|
+
await next(context);
|
|
243
|
+
}
|
|
244
|
+
catch (ValidationException ex)
|
|
245
|
+
{
|
|
246
|
+
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
247
|
+
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
|
248
|
+
{
|
|
249
|
+
Status = 400,
|
|
250
|
+
Title = "Validation Error",
|
|
251
|
+
Detail = string.Join(", ", ex.Errors.Select(e => e.ErrorMessage))
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (NotFoundException ex)
|
|
255
|
+
{
|
|
256
|
+
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
257
|
+
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
|
258
|
+
{
|
|
259
|
+
Status = 404,
|
|
260
|
+
Title = "Not Found",
|
|
261
|
+
Detail = ex.Message
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch (Exception ex)
|
|
265
|
+
{
|
|
266
|
+
logger.LogError(ex, "Unhandled exception");
|
|
267
|
+
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
|
268
|
+
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
|
269
|
+
{
|
|
270
|
+
Status = 500,
|
|
271
|
+
Title = "Server Error"
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Dependency Injection Setup
|
|
279
|
+
|
|
280
|
+
```csharp
|
|
281
|
+
// Application/DependencyInjection.cs
|
|
282
|
+
public static class DependencyInjection
|
|
283
|
+
{
|
|
284
|
+
public static IServiceCollection AddApplication(this IServiceCollection services)
|
|
285
|
+
{
|
|
286
|
+
services.AddMediatR(cfg => {
|
|
287
|
+
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
|
|
288
|
+
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
|
289
|
+
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
|
293
|
+
services.AddAutoMapper(Assembly.GetExecutingAssembly());
|
|
294
|
+
|
|
295
|
+
return services;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Infrastructure/DependencyInjection.cs
|
|
300
|
+
public static class DependencyInjection
|
|
301
|
+
{
|
|
302
|
+
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
|
303
|
+
{
|
|
304
|
+
services.AddDbContext<ApplicationDbContext>(options =>
|
|
305
|
+
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
|
|
306
|
+
|
|
307
|
+
services.AddScoped<IApplicationDbContext>(sp =>
|
|
308
|
+
sp.GetRequiredService<ApplicationDbContext>());
|
|
309
|
+
|
|
310
|
+
services.AddScoped<IUserRepository, UserRepository>();
|
|
311
|
+
|
|
312
|
+
return services;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Program.cs
|
|
317
|
+
builder.Services.AddApplication();
|
|
318
|
+
builder.Services.AddInfrastructure(builder.Configuration);
|
|
319
|
+
```
|