最後活躍 1 month ago

weehong 已修改 1 month ago. 還原成這個修訂版本

1 file changed, 800 insertions, 1 deletion

dotnet-10-clean-architecture-boilerplate-guide.md

@@ -1,3 +1,801 @@
1 + # The Ultimate .NET 10 Clean Architecture Boilerplate: Step-by-Step Implementation Guide
2 +
3 + This guide is an exhaustive, detail-oriented manual for recreating a .NET 10 Clean Architecture and CQRS boilerplate from scratch. It documents every file in the repository in strict chronological order—you will build the core first, and layer dependencies outward, so you never have to reference a file that doesn't exist yet.
4 +
5 + ## Table of Contents
6 +
7 + 1. [Architecture Overview](#1-architecture-overview)
8 + 2. [Prerequisites & Environment Setup](#2-prerequisites--environment-setup)
9 + 3. [Solution & Project Scaffolding](#3-solution--project-scaffolding)
10 + 4. [Root Configuration Files](#4-root-configuration-files)
11 + 5. [Layer 1: Domain](#5-layer-1-domain)
12 + 6. [Layer 2: Application](#6-layer-2-application)
13 + 7. [Layer 3: Infrastructure](#7-layer-3-infrastructure)
14 + 8. [Layer 4: API / Presentation](#8-layer-4-api--presentation)
15 + 9. [Docker & Dev Environment](#9-docker--dev-environment)
16 + 10. [Test Projects](#10-test-projects)
17 + 11. [CI/CD Pipeline](#11-cicd-pipeline)
18 + 12. [AI-Assisted Development](#12-ai-assisted-development)
19 + 13. [Running the Project](#13-running-the-project)
20 + 14. [Appendix A: Outbox Pattern (Optional)](#appendix-a-outbox-pattern-optional)
21 +
22 + ---
23 +
24 + ## 1. Architecture Overview
25 +
26 + Clean Architecture organises code into concentric layers where dependencies **always point inward**.
27 +
28 + - **Domain** references nothing.
29 + - **Application** references Domain only.
30 + - **Infrastructure** references Application (and transitively, Domain).
31 + - **API** references Application and Infrastructure.
32 +
33 + This guarantees the Domain and Application layers are fully testable without a database, web server, or framework.
34 +
35 + ---
36 +
37 + ## 2. Prerequisites & Environment Setup
38 +
39 + Ensure you have the **.NET 10 SDK**, **Docker**, and your preferred IDE installed.
40 +
41 + To make this guide copy-paste friendly, open your terminal and set your project variables. We use two variables: one for C# PascalCase naming, and one for lowercase systems (Docker/PostgreSQL).
42 +
43 + ```bash
44 + export PROJECT="Inventory"
45 + export PROJECT_LOWER="inventory"
46 + ```
47 +
48 + ---
49 +
50 + ## 3. Solution & Project Scaffolding
51 +
52 + Create the root directory and initialize the solution and projects using your variables:
53 +
54 + ```bash
55 + mkdir ${PROJECT_LOWER}-api
56 + cd ${PROJECT_LOWER}-api
57 +
58 + # Create the solution (slnx = lightweight XML format)
59 + dotnet new slnx -n ${PROJECT}
60 +
61 + # Create the source projects
62 + dotnet new webapi -n ${PROJECT}.Api -o src/${PROJECT}.Api
63 + dotnet new classlib -n ${PROJECT}.Application -o src/${PROJECT}.Application
64 + dotnet new classlib -n ${PROJECT}.Domain -o src/${PROJECT}.Domain
65 + dotnet new classlib -n ${PROJECT}.Infrastructure -o src/${PROJECT}.Infrastructure
66 +
67 + # Create the test projects
68 + dotnet new xunit -n ${PROJECT}.Application.Tests -o tests/${PROJECT}.Application.Tests
69 + dotnet new xunit -n ${PROJECT}.Domain.Tests -o tests/${PROJECT}.Domain.Tests
70 + dotnet new xunit -n ${PROJECT}.IntegrationTests -o tests/${PROJECT}.IntegrationTests
71 +
72 + # Add source projects to the solution
73 + dotnet sln ${PROJECT}.slnx add src/${PROJECT}.Api/${PROJECT}.Api.csproj --solution-folder src
74 + dotnet sln ${PROJECT}.slnx add src/${PROJECT}.Application/${PROJECT}.Application.csproj --solution-folder src
75 + dotnet sln ${PROJECT}.slnx add src/${PROJECT}.Domain/${PROJECT}.Domain.csproj --solution-folder src
76 + dotnet sln ${PROJECT}.slnx add src/${PROJECT}.Infrastructure/${PROJECT}.Infrastructure.csproj --solution-folder src
77 +
78 + # Add test projects to the solution
79 + dotnet sln ${PROJECT}.slnx add tests/${PROJECT}.Application.Tests/${PROJECT}.Application.Tests.csproj --solution-folder tests
80 + dotnet sln ${PROJECT}.slnx add tests/${PROJECT}.Domain.Tests/${PROJECT}.Domain.Tests.csproj --solution-folder tests
81 + dotnet sln ${PROJECT}.slnx add tests/${PROJECT}.IntegrationTests/${PROJECT}.IntegrationTests.csproj --solution-folder tests
82 +
83 + # Configure Clean Architecture Dependencies
84 + dotnet add src/${PROJECT}.Application/${PROJECT}.Application.csproj reference src/${PROJECT}.Domain/${PROJECT}.Domain.csproj
85 + dotnet add src/${PROJECT}.Infrastructure/${PROJECT}.Infrastructure.csproj reference src/${PROJECT}.Application/${PROJECT}.Application.csproj
86 + dotnet add src/${PROJECT}.Api/${PROJECT}.Api.csproj reference src/${PROJECT}.Application/${PROJECT}.Application.csproj
87 + dotnet add src/${PROJECT}.Api/${PROJECT}.Api.csproj reference src/${PROJECT}.Infrastructure/${PROJECT}.Infrastructure.csproj
88 + ```
89 +
90 + ---
91 +
92 + ## 4. Root Configuration Files
93 +
94 + These files lock SDK versions and manage NuGet packages centrally. Create them at the repository root.
95 +
96 + ### `global.json`
97 + ```json
98 + {
99 + "sdk": {
100 + "rollForward": "latestFeature",
101 + "version": "10.0.103"
102 + }
103 + }
104 + ```
105 +
106 + ### `Directory.Build.props`
107 + ```xml
108 + <Project>
109 + <PropertyGroup>
110 + <TargetFramework>net10.0</TargetFramework>
111 + <Nullable>enable</Nullable>
112 + <ImplicitUsings>enable</ImplicitUsings>
113 + <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
114 + </PropertyGroup>
115 + </Project>
116 + ```
117 +
118 + ### `Directory.Packages.props`
119 + ```xml
120 + <Project>
121 + <PropertyGroup>
122 + <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
123 + </PropertyGroup>
124 + <ItemGroup>
125 + <PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0-preview.2"/>
126 + <PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0-preview.2"/>
127 + <PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0"/>
128 + <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5"/>
129 + <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0"/>
130 + <PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1"/>
131 + <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0"/>
132 + <PackageVersion Include="FluentValidation" Version="12.1.1"/>
133 + <PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1"/>
134 + <PackageVersion Include="MediatR" Version="14.1.0"/>
135 + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5"/>
136 + <PackageVersion Include="Newtonsoft.Json" Version="13.0.4"/>
137 + <PackageVersion Include="Quartz.Extensions.Hosting" Version="3.16.1"/>
138 + <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.5"/>
139 + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5"/>
140 + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5"/>
141 + <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1"/>
142 + <PackageVersion Include="coverlet.collector" Version="8.0.0"/>
143 + <PackageVersion Include="FluentAssertions" Version="8.8.0"/>
144 + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
145 + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
146 + <PackageVersion Include="Moq" Version="4.20.72"/>
147 + <PackageVersion Include="xunit" Version="2.9.3"/>
148 + <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5"/>
149 + </ItemGroup>
150 + </Project>
151 + ```
152 +
153 + ### Apply NuGet Packages to Projects
154 + Now that versioning is centralized, add the packages to your projects.
155 +
156 + ```bash
157 + # Application Layer
158 + dotnet add src/${PROJECT}.Application package MediatR
159 + dotnet add src/${PROJECT}.Application package FluentValidation
160 + dotnet add src/${PROJECT}.Application package FluentValidation.DependencyInjectionExtensions
161 + dotnet add src/${PROJECT}.Application package Microsoft.Extensions.Logging.Abstractions
162 +
163 + # Infrastructure Layer
164 + dotnet add src/${PROJECT}.Infrastructure package Microsoft.EntityFrameworkCore
165 + dotnet add src/${PROJECT}.Infrastructure package Microsoft.EntityFrameworkCore.Design
166 + dotnet add src/${PROJECT}.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
167 + dotnet add src/${PROJECT}.Infrastructure package Newtonsoft.Json
168 + dotnet add src/${PROJECT}.Infrastructure package Quartz.Extensions.Hosting
169 +
170 + # API Layer
171 + dotnet add src/${PROJECT}.Api package Serilog.AspNetCore
172 + dotnet add src/${PROJECT}.Api package Serilog.Enrichers.Environment
173 + dotnet add src/${PROJECT}.Api package Serilog.Enrichers.Thread
174 + dotnet add src/${PROJECT}.Api package AspNetCore.HealthChecks.NpgSql
175 + dotnet add src/${PROJECT}.Api package Microsoft.AspNetCore.OpenApi
176 + dotnet add src/${PROJECT}.Api package Microsoft.EntityFrameworkCore.Tools
177 + dotnet add src/${PROJECT}.Api package Asp.Versioning.Mvc
178 + dotnet add src/${PROJECT}.Api package Asp.Versioning.Mvc.ApiExplorer
179 +
180 + # Test Projects (Domain)
181 + dotnet add tests/${PROJECT}.Domain.Tests package Microsoft.NET.Test.Sdk
182 + dotnet add tests/${PROJECT}.Domain.Tests package xunit
183 + dotnet add tests/${PROJECT}.Domain.Tests package xunit.runner.visualstudio
184 + dotnet add tests/${PROJECT}.Domain.Tests package coverlet.collector
185 + dotnet add tests/${PROJECT}.Domain.Tests package FluentAssertions
186 + dotnet add tests/${PROJECT}.Domain.Tests package Moq
187 +
188 + # Test Projects (Application)
189 + dotnet add tests/${PROJECT}.Application.Tests package Microsoft.NET.Test.Sdk
190 + dotnet add tests/${PROJECT}.Application.Tests package xunit
191 + dotnet add tests/${PROJECT}.Application.Tests package xunit.runner.visualstudio
192 + dotnet add tests/${PROJECT}.Application.Tests package coverlet.collector
193 + dotnet add tests/${PROJECT}.Application.Tests package FluentAssertions
194 + dotnet add tests/${PROJECT}.Application.Tests package Moq
195 +
196 + # Test Projects (Integration)
197 + dotnet add tests/${PROJECT}.IntegrationTests package Microsoft.NET.Test.Sdk
198 + dotnet add tests/${PROJECT}.IntegrationTests package xunit
199 + dotnet add tests/${PROJECT}.IntegrationTests package xunit.runner.visualstudio
200 + dotnet add tests/${PROJECT}.IntegrationTests package coverlet.collector
201 + dotnet add tests/${PROJECT}.IntegrationTests package FluentAssertions
202 + dotnet add tests/${PROJECT}.IntegrationTests package Moq
203 + dotnet add tests/${PROJECT}.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing
204 + ```
205 +
206 + ---
207 +
208 + ## 5. Layer 1: Domain
209 +
210 + *The innermost layer. Contains entities, value objects, domain events, the Result pattern, and repository abstractions. It has **zero** NuGet dependencies.*
211 +
212 + ### `src/${PROJECT}.Domain/Common/IDomainEvent.cs`
213 + ```csharp
214 + namespace ${PROJECT}.Domain.Common;
215 +
216 + public interface IDomainEvent
217 + {
218 + DateTime OccurredOn { get; }
219 + }
220 + ```
221 +
222 + ### `src/${PROJECT}.Domain/Common/BaseEntity.cs`
223 + ```csharp
224 + namespace ${PROJECT}.Domain.Common;
225 +
226 + public abstract class BaseEntity
227 + {
228 + private readonly List<IDomainEvent> _domainEvents = [];
229 + public Guid Id { get; private init; } = Guid.NewGuid();
230 + public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
231 +
232 + public void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
233 + public void RemoveDomainEvent(IDomainEvent domainEvent) => _domainEvents.Remove(domainEvent);
234 + public void ClearDomainEvents() => _domainEvents.Clear();
235 + }
236 + ```
237 +
238 + ### `src/${PROJECT}.Domain/Common/AuditableEntity.cs`
239 + ```csharp
240 + namespace ${PROJECT}.Domain.Common;
241 +
242 + public abstract class AuditableEntity : BaseEntity
243 + {
244 + public DateTime CreatedAt { get; private set; }
245 + public DateTime? UpdatedAt { get; private set; }
246 +
247 + public void SetCreatedAt(DateTime createdAt) => CreatedAt = createdAt;
248 + public void SetUpdatedAt(DateTime updatedAt) => UpdatedAt = updatedAt;
249 + }
250 + ```
251 +
252 + ### `src/${PROJECT}.Domain/Common/Error.cs`
253 + ```csharp
254 + namespace ${PROJECT}.Domain.Common;
255 +
256 + public enum ErrorType { None = 0, Failure = 1, Validation = 2, NotFound = 3, Conflict = 4 }
257 +
258 + public sealed record Error(string Code, string Description, ErrorType Type)
259 + {
260 + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None);
261 + public static readonly Error NullValue = new("Error.NullValue", "A null value was provided.", ErrorType.Failure);
262 + public static readonly Error NotFound = new("Error.NotFound", "The requested resource was not found.", ErrorType.NotFound);
263 + public static readonly Error Conflict = new("Error.Conflict", "A conflict occurred with the current state.", ErrorType.Conflict);
264 + public static readonly Error Validation = new("Error.Validation", "A validation error occurred.", ErrorType.Validation);
265 + }
266 + ```
267 +
268 + ### `src/${PROJECT}.Domain/Common/Result.cs`
269 + ```csharp
270 + namespace ${PROJECT}.Domain.Common;
271 +
272 + public interface IValidationResult
273 + {
274 + static abstract Result Failure(Error error);
275 + }
276 +
277 + public class Result : IValidationResult
278 + {
279 + protected Result(bool isSuccess, Error error)
280 + {
281 + if (isSuccess && error != Error.None) throw new InvalidOperationException("A successful result cannot have an error.");
282 + if (!isSuccess && error == Error.None) throw new InvalidOperationException("A failed result must have an error.");
283 + IsSuccess = isSuccess;
284 + Error = error;
285 + }
286 +
287 + public bool IsSuccess { get; }
288 + public bool IsFailure => !IsSuccess;
289 + public Error Error { get; }
290 +
291 + public static Result Success() => new(true, Error.None);
292 + public static Result<T> Success<T>(T value) => Result<T>.Success(value);
293 + public static Result Failure(Error error) => new(false, error);
294 + public static Result<T> Failure<T>(Error error) => Result<T>.Failure(error);
295 + }
296 +
297 + public class Result<T> : Result, IValidationResult
298 + {
299 + private readonly T? _value;
300 +
301 + private Result(T? value, bool isSuccess, Error error) : base(isSuccess, error)
302 + {
303 + _value = value;
304 + }
305 +
306 + public T Value => IsSuccess ? _value! : throw new InvalidOperationException("Cannot access the value of a failed result.");
307 +
308 + public static Result<T> Success(T value) => new(value, true, Error.None);
309 + public new static Result<T> Failure(Error error) => new(default, false, error);
310 + public static implicit operator Result<T>(T value) => Success(value);
311 + }
312 + ```
313 +
314 + ### `src/${PROJECT}.Domain/Abstractions/IUnitOfWork.cs`
315 + ```csharp
316 + namespace ${PROJECT}.Domain.Abstractions;
317 +
318 + public interface IUnitOfWork
319 + {
320 + Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
321 + }
322 + ```
323 +
324 + ---
325 +
326 + ## 6. Layer 2: Application
327 +
328 + *Contains use cases (commands, queries, handlers), validation, mapping, and MediatR pipeline behaviors. References Domain only.*
329 +
330 + ### `src/${PROJECT}.Application/Abstractions/Messaging/ICommand.cs`
331 + ```csharp
332 + using MediatR;
333 + using ${PROJECT}.Domain.Common;
334 +
335 + namespace ${PROJECT}.Application.Abstractions.Messaging;
336 +
337 + public interface ICommand : IRequest<Result>;
338 + public interface ICommand<TResponse> : IRequest<Result<TResponse>>;
339 + ```
340 +
341 + ### `src/${PROJECT}.Application/Abstractions/Messaging/ICommandHandler.cs`
342 + ```csharp
343 + using MediatR;
344 + using ${PROJECT}.Domain.Common;
345 +
346 + namespace ${PROJECT}.Application.Abstractions.Messaging;
347 +
348 + public interface ICommandHandler<in TCommand> : IRequestHandler<TCommand, Result> where TCommand : ICommand;
349 + public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>> where TCommand : ICommand<TResponse>;
350 + ```
351 +
352 + ### `src/${PROJECT}.Application/Abstractions/IDomainEventHandler.cs`
353 + ```csharp
354 + using MediatR;
355 + using ${PROJECT}.Domain.Common;
356 +
357 + namespace ${PROJECT}.Application.Abstractions;
358 +
359 + public sealed class DomainEventNotification<TDomainEvent>(TDomainEvent domainEvent) : INotification where TDomainEvent : IDomainEvent
360 + {
361 + public TDomainEvent DomainEvent { get; } = domainEvent;
362 + }
363 +
364 + public interface IDomainEventHandler<TDomainEvent> : INotificationHandler<DomainEventNotification<TDomainEvent>> where TDomainEvent : IDomainEvent;
365 + ```
366 +
367 + ### `src/${PROJECT}.Application/Behaviors/LoggingBehavior.cs`
368 + ```csharp
369 + using System.Diagnostics;
370 + using MediatR;
371 + using Microsoft.Extensions.Logging;
372 +
373 + namespace ${PROJECT}.Application.Behaviors;
374 +
375 + public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
376 + {
377 + public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
378 + {
379 + var requestName = typeof(TRequest).Name;
380 + logger.LogInformation("Handling {RequestName}", requestName);
381 + var stopwatch = Stopwatch.StartNew();
382 + var response = await next(cancellationToken);
383 + stopwatch.Stop();
384 + logger.LogInformation("Handled {RequestName} in {ElapsedMilliseconds}ms", requestName, stopwatch.ElapsedMilliseconds);
385 + return response;
386 + }
387 + }
388 + ```
389 +
390 + ### `src/${PROJECT}.Application/Behaviors/ValidationBehavior.cs`
391 + ```csharp
392 + using FluentValidation;
393 + using MediatR;
394 + using Microsoft.Extensions.Logging;
395 + using ${PROJECT}.Domain.Common;
396 +
397 + namespace ${PROJECT}.Application.Behaviors;
398 +
399 + public sealed class ValidationBehavior<TRequest, TResponse>(
400 + IEnumerable<IValidator<TRequest>> validators,
401 + ILogger<ValidationBehavior<TRequest, TResponse>> logger)
402 + : IPipelineBehavior<TRequest, TResponse>
403 + where TRequest : IRequest<TResponse>
404 + where TResponse : Result, IValidationResult
405 + {
406 + public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
407 + {
408 + var validatorList = validators as IReadOnlyList<IValidator<TRequest>> ?? [.. validators];
409 +
410 + if (validatorList.Count == 0) return await next(cancellationToken);
411 +
412 + var context = new ValidationContext<TRequest>(request);
413 + var validationResults = await Task.WhenAll(validatorList.Select(v => v.ValidateAsync(context, cancellationToken)));
414 + var failures = validationResults.SelectMany(r => r.Errors).Where(f => f is not null).ToList();
415 +
416 + if (failures.Count != 0)
417 + {
418 + var errorMessage = string.Join("; ", failures.Select(f => f.ErrorMessage));
419 + var error = new Error("Validation", errorMessage, ErrorType.Validation);
420 + logger.LogWarning("Validation failed for {RequestName}: {ErrorMessage}", typeof(TRequest).Name, errorMessage);
421 +
422 + // Direct call to static abstract Failure method. No reflection!
423 + return (TResponse)TResponse.Failure(error);
424 + }
425 +
426 + return await next(cancellationToken);
427 + }
428 + }
429 + ```
430 +
431 + ### `src/${PROJECT}.Application/DependencyInjection.cs`
432 + ```csharp
433 + using FluentValidation;
434 + using MediatR;
435 + using Microsoft.Extensions.DependencyInjection;
436 + using ${PROJECT}.Application.Behaviors;
437 +
438 + namespace ${PROJECT}.Application;
439 +
440 + public static class DependencyInjection
441 + {
442 + public static IServiceCollection AddApplication(this IServiceCollection services)
443 + {
444 + var assembly = typeof(DependencyInjection).Assembly;
445 +
446 + services.AddMediatR(cfg =>
447 + {
448 + cfg.RegisterServicesFromAssembly(assembly);
449 + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
450 + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
451 + });
452 +
453 + services.AddValidatorsFromAssembly(assembly);
454 + return services;
455 + }
456 + }
457 + ```
458 +
459 + ---
460 +
461 + ## 7. Layer 3: Infrastructure
462 +
463 + *Implements the abstractions defined in Domain and Application. Contains the EF Core `DbContext` and interceptors.*
464 +
465 + ### `src/${PROJECT}.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs`
466 + ```csharp
467 + using Microsoft.EntityFrameworkCore;
468 + using Microsoft.EntityFrameworkCore.Diagnostics;
469 + using ${PROJECT}.Domain.Common;
470 +
471 + namespace ${PROJECT}.Infrastructure.Persistence.Interceptors;
472 +
473 + public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
474 + {
475 + public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
476 + {
477 + UpdateAuditableEntities(eventData.Context);
478 + return base.SavingChangesAsync(eventData, result, cancellationToken);
479 + }
480 +
481 + private static void UpdateAuditableEntities(DbContext? context)
482 + {
483 + if (context is null) return;
484 + var utcNow = DateTime.UtcNow;
485 + foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
486 + {
487 + if (entry.State == EntityState.Added) entry.Entity.SetCreatedAt(utcNow);
488 + if (entry.State == EntityState.Modified) entry.Entity.SetUpdatedAt(utcNow);
489 + }
490 + }
491 + }
492 + ```
493 +
494 + ### `src/${PROJECT}.Infrastructure/Persistence/Interceptors/DomainEventInterceptor.cs`
495 + ```csharp
496 + using MediatR;
497 + using Microsoft.EntityFrameworkCore;
498 + using Microsoft.EntityFrameworkCore.Diagnostics;
499 + using ${PROJECT}.Application.Abstractions;
500 + using ${PROJECT}.Domain.Common;
501 +
502 + namespace ${PROJECT}.Infrastructure.Persistence.Interceptors;
503 +
504 + public sealed class DomainEventInterceptor(IPublisher publisher) : SaveChangesInterceptor
505 + {
506 + public override async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
507 + {
508 + if (eventData.Context is not null)
509 + await PublishDomainEventsAsync(eventData.Context, cancellationToken);
510 + return await base.SavedChangesAsync(eventData, result, cancellationToken);
511 + }
512 +
513 + private async Task PublishDomainEventsAsync(DbContext context, CancellationToken cancellationToken)
514 + {
515 + var entities = context.ChangeTracker.Entries<BaseEntity>().Where(e => e.Entity.DomainEvents.Count != 0).Select(e => e.Entity).ToList();
516 + var domainEvents = entities.SelectMany(e => e.DomainEvents).ToList();
517 + entities.ForEach(e => e.ClearDomainEvents());
518 +
519 + foreach (var domainEvent in domainEvents)
520 + {
521 + var notificationType = typeof(DomainEventNotification<>).MakeGenericType(domainEvent.GetType());
522 + var notification = Activator.CreateInstance(notificationType, domainEvent)!;
523 + await publisher.Publish(notification, cancellationToken);
524 + }
525 + }
526 + }
527 + ```
528 +
529 + ### `src/${PROJECT}.Infrastructure/Persistence/ApplicationDbContext.cs`
530 + ```csharp
531 + using Microsoft.EntityFrameworkCore;
532 + using ${PROJECT}.Domain.Abstractions;
533 +
534 + namespace ${PROJECT}.Infrastructure.Persistence;
535 +
536 + public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options), IUnitOfWork
537 + {
538 + protected override void OnModelCreating(ModelBuilder modelBuilder)
539 + {
540 + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
541 + base.OnModelCreating(modelBuilder);
542 + }
543 + }
544 + ```
545 +
546 + ### `src/${PROJECT}.Infrastructure/DependencyInjection.cs`
547 + ```csharp
548 + using Microsoft.EntityFrameworkCore;
549 + using Microsoft.Extensions.Configuration;
550 + using Microsoft.Extensions.DependencyInjection;
551 + using ${PROJECT}.Domain.Abstractions;
552 + using ${PROJECT}.Infrastructure.Persistence;
553 + using ${PROJECT}.Infrastructure.Persistence.Interceptors;
554 +
555 + namespace ${PROJECT}.Infrastructure;
556 +
557 + public static class DependencyInjection
558 + {
559 + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
560 + {
561 + services.AddSingleton<AuditableEntityInterceptor>();
562 + services.AddScoped<DomainEventInterceptor>();
563 +
564 + services.AddDbContext<ApplicationDbContext>((sp, options) =>
565 + {
566 + var auditableInterceptor = sp.GetRequiredService<AuditableEntityInterceptor>();
567 + var domainEventInterceptor = sp.GetRequiredService<DomainEventInterceptor>();
568 +
569 + options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))
570 + .AddInterceptors(auditableInterceptor, domainEventInterceptor);
571 + });
572 +
573 + services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<ApplicationDbContext>());
574 +
575 + return services;
576 + }
577 + }
578 + ```
579 +
580 + ---
581 +
582 + ## 8. Layer 4: API / Presentation
583 +
584 + *The composition root where all layers meet. Controllers, Middleware, and Program.cs.*
585 +
586 + ### `src/${PROJECT}.Api/appsettings.json`
587 + ```json
588 + {
589 + "ConnectionStrings": {
590 + "DefaultConnection": ""
591 + },
592 + "Serilog": {
593 + "Using": ["Serilog.Sinks.Console"],
594 + "MinimumLevel": {
595 + "Default": "Information",
596 + "Override": { "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" }
597 + },
598 + "WriteTo": [
599 + { "Name": "Console", "Args": { "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}{CorrelationId: [{CorrelationId}]} {Message:lj}{NewLine}{Exception}" } }
600 + ],
601 + "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
602 + },
603 + "AllowedHosts": "*"
604 + }
605 + ```
606 +
607 + ### `src/${PROJECT}.Api/Extensions/ServiceCollectionExtensions.cs`
608 + ```csharp
609 + using Asp.Versioning;
610 + using Serilog;
611 + using ${PROJECT}.Application;
612 + using ${PROJECT}.Infrastructure;
613 +
614 + namespace ${PROJECT}.Api.Extensions;
615 +
616 + public static class ServiceCollectionExtensions
617 + {
618 + public static WebApplicationBuilder AddServices(this WebApplicationBuilder builder)
619 + {
620 + builder.Host.UseSerilog((context, loggerConfiguration) =>
621 + loggerConfiguration.ReadFrom.Configuration(context.Configuration));
622 +
623 + builder.Services.AddControllers();
624 + builder.Services.AddOpenApi();
625 + builder.Services.AddProblemDetails();
626 +
627 + builder.Services.AddApiVersioning(options =>
628 + {
629 + options.DefaultApiVersion = new ApiVersion(1, 0);
630 + options.AssumeDefaultVersionWhenUnspecified = true;
631 + options.ReportApiVersions = true;
632 + options.ApiVersionReader = new UrlSegmentApiVersionReader();
633 + })
634 + .AddApiExplorer(options =>
635 + {
636 + options.GroupNameFormat = "'v'VVV";
637 + options.SubstituteApiVersionInUrl = true;
638 + });
639 +
640 + builder.Services.AddApplication();
641 + builder.Services.AddInfrastructure(builder.Configuration);
642 +
643 + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
644 + ?? throw new InvalidOperationException("Connection string 'DefaultConnection' is not configured.");
645 +
646 + builder.Services.AddHealthChecks().AddNpgSql(connectionString);
647 +
648 + return builder;
649 + }
650 + }
651 + ```
652 +
653 + ### `src/${PROJECT}.Api/Extensions/WebApplicationExtensions.cs`
654 + ```csharp
655 + namespace ${PROJECT}.Api.Extensions;
656 +
657 + public static class WebApplicationExtensions
658 + {
659 + public static WebApplication ConfigurePipeline(this WebApplication app)
660 + {
661 + if (app.Environment.IsDevelopment())
662 + app.MapOpenApi();
663 +
664 + app.UseExceptionHandler();
665 + app.UseHttpsRedirection();
666 + app.UseAuthorization();
667 + app.MapControllers();
668 + app.MapHealthChecks("/health");
669 +
670 + return app;
671 + }
672 + }
673 + ```
674 +
675 + ### `src/${PROJECT}.Api/Program.cs`
676 + ```csharp
677 + using Serilog;
678 + using ${PROJECT}.Api.Extensions;
679 +
680 + var builder = WebApplication.CreateBuilder(args);
681 + builder.AddServices();
682 +
683 + var app = builder.Build();
684 + app.ConfigurePipeline();
685 +
686 + try
687 + {
688 + Log.Information("Starting ${PROJECT} API in {Environment} environment", app.Environment.EnvironmentName);
689 + app.Run();
690 + }
691 + catch (Exception ex)
692 + {
693 + Log.Fatal(ex, "Application terminated unexpectedly");
694 + }
695 + finally
696 + {
697 + Log.CloseAndFlush();
698 + }
699 +
700 + public partial class Program; // Needed for integration tests
701 + ```
702 +
703 + ---
704 +
705 + ## 9. Docker & Dev Environment
706 +
707 + Create these files in your repository root.
708 +
709 + ### `.env.example`
710 + ```bash
711 + DOCKER_IMAGE=your-dockerhub-username/${PROJECT_LOWER}-api
712 + IMAGE_TAG=latest
713 +
714 + ASPNETCORE_ENVIRONMENT=Development
715 + API_PORT=5212
716 +
717 + POSTGRES_DB=${PROJECT_LOWER}_dev
718 + POSTGRES_USER=postgres
719 + POSTGRES_PASSWORD=change-me-to-a-strong-password
720 + DB_PORT=5432
721 +
722 + CONNECTION_STRING=Host=db;Port=5432;Database=${PROJECT_LOWER}_dev;Username=postgres;Password=change-me-to-a-strong-password
723 + ```
724 +
725 + ### `compose.yml`
726 + ```yaml
727 + services:
728 + api:
729 + container_name: ${PROJECT_LOWER}-api
730 + image: ${DOCKER_IMAGE:-${PROJECT_LOWER}-api}:${IMAGE_TAG:-latest}
731 + build:
732 + context: .
733 + dockerfile: Dockerfile
734 + ports:
735 + - "${API_PORT:-5212}:8080"
736 + environment:
737 + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
738 + - ConnectionStrings__DefaultConnection=${CONNECTION_STRING:-Host=db;Port=5432;Database=${PROJECT_LOWER}_dev;Username=postgres;Password=postgres}
739 + depends_on:
740 + db:
741 + condition: service_healthy
742 +
743 + db:
744 + container_name: ${PROJECT_LOWER}-db
745 + image: postgres:17-alpine
746 + ports:
747 + - "${DB_PORT:-5432}:5432"
748 + environment:
749 + POSTGRES_DB: ${POSTGRES_DB:-${PROJECT_LOWER}_dev}
750 + POSTGRES_USER: ${POSTGRES_USER:-postgres}
751 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
752 + volumes:
753 + - postgres_data:/var/lib/postgresql/data
754 + healthcheck:
755 + test: ["CMD-SHELL", "pg_isready -U postgres"]
756 + interval: 5s
757 + timeout: 5s
758 + retries: 5
759 +
760 + volumes:
761 + postgres_data:
762 + ```
763 +
764 + ### `Dockerfile`
765 + ```dockerfile
766 + FROM [mcr.microsoft.com/dotnet/aspnet:10.0](https://mcr.microsoft.com/dotnet/aspnet:10.0) AS base
767 + WORKDIR /app
768 + EXPOSE 8080
769 +
770 + FROM [mcr.microsoft.com/dotnet/sdk:10.0](https://mcr.microsoft.com/dotnet/sdk:10.0) AS build
771 + ARG BUILD_CONFIGURATION=Release
772 + WORKDIR /src
773 +
774 + COPY global.json .
775 + COPY Directory.Build.props .
776 + COPY Directory.Packages.props .
777 + COPY src/${PROJECT}.Api/${PROJECT}.Api.csproj src/${PROJECT}.Api/
778 + COPY src/${PROJECT}.Application/${PROJECT}.Application.csproj src/${PROJECT}.Application/
779 + COPY src/${PROJECT}.Domain/${PROJECT}.Domain.csproj src/${PROJECT}.Domain/
780 + COPY src/${PROJECT}.Infrastructure/${PROJECT}.Infrastructure.csproj src/${PROJECT}.Infrastructure/
781 +
782 + RUN dotnet restore src/${PROJECT}.Api/${PROJECT}.Api.csproj
783 +
784 + COPY . .
785 + RUN dotnet build src/${PROJECT}.Api -c $BUILD_CONFIGURATION --no-restore
786 +
787 + FROM build AS publish
788 + ARG BUILD_CONFIGURATION=Release
789 + RUN dotnet publish src/${PROJECT}.Api -c $BUILD_CONFIGURATION --no-build -o /app/publish /p:UseAppHost=false
790 +
791 + FROM base AS final
792 + WORKDIR /app
793 + COPY --from=publish /app/publish .
794 + ENTRYPOINT ["dotnet", "${PROJECT}.Api.dll"]
795 + ```
796 +
797 + ---
798 +
1 799 ## 10. Test Projects
2 800
3 801 ### Test Strategy
@@ -565,4 +1363,5 @@ public sealed class ProcessOutboxMessagesJob(
565 1363 - You need at-least-once delivery guarantees
566 1364 - You're in a microservices architecture where services communicate via events
567 1365
568 - For purely in-process event handling (the common case in a monolith), the existing `DomainEventInterceptor` approach is simpler and sufficient.
1366 + For purely in-process event handling (the common case in a monolith), the existing `DomainEventInterceptor` approach is simpler and sufficient.
1367 + ```

weehong 已修改 1 month ago. 還原成這個修訂版本

1 file changed, 32 insertions, 2826 deletions

dotnet-10-clean-architecture-boilerplate-guide.md

@@ -1,2774 +1,4 @@
1 - # The Ultimate .NET 10 Clean Architecture Boilerplate: Step-by-Step Implementation Guide
2 -
3 - This guide is an exhaustive, detail-oriented manual for recreating this .NET 10 Clean Architecture and CQRS boilerplate from scratch. It documents every file in the repository — from root-level configuration to Docker setups, middleware, and test scaffolding — so you can understand not just *what* each file does, but *why* it exists.
4 -
5 - ## How to Use This Guide
6 -
7 - - **Prose** uses `{ProjectName}` as a placeholder. Replace it with your actual project name (e.g., `{Projects}`, `Inventory`, `Payments`).
8 - - **Code blocks** use `{Projects}` as the concrete example — the actual project this guide was written for.
9 - - Sections are ordered by dependency: set up root config first, then work inward from Domain → Application → Infrastructure → API.
10 -
11 - ---
12 -
13 - ## Table of Contents
14 -
15 - 1. [Architecture Overview](#1-architecture-overview)
16 - 2. [Design Decisions & Trade-offs](#2-design-decisions--trade-offs)
17 - 3. [Prerequisites](#3-prerequisites)
18 - 4. [Solution & Project Scaffolding](#4-solution--project-scaffolding)
19 - 5. [Root Configuration Files](#5-root-configuration-files)
20 - 6. [Project Files (.csproj)](#6-project-files-csproj)
21 - 7. [Docker & Dev Environment](#7-docker--dev-environment)
22 - 8. [Layer 1: Domain](#8-layer-1-domain)
23 - 9. [Layer 2: Application](#9-layer-2-application)
24 - 10. [Layer 3: Infrastructure](#10-layer-3-infrastructure)
25 - 11. [Layer 4: API / Presentation](#11-layer-4-api--presentation)
26 - 12. [Test Projects](#12-test-projects)
27 - 13. [CI/CD Pipeline](#13-cicd-pipeline)
28 - 14. [AI-Assisted Development](#14-ai-assisted-development)
29 - 15. [Running the Project](#15-running-the-project)
30 -
31 - ---
32 -
33 - ## 1. Architecture Overview
34 -
35 - ### Clean Architecture
36 -
37 - Clean Architecture organises code into concentric layers where dependencies **always point inward**. The innermost layer (Domain) has zero external dependencies; each outer layer may only reference layers closer to the core.
38 -
39 - ```
40 - ┌─────────────────────────────────────────────────────────────┐
41 - │ API / Presentation │
42 - │ Controllers · Middleware · Serilog · OpenAPI · Program.cs │
43 - │ │
44 - │ ┌─────────────────────────────────────────────────────┐ │
45 - │ │ Infrastructure │ │
46 - │ │ EF Core DbContext · Repositories · Interceptors │ │
47 - │ │ │ │
48 - │ │ ┌─────────────────────────────────────────────┐ │ │
49 - │ │ │ Application │ │ │
50 - │ │ │ Commands · Queries · Handlers · Behaviors │ │ │
51 - │ │ │ Validators · Mapping · DI Registration │ │ │
52 - │ │ │ │ │ │
53 - │ │ │ ┌──────────────────────────────────────┐ │ │ │
54 - │ │ │ │ Domain │ │ │ │
55 - │ │ │ │ Entities · Value Objects · Events │ │ │ │
56 - │ │ │ │ Result · Error · Abstractions │ │ │ │
57 - │ │ │ │ (ZERO external dependencies) │ │ │ │
58 - │ │ │ └──────────────────────────────────────┘ │ │ │
59 - │ │ └─────────────────────────────────────────────┘ │ │
60 - │ └─────────────────────────────────────────────────────┘ │
61 - └─────────────────────────────────────────────────────────────┘
62 - ```
63 -
64 - **The Dependency Rule:** Source-code dependencies must point inward. Nothing in an inner circle can reference anything in an outer circle. Concretely:
65 -
66 - - **Domain** references nothing.
67 - - **Application** references Domain only.
68 - - **Infrastructure** references Application (and transitively, Domain).
69 - - **API** references Application and Infrastructure.
70 -
71 - This means the Domain and Application layers are fully testable without a database, web server, or any framework.
72 -
73 - ### Project Dependency Graph
74 -
75 - ```
76 - {ProjectName}.Api
77 - ├── {ProjectName}.Application
78 - │ └── {ProjectName}.Domain (zero NuGet deps)
79 - └── {ProjectName}.Infrastructure
80 - └── {ProjectName}.Application
81 - └── {ProjectName}.Domain
82 -
83 - {ProjectName}.Domain.Tests
84 - └── {ProjectName}.Domain
85 -
86 - {ProjectName}.Application.Tests
87 - └── {ProjectName}.Application
88 -
89 - {ProjectName}.IntegrationTests
90 - └── {ProjectName}.Api
91 - ```
92 -
93 - ### CQRS (Command Query Responsibility Segregation)
94 -
95 - CQRS separates read operations (Queries) from write operations (Commands). Each operation is a standalone class that carries all the data it needs, and each has a dedicated handler. This gives you:
96 -
97 - - **Single Responsibility** — every handler does exactly one thing.
98 - - **Explicit contracts** — the request shape *is* the documentation.
99 - - **Pipeline behaviors** — cross-cutting concerns (logging, validation) are applied uniformly via MediatR's pipeline, not scattered through service classes.
100 -
101 - In this boilerplate, CQRS is implemented through MediatR:
102 -
103 - ```
104 - Controller Handler
105 - │ ▲
106 - │ IMediator.Send(command) │
107 - ▼ │
108 - ┌──────────────────────────────────────────────────┐
109 - │ MediatR Pipeline │
110 - │ │
111 - │ ┌─────────────────┐ ┌──────────────────────┐ │
112 - │ │ LoggingBehavior │──▶│ ValidationBehavior │──┼──▶ Handler
113 - │ └─────────────────┘ └──────────────────────┘ │
114 - └──────────────────────────────────────────────────┘
115 - ```
116 -
117 - Controllers never call services directly. They send a command or query to `IMediator`, which routes it through the pipeline behaviors and into the correct handler.
118 -
119 - ### The Result Pattern
120 -
121 - Instead of throwing exceptions for expected failures (validation errors, not-found, conflicts), every handler returns a `Result` or `Result<T>`. This makes the success/failure path explicit in the type system:
122 -
123 - ```csharp
124 - // Handler returns Result<Guid>, not just Guid
125 - public async Task<Result<Guid>> Handle(CreateMatchCommand request, CancellationToken ct)
126 - {
127 - // Failure path — no exception thrown
128 - if (exists) return Result.Failure<Guid>(Error.Conflict);
129 -
130 - // Success path
131 - return Result.Success(match.Id);
132 - }
133 - ```
134 -
135 - The `IValidationResult` interface with a `static abstract` method enables the `ValidationBehavior` to create typed failure results without reflection — a zero-reflection, high-performance validation pipeline.
136 -
137 - ### MediatR Pipeline Behaviors
138 -
139 - Pipeline behaviors are middleware that wrap every MediatR request. They execute in registration order, forming a chain:
140 -
141 - 1. **LoggingBehavior** — logs the request name and execution time.
142 - 2. **ValidationBehavior** — runs all FluentValidation validators for the request. If any fail, it short-circuits and returns a `Result.Failure` without ever reaching the handler.
143 -
144 - You can add more behaviors (e.g., authorization, caching, transaction management) by registering additional `IPipelineBehavior<,>` implementations.
145 -
146 - ---
147 -
148 - ## 2. Design Decisions & Trade-offs
149 -
150 - | Decision | Why | Alternatives Considered |
151 - |---|---|---|
152 - | **Central Package Management (CPM)** | Single `Directory.Packages.props` controls all NuGet versions — no version drift between projects | Per-project `<PackageVersion>` attributes |
153 - | **Result pattern over exceptions** | Makes success/failure explicit in return types; eliminates try/catch ceremony in callers; no stack-trace overhead for expected failures | Throwing domain exceptions; `OneOf<T>` discriminated unions |
154 - | **MediatR for CQRS** | Decouples controllers from handlers; pipeline behaviors give free cross-cutting concerns; widely adopted in .NET ecosystem | Hand-rolled mediator; direct service injection; Wolverine |
155 - | **`static abstract` on IValidationResult** | Enables `ValidationBehavior` to create typed `Result.Failure` without reflection or `Activator.CreateInstance` | Reflection-based factory; generic constraints with `new()` |
156 - | **Manual mapping (extension methods)** | Zero magic, fully debuggable, no hidden runtime behavior; keeps mapping close to the feature that uses it | AutoMapper; Mapster |
157 - | **FluentValidation** | Declarative, composable rules; integrates cleanly with MediatR pipeline | Data Annotations; hand-rolled validation |
158 - | **Serilog** | Structured logging with rich sink ecosystem; configuration-driven via `appsettings.json` | Built-in `ILogger` with console provider; NLog; log4net |
159 - | **EF Core Interceptors** | Keeps audit (`CreatedAt`/`UpdatedAt`) and domain event dispatch out of the DbContext, making them composable and testable | Overriding `SaveChangesAsync` directly; domain event outbox pattern |
160 - | **API Versioning (URL path segment)** | Non-breaking evolution of APIs; URL path (`/api/v1/`) is the most explicit and cache-friendly strategy; `Asp.Versioning.Mvc` is the official Microsoft-maintained library | Query string versioning; header versioning; no versioning |
161 - | **C# 13 (via .NET 10)** | Latest language version: collection expressions, `static abstract` interfaces, primary constructors, file-scoped namespaces, etc. | Pinning an older `LangVersion` |
162 - | **`.slnx` (XML solution file)** | New lightweight format; cleaner diffs than `.sln`; created directly via `dotnet new slnx` | Traditional `.sln` |
163 - | **PostgreSQL** | Open-source, production-grade RDBMS; excellent JSON support; strong EF Core provider | SQL Server; SQLite (dev-only); MySQL |
164 - | **Quartz.NET** | Mature, cron-capable job scheduler for background tasks (email, cleanup, etc.) | Hangfire; `IHostedService` with `Timer`; custom `BackgroundService` |
165 - | **`compose.yml` (not `docker-compose.yml`)** | Docker Compose V2 standard; shorter name; `docker compose` CLI (no hyphen) | Legacy `docker-compose.yml` |
166 - | **Multi-stage Dockerfile** | Separates build and runtime images; final image contains only published output (~200 MB vs ~1.5 GB) | Single-stage build; publishing locally and copying artifacts |
167 - | **Correlation ID middleware** | Traces a request across services and log entries; accepts client-supplied IDs or generates new ones | W3C Trace Context; OpenTelemetry baggage (heavier) |
168 - | **Sensitive data redaction** | Prevents passwords, tokens, and PII from appearing in logs; JSON DOM + regex fallback handles truncated bodies | Manual redaction per log call; not logging bodies at all |
169 -
170 - ---
171 -
172 - ## 3. Prerequisites
173 -
174 - Ensure you have the following installed:
175 - - **.NET 10 SDK** (Version 10.0.103 or higher)
176 - - **Docker & Docker Compose** (for PostgreSQL and containerization)
177 - - **IDE**: Visual Studio, Rider, or VS Code
178 -
179 - ---
180 -
181 - ## 4. Solution & Project Scaffolding
182 -
183 - Create the root directory and initialize the solution and projects:
184 -
185 - ```bash
186 - mkdir {projectname}-api
187 - cd {projectname}-api
188 -
189 - # Create the solution (slnx = lightweight XML format, cleaner diffs than .sln)
190 - dotnet new slnx -n {Projects}
191 -
192 - # Create the source projects
193 - dotnet new webapi -n {Projects}.Api -o src/{Projects}.Api
194 - dotnet new classlib -n {Projects}.Application -o src/{Projects}.Application
195 - dotnet new classlib -n {Projects}.Domain -o src/{Projects}.Domain
196 - dotnet new classlib -n {Projects}.Infrastructure -o src/{Projects}.Infrastructure
197 -
198 - # Create the test projects
199 - dotnet new xunit -n {Projects}.Application.Tests -o tests/{Projects}.Application.Tests
200 - dotnet new xunit -n {Projects}.Domain.Tests -o tests/{Projects}.Domain.Tests
201 - dotnet new xunit -n {Projects}.IntegrationTests -o tests/{Projects}.IntegrationTests
202 -
203 - # Add source projects to the solution
204 - dotnet sln {Projects}.slnx add src/{Projects}.Api/{Projects}.Api.csproj --solution-folder src
205 - dotnet sln {Projects}.slnx add src/{Projects}.Application/{Projects}.Application.csproj --solution-folder src
206 - dotnet sln {Projects}.slnx add src/{Projects}.Domain/{Projects}.Domain.csproj --solution-folder src
207 - dotnet sln {Projects}.slnx add src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj --solution-folder src
208 -
209 - # Add test projects to the solution
210 - dotnet sln {Projects}.slnx add tests/{Projects}.Application.Tests/{Projects}.Application.Tests.csproj --solution-folder tests
211 - dotnet sln {Projects}.slnx add tests/{Projects}.Domain.Tests/{Projects}.Domain.Tests.csproj --solution-folder tests
212 - dotnet sln {Projects}.slnx add tests/{Projects}.IntegrationTests/{Projects}.IntegrationTests.csproj --solution-folder tests
213 -
214 - # Configure Clean Architecture Dependencies
215 - dotnet add src/{Projects}.Application/{Projects}.Application.csproj reference src/{Projects}.Domain/{Projects}.Domain.csproj
216 - dotnet add src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj reference src/{Projects}.Application/{Projects}.Application.csproj
217 - dotnet add src/{Projects}.Api/{Projects}.Api.csproj reference src/{Projects}.Application/{Projects}.Application.csproj
218 - dotnet add src/{Projects}.Api/{Projects}.Api.csproj reference src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj
219 - ```
220 -
221 - ### Resulting `{ProjectName}.slnx`
222 -
223 - The `dotnet new slnx` command creates the lightweight XML-based solution format directly — no migration from `.sln` needed. The resulting file is concise:
224 -
225 - ```xml
226 - <Solution>
227 - <Folder Name="/src/">
228 - <Project Path="src/{Projects}.Api/{Projects}.Api.csproj"/>
229 - <Project Path="src/{Projects}.Application/{Projects}.Application.csproj"/>
230 - <Project Path="src/{Projects}.Domain/{Projects}.Domain.csproj"/>
231 - <Project Path="src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj"/>
232 - </Folder>
233 - <Folder Name="/tests/">
234 - <Project Path="tests/{Projects}.Application.Tests/{Projects}.Application.Tests.csproj"/>
235 - <Project Path="tests/{Projects}.Domain.Tests/{Projects}.Domain.Tests.csproj"/>
236 - <Project Path="tests/{Projects}.IntegrationTests/{Projects}.IntegrationTests.csproj"/>
237 - </Folder>
238 - </Solution>
239 - ```
240 -
241 - ---
242 -
243 - ## 5. Root Configuration Files
244 -
245 - These files enforce consistency, lock SDK versions, and centrally manage NuGet packages across all projects. Create them at the repository root (next to the `.slnx` file).
246 -
247 - ### `global.json`
248 -
249 - Locks the .NET SDK version so every developer and CI runner uses the same toolchain. The `rollForward: latestFeature` policy allows patch updates within the 10.0.1xx band but prevents major/minor surprises.
250 -
251 - ```json
252 - {
253 - "sdk": {
254 - "rollForward": "latestFeature",
255 - "version": "10.0.103"
256 - }
257 - }
258 - ```
259 -
260 - ### `Directory.Build.props`
261 -
262 - MSBuild imports this file automatically into every `.csproj` in the repo tree. It sets the target framework, enables nullable reference types, implicit usings, and treats warnings as errors — so no project can accidentally diverge from these defaults.
263 -
264 - ```xml
265 - <Project>
266 - <PropertyGroup>
267 - <TargetFramework>net10.0</TargetFramework>
268 - <Nullable>enable</Nullable>
269 - <ImplicitUsings>enable</ImplicitUsings>
270 - <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
271 - </PropertyGroup>
272 - </Project>
273 - ```
274 -
275 - ### `Directory.Packages.props`
276 -
277 - Enables Central Package Management (CPM). Every NuGet package version is declared once here. Individual `.csproj` files reference packages by name only (no `Version` attribute). This eliminates version drift across projects and makes upgrades a single-file change.
278 -
279 - ```xml
280 - <Project>
281 -
282 - <PropertyGroup>
283 - <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
284 - </PropertyGroup>
285 -
286 - <ItemGroup>
287 - <!-- Web & API -->
288 - <PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0-preview.2"/>
289 - <PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0-preview.2"/>
290 - <PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0"/>
291 - <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5"/>
292 -
293 - <!-- Logging -->
294 - <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0"/>
295 - <PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1"/>
296 - <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0"/>
297 -
298 - <!-- Application -->
299 - <PackageVersion Include="FluentValidation" Version="12.1.1"/>
300 - <PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1"/>
301 - <PackageVersion Include="MediatR" Version="14.1.0"/>
302 - <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5"/>
303 -
304 - <!-- Infrastructure -->
305 - <PackageVersion Include="Newtonsoft.Json" Version="13.0.4"/>
306 - <PackageVersion Include="Quartz.Extensions.Hosting" Version="3.16.1"/>
307 -
308 - <!-- Entity Framework Core -->
309 - <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.5"/>
310 - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5"/>
311 - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5"/>
312 - <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1"/>
313 -
314 - <!-- Testing -->
315 - <PackageVersion Include="coverlet.collector" Version="8.0.0"/>
316 - <PackageVersion Include="FluentAssertions" Version="8.8.0"/>
317 - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
318 - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
319 - <PackageVersion Include="Moq" Version="4.20.72"/>
320 - <PackageVersion Include="xunit" Version="2.9.3"/>
321 - <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5"/>
322 - </ItemGroup>
323 -
324 - </Project>
325 - ```
326 -
327 - ### `nuget.config`
328 -
329 - Explicitly clears any inherited package sources and sets the official NuGet feed as the only source. This prevents builds from silently pulling packages from unexpected feeds (e.g., a corporate proxy or local cache).
330 -
331 - ```xml
332 - <?xml version="1.0" encoding="utf-8"?>
333 - <configuration>
334 - <packageSources>
335 - <clear />
336 - <add key="nuget" value="https://api.nuget.org/v3/index.json" />
337 - </packageSources>
338 - </configuration>
339 - ```
340 -
341 - ### `.editorconfig`
342 -
343 - Enforces consistent code style across all editors and IDEs. The C# section is particularly important — it mandates file-scoped namespaces, `var` usage, and naming conventions (e.g., `_camelCase` for private fields, `I` prefix for interfaces). These rules integrate with Roslyn analyzers, so violations appear as warnings during build.
344 -
345 - ```editorconfig
346 - root = true
347 -
348 - # All files
349 - [*]
350 - indent_style = space
351 -
352 - # Xml files
353 - [*.{xml,csproj,props,targets,ruleset,nuspec,resx}]
354 - indent_size = 2
355 -
356 - # Javascript files
357 - [*.js]
358 - indent_size = 2
359 -
360 - # Json files
361 - [*.{json,config,nswag}]
362 - indent_size = 2
363 -
364 - # C# files
365 - [*.cs]
366 -
367 - #### Core EditorConfig Options ####
368 -
369 - # Indentation and spacing
370 - indent_size = 4
371 - tab_width = 4
372 -
373 - # New line preferences
374 - end_of_line = lf
375 - insert_final_newline = true
376 -
377 - #### .NET Coding Conventions ####
378 - [*.{cs,vb}]
379 -
380 - # Organize usings
381 - dotnet_separate_import_directive_groups = false
382 - dotnet_sort_system_directives_first = true
383 - file_header_template = unset
384 -
385 - # this. and Me. preferences
386 - dotnet_style_qualification_for_event = false:silent
387 - dotnet_style_qualification_for_field = false:silent
388 - dotnet_style_qualification_for_method = false:silent
389 - dotnet_style_qualification_for_property = false:silent
390 -
391 - # Language keywords vs BCL types preferences
392 - dotnet_style_predefined_type_for_locals_parameters_members = true:silent
393 - dotnet_style_predefined_type_for_member_access = true:silent
394 -
395 - # Parentheses preferences
396 - dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
397 - dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
398 - dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
399 - dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
400 -
401 - # Modifier preferences
402 - dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
403 -
404 - # Expression-level preferences
405 - dotnet_style_coalesce_expression = true:suggestion
406 - dotnet_style_collection_initializer = true:suggestion
407 - dotnet_style_explicit_tuple_names = true:suggestion
408 - dotnet_style_null_propagation = true:suggestion
409 - dotnet_style_object_initializer = true:suggestion
410 - dotnet_style_operator_placement_when_wrapping = beginning_of_line
411 - dotnet_style_prefer_auto_properties = true:suggestion
412 - dotnet_style_prefer_compound_assignment = true:suggestion
413 - dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
414 - dotnet_style_prefer_conditional_expression_over_return = true:suggestion
415 - dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
416 - dotnet_style_prefer_inferred_tuple_names = true:suggestion
417 - dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
418 - dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
419 - dotnet_style_prefer_simplified_interpolation = true:suggestion
420 -
421 - # Field preferences
422 - dotnet_style_readonly_field = true:warning
423 -
424 - # Parameter preferences
425 - dotnet_code_quality_unused_parameters = all:suggestion
426 -
427 - # Suppression preferences
428 - dotnet_remove_unnecessary_suppression_exclusions = none
429 -
430 - #### C# Coding Conventions ####
431 - [*.cs]
432 -
433 - # var preferences
434 - csharp_style_var_elsewhere = false:silent
435 - csharp_style_var_for_built_in_types = false:silent
436 - csharp_style_var_when_type_is_apparent = true:suggestion
437 -
438 - # Expression-bodied members
439 - csharp_style_expression_bodied_accessors = true:silent
440 - csharp_style_expression_bodied_constructors = true:suggestion
441 - csharp_style_expression_bodied_indexers = true:silent
442 - csharp_style_expression_bodied_lambdas = true:suggestion
443 - csharp_style_expression_bodied_local_functions = true:suggestion
444 - csharp_style_expression_bodied_methods = true:suggestion
445 - csharp_style_expression_bodied_operators = true:suggestion
446 - csharp_style_expression_bodied_properties = true:silent
447 -
448 - # Pattern matching preferences
449 - csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
450 - csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
451 - csharp_style_prefer_not_pattern = true:suggestion
452 - csharp_style_prefer_pattern_matching = true:silent
453 - csharp_style_prefer_switch_expression = true:suggestion
454 -
455 - # Null-checking preferences
456 - csharp_style_conditional_delegate_call = true:suggestion
457 -
458 - # Modifier preferences
459 - csharp_prefer_static_local_function = true:warning
460 - csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent
461 -
462 - # Code-block preferences
463 - csharp_prefer_braces = true:silent
464 - csharp_prefer_simple_using_statement = true:suggestion
465 -
466 - # Expression-level preferences
467 - csharp_prefer_simple_default_expression = true:suggestion
468 - csharp_style_deconstructed_variable_declaration = true:suggestion
469 - csharp_style_inlined_variable_declaration = true:suggestion
470 - csharp_style_pattern_local_over_anonymous_function = true:suggestion
471 - csharp_style_prefer_index_operator = true:suggestion
472 - csharp_style_prefer_range_operator = true:suggestion
473 - csharp_style_throw_expression = true:suggestion
474 - csharp_style_unused_value_assignment_preference = discard_variable:suggestion
475 - csharp_style_unused_value_expression_statement_preference = discard_variable:silent
476 -
477 - # 'using' directive preferences
478 - csharp_using_directive_placement = outside_namespace:silent
479 -
480 - #### C# Formatting Rules ####
481 -
482 - # New line preferences
483 - csharp_new_line_before_catch = true
484 - csharp_new_line_before_else = true
485 - csharp_new_line_before_finally = true
486 - csharp_new_line_before_members_in_anonymous_types = true
487 - csharp_new_line_before_members_in_object_initializers = true
488 - csharp_new_line_before_open_brace = all
489 - csharp_new_line_between_query_expression_clauses = true
490 -
491 - # Indentation preferences
492 - csharp_indent_block_contents = true
493 - csharp_indent_braces = false
494 - csharp_indent_case_contents = true
495 - csharp_indent_case_contents_when_block = true
496 - csharp_indent_labels = one_less_than_current
497 - csharp_indent_switch_labels = true
498 -
499 - # Space preferences
500 - csharp_space_after_cast = false
501 - csharp_space_after_colon_in_inheritance_clause = true
502 - csharp_space_after_comma = true
503 - csharp_space_after_dot = false
504 - csharp_space_after_keywords_in_control_flow_statements = true
505 - csharp_space_after_semicolon_in_for_statement = true
506 - csharp_space_around_binary_operators = before_and_after
507 - csharp_space_around_declaration_statements = false
508 - csharp_space_before_colon_in_inheritance_clause = true
509 - csharp_space_before_comma = false
510 - csharp_space_before_dot = false
511 - csharp_space_before_open_square_brackets = false
512 - csharp_space_before_semicolon_in_for_statement = false
513 - csharp_space_between_empty_square_brackets = false
514 - csharp_space_between_method_call_empty_parameter_list_parentheses = false
515 - csharp_space_between_method_call_name_and_opening_parenthesis = false
516 - csharp_space_between_method_call_parameter_list_parentheses = false
517 - csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
518 - csharp_space_between_method_declaration_name_and_open_parenthesis = false
519 - csharp_space_between_method_declaration_parameter_list_parentheses = false
520 - csharp_space_between_parentheses = false
521 - csharp_space_between_square_brackets = false
522 -
523 - # Wrapping preferences
524 - csharp_preserve_single_line_blocks = true
525 - csharp_preserve_single_line_statements = true
526 - csharp_style_namespace_declarations = file_scoped:suggestion
527 - csharp_style_prefer_method_group_conversion = true:silent
528 - csharp_style_prefer_top_level_statements = true:silent
529 - csharp_style_prefer_primary_constructors = true:warning
530 - csharp_style_prefer_null_check_over_type_check = true:suggestion
531 - csharp_style_prefer_local_over_anonymous_function = true:suggestion
532 - csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
533 - csharp_style_prefer_tuple_swap = true:suggestion
534 - csharp_style_prefer_utf8_string_literals = true:suggestion
535 -
536 - #### Naming styles ####
537 - [*.{cs,vb}]
538 -
539 - # Naming rules
540 -
541 - dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
542 - dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
543 - dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
544 -
545 - dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
546 - dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
547 - dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
548 -
549 - dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
550 - dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
551 - dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
552 -
553 - dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
554 - dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
555 - dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
556 -
557 - dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
558 - dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
559 - dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
560 -
561 - dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
562 - dotnet_naming_rule.events_should_be_pascalcase.symbols = events
563 - dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
564 -
565 - dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
566 - dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
567 - dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
568 -
569 - dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
570 - dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
571 - dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
572 -
573 - dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
574 - dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
575 - dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
576 -
577 - dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
578 - dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
579 - dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
580 -
581 - dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
582 - dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
583 - dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
584 -
585 - dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
586 - dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
587 - dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
588 -
589 - dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
590 - dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
591 - dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
592 -
593 - dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
594 - dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
595 - dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
596 -
597 - dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
598 - dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
599 - dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
600 -
601 - dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
602 - dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
603 - dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
604 -
605 - dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
606 - dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
607 - dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
608 -
609 - dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
610 - dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
611 - dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
612 -
613 - dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
614 - dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
615 - dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
616 -
617 - # Symbol specifications
618 -
619 - dotnet_naming_symbols.interfaces.applicable_kinds = interface
620 - dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
621 - dotnet_naming_symbols.interfaces.required_modifiers =
622 -
623 - dotnet_naming_symbols.enums.applicable_kinds = enum
624 - dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
625 - dotnet_naming_symbols.enums.required_modifiers =
626 -
627 - dotnet_naming_symbols.events.applicable_kinds = event
628 - dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
629 - dotnet_naming_symbols.events.required_modifiers =
630 -
631 - dotnet_naming_symbols.methods.applicable_kinds = method
632 - dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
633 - dotnet_naming_symbols.methods.required_modifiers =
634 -
635 - dotnet_naming_symbols.properties.applicable_kinds = property
636 - dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
637 - dotnet_naming_symbols.properties.required_modifiers =
638 -
639 - dotnet_naming_symbols.public_fields.applicable_kinds = field
640 - dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
641 - dotnet_naming_symbols.public_fields.required_modifiers =
642 -
643 - dotnet_naming_symbols.private_fields.applicable_kinds = field
644 - dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
645 - dotnet_naming_symbols.private_fields.required_modifiers =
646 -
647 - dotnet_naming_symbols.private_static_fields.applicable_kinds = field
648 - dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
649 - dotnet_naming_symbols.private_static_fields.required_modifiers = static
650 -
651 - dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
652 - dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
653 - dotnet_naming_symbols.types_and_namespaces.required_modifiers =
654 -
655 - dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
656 - dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
657 - dotnet_naming_symbols.non_field_members.required_modifiers =
658 -
659 - dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
660 - dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
661 - dotnet_naming_symbols.type_parameters.required_modifiers =
662 -
663 - dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
664 - dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
665 - dotnet_naming_symbols.private_constant_fields.required_modifiers = const
666 -
667 - dotnet_naming_symbols.local_variables.applicable_kinds = local
668 - dotnet_naming_symbols.local_variables.applicable_accessibilities = local
669 - dotnet_naming_symbols.local_variables.required_modifiers =
670 -
671 - dotnet_naming_symbols.local_constants.applicable_kinds = local
672 - dotnet_naming_symbols.local_constants.applicable_accessibilities = local
673 - dotnet_naming_symbols.local_constants.required_modifiers = const
674 -
675 - dotnet_naming_symbols.parameters.applicable_kinds = parameter
676 - dotnet_naming_symbols.parameters.applicable_accessibilities = *
677 - dotnet_naming_symbols.parameters.required_modifiers =
678 -
679 - dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
680 - dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
681 - dotnet_naming_symbols.public_constant_fields.required_modifiers = const
682 -
683 - dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
684 - dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
685 - dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
686 -
687 - dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
688 - dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
689 - dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
690 -
691 - dotnet_naming_symbols.local_functions.applicable_kinds = local_function
692 - dotnet_naming_symbols.local_functions.applicable_accessibilities = *
693 - dotnet_naming_symbols.local_functions.required_modifiers =
694 -
695 - # Naming styles
696 -
697 - dotnet_naming_style.pascalcase.required_prefix =
698 - dotnet_naming_style.pascalcase.required_suffix =
699 - dotnet_naming_style.pascalcase.word_separator =
700 - dotnet_naming_style.pascalcase.capitalization = pascal_case
701 -
702 - dotnet_naming_style.ipascalcase.required_prefix = I
703 - dotnet_naming_style.ipascalcase.required_suffix =
704 - dotnet_naming_style.ipascalcase.word_separator =
705 - dotnet_naming_style.ipascalcase.capitalization = pascal_case
706 -
707 - dotnet_naming_style.tpascalcase.required_prefix = T
708 - dotnet_naming_style.tpascalcase.required_suffix =
709 - dotnet_naming_style.tpascalcase.word_separator =
710 - dotnet_naming_style.tpascalcase.capitalization = pascal_case
711 -
712 - dotnet_naming_style._camelcase.required_prefix = _
713 - dotnet_naming_style._camelcase.required_suffix =
714 - dotnet_naming_style._camelcase.word_separator =
715 - dotnet_naming_style._camelcase.capitalization = camel_case
716 -
717 - dotnet_naming_style.camelcase.required_prefix =
718 - dotnet_naming_style.camelcase.required_suffix =
719 - dotnet_naming_style.camelcase.word_separator =
720 - dotnet_naming_style.camelcase.capitalization = camel_case
721 -
722 - dotnet_naming_style.s_camelcase.required_prefix = s_
723 - dotnet_naming_style.s_camelcase.required_suffix =
724 - dotnet_naming_style.s_camelcase.word_separator =
725 - dotnet_naming_style.s_camelcase.capitalization = camel_case
726 -
727 - dotnet_style_namespace_match_folder = true:suggestion
728 - ```
729 -
730 - ### `.dockerignore`
731 -
732 - Tells Docker which files to exclude from the build context. Keeping the context small speeds up `docker build` significantly. Test projects, CI config, docs, and IDE metadata are not needed in the production image.
733 -
734 - ```
735 - **/.git
736 - **/.vs
737 - **/.vscode
738 - **/.idea
739 - **/bin
740 - **/obj
741 - **/logs
742 - **/.DS_Store
743 - **/node_modules
744 - tests/
745 - .github/
746 - *.md
747 - .editorconfig
748 - .gitignore
749 - .env
750 - .env.example
751 - qodana.yaml
752 - compose.yml
753 - ```
754 -
755 - ### `.gitignore`
756 -
757 - The `.gitignore` file is a standard .NET template that excludes build output (`bin/`, `obj/`), IDE-specific directories (`.vs/`, `.idea/`, `.vscode/`), user-specific files (`*.user`, `launchSettings.json` overrides), and environment files (`.env`). It is ~300 lines and is generated via `dotnet new gitignore` — not reproduced here for brevity.
758 -
759 - ### `.env.example`
760 -
761 - Template for environment variables consumed by `compose.yml`. Developers copy this to `.env` and customize. The `.env` file is gitignored; this `.example` file is committed so new developers know what variables are needed.
762 -
763 - ```bash
764 - # ──────────────────────────────────────────────
765 - # Docker image
766 - # ──────────────────────────────────────────────
767 - DOCKER_IMAGE=your-dockerhub-username/{projects}-api
768 - IMAGE_TAG=latest
769 -
770 - # ──────────────────────────────────────────────
771 - # API configuration
772 - # ──────────────────────────────────────────────
773 - ASPNETCORE_ENVIRONMENT=Production
774 - API_PORT=5212
775 -
776 - # ──────────────────────────────────────────────
777 - # Database configuration
778 - # ──────────────────────────────────────────────
779 - POSTGRES_DB={projects}
780 - POSTGRES_USER=postgres
781 - POSTGRES_PASSWORD=change-me-to-a-strong-password
782 - DB_PORT=5432
783 -
784 - # ──────────────────────────────────────────────
785 - # Connection string (must match DB settings above)
786 - # ──────────────────────────────────────────────
787 - CONNECTION_STRING=Host=db;Port=5432;Database={projects};Username=postgres;Password=change-me-to-a-strong-password
788 - ```
789 -
790 - ### Install Packages into Projects
791 -
792 - With CPM, running `dotnet add package` registers the package in the `.csproj` (without a version). The version is resolved from `Directory.Packages.props`.
793 -
794 - ```bash
795 - # Application Layer
796 - dotnet add src/{ProjectName}.Application package MediatR
797 - dotnet add src/{ProjectName}.Application package FluentValidation
798 - dotnet add src/{ProjectName}.Application package FluentValidation.DependencyInjectionExtensions
799 - dotnet add src/{ProjectName}.Application package Microsoft.Extensions.Logging.Abstractions
800 -
801 - # Infrastructure Layer
802 - dotnet add src/{ProjectName}.Infrastructure package Microsoft.EntityFrameworkCore
803 - dotnet add src/{ProjectName}.Infrastructure package Microsoft.EntityFrameworkCore.Design
804 - dotnet add src/{ProjectName}.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
805 - dotnet add src/{ProjectName}.Infrastructure package Newtonsoft.Json
806 - dotnet add src/{ProjectName}.Infrastructure package Quartz.Extensions.Hosting
807 -
808 - # API Layer
809 - dotnet add src/{ProjectName}.Api package Serilog.AspNetCore
810 - dotnet add src/{ProjectName}.Api package Serilog.Enrichers.Environment
811 - dotnet add src/{ProjectName}.Api package Serilog.Enrichers.Thread
812 - dotnet add src/{ProjectName}.Api package AspNetCore.HealthChecks.NpgSql
813 - dotnet add src/{ProjectName}.Api package Microsoft.AspNetCore.OpenApi
814 - dotnet add src/{ProjectName}.Api package Microsoft.EntityFrameworkCore.Tools
815 - dotnet add src/{ProjectName}.Api package Asp.Versioning.Mvc
816 - dotnet add src/{ProjectName}.Api package Asp.Versioning.Mvc.ApiExplorer
817 -
818 - # Test Projects (Domain)
819 - dotnet add tests/{ProjectName}.Domain.Tests package Microsoft.NET.Test.Sdk
820 - dotnet add tests/{ProjectName}.Domain.Tests package xunit
821 - dotnet add tests/{ProjectName}.Domain.Tests package xunit.runner.visualstudio
822 - dotnet add tests/{ProjectName}.Domain.Tests package coverlet.collector
823 - dotnet add tests/{ProjectName}.Domain.Tests package FluentAssertions
824 - dotnet add tests/{ProjectName}.Domain.Tests package Moq
825 -
826 - # Test Projects (Application)
827 - dotnet add tests/{ProjectName}.Application.Tests package Microsoft.NET.Test.Sdk
828 - dotnet add tests/{ProjectName}.Application.Tests package xunit
829 - dotnet add tests/{ProjectName}.Application.Tests package xunit.runner.visualstudio
830 - dotnet add tests/{ProjectName}.Application.Tests package coverlet.collector
831 - dotnet add tests/{ProjectName}.Application.Tests package FluentAssertions
832 - dotnet add tests/{ProjectName}.Application.Tests package Moq
833 -
834 - # Test Projects (Integration)
835 - dotnet add tests/{ProjectName}.IntegrationTests package Microsoft.NET.Test.Sdk
836 - dotnet add tests/{ProjectName}.IntegrationTests package xunit
837 - dotnet add tests/{ProjectName}.IntegrationTests package xunit.runner.visualstudio
838 - dotnet add tests/{ProjectName}.IntegrationTests package coverlet.collector
839 - dotnet add tests/{ProjectName}.IntegrationTests package FluentAssertions
840 - dotnet add tests/{ProjectName}.IntegrationTests package Moq
841 - dotnet add tests/{ProjectName}.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing
842 - ```
843 -
844 - ---
845 -
846 - ## 6. Project Files (.csproj)
847 -
848 - With CPM and `Directory.Build.props`, individual `.csproj` files are minimal. They declare only package references (no versions) and project references (enforcing the dependency rule).
849 -
850 - ### `src/{ProjectName}.Domain/{ProjectName}.Domain.csproj`
851 -
852 - The Domain project has **no NuGet dependencies at all**. This is intentional — the Domain layer must be pure C# with zero framework coupling.
853 -
854 - ```xml
855 - <Project Sdk="Microsoft.NET.Sdk">
856 -
857 - </Project>
858 - ```
859 -
860 - > Note: `TargetFramework`, `Nullable`, `ImplicitUsings`, and `TreatWarningsAsErrors` are inherited from `Directory.Build.props` — no need to repeat them.
861 -
862 - ### `src/{ProjectName}.Application/{ProjectName}.Application.csproj`
863 -
864 - References Domain and adds MediatR, FluentValidation, and logging abstractions.
865 -
866 - ```xml
867 - <Project Sdk="Microsoft.NET.Sdk">
868 -
869 - <ItemGroup>
870 - <ProjectReference Include="..\{Projects}.Domain\{Projects}.Domain.csproj"/>
871 - </ItemGroup>
872 -
873 - <ItemGroup>
874 - <PackageReference Include="FluentValidation"/>
875 - <PackageReference Include="FluentValidation.DependencyInjectionExtensions"/>
876 - <PackageReference Include="MediatR"/>
877 - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
878 - </ItemGroup>
879 -
880 - </Project>
881 - ```
882 -
883 - ### `src/{ProjectName}.Infrastructure/{ProjectName}.Infrastructure.csproj`
884 -
885 - References Application and adds EF Core with PostgreSQL, Newtonsoft.Json, and Quartz for background jobs.
886 -
887 - ```xml
888 - <Project Sdk="Microsoft.NET.Sdk">
889 -
890 - <ItemGroup>
891 - <ProjectReference Include="..\{Projects}.Application\{Projects}.Application.csproj"/>
892 - </ItemGroup>
893 -
894 - <ItemGroup>
895 - <PackageReference Include="Microsoft.EntityFrameworkCore"/>
896 - <PackageReference Include="Microsoft.EntityFrameworkCore.Design">
897 - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
898 - <PrivateAssets>all</PrivateAssets>
899 - </PackageReference>
900 - <PackageReference Include="Newtonsoft.Json"/>
901 - <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL"/>
902 - <PackageReference Include="Quartz.Extensions.Hosting"/>
903 - </ItemGroup>
904 -
905 - </Project>
906 - ```
907 -
908 - > `EF Core Design` is marked as a development-only dependency (`PrivateAssets: all`) — it's used by `dotnet ef` tooling at design time, not at runtime.
909 -
910 - ### `src/{ProjectName}.Api/{ProjectName}.Api.csproj`
911 -
912 - The web application project. Uses `Microsoft.NET.Sdk.Web` (not `Microsoft.NET.Sdk`). References both Application and Infrastructure to wire everything together at the composition root.
913 -
914 - ```xml
915 - <Project Sdk="Microsoft.NET.Sdk.Web">
916 -
917 - <ItemGroup>
918 - <PackageReference Include="Asp.Versioning.Mvc"/>
919 - <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer"/>
920 - <PackageReference Include="AspNetCore.HealthChecks.NpgSql"/>
921 - <PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
922 - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
923 - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
924 - <PrivateAssets>all</PrivateAssets>
925 - </PackageReference>
926 - <PackageReference Include="Serilog.AspNetCore"/>
927 - <PackageReference Include="Serilog.Enrichers.Environment"/>
928 - <PackageReference Include="Serilog.Enrichers.Thread"/>
929 - </ItemGroup>
930 -
931 - <ItemGroup>
932 - <ProjectReference Include="..\{Projects}.Application\{Projects}.Application.csproj"/>
933 - <ProjectReference Include="..\{Projects}.Infrastructure\{Projects}.Infrastructure.csproj"/>
934 - </ItemGroup>
935 -
936 - </Project>
937 - ```
938 -
939 - ### Test `.csproj` Files
940 -
941 - All test projects share the same structure: `IsPackable` set to `false` (prevents accidentally publishing test assemblies as NuGet packages), common test packages, a global `Using` for xUnit, and a single project reference to the layer under test.
942 -
943 - **`tests/{ProjectName}.Domain.Tests/{ProjectName}.Domain.Tests.csproj`**
944 - ```xml
945 - <Project Sdk="Microsoft.NET.Sdk">
946 -
947 - <PropertyGroup>
948 - <IsPackable>false</IsPackable>
949 - </PropertyGroup>
950 -
951 - <ItemGroup>
952 - <PackageReference Include="coverlet.collector"/>
953 - <PackageReference Include="FluentAssertions"/>
954 - <PackageReference Include="Microsoft.NET.Test.Sdk"/>
955 - <PackageReference Include="Moq"/>
956 - <PackageReference Include="xunit"/>
957 - <PackageReference Include="xunit.runner.visualstudio"/>
958 - </ItemGroup>
959 -
960 - <ItemGroup>
961 - <Using Include="Xunit"/>
962 - </ItemGroup>
963 -
964 - <ItemGroup>
965 - <ProjectReference Include="..\..\src\{Projects}.Domain\{Projects}.Domain.csproj"/>
966 - </ItemGroup>
967 -
968 - </Project>
969 - ```
970 -
971 - **`tests/{ProjectName}.Application.Tests/{ProjectName}.Application.Tests.csproj`**
972 - ```xml
973 - <Project Sdk="Microsoft.NET.Sdk">
974 -
975 - <PropertyGroup>
976 - <IsPackable>false</IsPackable>
977 - </PropertyGroup>
978 -
979 - <ItemGroup>
980 - <PackageReference Include="coverlet.collector"/>
981 - <PackageReference Include="FluentAssertions"/>
982 - <PackageReference Include="Microsoft.NET.Test.Sdk"/>
983 - <PackageReference Include="Moq"/>
984 - <PackageReference Include="xunit"/>
985 - <PackageReference Include="xunit.runner.visualstudio"/>
986 - </ItemGroup>
987 -
988 - <ItemGroup>
989 - <Using Include="Xunit"/>
990 - </ItemGroup>
991 -
992 - <ItemGroup>
993 - <ProjectReference Include="..\..\src\{Projects}.Application\{Projects}.Application.csproj"/>
994 - </ItemGroup>
995 -
996 - </Project>
997 - ```
998 -
999 - **`tests/{ProjectName}.IntegrationTests/{ProjectName}.IntegrationTests.csproj`**
1000 -
1001 - Integration tests reference the API project (which transitively brings in everything) and add `Microsoft.AspNetCore.Mvc.Testing` for `WebApplicationFactory<Program>` support.
1002 -
1003 - ```xml
1004 - <Project Sdk="Microsoft.NET.Sdk">
1005 -
1006 - <PropertyGroup>
1007 - <IsPackable>false</IsPackable>
1008 - </PropertyGroup>
1009 -
1010 - <ItemGroup>
1011 - <PackageReference Include="coverlet.collector"/>
1012 - <PackageReference Include="FluentAssertions"/>
1013 - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
1014 - <PackageReference Include="Microsoft.NET.Test.Sdk"/>
1015 - <PackageReference Include="Moq"/>
1016 - <PackageReference Include="xunit"/>
1017 - <PackageReference Include="xunit.runner.visualstudio"/>
1018 - </ItemGroup>
1019 -
1020 - <ItemGroup>
1021 - <Using Include="Xunit"/>
1022 - </ItemGroup>
1023 -
1024 - <ItemGroup>
1025 - <ProjectReference Include="..\..\src\{Projects}.Api\{Projects}.Api.csproj"/>
1026 - </ItemGroup>
1027 -
1028 - </Project>
1029 - ```
1030 -
1031 - ---
1032 -
1033 - ## 7. Docker & Dev Environment
1034 -
1035 - ### `compose.yml` (Root)
1036 -
1037 - Sets up the API and a PostgreSQL database. All values are configurable via `.env` with sensible defaults for local development.
1038 -
1039 - ```yaml
1040 - services:
1041 - api:
1042 - container_name: {projects}-api
1043 - image: ${DOCKER_IMAGE:-{projects}-api}:${IMAGE_TAG:-latest}
1044 - build:
1045 - context: .
1046 - dockerfile: Dockerfile
1047 - ports:
1048 - - "${API_PORT:-5212}:8080"
1049 - environment:
1050 - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
1051 - - ConnectionStrings__DefaultConnection=${CONNECTION_STRING:-Host=db;Port=5432;Database={projects}_dev;Username=postgres;Password=postgres}
1052 - depends_on:
1053 - db:
1054 - condition: service_healthy
1055 -
1056 - db:
1057 - container_name: {projects}-db
1058 - image: postgres:17-alpine
1059 - ports:
1060 - - "${DB_PORT:-5432}:5432"
1061 - environment:
1062 - POSTGRES_DB: ${POSTGRES_DB:-{projects}_dev}
1063 - POSTGRES_USER: ${POSTGRES_USER:-postgres}
1064 - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
1065 - volumes:
1066 - - postgres_data:/var/lib/postgresql/data
1067 - healthcheck:
1068 - test: ["CMD-SHELL", "pg_isready -U postgres"]
1069 - interval: 5s
1070 - timeout: 5s
1071 - retries: 5
1072 -
1073 - volumes:
1074 - postgres_data:
1075 - ```
1076 -
1077 - ### `Dockerfile` (Root)
1078 -
1079 - Optimized multi-stage build. The key optimization is copying `.csproj` files first and running `dotnet restore` before copying source code — this means the NuGet restore layer is cached and only invalidated when dependencies change, not when code changes.
1080 -
1081 - ```dockerfile
1082 - FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
1083 - WORKDIR /app
1084 - EXPOSE 8080
1085 -
1086 - FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
1087 - ARG BUILD_CONFIGURATION=Release
1088 - WORKDIR /src
1089 -
1090 - COPY global.json .
1091 - COPY nuget.config .
1092 - COPY Directory.Build.props .
1093 - COPY Directory.Packages.props .
1094 - COPY src/{Projects}.Api/{Projects}.Api.csproj src/{Projects}.Api/
1095 - COPY src/{Projects}.Application/{Projects}.Application.csproj src/{Projects}.Application/
1096 - COPY src/{Projects}.Domain/{Projects}.Domain.csproj src/{Projects}.Domain/
1097 - COPY src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj src/{Projects}.Infrastructure/
1098 -
1099 - RUN dotnet restore src/{Projects}.Api/{Projects}.Api.csproj
1100 -
1101 - COPY . .
1102 - RUN dotnet build src/{Projects}.Api -c $BUILD_CONFIGURATION --no-restore
1103 -
1104 - FROM build AS publish
1105 - ARG BUILD_CONFIGURATION=Release
1106 - RUN dotnet publish src/{Projects}.Api -c $BUILD_CONFIGURATION --no-build -o /app/publish /p:UseAppHost=false
1107 -
1108 - FROM base AS final
1109 - WORKDIR /app
1110 - COPY --from=publish /app/publish .
1111 - ENTRYPOINT ["dotnet", "{Projects}.Api.dll"]
1112 - ```
1113 -
1114 - ### `src/{ProjectName}.Api/Properties/launchSettings.json`
1115 -
1116 - Configures how `dotnet run` launches the API locally. Two profiles are defined: HTTP-only (port 5212) and HTTPS (ports 7031 + 5212). `launchBrowser` is disabled — APIs don't need a browser window.
1117 -
1118 - ```json
1119 - {
1120 - "$schema": "https://json.schemastore.org/launchsettings.json",
1121 - "profiles": {
1122 - "http": {
1123 - "commandName": "Project",
1124 - "dotnetRunMessages": true,
1125 - "launchBrowser": false,
1126 - "applicationUrl": "http://localhost:5212",
1127 - "environmentVariables": {
1128 - "ASPNETCORE_ENVIRONMENT": "Development"
1129 - }
1130 - },
1131 - "https": {
1132 - "commandName": "Project",
1133 - "dotnetRunMessages": true,
1134 - "launchBrowser": false,
1135 - "applicationUrl": "https://localhost:7031;http://localhost:5212",
1136 - "environmentVariables": {
1137 - "ASPNETCORE_ENVIRONMENT": "Development"
1138 - }
1139 - }
1140 - }
1141 - }
1142 - ```
1143 -
1144 - ---
1145 -
1146 - ## 8. Layer 1: Domain
1147 -
1148 - *The innermost layer. Contains entities, value objects, domain events, the Result pattern, and repository abstractions. It has **zero** NuGet dependencies — pure C# only.*
1149 -
1150 - **Why a dependency-free Domain?** The Domain layer encodes business rules. By keeping it free of frameworks (no EF Core attributes, no MediatR, no JSON serializers), it remains:
1151 - - Testable with plain unit tests (no mocking infrastructure).
1152 - - Portable — you can swap EF Core for Dapper or PostgreSQL for MongoDB without touching a single Domain file.
1153 - - Focused — developers reading Domain code see only business logic, not framework ceremony.
1154 -
1155 - ### `src/{ProjectName}.Domain/Common/BaseEntity.cs`
1156 -
1157 - All entities inherit from this. It provides a GUID primary key and a domain events collection. Domain events are raised by entities during business operations and dispatched after `SaveChanges` by the `DomainEventInterceptor` in the Infrastructure layer.
1158 -
1159 - ```csharp
1160 - namespace {Projects}.Domain.Common;
1161 -
1162 - public abstract class BaseEntity
1163 - {
1164 - private readonly List<IDomainEvent> _domainEvents = [];
1165 - public Guid Id { get; private init; } = Guid.NewGuid();
1166 - public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
1167 -
1168 - public void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
1169 - public void RemoveDomainEvent(IDomainEvent domainEvent) => _domainEvents.Remove(domainEvent);
1170 - public void ClearDomainEvents() => _domainEvents.Clear();
1171 - }
1172 - ```
1173 -
1174 - ### `src/{ProjectName}.Domain/Common/AuditableEntity.cs`
1175 -
1176 - Extends `BaseEntity` with `CreatedAt` and `UpdatedAt` timestamps. The setters are accessed by the `AuditableEntityInterceptor` — entities themselves don't need to worry about setting timestamps.
1177 -
1178 - ```csharp
1179 - namespace {Projects}.Domain.Common;
1180 -
1181 - public abstract class AuditableEntity : BaseEntity
1182 - {
1183 - public DateTime CreatedAt { get; private set; }
1184 - public DateTime? UpdatedAt { get; private set; }
1185 -
1186 - public void SetCreatedAt(DateTime createdAt) => CreatedAt = createdAt;
1187 - public void SetUpdatedAt(DateTime updatedAt) => UpdatedAt = updatedAt;
1188 - }
1189 - ```
1190 -
1191 - ### `src/{ProjectName}.Domain/Common/IDomainEvent.cs`
1192 -
1193 - Marker interface for domain events. Events carry a timestamp so consumers know when the event occurred.
1194 -
1195 - ```csharp
1196 - namespace {Projects}.Domain.Common;
1197 -
1198 - public interface IDomainEvent
1199 - {
1200 - DateTime OccurredOn { get; }
1201 - }
1202 - ```
1203 -
1204 - ### `src/{ProjectName}.Domain/Common/ValueObject.cs`
1205 -
1206 - Base class for value objects (DDD concept). Value objects are compared by their component values, not by identity. Two `Money(100, "USD")` instances are equal regardless of reference identity.
1207 -
1208 - ```csharp
1209 - namespace {Projects}.Domain.Common;
1210 -
1211 - public abstract class ValueObject : IEquatable<ValueObject>
1212 - {
1213 - protected abstract IEnumerable<object?> GetEqualityComponents();
1214 -
1215 - public override bool Equals(object? obj)
1216 - {
1217 - if (obj is null || obj.GetType() != GetType()) return false;
1218 - return Equals((ValueObject)obj);
1219 - }
1220 -
1221 - public bool Equals(ValueObject? other)
1222 - {
1223 - if (other is null || other.GetType() != GetType()) return false;
1224 - return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
1225 - }
1226 -
1227 - public override int GetHashCode() => GetEqualityComponents().Aggregate(0, (current, obj) => HashCode.Combine(current, obj?.GetHashCode() ?? 0));
1228 - public static bool operator ==(ValueObject? left, ValueObject? right) => left is null && right is null || (left is not null && right is not null && left.Equals(right));
1229 - public static bool operator !=(ValueObject? left, ValueObject? right) => !(left == right);
1230 - }
1231 - ```
1232 -
1233 - ### `src/{ProjectName}.Domain/Common/Error.cs`
1234 -
1235 - Defines the `Error` record and `ErrorType` enum used throughout the Result pattern. Pre-defined static errors cover the most common failure cases. Domain-specific errors are defined alongside their entities (e.g., `User.Errors.EmailTaken`).
1236 -
1237 - ```csharp
1238 - namespace {Projects}.Domain.Common;
1239 -
1240 - public enum ErrorType { None = 0, Failure = 1, Validation = 2, NotFound = 3, Conflict = 4 }
1241 -
1242 - public sealed record Error(string Code, string Description, ErrorType Type)
1243 - {
1244 - public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None);
1245 - public static readonly Error NullValue = new("Error.NullValue", "A null value was provided.", ErrorType.Failure);
1246 - public static readonly Error NotFound = new("Error.NotFound", "The requested resource was not found.", ErrorType.NotFound);
1247 - public static readonly Error Conflict = new("Error.Conflict", "A conflict occurred with the current state.", ErrorType.Conflict);
1248 - public static readonly Error Validation = new("Error.Validation", "A validation error occurred.", ErrorType.Validation);
1249 - }
1250 - ```
1251 -
1252 - ### `src/{ProjectName}.Domain/Common/Result.cs`
1253 -
1254 - The Result pattern implementation. Key design points:
1255 - - `IValidationResult` uses **static abstract interface members** (C# 13) — this enables `ValidationBehavior` to call `TResponse.Failure(error)` without reflection or `Activator.CreateInstance`.
1256 - - Constructor guards prevent invalid states (success with error, failure without error).
1257 - - `Result<T>` provides an implicit conversion from `T` for ergonomic returns.
1258 -
1259 - ```csharp
1260 - namespace {Projects}.Domain.Common;
1261 -
1262 - /// <summary>
1263 - /// Defines a contract for creating a failure result of a specific type.
1264 - /// Used for type-safe, high-performance validation in CQRS pipelines.
1265 - /// </summary>
1266 - public interface IValidationResult
1267 - {
1268 - static abstract Result Failure(Error error);
1269 - }
1270 -
1271 - public class Result : IValidationResult
1272 - {
1273 - protected Result(bool isSuccess, Error error)
1274 - {
1275 - if (isSuccess && error != Error.None)
1276 - throw new InvalidOperationException("A successful result cannot have an error.");
1277 -
1278 - if (!isSuccess && error == Error.None)
1279 - throw new InvalidOperationException("A failed result must have an error.");
1280 -
1281 - IsSuccess = isSuccess;
1282 - Error = error;
1283 - }
1284 -
1285 - public bool IsSuccess { get; }
1286 - public bool IsFailure => !IsSuccess;
1287 - public Error Error { get; }
1288 -
1289 - public static Result Success()
1290 - {
1291 - return new Result(true, Error.None);
1292 - }
1293 -
1294 - public static Result<T> Success<T>(T value)
1295 - {
1296 - return Result<T>.Success(value);
1297 - }
1298 -
1299 - public static Result Failure(Error error)
1300 - {
1301 - return new Result(false, error);
1302 - }
1303 -
1304 - public static Result<T> Failure<T>(Error error)
1305 - {
1306 - return Result<T>.Failure(error);
1307 - }
1308 - }
1309 -
1310 - public class Result<T> : Result, IValidationResult
1311 - {
1312 - private readonly T? _value;
1313 -
1314 - private Result(T? value, bool isSuccess, Error error)
1315 - : base(isSuccess, error)
1316 - {
1317 - _value = value;
1318 - }
1319 -
1320 - public T Value => IsSuccess
1321 - ? _value!
1322 - : throw new InvalidOperationException("Cannot access the value of a failed result.");
1323 -
1324 - public static Result<T> Success(T value)
1325 - {
1326 - return new Result<T>(value, true, Error.None);
1327 - }
1328 -
1329 - public new static Result<T> Failure(Error error)
1330 - {
1331 - return new Result<T>(default, false, error);
1332 - }
1333 -
1334 - public static implicit operator Result<T>(T value)
1335 - {
1336 - return Success(value);
1337 - }
1338 - }
1339 - ```
1340 -
1341 - ### `src/{ProjectName}.Domain/Abstractions/IUnitOfWork.cs`
1342 -
1343 - Abstracts the "save all pending changes" operation. In the Infrastructure layer, `ApplicationDbContext` implements this interface directly.
1344 -
1345 - ```csharp
1346 - namespace {Projects}.Domain.Abstractions;
1347 -
1348 - public interface IUnitOfWork
1349 - {
1350 - Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
1351 - }
1352 - ```
1353 -
1354 - ---
1355 -
1356 - ## 9. Layer 2: Application
1357 -
1358 - *Contains use cases (commands, queries, handlers), validation, mapping, and MediatR pipeline behaviors. References Domain only.*
1359 -
1360 - **Why a separate Application layer?** The Application layer orchestrates business operations without knowing *how* data is persisted or *how* HTTP requests arrive. This means:
1361 - - Handlers are testable by mocking `ApplicationDbContext` and `IUnitOfWork` — no database needed.
1362 - - The same handlers can serve a REST API, gRPC service, or message queue consumer.
1363 - - Validation is co-located with the command/query it validates.
1364 -
1365 - ### Messaging Interfaces (`src/{ProjectName}.Application/Abstractions/Messaging/`)
1366 -
1367 - These interfaces wrap MediatR's `IRequest` and `IRequestHandler` to enforce that all commands and queries return `Result` or `Result<T>`. This guarantees the Result pattern is used consistently throughout the application.
1368 -
1369 - **`ICommand.cs`**
1370 - ```csharp
1371 - using MediatR;
1372 - using {Projects}.Domain.Common;
1373 -
1374 - namespace {Projects}.Application.Abstractions.Messaging;
1375 -
1376 - /// <summary>
1377 - /// Marker interface for commands that do not return a value.
1378 - /// </summary>
1379 - public interface ICommand : IRequest<Result>;
1380 -
1381 - /// <summary>
1382 - /// Marker interface for commands that return a value wrapped in Result.
1383 - /// </summary>
1384 - public interface ICommand<TResponse> : IRequest<Result<TResponse>>;
1385 - ```
1386 -
1387 - **`ICommandHandler.cs`**
1388 - ```csharp
1389 - using MediatR;
1390 - using {Projects}.Domain.Common;
1391 -
1392 - namespace {Projects}.Application.Abstractions.Messaging;
1393 -
1394 - /// <summary>
1395 - /// Handler for commands that do not return a value.
1396 - /// </summary>
1397 - public interface ICommandHandler<in TCommand> : IRequestHandler<TCommand, Result>
1398 - where TCommand : ICommand;
1399 -
1400 - /// <summary>
1401 - /// Handler for commands that return a value wrapped in Result.
1402 - /// </summary>
1403 - public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
1404 - where TCommand : ICommand<TResponse>;
1405 - ```
1406 -
1407 - **`IQuery.cs`**
1408 - ```csharp
1409 - using MediatR;
1410 - using {Projects}.Domain.Common;
1411 -
1412 - namespace {Projects}.Application.Abstractions.Messaging;
1413 -
1414 - /// <summary>
1415 - /// Marker interface for queries that return a value wrapped in Result.
1416 - /// </summary>
1417 - public interface IQuery<TResponse> : IRequest<Result<TResponse>>;
1418 - ```
1419 -
1420 - **`IQueryHandler.cs`**
1421 - ```csharp
1422 - using MediatR;
1423 - using {Projects}.Domain.Common;
1424 -
1425 - namespace {Projects}.Application.Abstractions.Messaging;
1426 -
1427 - /// <summary>
1428 - /// Handler for queries that return a value wrapped in Result.
1429 - /// </summary>
1430 - public interface IQueryHandler<in TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
1431 - where TQuery : IQuery<TResponse>;
1432 - ```
1433 -
1434 - ### Domain Event Handler (`src/{ProjectName}.Application/Abstractions/IDomainEventHandler.cs`)
1435 -
1436 - Bridges domain events to MediatR's notification pipeline. `DomainEventNotification<T>` wraps any `IDomainEvent` as an `INotification`, keeping the Domain layer free of MediatR references.
1437 -
1438 - ```csharp
1439 - using MediatR;
1440 - using {Projects}.Domain.Common;
1441 -
1442 - namespace {Projects}.Application.Abstractions;
1443 -
1444 - /// <summary>
1445 - /// Wraps a domain event as a MediatR notification so it can be published
1446 - /// through the MediatR pipeline without coupling the Domain layer to MediatR.
1447 - /// </summary>
1448 - public sealed class DomainEventNotification<TDomainEvent>(TDomainEvent domainEvent)
1449 - : INotification where TDomainEvent : IDomainEvent
1450 - {
1451 - public TDomainEvent DomainEvent { get; } = domainEvent;
1452 - }
1453 -
1454 - /// <summary>
1455 - /// Convenience interface for handling domain events via MediatR.
1456 - /// Implement this instead of INotificationHandler&lt;DomainEventNotification&lt;T&gt;&gt; directly.
1457 - /// </summary>
1458 - public interface IDomainEventHandler<TDomainEvent>
1459 - : INotificationHandler<DomainEventNotification<TDomainEvent>>
1460 - where TDomainEvent : IDomainEvent;
1461 - ```
1462 -
1463 - ### Behaviors (`src/{ProjectName}.Application/Behaviors/`)
1464 -
1465 - Pipeline behaviors are MediatR middleware. They wrap every request and can inspect, modify, or short-circuit the pipeline.
1466 -
1467 - **`LoggingBehavior.cs`**
1468 -
1469 - Logs the request name before handling and the elapsed time after. Uses `Stopwatch` for high-resolution timing.
1470 -
1471 - ```csharp
1472 - using System.Diagnostics;
1473 - using MediatR;
1474 - using Microsoft.Extensions.Logging;
1475 -
1476 - namespace {Projects}.Application.Behaviors;
1477 -
1478 - public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
1479 - {
1480 - public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
1481 - {
1482 - var requestName = typeof(TRequest).Name;
1483 - logger.LogInformation("Handling {RequestName}", requestName);
1484 - var stopwatch = Stopwatch.StartNew();
1485 - var response = await next(cancellationToken);
1486 - stopwatch.Stop();
1487 - logger.LogInformation("Handled {RequestName} in {ElapsedMilliseconds}ms", requestName, stopwatch.ElapsedMilliseconds);
1488 - return response;
1489 - }
1490 - }
1491 - ```
1492 -
1493 - **`ValidationBehavior.cs`** *(Zero Reflection / High-Performance)*
1494 -
1495 - Runs all registered `IValidator<TRequest>` validators before the handler executes. If any validation fails, it short-circuits the pipeline and returns a `Result.Failure` — the handler is never invoked.
1496 -
1497 - The key innovation is the `where TResponse : Result, IValidationResult` constraint combined with the `static abstract` method on `IValidationResult`. This allows `TResponse.Failure(error)` to be called directly — no reflection, no `Activator.CreateInstance`, fully AOT-compatible.
1498 -
1499 - ```csharp
1500 - using FluentValidation;
1501 - using MediatR;
1502 - using Microsoft.Extensions.Logging;
1503 - using {Projects}.Domain.Common;
1504 -
1505 - namespace {Projects}.Application.Behaviors;
1506 -
1507 - public sealed class ValidationBehavior<TRequest, TResponse>(
1508 - IEnumerable<IValidator<TRequest>> validators,
1509 - ILogger<ValidationBehavior<TRequest, TResponse>> logger)
1510 - : IPipelineBehavior<TRequest, TResponse>
1511 - where TRequest : IRequest<TResponse>
1512 - where TResponse : Result, IValidationResult
1513 - {
1514 - public async Task<TResponse> Handle(
1515 - TRequest request,
1516 - RequestHandlerDelegate<TResponse> next,
1517 - CancellationToken cancellationToken)
1518 - {
1519 - var validatorList = validators as IReadOnlyList<IValidator<TRequest>> ?? [.. validators];
1520 -
1521 - if (validatorList.Count == 0)
1522 - return await next(cancellationToken);
1523 -
1524 - var context = new ValidationContext<TRequest>(request);
1525 -
1526 - var validationResults = await Task.WhenAll(
1527 - validatorList.Select(v => v.ValidateAsync(context, cancellationToken)));
1528 -
1529 - var failures = validationResults
1530 - .SelectMany(r => r.Errors)
1531 - .Where(f => f is not null)
1532 - .ToList();
1533 -
1534 - if (failures.Count != 0)
1535 - {
1536 - var errorMessage = string.Join("; ", failures.Select(f => f.ErrorMessage));
1537 - var error = new Error("Validation", errorMessage, ErrorType.Validation);
1538 -
1539 - logger.LogWarning(
1540 - "Validation failed for {RequestName}: {ErrorMessage}",
1541 - typeof(TRequest).Name,
1542 - errorMessage);
1543 -
1544 - // Directly call the static abstract Failure method.
1545 - // No reflection, 100% type-safe and high performance.
1546 - return (TResponse)TResponse.Failure(error);
1547 - }
1548 -
1549 - return await next(cancellationToken);
1550 - }
1551 - }
1552 - ```
1553 -
1554 - ### Dependency Injection (`src/{ProjectName}.Application/DependencyInjection.cs`)
1555 -
1556 - Registers MediatR (with pipeline behaviors in order) and FluentValidation validators (auto-discovered from the assembly).
1557 -
1558 - ```csharp
1559 - using FluentValidation;
1560 - using MediatR;
1561 - using Microsoft.Extensions.DependencyInjection;
1562 - using {Projects}.Application.Behaviors;
1563 -
1564 - namespace {Projects}.Application;
1565 -
1566 - public static class DependencyInjection
1567 - {
1568 - public static IServiceCollection AddApplication(this IServiceCollection services)
1569 - {
1570 - var assembly = typeof(DependencyInjection).Assembly;
1571 -
1572 - services.AddMediatR(cfg =>
1573 - {
1574 - cfg.RegisterServicesFromAssembly(assembly);
1575 - cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
1576 - cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
1577 - });
1578 -
1579 - services.AddValidatorsFromAssembly(assembly);
1580 -
1581 - return services;
1582 - }
1583 - }
1584 - ```
1585 -
1586 - ### Vertical Slice Convention
1587 -
1588 - > **Customize:** The folder structure below is a convention, not enforced by tooling. Adapt the nesting depth to your project's complexity.
1589 -
1590 - When adding features, organize the Application layer by **vertical slices** rather than by technical concern (no `Commands/`, `Queries/`, `Validators/` top-level folders). Each feature is a self-contained folder:
1591 -
1592 - ```
1593 - src/{ProjectName}.Application/
1594 - └── Features/
1595 - └── {FeatureName}/
1596 - ├── {Operation}/
1597 - │ ├── {Operation}Command.cs (or {Operation}Query.cs)
1598 - │ ├── {Operation}CommandHandler.cs (or {Operation}QueryHandler.cs)
1599 - │ ├── {Operation}CommandValidator.cs (optional)
1600 - │ └── {Operation}Response.cs (optional — only if returning data)
1601 - └── Mappings/
1602 - └── {FeatureName}Mappings.cs (static extension methods)
1603 - ```
1604 -
1605 - **Example for an "Items" feature:**
1606 -
1607 - ```
1608 - Features/
1609 - └── Items/
1610 - ├── CreateItem/
1611 - │ ├── CreateItemCommand.cs
1612 - │ ├── CreateItemCommandHandler.cs
1613 - │ └── CreateItemCommandValidator.cs
1614 - ├── GetItem/
1615 - │ ├── GetItemQuery.cs
1616 - │ ├── GetItemQueryHandler.cs
1617 - │ └── ItemResponse.cs
1618 - └── Mappings/
1619 - └── ItemMappings.cs
1620 - ```
1621 -
1622 - **Why vertical slices?**
1623 - - **Cohesion** — everything needed for a use case is in one folder; no jumping between `Commands/`, `Validators/`, `Handlers/`.
1624 - - **Discoverability** — new developers find related code immediately.
1625 - - **Safe deletion** — removing a feature means deleting one folder.
1626 - - **Reduced merge conflicts** — different developers working on different features rarely touch the same files.
1627 -
1628 - MediatR auto-discovers all `IRequestHandler<,>` and `IValidator<>` implementations from the assembly scan (configured in `DependencyInjection.cs`), so no manual registration is needed when adding new slices.
1629 -
1630 - ### Mapping Convention
1631 -
1632 - > **Customize:** If your project grows large enough to benefit from auto-mapping, you can introduce Mapster or AutoMapper later. Start with manual mapping.
1633 -
1634 - Use **static extension methods** for mapping between domain entities and response DTOs. Keep mapping logic close to the feature that uses it:
1635 -
1636 - ```csharp
1637 - // Features/Items/Mappings/ItemMappings.cs
1638 - namespace {Projects}.Application.Features.Items.Mappings;
1639 -
1640 - public static class ItemMappings
1641 - {
1642 - public static ItemResponse ToResponse(this Item entity) => new(
1643 - entity.Id,
1644 - entity.Name,
1645 - entity.CreatedAt);
1646 - }
1647 - ```
1648 -
1649 - **Why manual mapping over AutoMapper/Mapster?**
1650 - - **Zero magic** — mappings are plain C# code, fully debuggable with F12 / Go to Definition.
1651 - - **Compile-time safety** — missing properties cause build errors, not runtime surprises.
1652 - - **No hidden performance costs** — no reflection, no expression compilation, no global configuration scanning.
1653 - - **Co-located** — the mapping lives next to the feature that uses it.
1654 -
1655 - ### API Versioning Convention
1656 -
1657 - > **Customize:** The versioning strategy (URL path) is baked in. The version numbers and deprecation schedule are project-specific.
1658 -
1659 - Controllers use URL path versioning via `Asp.Versioning.Mvc`. Decorate controllers with `[ApiVersion]` and use `[Route("api/v{version:apiVersion}/[controller]")]`:
1660 -
1661 - ```csharp
1662 - using Asp.Versioning;
1663 -
1664 - [ApiVersion(1.0)]
1665 - [ApiController]
1666 - [Route("api/v{version:apiVersion}/[controller]")]
1667 - public class ItemsController : ControllerBase
1668 - {
1669 - // All endpoints in this controller are v1
1670 - // URL: /api/v1/items
1671 - }
1672 - ```
1673 -
1674 - When introducing breaking changes, add a new version:
1675 -
1676 - ```csharp
1677 - [ApiVersion(2.0)]
1678 - [ApiController]
1679 - [Route("api/v{version:apiVersion}/[controller]")]
1680 - public class ItemsV2Controller : ControllerBase
1681 - {
1682 - // URL: /api/v2/items
1683 - }
1684 - ```
1685 -
1686 - To deprecate an older version: `[ApiVersion(1.0, Deprecated = true)]`.
1687 -
1688 - ---
1689 -
1690 - ## 10. Layer 3: Infrastructure
1691 -
1692 - *Implements the abstractions defined in Domain and Application. Contains the EF Core `DbContext` and interceptors.*
1693 -
1694 - **Why Infrastructure is separate from Application:** The Application layer defines *what* operations are needed (via `IUnitOfWork` and feature-specific repository interfaces). Infrastructure provides *how* they're implemented (via EF Core, PostgreSQL, etc.). If you ever need to swap databases or add a caching layer, you change Infrastructure — Application and Domain remain untouched.
1695 -
1696 - ### Interceptors (`src/{ProjectName}.Infrastructure/Persistence/Interceptors/`)
1697 -
1698 - EF Core interceptors hook into the `SaveChanges` pipeline. They keep cross-cutting concerns out of the `DbContext` itself, making them individually testable and composable.
1699 -
1700 - **`AuditableEntityInterceptor.cs`**
1701 -
1702 - Automatically sets `CreatedAt` (on insert) and `UpdatedAt` (on update) for any entity that extends `AuditableEntity`. This means domain code never needs to manually set timestamps.
1703 -
1704 - ```csharp
1705 - using Microsoft.EntityFrameworkCore;
1706 - using Microsoft.EntityFrameworkCore.Diagnostics;
1707 - using {Projects}.Domain.Common;
1708 -
1709 - namespace {Projects}.Infrastructure.Persistence.Interceptors;
1710 -
1711 - public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
1712 - {
1713 - public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
1714 - {
1715 - UpdateAuditableEntities(eventData.Context);
1716 - return base.SavingChangesAsync(eventData, result, cancellationToken);
1717 - }
1718 -
1719 - private static void UpdateAuditableEntities(DbContext? context)
1720 - {
1721 - if (context is null) return;
1722 - var utcNow = DateTime.UtcNow;
1723 - foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
1724 - {
1725 - if (entry.State == EntityState.Added) entry.Entity.SetCreatedAt(utcNow);
1726 - if (entry.State == EntityState.Modified) entry.Entity.SetUpdatedAt(utcNow);
1727 - }
1728 - }
1729 - }
1730 - ```
1731 -
1732 - **`DomainEventInterceptor.cs`**
1733 -
1734 - Dispatches domain events **after** `SaveChanges` completes successfully. This ensures events are only published when the database transaction has committed. Events are collected from all tracked entities, the entity event lists are cleared, and each event is published through MediatR as a `DomainEventNotification<T>`.
1735 -
1736 - ```csharp
1737 - using MediatR;
1738 - using Microsoft.EntityFrameworkCore;
1739 - using Microsoft.EntityFrameworkCore.Diagnostics;
1740 - using {Projects}.Application.Abstractions;
1741 - using {Projects}.Domain.Common;
1742 -
1743 - namespace {Projects}.Infrastructure.Persistence.Interceptors;
1744 -
1745 - public sealed class DomainEventInterceptor(IPublisher publisher) : SaveChangesInterceptor
1746 - {
1747 - public override async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
1748 - {
1749 - if (eventData.Context is not null)
1750 - await PublishDomainEventsAsync(eventData.Context, cancellationToken);
1751 - return await base.SavedChangesAsync(eventData, result, cancellationToken);
1752 - }
1753 -
1754 - private async Task PublishDomainEventsAsync(DbContext context, CancellationToken cancellationToken)
1755 - {
1756 - var entities = context.ChangeTracker.Entries<BaseEntity>().Where(e => e.Entity.DomainEvents.Count != 0).Select(e => e.Entity).ToList();
1757 - var domainEvents = entities.SelectMany(e => e.DomainEvents).ToList();
1758 - entities.ForEach(e => e.ClearDomainEvents());
1759 -
1760 - foreach (var domainEvent in domainEvents)
1761 - {
1762 - var notificationType = typeof(DomainEventNotification<>).MakeGenericType(domainEvent.GetType());
1763 - var notification = Activator.CreateInstance(notificationType, domainEvent)!;
1764 - await publisher.Publish(notification, cancellationToken);
1765 - }
1766 - }
1767 - }
1768 - ```
1769 -
1770 - ### Database Context (`src/{ProjectName}.Infrastructure/Persistence/ApplicationDbContext.cs`)
1771 -
1772 - The EF Core `DbContext`. It also implements `IUnitOfWork` — calling `SaveChangesAsync` on the context fulfills the unit-of-work contract. Entity configurations are auto-discovered from the Infrastructure assembly via `ApplyConfigurationsFromAssembly`.
1773 -
1774 - ```csharp
1775 - using Microsoft.EntityFrameworkCore;
1776 - using {Projects}.Domain.Abstractions;
1777 -
1778 - namespace {Projects}.Infrastructure.Persistence;
1779 -
1780 - public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options), IUnitOfWork
1781 - {
1782 - protected override void OnModelCreating(ModelBuilder modelBuilder)
1783 - {
1784 - modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
1785 - base.OnModelCreating(modelBuilder);
1786 - }
1787 - }
1788 - ```
1789 -
1790 - ### Dependency Injection (`src/{ProjectName}.Infrastructure/DependencyInjection.cs`)
1791 -
1792 - Registers the interceptors, `DbContext` (with PostgreSQL and interceptors wired in), and `IUnitOfWork`. Handlers access data directly through `ApplicationDbContext` — no generic repository abstraction. For complex data access patterns, create feature-specific repository interfaces in the Domain layer (e.g., `IMatchRepository`).
1793 -
1794 - ```csharp
1795 - using Microsoft.EntityFrameworkCore;
1796 - using Microsoft.Extensions.Configuration;
1797 - using Microsoft.Extensions.DependencyInjection;
1798 - using {Projects}.Domain.Abstractions;
1799 - using {Projects}.Infrastructure.Persistence;
1800 - using {Projects}.Infrastructure.Persistence.Interceptors;
1801 -
1802 - namespace {Projects}.Infrastructure;
1803 -
1804 - public static class DependencyInjection
1805 - {
1806 - public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
1807 - {
1808 - services.AddSingleton<AuditableEntityInterceptor>();
1809 - services.AddScoped<DomainEventInterceptor>();
1810 -
1811 - services.AddDbContext<ApplicationDbContext>((sp, options) =>
1812 - {
1813 - var auditableInterceptor = sp.GetRequiredService<AuditableEntityInterceptor>();
1814 - var domainEventInterceptor = sp.GetRequiredService<DomainEventInterceptor>();
1815 -
1816 - options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))
1817 - .AddInterceptors(auditableInterceptor, domainEventInterceptor);
1818 - });
1819 -
1820 - services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<ApplicationDbContext>());
1821 -
1822 - return services;
1823 - }
1824 - }
1825 - ```
1826 -
1827 - > **Why `AuditableEntityInterceptor` is Singleton:** It has no mutable state and doesn't depend on scoped services — a single instance is reused across all requests, avoiding unnecessary allocations.
1828 - >
1829 - > **Why `DomainEventInterceptor` is Scoped:** It depends on `IPublisher` (MediatR), which is scoped to the HTTP request. Using a scoped lifetime ensures events are published through the correct scope.
1830 -
1831 - ---
1832 -
1833 - ## 11. Layer 4: API / Presentation
1834 -
1835 - *The outermost layer. Contains the ASP.NET Core web host, middleware, configuration, and the composition root where all layers are wired together.*
1836 -
1837 - **Why the API layer exists separately:** It is the composition root — the only place where all layers meet. Controllers receive HTTP requests, translate them into MediatR commands/queries, and map results back to HTTP responses. By keeping this layer thin, you ensure that business logic stays in Application and domain rules stay in Domain.
1838 -
1839 - ### `src/{ProjectName}.Api/appsettings.json`
1840 -
1841 - Main configuration file. Defines the connection string placeholder, Serilog configuration (structured console and file output with correlation ID, daily rolling log files), request logging options, and allowed hosts.
1842 -
1843 - ```json
1844 - {
1845 - "ConnectionStrings": {
1846 - "DefaultConnection": ""
1847 - },
1848 - "Serilog": {
1849 - "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
1850 - "MinimumLevel": {
1851 - "Default": "Information",
1852 - "Override": { "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" }
1853 - },
1854 - "WriteTo": [
1855 - { "Name": "Console", "Args": { "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}{CorrelationId: [{CorrelationId}]} {Message:lj}{NewLine}{Exception}" } },
1856 - { "Name": "File", "Args": { "path": "logs/log-.txt", "rollingInterval": "Day", "retainedFileCountLimit": 7, "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {SourceContext}{CorrelationId: [{CorrelationId}]} {Message:lj}{NewLine}{Exception}" } }
1857 - ],
1858 - "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
1859 - },
1860 - "RequestLogging": {
1861 - "MaxBodySizeBytes": 65536,
1862 - "SlowRequestThresholdMs": 500,
1863 - "CorrelationIdHeader": "X-Correlation-Id",
1864 - "SensitiveFields": ["password", "token", "secret", "authorization", "creditCard", "ssn", "accessToken", "refreshToken"],
1865 - "LoggableContentTypes": ["application/json"],
1866 - "EnableRequestBodyLogging": true,
1867 - "EnableResponseBodyLogging": true,
1868 - "ExcludedPaths": ["/health"]
1869 - },
1870 - "AllowedHosts": "*"
1871 - }
1872 - ```
1873 -
1874 - ### `src/{ProjectName}.Api/appsettings.Development.json`
1875 -
1876 - Development overrides. Lowers the minimum log level to `Debug` for richer output during development, raises the slow request threshold to avoid noise, and provides a local PostgreSQL connection string.
1877 -
1878 - ```json
1879 - {
1880 - "ConnectionStrings": {
1881 - "DefaultConnection": "Host=localhost;Port=5432;Database={projects}_dev;Username=postgres;Password=postgres"
1882 - },
1883 - "Serilog": {
1884 - "MinimumLevel": {
1885 - "Default": "Debug",
1886 - "Override": {
1887 - "Microsoft.AspNetCore": "Information",
1888 - "Microsoft.EntityFrameworkCore": "Information"
1889 - }
1890 - }
1891 - },
1892 - "RequestLogging": {
1893 - "SlowRequestThresholdMs": 1000
1894 - }
1895 - }
1896 - ```
1897 -
1898 - ### `src/{ProjectName}.Api/Configuration/RequestLoggingOptions.cs`
1899 -
1900 - Strongly-typed options class for the request logging middleware. Bound from the `RequestLogging` section of `appsettings.json` via `IOptions<RequestLoggingOptions>`. All values have sensible defaults so the middleware works out of the box even without explicit configuration.
1901 -
1902 - ```csharp
1903 - namespace {Projects}.Api.Configuration;
1904 -
1905 - public sealed class RequestLoggingOptions
1906 - {
1907 - public const string SectionName = "RequestLogging";
1908 -
1909 - /// <summary>
1910 - /// Maximum request/response body size (in bytes) to capture in logs.
1911 - /// Bodies exceeding this limit are truncated. Default: 65,536 (64 KB).
1912 - /// </summary>
1913 - public int MaxBodySizeBytes { get; set; } = 65_536;
1914 -
1915 - /// <summary>
1916 - /// Requests exceeding this duration (in milliseconds) are logged at Warning level.
1917 - /// Default: 500ms.
1918 - /// </summary>
1919 - public int SlowRequestThresholdMs { get; set; } = 500;
1920 -
1921 - /// <summary>
1922 - /// The HTTP header name used for correlation ID propagation.
1923 - /// If the header is present on the incoming request, its value is reused;
1924 - /// otherwise a new GUID is generated. The correlation ID is always returned
1925 - /// in the response headers.
1926 - /// </summary>
1927 - public string CorrelationIdHeader { get; set; } = "X-Correlation-Id";
1928 -
1929 - /// <summary>
1930 - /// JSON field names whose values should be replaced with a redaction placeholder
1931 - /// before logging request/response bodies. Matching is case-insensitive.
1932 - /// </summary>
1933 - public List<string> SensitiveFields { get; set; } =
1934 - [
1935 - "password",
1936 - "token",
1937 - "secret",
1938 - "authorization",
1939 - "creditCard",
1940 - "ssn",
1941 - "accessToken",
1942 - "refreshToken"
1943 - ];
1944 -
1945 - /// <summary>
1946 - /// Content types for which request/response body logging is enabled.
1947 - /// Only JSON content types are included by default because
1948 - /// <see cref="SensitiveDataRedactor"/> only redacts JSON payloads.
1949 - /// Adding non-JSON types (XML, plain text) will cause sensitive data in
1950 - /// those formats to be logged unredacted.
1951 - /// </summary>
1952 - public List<string> LoggableContentTypes { get; set; } =
1953 - [
1954 - "application/json"
1955 - ];
1956 -
1957 - /// <summary>
1958 - /// When true, request bodies are captured and logged.
1959 - /// </summary>
1960 - public bool EnableRequestBodyLogging { get; set; } = true;
1961 -
1962 - /// <summary>
1963 - /// When true, response bodies are captured and logged.
1964 - /// </summary>
1965 - public bool EnableResponseBodyLogging { get; set; } = true;
1966 -
1967 - /// <summary>
1968 - /// Request paths that should be excluded from logging entirely.
1969 - /// Useful for high-frequency endpoints like health checks and readiness probes
1970 - /// that would otherwise generate excessive log noise.
1971 - /// </summary>
1972 - public List<string> ExcludedPaths { get; set; } =
1973 - [
1974 - "/health"
1975 - ];
1976 - }
1977 - ```
1978 -
1979 - ### `src/{ProjectName}.Api/Middleware/SensitiveDataRedactor.cs`
1980 -
1981 - Redacts sensitive field values from JSON content before it reaches the logs. This prevents passwords, tokens, and PII from being stored in log aggregation systems.
1982 -
1983 - **Dual-strategy approach:**
1984 - 1. **JSON DOM path** — for valid JSON, parses the content into a `JsonNode` tree and recursively replaces sensitive field values with `***REDACTED***`. This handles nested objects and arrays reliably.
1985 - 2. **Regex fallback** — for invalid/truncated JSON (e.g., bodies that exceeded `MaxBodySizeBytes`), uses a pre-compiled regex that matches `"sensitiveField": "value"` patterns. The regex has a 100ms timeout to prevent ReDoS attacks.
1986 -
1987 - ```csharp
1988 - using System.Text.Json;
1989 - using System.Text.Json.Nodes;
1990 - using System.Text.RegularExpressions;
1991 - using Microsoft.Extensions.Logging;
1992 - using Microsoft.Extensions.Options;
1993 - using {Projects}.Api.Configuration;
1994 -
1995 - namespace {Projects}.Api.Middleware;
1996 -
1997 - /// <summary>
1998 - /// Redacts sensitive field values from content before it is written to logs.
1999 - /// Field names to redact are configured via <see cref="RequestLoggingOptions.SensitiveFields"/>.
2000 - /// For valid JSON, uses a DOM-based approach for reliable recursive redaction.
2001 - /// For invalid/truncated JSON, falls back to regex-based pattern matching.
2002 - /// </summary>
2003 - public sealed class SensitiveDataRedactor
2004 - {
2005 - private const string RedactedPlaceholder = "***REDACTED***";
2006 -
2007 - private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = false };
2008 -
2009 - private readonly ILogger<SensitiveDataRedactor> _logger;
2010 - private readonly HashSet<string> _sensitiveFields;
2011 - private readonly Regex _fallbackRegex;
2012 -
2013 - public SensitiveDataRedactor(
2014 - ILogger<SensitiveDataRedactor> logger,
2015 - IOptions<RequestLoggingOptions> options)
2016 - {
2017 - _logger = logger;
2018 - _sensitiveFields = new HashSet<string>(
2019 - options.Value.SensitiveFields,
2020 - StringComparer.OrdinalIgnoreCase);
2021 -
2022 - // Build a regex that matches JSON key-value pairs for any sensitive field name.
2023 - // Pattern: "fieldName" : "..." — matches quoted string values only.
2024 - // Non-string values (numbers, booleans, null) are not matched by this regex;
2025 - // those are handled by the JSON DOM path for valid JSON.
2026 - if (_sensitiveFields.Count > 0)
2027 - {
2028 - var escapedFields = _sensitiveFields.Select(Regex.Escape);
2029 - var alternation = string.Join("|", escapedFields);
2030 -
2031 - var pattern = $"""
2032 - (?<="(?:{alternation})"\s*:\s*)"(?:[^"\\]|\\.)*"
2033 - """;
2034 -
2035 - _fallbackRegex = new Regex(
2036 - pattern.Trim(),
2037 - RegexOptions.IgnoreCase | RegexOptions.Compiled,
2038 - matchTimeout: TimeSpan.FromMilliseconds(100));
2039 - }
2040 - else
2041 - {
2042 - // No sensitive fields configured — use a regex that never matches.
2043 - _fallbackRegex = new Regex(
2044 - "(?!)",
2045 - RegexOptions.Compiled,
2046 - matchTimeout: TimeSpan.FromMilliseconds(100));
2047 - }
2048 - }
2049 -
2050 - /// <summary>
2051 - /// Redacts values of sensitive fields in the provided content.
2052 - /// Attempts JSON DOM-based redaction first. If the content is not valid JSON
2053 - /// (e.g. truncated bodies), falls back to regex-based pattern matching.
2054 - /// </summary>
2055 - public string Redact(string content)
2056 - {
2057 - if (string.IsNullOrWhiteSpace(content))
2058 - return content;
2059 -
2060 - try
2061 - {
2062 - var node = JsonNode.Parse(content);
2063 -
2064 - if (node is null)
2065 - return content;
2066 -
2067 - RedactNode(node);
2068 -
2069 - return node.ToJsonString(SerializerOptions);
2070 - }
2071 - catch (JsonException)
2072 - {
2073 - // Content is not valid JSON (e.g. truncated). Fall back to regex redaction.
2074 - return RedactWithRegex(content);
2075 - }
2076 - }
2077 -
2078 - /// <summary>
2079 - /// Regex-based fallback for redacting sensitive values in content that is not
2080 - /// parseable as JSON (e.g. truncated bodies). Replaces quoted string values
2081 - /// following sensitive field names with the redaction placeholder.
2082 - /// </summary>
2083 - private string RedactWithRegex(string content)
2084 - {
2085 - try
2086 - {
2087 - return _fallbackRegex.Replace(content, $"\"{RedactedPlaceholder}\"");
2088 - }
2089 - catch (RegexMatchTimeoutException)
2090 - {
2091 - _logger.LogWarning(
2092 - "Sensitive data redaction regex timed out — body redacted entirely to prevent sensitive data exposure");
2093 - return "[REDACTION FAILED — BODY SUPPRESSED]";
2094 - }
2095 - }
2096 -
2097 - private void RedactNode(JsonNode node)
2098 - {
2099 - switch (node)
2100 - {
2101 - case JsonObject jsonObject:
2102 - RedactObject(jsonObject);
2103 - break;
2104 -
2105 - case JsonArray jsonArray:
2106 - RedactArray(jsonArray);
2107 - break;
2108 - }
2109 - }
2110 -
2111 - private void RedactObject(JsonObject jsonObject)
2112 - {
2113 - var propertyNames = jsonObject.Select(p => p.Key).ToList();
2114 -
2115 - foreach (var name in propertyNames)
2116 - {
2117 - if (_sensitiveFields.Contains(name))
2118 - {
2119 - jsonObject[name] = RedactedPlaceholder;
2120 - continue;
2121 - }
2122 -
2123 - var child = jsonObject[name];
2124 -
2125 - if (child is not null)
2126 - RedactNode(child);
2127 - }
2128 - }
2129 -
2130 - private void RedactArray(JsonArray jsonArray)
2131 - {
2132 - foreach (var element in jsonArray)
2133 - {
2134 - if (element is not null)
2135 - RedactNode(element);
2136 - }
2137 - }
2138 - }
2139 - ```
2140 -
2141 - ### `src/{ProjectName}.Api/Middleware/RequestLoggingMiddleware.cs`
2142 -
2143 - Comprehensive HTTP request/response logging middleware. This is the largest single file in the boilerplate (~450 lines) because it handles many concerns carefully:
2144 -
2145 - - **Correlation ID tracking** — accepts a client-supplied correlation ID (validated for safety) or generates a new GUID. The ID is pushed into Serilog's `LogContext` so all downstream log entries include it.
2146 - - **Request/response body capture** — uses `EnableBuffering()` for the request stream and a `MemoryStream` swap for the response stream. Both are size-limited to `MaxBodySizeBytes`.
2147 - - **Sensitive data redaction** — bodies are passed through `SensitiveDataRedactor` before logging.
2148 - - **Slow request warnings** — requests exceeding `SlowRequestThresholdMs` trigger a warning-level log.
2149 - - **Path exclusion** — health checks and other high-frequency endpoints can be excluded to reduce log noise.
2150 - - **Security hardening** — correlation IDs are validated against a safe character set, client IPs are sanitized to prevent log injection, and UTF-8 truncation respects character boundaries.
2151 -
2152 - ```csharp
2153 - using System.Buffers;
2154 - using System.Diagnostics;
2155 - using System.Text;
2156 - using System.Text.RegularExpressions;
2157 - using Microsoft.Extensions.Options;
2158 - using Serilog.Context;
2159 - using {Projects}.Api.Configuration;
2160 -
2161 - namespace {Projects}.Api.Middleware;
2162 -
2163 - /// <summary>
2164 - /// Middleware that provides comprehensive HTTP request/response logging including:
2165 - /// <list type="bullet">
2166 - /// <item>Correlation ID tracking (accept from header or generate)</item>
2167 - /// <item>Request and response body capture (with configurable size limits)</item>
2168 - /// <item>Sensitive data redaction in logged bodies</item>
2169 - /// <item>Slow-request performance warnings</item>
2170 - /// <item>Enriched Serilog LogContext (client IP, user agent, user identity, etc.)</item>
2171 - /// </list>
2172 - /// </summary>
2173 - public sealed partial class RequestLoggingMiddleware
2174 - {
2175 - private const int MaxCorrelationIdLength = 128;
2176 -
2177 - private readonly RequestDelegate _next;
2178 - private readonly ILogger<RequestLoggingMiddleware> _logger;
2179 - private readonly RequestLoggingOptions _options;
2180 - private readonly SensitiveDataRedactor _redactor;
2181 - private readonly List<string> _excludedPathPrefixes;
2182 -
2183 - public RequestLoggingMiddleware(
2184 - RequestDelegate next,
2185 - ILogger<RequestLoggingMiddleware> logger,
2186 - IOptions<RequestLoggingOptions> options,
2187 - SensitiveDataRedactor redactor)
2188 - {
2189 - _next = next;
2190 - _logger = logger;
2191 - _options = options.Value;
2192 - _redactor = redactor;
2193 - _excludedPathPrefixes = _options.ExcludedPaths
2194 - .Select(p => p.TrimEnd('/'))
2195 - .ToList();
2196 - }
2197 -
2198 - public async Task InvokeAsync(HttpContext context)
2199 - {
2200 - // Skip logging for excluded paths (prefix match, e.g. /health also covers /health/ready).
2201 - var requestPath = context.Request.Path.ToString();
2202 -
2203 - if (IsExcludedPath(requestPath))
2204 - {
2205 - await _next(context);
2206 - return;
2207 - }
2208 -
2209 - var correlationId = GetOrCreateCorrelationId(context);
2210 - context.Items["CorrelationId"] = correlationId;
2211 - context.Response.OnStarting(() =>
2212 - {
2213 - context.Response.Headers[_options.CorrelationIdHeader] = correlationId;
2214 - return Task.CompletedTask;
2215 - });
2216 -
2217 - var clientIp = GetClientIp(context);
2218 - var userAgent = context.Request.Headers.UserAgent.ToString();
2219 -
2220 - // Push enrichment properties into Serilog's LogContext so that all
2221 - // downstream log entries within this request scope include them automatically.
2222 - using (LogContext.PushProperty("CorrelationId", correlationId))
2223 - using (LogContext.PushProperty("ClientIp", clientIp))
2224 - using (LogContext.PushProperty("UserAgent", userAgent))
2225 - using (LogContext.PushProperty("RequestMethod", context.Request.Method))
2226 - using (LogContext.PushProperty("RequestPath", requestPath))
2227 - using (LogContext.PushProperty("QueryString", context.Request.QueryString.ToString()))
2228 - using (LogContext.PushProperty("UserIdentity", context.User.Identity?.Name ?? "anonymous"))
2229 - {
2230 - var requestBody = await CaptureRequestBodyAsync(context);
2231 -
2232 - _logger.LogDebug(
2233 - "HTTP {RequestMethod} {RequestPath}{QueryString} started",
2234 - context.Request.Method,
2235 - context.Request.Path,
2236 - context.Request.QueryString);
2237 -
2238 - if (requestBody.Content.Length > 0)
2239 - {
2240 - _logger.LogDebug(
2241 - "Request body: {RequestBody}",
2242 - FormatBodyForLog(requestBody));
2243 - }
2244 -
2245 - // Only allocate and swap the response body stream when response body logging is enabled.
2246 - Stream? originalBodyStream = null;
2247 - MemoryStream? responseBodyStream = null;
2248 -
2249 - if (_options.EnableResponseBodyLogging)
2250 - {
2251 - originalBodyStream = context.Response.Body;
2252 - responseBodyStream = new MemoryStream();
2253 - context.Response.Body = responseBodyStream;
2254 - }
2255 -
2256 - // Start timing just before calling the next middleware so the elapsed time
2257 - // reflects actual pipeline processing, not request body capture overhead.
2258 - var stopwatch = Stopwatch.StartNew();
2259 -
2260 - try
2261 - {
2262 - await _next(context);
2263 - }
2264 - finally
2265 - {
2266 - stopwatch.Stop();
2267 - var elapsedMs = stopwatch.ElapsedMilliseconds;
2268 -
2269 - var responseBody = CapturedBody.Empty;
2270 -
2271 - if (_options.EnableResponseBodyLogging
2272 - && responseBodyStream is not null
2273 - && originalBodyStream is not null)
2274 - {
2275 - responseBody = await CaptureResponseBodySafeAsync(
2276 - context, responseBodyStream, originalBodyStream);
2277 - }
2278 -
2279 - _logger.LogInformation(
2280 - "HTTP {RequestMethod} {RequestPath} completed {StatusCode} in {ElapsedMs}ms",
2281 - context.Request.Method,
2282 - context.Request.Path,
2283 - context.Response.StatusCode,
2284 - elapsedMs);
2285 -
2286 - if (responseBody.Content.Length > 0)
2287 - {
2288 - _logger.LogDebug(
2289 - "Response body: {ResponseBody}",
2290 - FormatBodyForLog(responseBody));
2291 - }
2292 -
2293 - if (elapsedMs > _options.SlowRequestThresholdMs)
2294 - {
2295 - _logger.LogWarning(
2296 - "Slow request detected: HTTP {RequestMethod} {RequestPath} took {ElapsedMs}ms (threshold: {ThresholdMs}ms)",
2297 - context.Request.Method,
2298 - context.Request.Path,
2299 - elapsedMs,
2300 - _options.SlowRequestThresholdMs);
2301 - }
2302 -
2303 - if (responseBodyStream is not null)
2304 - await responseBodyStream.DisposeAsync();
2305 - }
2306 - }
2307 - }
2308 -
2309 - /// <summary>
2310 - /// Redacts the body content first, then appends the truncation marker if needed.
2311 - /// This ensures sensitive fields are redacted even in truncated bodies.
2312 - /// </summary>
2313 - private string FormatBodyForLog(CapturedBody body)
2314 - {
2315 - var redacted = _redactor.Redact(body.Content);
2316 -
2317 - return body.IsTruncated
2318 - ? $"{redacted} ... [TRUNCATED - body exceeds {_options.MaxBodySizeBytes} bytes]"
2319 - : redacted;
2320 - }
2321 -
2322 - private bool IsExcludedPath(string path)
2323 - {
2324 - foreach (var prefix in _excludedPathPrefixes)
2325 - {
2326 - if (path.Equals(prefix, StringComparison.OrdinalIgnoreCase)
2327 - || path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase))
2328 - {
2329 - return true;
2330 - }
2331 - }
2332 -
2333 - return false;
2334 - }
2335 -
2336 - private string GetOrCreateCorrelationId(HttpContext context)
2337 - {
2338 - if (context.Request.Headers.TryGetValue(_options.CorrelationIdHeader, out var existingId)
2339 - && !string.IsNullOrWhiteSpace(existingId))
2340 - {
2341 - var candidate = existingId.ToString();
2342 -
2343 - if (candidate.Length <= MaxCorrelationIdLength && SafeCorrelationIdRegex().IsMatch(candidate))
2344 - return candidate;
2345 -
2346 - // Invalid or oversized correlation ID from client; generate a new one.
2347 - }
2348 -
2349 - return Guid.NewGuid().ToString();
2350 - }
2351 -
2352 - private async Task<CapturedBody> CaptureRequestBodyAsync(HttpContext context)
2353 - {
2354 - if (!_options.EnableRequestBodyLogging)
2355 - return CapturedBody.Empty;
2356 -
2357 - if (!IsLoggableContentType(context.Request.ContentType))
2358 - return CapturedBody.Empty;
2359 -
2360 - context.Request.EnableBuffering();
2361 -
2362 - // Read the request body at the byte level. EnableBuffering() wraps the stream
2363 - // so it supports seeking, allowing us to reset the position after reading.
2364 - var body = await ReadStreamBytesAsync(context.Request.Body, _options.MaxBodySizeBytes);
2365 -
2366 - context.Request.Body.Position = 0;
2367 -
2368 - return body;
2369 - }
2370 -
2371 - /// <summary>
2372 - /// Captures the response body from the memory stream and copies it to the original
2373 - /// response stream. Wrapped in a try/catch so that a failure here (e.g. the response
2374 - /// has already started on the original stream) does not mask the original exception.
2375 - /// </summary>
2376 - private async Task<CapturedBody> CaptureResponseBodySafeAsync(
2377 - HttpContext context,
2378 - MemoryStream responseBodyStream,
2379 - Stream originalBodyStream)
2380 - {
2381 - try
2382 - {
2383 - responseBodyStream.Position = 0;
2384 -
2385 - var body = CapturedBody.Empty;
2386 -
2387 - if (IsLoggableContentType(context.Response.ContentType))
2388 - {
2389 - body = await ReadStreamFromMemoryAsync(responseBodyStream, _options.MaxBodySizeBytes);
2390 - }
2391 -
2392 - responseBodyStream.Position = 0;
2393 - await responseBodyStream.CopyToAsync(originalBodyStream);
2394 - context.Response.Body = originalBodyStream;
2395 -
2396 - return body;
2397 - }
2398 - catch (Exception ex)
2399 - {
2400 - _logger.LogDebug(ex, "Failed to capture response body for logging");
2401 -
2402 - // Best-effort: try to restore the original body stream so the client gets a response.
2403 - try
2404 - {
2405 - context.Response.Body = originalBodyStream;
2406 - }
2407 - catch
2408 - {
2409 - // Nothing more we can do.
2410 - }
2411 -
2412 - return CapturedBody.Empty;
2413 - }
2414 - }
2415 -
2416 - /// <summary>
2417 - /// Reads a stream at the byte level, suitable for forward-only streams (e.g. request body)
2418 - /// where <c>stream.Length</c> may not be available before the stream is consumed.
2419 - /// Uses <see cref="ArrayPool{T}"/> to avoid large object heap allocations.
2420 - /// The limit is enforced in bytes to match <c>MaxBodySizeBytes</c>.
2421 - /// Truncation respects UTF-8 character boundaries.
2422 - /// </summary>
2423 - private static async Task<CapturedBody> ReadStreamBytesAsync(Stream stream, int maxBytes)
2424 - {
2425 - stream.Position = 0;
2426 -
2427 - // Read one extra byte to detect whether the stream has more data beyond the limit.
2428 - var readLimit = maxBytes + 1;
2429 - var buffer = ArrayPool<byte>.Shared.Rent(readLimit);
2430 -
2431 - try
2432 - {
2433 - var totalRead = 0;
2434 -
2435 - while (totalRead < readLimit)
2436 - {
2437 - var bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, readLimit - totalRead));
2438 -
2439 - if (bytesRead == 0)
2440 - break;
2441 -
2442 - totalRead += bytesRead;
2443 - }
2444 -
2445 - var isTruncated = totalRead > maxBytes;
2446 - var usableBytes = Math.Min(totalRead, maxBytes);
2447 -
2448 - // Adjust the truncation point to avoid splitting a multi-byte UTF-8 sequence.
2449 - if (isTruncated)
2450 - usableBytes = FindUtf8SafeTruncationPoint(buffer, usableBytes);
2451 -
2452 - var content = Encoding.UTF8.GetString(buffer, 0, usableBytes);
2453 -
2454 - return new CapturedBody(content, isTruncated);
2455 - }
2456 - finally
2457 - {
2458 - ArrayPool<byte>.Shared.Return(buffer);
2459 - }
2460 - }
2461 -
2462 - /// <summary>
2463 - /// Reads a MemoryStream where <c>stream.Length</c> is reliable.
2464 - /// Used for response body capture. Uses <see cref="ArrayPool{T}"/> to avoid
2465 - /// per-request heap allocations.
2466 - /// Truncation respects UTF-8 character boundaries.
2467 - /// </summary>
2468 - private static async Task<CapturedBody> ReadStreamFromMemoryAsync(MemoryStream stream, int maxBytes)
2469 - {
2470 - stream.Position = 0;
2471 -
2472 - var length = stream.Length;
2473 - var bytesToRead = (int)Math.Min(maxBytes, length);
2474 - var buffer = ArrayPool<byte>.Shared.Rent(bytesToRead);
2475 -
2476 - try
2477 - {
2478 - var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, bytesToRead));
2479 -
2480 - var isTruncated = length > maxBytes;
2481 -
2482 - var usableBytes = bytesRead;
2483 -
2484 - // Adjust the truncation point to avoid splitting a multi-byte UTF-8 sequence.
2485 - if (isTruncated)
2486 - usableBytes = FindUtf8SafeTruncationPoint(buffer, bytesRead);
2487 -
2488 - var content = Encoding.UTF8.GetString(buffer, 0, usableBytes);
2489 -
2490 - return new CapturedBody(content, isTruncated);
2491 - }
2492 - finally
2493 - {
2494 - ArrayPool<byte>.Shared.Return(buffer);
2495 - }
2496 - }
2497 -
2498 - /// <summary>
2499 - /// Walks backwards from <paramref name="length"/> to find a byte position that
2500 - /// does not split a multi-byte UTF-8 character. UTF-8 continuation bytes have the
2501 - /// bit pattern <c>10xxxxxx</c> (0x80..0xBF). If the byte at the truncation point
2502 - /// is a continuation byte, we step back until we reach the leading byte of that
2503 - /// character and exclude the incomplete sequence.
2504 - /// </summary>
2505 - private static int FindUtf8SafeTruncationPoint(byte[] buffer, int length)
2506 - {
2507 - if (length == 0)
2508 - return 0;
2509 -
2510 - // Walk backwards over any continuation bytes (10xxxxxx).
2511 - var i = length - 1;
2512 - while (i > 0 && (buffer[i] & 0xC0) == 0x80)
2513 - i--;
2514 -
2515 - // i now points at a leading byte (or byte 0). Determine the expected
2516 - // character length from the leading byte.
2517 - var leadByte = buffer[i];
2518 - int expectedCharBytes;
2519 -
2520 - if ((leadByte & 0x80) == 0)
2521 - expectedCharBytes = 1; // 0xxxxxxx — ASCII
2522 - else if ((leadByte & 0xE0) == 0xC0)
2523 - expectedCharBytes = 2; // 110xxxxx
2524 - else if ((leadByte & 0xF0) == 0xE0)
2525 - expectedCharBytes = 3; // 1110xxxx
2526 - else if ((leadByte & 0xF8) == 0xF0)
2527 - expectedCharBytes = 4; // 11110xxx
2528 - else
2529 - return i; // Invalid leading byte — truncate before it.
2530 -
2531 - // If the full character fits within the buffer, keep it; otherwise drop it.
2532 - return i + expectedCharBytes <= length ? length : i;
2533 - }
2534 -
2535 - private bool IsLoggableContentType(string? contentType)
2536 - {
2537 - if (string.IsNullOrWhiteSpace(contentType))
2538 - return false;
2539 -
2540 - return _options.LoggableContentTypes.Exists(
2541 - ct => contentType.Contains(ct, StringComparison.OrdinalIgnoreCase));
2542 - }
2543 -
2544 - private static string GetClientIp(HttpContext context)
2545 - {
2546 - // Check for forwarded headers first (reverse proxy scenarios).
2547 - var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
2548 -
2549 - if (!string.IsNullOrWhiteSpace(forwardedFor))
2550 - {
2551 - // X-Forwarded-For may contain multiple IPs; the first is the original client.
2552 - var ip = forwardedFor.Split(',', StringSplitOptions.TrimEntries)[0];
2553 - return SanitizeForLog(ip);
2554 - }
2555 -
2556 - return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
2557 - }
2558 -
2559 - /// <summary>
2560 - /// Strips control characters and newlines from a string to prevent log injection.
2561 - /// Limits length to avoid unbounded values in log output.
2562 - /// </summary>
2563 - private static string SanitizeForLog(string value)
2564 - {
2565 - const int maxLength = 45; // Max length of an IPv6 address with zone ID
2566 -
2567 - if (value.Length > maxLength)
2568 - value = value[..maxLength];
2569 -
2570 - return LogSanitizeRegex().Replace(value, string.Empty);
2571 - }
2572 -
2573 - /// <summary>
2574 - /// Matches control characters, newlines, and other non-printable characters
2575 - /// that could be used for log injection.
2576 - /// </summary>
2577 - [GeneratedRegex(@"[\x00-\x1F\x7F]")]
2578 - private static partial Regex LogSanitizeRegex();
2579 -
2580 - /// <summary>
2581 - /// Matches safe correlation ID values: alphanumeric characters, hyphens, underscores,
2582 - /// periods, and colons. Rejects control characters, braces (Serilog template injection),
2583 - /// and other unsafe characters.
2584 - /// </summary>
2585 - [GeneratedRegex(@"^[\w\-.:]+$")]
2586 - private static partial Regex SafeCorrelationIdRegex();
2587 -
2588 - /// <summary>
2589 - /// Represents a captured request or response body along with a flag indicating
2590 - /// whether the body was truncated to fit within the configured size limit.
2591 - /// Separating the content from the truncation flag allows the redactor to operate
2592 - /// on valid (non-truncated) content before the truncation marker is appended.
2593 - /// </summary>
2594 - private readonly record struct CapturedBody(string Content, bool IsTruncated)
2595 - {
2596 - public static readonly CapturedBody Empty = new(string.Empty, false);
2597 - }
2598 - }
2599 - ```
2600 -
2601 - ### `src/{ProjectName}.Api/Middleware/GlobalExceptionHandler.cs`
2602 -
2603 - Catches all unhandled exceptions and returns a standardized `ProblemDetails` response. In development, the exception message is included for debugging; in production, a generic message is returned to avoid leaking internals.
2604 -
2605 - ```csharp
2606 - using System.Net;
2607 - using Microsoft.AspNetCore.Diagnostics;
2608 - using Microsoft.AspNetCore.Mvc;
2609 -
2610 - namespace {Projects}.Api.Middleware;
2611 -
2612 - public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
2613 - {
2614 - public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
2615 - {
2616 - logger.LogError(exception, "An unhandled exception occurred: {Message}", exception.Message);
2617 -
2618 - var problemDetails = new ProblemDetails
2619 - {
2620 - Status = (int)HttpStatusCode.InternalServerError,
2621 - Title = "An unexpected error occurred",
2622 - Detail = httpContext.RequestServices.GetRequiredService<IHostEnvironment>().IsDevelopment() ? exception.Message : "An internal server error has occurred.",
2623 - Instance = httpContext.Request.Path
2624 - };
2625 -
2626 - httpContext.Response.StatusCode = problemDetails.Status.Value;
2627 - httpContext.Response.ContentType = "application/problem+json";
2628 - await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken: cancellationToken);
2629 - return true;
2630 - }
2631 - }
2632 - ```
2633 -
2634 - ### `src/{ProjectName}.Api/Extensions/ServiceCollectionExtensions.cs`
2635 -
2636 - The composition root's service registration. Wires together:
2637 - - Serilog (reads config from `appsettings.json`)
2638 - - Request logging options and redactor
2639 - - Controllers, OpenAPI, exception handler, problem details
2640 - - API versioning (URL path segment: `/api/v1/`)
2641 - - Application layer (MediatR + behaviors + validators)
2642 - - Infrastructure layer (EF Core + interceptors)
2643 - - Health checks (PostgreSQL connectivity)
2644 -
2645 - ```csharp
2646 - using Asp.Versioning;
2647 - using Serilog;
2648 - using {Projects}.Api.Configuration;
2649 - using {Projects}.Api.Middleware;
2650 - using {Projects}.Application;
2651 - using {Projects}.Infrastructure;
2652 -
2653 - namespace {Projects}.Api.Extensions;
2654 -
2655 - public static class ServiceCollectionExtensions
2656 - {
2657 - public static WebApplicationBuilder AddServices(this WebApplicationBuilder builder)
2658 - {
2659 - builder.Host.UseSerilog((context, loggerConfiguration) =>
2660 - loggerConfiguration.ReadFrom.Configuration(context.Configuration));
2661 -
2662 - builder.Services.Configure<RequestLoggingOptions>(
2663 - builder.Configuration.GetSection(RequestLoggingOptions.SectionName));
2664 - builder.Services.AddSingleton<SensitiveDataRedactor>();
2665 -
2666 - builder.Services.AddControllers();
2667 - builder.Services.AddOpenApi();
2668 - builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
2669 - builder.Services.AddProblemDetails();
2670 -
2671 - builder.Services.AddApiVersioning(options =>
2672 - {
2673 - options.DefaultApiVersion = new ApiVersion(1, 0);
2674 - options.AssumeDefaultVersionWhenUnspecified = true;
2675 - options.ReportApiVersions = true;
2676 - options.ApiVersionReader = new UrlSegmentApiVersionReader();
2677 - })
2678 - .AddApiExplorer(options =>
2679 - {
2680 - options.GroupNameFormat = "'v'VVV";
2681 - options.SubstituteApiVersionInUrl = true;
2682 - });
2683 -
2684 - builder.Services.AddApplication();
2685 - builder.Services.AddInfrastructure(builder.Configuration);
2686 -
2687 - var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
2688 - ?? throw new InvalidOperationException(
2689 - "Connection string 'DefaultConnection' is not configured.");
2690 -
2691 - builder.Services.AddHealthChecks()
2692 - .AddNpgSql(connectionString);
2693 -
2694 - return builder;
2695 - }
2696 - }
2697 - ```
2698 -
2699 - ### `src/{ProjectName}.Api/Extensions/WebApplicationExtensions.cs`
2700 -
2701 - Configures the HTTP request pipeline (middleware order matters):
2702 - 1. OpenAPI endpoint (development only)
2703 - 2. `RequestLoggingMiddleware` — must come early to capture the full request lifecycle
2704 - 3. Exception handler — catches exceptions from downstream middleware
2705 - 4. HTTPS redirection
2706 - 5. Authorization
2707 - 6. Controller mapping
2708 - 7. Health check endpoint
2709 -
2710 - ```csharp
2711 - using {Projects}.Api.Middleware;
2712 -
2713 - namespace {Projects}.Api.Extensions;
2714 -
2715 - public static class WebApplicationExtensions
2716 - {
2717 - public static WebApplication ConfigurePipeline(this WebApplication app)
2718 - {
2719 - if (app.Environment.IsDevelopment())
2720 - app.MapOpenApi();
2721 -
2722 - app.UseMiddleware<RequestLoggingMiddleware>();
2723 - app.UseExceptionHandler();
2724 - app.UseHttpsRedirection();
2725 - app.UseAuthorization();
2726 - app.MapControllers();
2727 - app.MapHealthChecks("/health");
2728 -
2729 - return app;
2730 - }
2731 - }
2732 - ```
2733 -
2734 - ### `src/{ProjectName}.Api/Program.cs`
2735 -
2736 - The application entry point. Deliberately minimal — all setup is delegated to extension methods. The try/catch/finally ensures Serilog captures fatal startup errors and flushes all buffered log events on shutdown.
2737 -
2738 - The `public partial class Program;` declaration at the end enables `WebApplicationFactory<Program>` in integration tests.
2739 -
2740 - ```csharp
2741 - using Serilog;
2742 - using {Projects}.Api.Extensions;
2743 -
2744 - var builder = WebApplication.CreateBuilder(args);
2745 -
2746 - builder.AddServices();
2747 -
2748 - var app = builder.Build();
2749 -
2750 - app.ConfigurePipeline();
2751 -
2752 - try
2753 - {
2754 - Log.Information("Starting {Projects} API in {Environment} environment", app.Environment.EnvironmentName);
2755 - app.Run();
2756 - }
2757 - catch (Exception ex)
2758 - {
2759 - Log.Fatal(ex, "Application terminated unexpectedly");
2760 - }
2761 - finally
2762 - {
2763 - Log.CloseAndFlush();
2764 - }
2765 -
2766 - public partial class Program;
2767 - ```
2768 -
2769 - ---
2770 -
2771 - ## 12. Test Projects
1 + ## 10. Test Projects
2772 2
2773 3 ### Test Strategy
2774 4
@@ -2776,9 +6,9 @@ The boilerplate scaffolds three test projects, each targeting a different layer
2776 6
2777 7 | Project | Tests | Style |
2778 8 |---|---|---|
2779 - | `{ProjectName}.Domain.Tests` | Entities, value objects, Result pattern, domain logic | Pure unit tests — no mocks needed (Domain has zero dependencies) |
2780 - | `{ProjectName}.Application.Tests` | Command/query handlers, validators, pipeline behaviors | Unit tests with mocked `ApplicationDbContext` and `IUnitOfWork` |
2781 - | `{ProjectName}.IntegrationTests` | Full HTTP request/response cycle through the API | Integration tests using `WebApplicationFactory<Program>` with a real PostgreSQL instance |
9 + | `${PROJECT}.Domain.Tests` | Entities, value objects, Result pattern, domain logic | Pure unit tests — no mocks needed (Domain has zero dependencies) |
10 + | `${PROJECT}.Application.Tests` | Command/query handlers, validators, pipeline behaviors | Unit tests with mocked `ApplicationDbContext` and `IUnitOfWork` |
11 + | `${PROJECT}.IntegrationTests` | Full HTTP request/response cycle through the API | Integration tests using `WebApplicationFactory<Program>` with a real PostgreSQL instance |
2782 12
2783 13 **Shared test tooling across all projects:**
2784 14 - **xUnit** — test framework (`[Fact]`, `[Theory]`)
@@ -2806,7 +36,7 @@ This mirrors the Clean Architecture dependency rule — test projects never reac
2806 36
2807 37 ---
2808 38
2809 - ## 13. CI/CD Pipeline
39 + ## 11. CI/CD Pipeline
2810 40
2811 41 The CI/CD pipeline is split into two conceptual sections:
2812 42
@@ -2820,7 +50,7 @@ Configuration for JetBrains Qodana static analysis. Points to the `.slnx` soluti
2820 50 ```yaml
2821 51 #-------------------------------------------------------------------------------#
2822 52 # Qodana analysis is configured by qodana.yaml file #
2823 - # https://www.jetbrains.com/help/qodana/qodana-yaml.html #
53 + # [https://www.jetbrains.com/help/qodana/qodana-yaml.html](https://www.jetbrains.com/help/qodana/qodana-yaml.html) #
2824 54 #-------------------------------------------------------------------------------#
2825 55
2826 56 #################################################################################
@@ -2834,7 +64,7 @@ ide: QDNET
2834 64
2835 65 #Specify the .NET solution to analyze
2836 66 dotnet:
2837 - solution: {Projects}.slnx
67 + solution: ${PROJECT}.slnx
2838 68
2839 69 #Specify inspection profile for code analysis
2840 70 profile:
@@ -2855,7 +85,7 @@ profile:
2855 85
2856 86 #Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
2857 87 #plugins:
2858 - # - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
88 + # - id: <plugin.id> #(plugin id can be found at [https://plugins.jetbrains.com](https://plugins.jetbrains.com))
2859 89
2860 90 # Quality gate. Will fail the CI/CD pipeline if any condition is not met
2861 91 # severityThresholds - configures maximum thresholds for different problem severities
@@ -2886,18 +116,12 @@ on:
2886 116 env:
2887 117 DOTNET_VERSION: "10.0.x"
2888 118 JAVA_VERSION: "17"
2889 - DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/{projects}-api
2890 - ```
2891 -
2892 - #### Job 1: Build & Test
119 + DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/${PROJECT_LOWER}-api
2893 120
2894 - Runs on every push and PR. Spins up a PostgreSQL service container, restores, builds, and runs all tests. Test results and coverage reports are uploaded as artifacts.
2895 -
2896 - ```yaml
2897 121 jobs:
2898 - # ──────────────────────────────────────────────
122 + # ─────────────────────────────────────────────────────────────────
2899 123 # Job 1: Build, test, and collect coverage
2900 - # ──────────────────────────────────────────────
124 + # ─────────────────────────────────────────────────────────────────
2901 125 test:
2902 126 name: Build & Test
2903 127 runs-on: ubuntu-latest
@@ -2906,7 +130,7 @@ jobs:
2906 130 postgres:
2907 131 image: postgres:17-alpine
2908 132 env:
2909 - POSTGRES_DB: {projects}_test
133 + POSTGRES_DB: ${PROJECT_LOWER}_test
2910 134 POSTGRES_USER: postgres
2911 135 POSTGRES_PASSWORD: postgres
2912 136 ports:
@@ -2941,7 +165,7 @@ jobs:
2941 165 --collect:"XPlat Code Coverage"
2942 166 --results-directory ./TestResults
2943 167 env:
2944 - ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database={projects}_test;Username=postgres;Password=postgres"
168 + ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=${PROJECT_LOWER}_test;Username=postgres;Password=postgres"
2945 169
2946 170 - name: Upload test results
2947 171 uses: actions/upload-artifact@v4
@@ -2950,16 +174,10 @@ jobs:
2950 174 name: test-results
2951 175 path: ./TestResults
2952 176 retention-days: 7
2953 - ```
2954 -
2955 - #### Job 2: SonarCloud Analysis
2956 177
2957 - Runs after tests pass. Performs static analysis and code coverage reporting via SonarCloud. Requires `SONAR_TOKEN` secret and `SONAR_PROJECT_KEY` / `SONAR_ORGANIZATION_KEY` variables.
2958 -
2959 - ```yaml
2960 - # ──────────────────────────────────────────────
178 + # ─────────────────────────────────────────────────────────────────
2961 179 # Job 2: SonarCloud analysis
2962 - # ──────────────────────────────────────────────
180 + # ─────────────────────────────────────────────────────────────────
2963 181 sonar:
2964 182 name: SonarCloud Analysis
2965 183 runs-on: ubuntu-latest
@@ -2969,7 +187,7 @@ Runs after tests pass. Performs static analysis and code coverage reporting via
2969 187 postgres:
2970 188 image: postgres:17-alpine
2971 189 env:
2972 - POSTGRES_DB: {projects}_test
190 + POSTGRES_DB: ${PROJECT_LOWER}_test
2973 191 POSTGRES_USER: postgres
2974 192 POSTGRES_PASSWORD: postgres
2975 193 ports:
@@ -3008,7 +226,7 @@ Runs after tests pass. Performs static analysis and code coverage reporting via
3008 226 /k:"${{ vars.SONAR_PROJECT_KEY }}"
3009 227 /o:"${{ vars.SONAR_ORGANIZATION_KEY }}"
3010 228 /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
3011 - /d:sonar.host.url="https://sonarcloud.io"
229 + /d:sonar.host.url="[https://sonarcloud.io](https://sonarcloud.io)"
3012 230 /d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml"
3013 231 /d:sonar.exclusions="**/obj/**,**/bin/**"
3014 232 /d:sonar.coverage.exclusions="**/obj/**,**/bin/**,**/Migrations/**"
@@ -3024,7 +242,7 @@ Runs after tests pass. Performs static analysis and code coverage reporting via
3024 242 --collect:"XPlat Code Coverage;Format=opencover"
3025 243 --results-directory ./TestResults
3026 244 env:
3027 - ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database={projects}_test;Username=postgres;Password=postgres"
245 + ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=${PROJECT_LOWER}_test;Username=postgres;Password=postgres"
3028 246
3029 247 - name: End SonarCloud analysis
3030 248 env:
@@ -3039,16 +257,10 @@ Runs after tests pass. Performs static analysis and code coverage reporting via
3039 257 scanMetadataReportFile: .sonarqube/out/.sonar/report-task.txt
3040 258 env:
3041 259 SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
3042 - ```
3043 -
3044 - #### Job 3: Qodana Analysis
3045 260
3046 - Runs in parallel with SonarCloud (both depend on `test`). Uses JetBrains Qodana for .NET static analysis. Requires `QODANA_TOKEN` secret and `contents: write` permissions for PR annotations.
3047 -
3048 - ```yaml
3049 - # ──────────────────────────────────────────────
261 + # ─────────────────────────────────────────────────────────────────
3050 262 # Job 3: Qodana analysis (parallel with SonarCloud)
3051 - # ──────────────────────────────────────────────
263 + # ─────────────────────────────────────────────────────────────────
3052 264 qodana:
3053 265 name: Qodana Analysis
3054 266 runs-on: ubuntu-latest
@@ -3069,16 +281,10 @@ Runs in parallel with SonarCloud (both depend on `test`). Uses JetBrains Qodana
3069 281 uses: JetBrains/qodana-action@v2025.1
3070 282 env:
3071 283 QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
3072 - ```
3073 -
3074 - #### Job 4: Deploy to Server
3075 284
3076 - Only runs on pushes to `dev` (not PRs, not `main`). Builds a Docker image, pushes it to Docker Hub, then deploys to a remote server via SSH over Tailscale. This job is project-specific — customize the deployment target, Tailscale OAuth credentials, and SSH details.
3077 -
3078 - ```yaml
3079 - # ──────────────────────────────────────────────
285 + # ─────────────────────────────────────────────────────────────────
3080 286 # Job 4: Build, push, and deploy
3081 - # ──────────────────────────────────────────────
287 + # ─────────────────────────────────────────────────────────────────
3082 288 deploy:
3083 289 name: Deploy to Server
3084 290 runs-on: ubuntu-latest
@@ -3183,7 +389,7 @@ Only runs on pushes to `dev` (not PRs, not `main`). Builds a Docker image, pushe
3183 389
3184 390 ---
3185 391
3186 - ## 14. AI-Assisted Development
392 + ## 12. AI-Assisted Development
3187 393
3188 394 ### `.github/copilot-instructions.md`
3189 395
@@ -3200,7 +406,7 @@ This file is automatically picked up by Copilot in VS Code and GitHub.com, provi
3200 406
3201 407 ---
3202 408
3203 - ## 15. Running the Project
409 + ## 13. Running the Project
3204 410
3205 411 ### Local Development (without Docker)
3206 412
@@ -3209,7 +415,7 @@ This file is automatically picked up by Copilot in VS Code and GitHub.com, provi
3209 415 docker compose up db -d
3210 416
3211 417 # Run the API
3212 - dotnet run --project src/{Projects}.Api
418 + dotnet run --project src/${PROJECT}.Api
3213 419
3214 420 # API available at http://localhost:5212
3215 421 # Health check: http://localhost:5212/health
@@ -3233,9 +439,9 @@ docker compose up --build -d
3233 439 dotnet test
3234 440
3235 441 # Specific test project
3236 - dotnet test tests/{Projects}.Domain.Tests
3237 - dotnet test tests/{Projects}.Application.Tests
3238 - dotnet test tests/{Projects}.IntegrationTests
442 + dotnet test tests/${PROJECT}.Domain.Tests
443 + dotnet test tests/${PROJECT}.Application.Tests
444 + dotnet test tests/${PROJECT}.IntegrationTests
3239 445
3240 446 # Single test by name
3241 447 dotnet test --filter "FullyQualifiedName~MyTestMethod"
@@ -3246,13 +452,13 @@ dotnet test --filter "FullyQualifiedName~MyTestMethod"
3246 452 ```bash
3247 453 # Add a new migration
3248 454 dotnet ef migrations add <MigrationName> \
3249 - --project src/{Projects}.Infrastructure \
3250 - --startup-project src/{Projects}.Api
455 + --project src/${PROJECT}.Infrastructure \
456 + --startup-project src/${PROJECT}.Api
3251 457
3252 458 # Apply migrations
3253 459 dotnet ef database update \
3254 - --project src/{Projects}.Infrastructure \
3255 - --startup-project src/{Projects}.Api
460 + --project src/${PROJECT}.Infrastructure \
461 + --startup-project src/${PROJECT}.Api
3256 462 ```
3257 463
3258 464 ---

weehong 已修改 1 month ago. 還原成這個修訂版本

1 file changed, 342 insertions, 45 deletions

dotnet-10-clean-architecture-boilerplate-guide.md

@@ -348,86 +348,383 @@ root = true
348 348 # All files
349 349 [*]
350 350 indent_style = space
351 - indent_size = 4
352 - end_of_line = lf
353 - charset = utf-8
354 - trim_trailing_whitespace = true
355 - insert_final_newline = true
356 351
357 - # XML project files
358 - [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,props,targets}]
352 + # Xml files
353 + [*.{xml,csproj,props,targets,ruleset,nuspec,resx}]
359 354 indent_size = 2
360 355
361 - # XML files
362 - [*.{xml,config,nuspec,resx}]
356 + # Javascript files
357 + [*.js]
363 358 indent_size = 2
364 359
365 - # JSON files
366 - [*.json]
360 + # Json files
361 + [*.{json,config,nswag}]
367 362 indent_size = 2
368 363
369 - # YAML files
370 - [*.{yml,yaml}]
371 - indent_size = 2
372 -
373 - # Markdown files
374 - [*.md]
375 - trim_trailing_whitespace = false
376 -
377 364 # C# files
378 365 [*.cs]
379 366
367 + #### Core EditorConfig Options ####
368 +
369 + # Indentation and spacing
370 + indent_size = 4
371 + tab_width = 4
372 +
373 + # New line preferences
374 + end_of_line = lf
375 + insert_final_newline = true
376 +
377 + #### .NET Coding Conventions ####
378 + [*.{cs,vb}]
379 +
380 380 # Organize usings
381 - dotnet_sort_system_directives_first = true
382 381 dotnet_separate_import_directive_groups = false
382 + dotnet_sort_system_directives_first = true
383 + file_header_template = unset
384 +
385 + # this. and Me. preferences
386 + dotnet_style_qualification_for_event = false:silent
387 + dotnet_style_qualification_for_field = false:silent
388 + dotnet_style_qualification_for_method = false:silent
389 + dotnet_style_qualification_for_property = false:silent
390 +
391 + # Language keywords vs BCL types preferences
392 + dotnet_style_predefined_type_for_locals_parameters_members = true:silent
393 + dotnet_style_predefined_type_for_member_access = true:silent
383 394
384 - # Namespace settings
385 - csharp_style_namespace_declarations = file_scoped:warning
395 + # Parentheses preferences
396 + dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
397 + dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
398 + dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
399 + dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
400 +
401 + # Modifier preferences
402 + dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
403 +
404 + # Expression-level preferences
405 + dotnet_style_coalesce_expression = true:suggestion
406 + dotnet_style_collection_initializer = true:suggestion
407 + dotnet_style_explicit_tuple_names = true:suggestion
408 + dotnet_style_null_propagation = true:suggestion
409 + dotnet_style_object_initializer = true:suggestion
410 + dotnet_style_operator_placement_when_wrapping = beginning_of_line
411 + dotnet_style_prefer_auto_properties = true:suggestion
412 + dotnet_style_prefer_compound_assignment = true:suggestion
413 + dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
414 + dotnet_style_prefer_conditional_expression_over_return = true:suggestion
415 + dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
416 + dotnet_style_prefer_inferred_tuple_names = true:suggestion
417 + dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
418 + dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
419 + dotnet_style_prefer_simplified_interpolation = true:suggestion
420 +
421 + # Field preferences
422 + dotnet_style_readonly_field = true:warning
423 +
424 + # Parameter preferences
425 + dotnet_code_quality_unused_parameters = all:suggestion
426 +
427 + # Suppression preferences
428 + dotnet_remove_unnecessary_suppression_exclusions = none
429 +
430 + #### C# Coding Conventions ####
431 + [*.cs]
386 432
387 433 # var preferences
388 - csharp_style_var_for_built_in_types = true:suggestion
434 + csharp_style_var_elsewhere = false:silent
435 + csharp_style_var_for_built_in_types = false:silent
389 436 csharp_style_var_when_type_is_apparent = true:suggestion
390 - csharp_style_var_elsewhere = true:suggestion
391 437
392 - # Expression-level preferences
393 - csharp_prefer_simple_using_statement = true:warning
438 + # Expression-bodied members
439 + csharp_style_expression_bodied_accessors = true:silent
440 + csharp_style_expression_bodied_constructors = true:suggestion
441 + csharp_style_expression_bodied_indexers = true:silent
442 + csharp_style_expression_bodied_lambdas = true:suggestion
443 + csharp_style_expression_bodied_local_functions = true:suggestion
444 + csharp_style_expression_bodied_methods = true:suggestion
445 + csharp_style_expression_bodied_operators = true:suggestion
446 + csharp_style_expression_bodied_properties = true:silent
447 +
448 + # Pattern matching preferences
449 + csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
450 + csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
451 + csharp_style_prefer_not_pattern = true:suggestion
452 + csharp_style_prefer_pattern_matching = true:silent
394 453 csharp_style_prefer_switch_expression = true:suggestion
395 - csharp_style_prefer_pattern_matching = true:suggestion
396 454
397 455 # Null-checking preferences
398 - csharp_style_throw_expression = true:suggestion
399 456 csharp_style_conditional_delegate_call = true:suggestion
400 457
458 + # Modifier preferences
459 + csharp_prefer_static_local_function = true:warning
460 + csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent
461 +
462 + # Code-block preferences
463 + csharp_prefer_braces = true:silent
464 + csharp_prefer_simple_using_statement = true:suggestion
465 +
466 + # Expression-level preferences
467 + csharp_prefer_simple_default_expression = true:suggestion
468 + csharp_style_deconstructed_variable_declaration = true:suggestion
469 + csharp_style_inlined_variable_declaration = true:suggestion
470 + csharp_style_pattern_local_over_anonymous_function = true:suggestion
471 + csharp_style_prefer_index_operator = true:suggestion
472 + csharp_style_prefer_range_operator = true:suggestion
473 + csharp_style_throw_expression = true:suggestion
474 + csharp_style_unused_value_assignment_preference = discard_variable:suggestion
475 + csharp_style_unused_value_expression_statement_preference = discard_variable:silent
476 +
477 + # 'using' directive preferences
478 + csharp_using_directive_placement = outside_namespace:silent
479 +
480 + #### C# Formatting Rules ####
481 +
401 482 # New line preferences
402 - csharp_new_line_before_open_brace = all
403 - csharp_new_line_before_else = true
404 483 csharp_new_line_before_catch = true
484 + csharp_new_line_before_else = true
405 485 csharp_new_line_before_finally = true
486 + csharp_new_line_before_members_in_anonymous_types = true
487 + csharp_new_line_before_members_in_object_initializers = true
488 + csharp_new_line_before_open_brace = all
489 + csharp_new_line_between_query_expression_clauses = true
406 490
407 491 # Indentation preferences
492 + csharp_indent_block_contents = true
493 + csharp_indent_braces = false
408 494 csharp_indent_case_contents = true
495 + csharp_indent_case_contents_when_block = true
496 + csharp_indent_labels = one_less_than_current
409 497 csharp_indent_switch_labels = true
410 498
411 - # Naming conventions
412 - dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning
413 - dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
414 - dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
499 + # Space preferences
500 + csharp_space_after_cast = false
501 + csharp_space_after_colon_in_inheritance_clause = true
502 + csharp_space_after_comma = true
503 + csharp_space_after_dot = false
504 + csharp_space_after_keywords_in_control_flow_statements = true
505 + csharp_space_after_semicolon_in_for_statement = true
506 + csharp_space_around_binary_operators = before_and_after
507 + csharp_space_around_declaration_statements = false
508 + csharp_space_before_colon_in_inheritance_clause = true
509 + csharp_space_before_comma = false
510 + csharp_space_before_dot = false
511 + csharp_space_before_open_square_brackets = false
512 + csharp_space_before_semicolon_in_for_statement = false
513 + csharp_space_between_empty_square_brackets = false
514 + csharp_space_between_method_call_empty_parameter_list_parentheses = false
515 + csharp_space_between_method_call_name_and_opening_parenthesis = false
516 + csharp_space_between_method_call_parameter_list_parentheses = false
517 + csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
518 + csharp_space_between_method_declaration_name_and_open_parenthesis = false
519 + csharp_space_between_method_declaration_parameter_list_parentheses = false
520 + csharp_space_between_parentheses = false
521 + csharp_space_between_square_brackets = false
522 +
523 + # Wrapping preferences
524 + csharp_preserve_single_line_blocks = true
525 + csharp_preserve_single_line_statements = true
526 + csharp_style_namespace_declarations = file_scoped:suggestion
527 + csharp_style_prefer_method_group_conversion = true:silent
528 + csharp_style_prefer_top_level_statements = true:silent
529 + csharp_style_prefer_primary_constructors = true:warning
530 + csharp_style_prefer_null_check_over_type_check = true:suggestion
531 + csharp_style_prefer_local_over_anonymous_function = true:suggestion
532 + csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
533 + csharp_style_prefer_tuple_swap = true:suggestion
534 + csharp_style_prefer_utf8_string_literals = true:suggestion
535 +
536 + #### Naming styles ####
537 + [*.{cs,vb}]
538 +
539 + # Naming rules
540 +
541 + dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
542 + dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
543 + dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
544 +
545 + dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
546 + dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
547 + dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
548 +
549 + dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
550 + dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
551 + dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
552 +
553 + dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
554 + dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
555 + dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
556 +
557 + dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
558 + dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
559 + dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
560 +
561 + dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
562 + dotnet_naming_rule.events_should_be_pascalcase.symbols = events
563 + dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
564 +
565 + dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
566 + dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
567 + dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
568 +
569 + dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
570 + dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
571 + dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
572 +
573 + dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
574 + dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
575 + dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
576 +
577 + dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
578 + dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
579 + dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
580 +
581 + dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
582 + dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
583 + dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
584 +
585 + dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
586 + dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
587 + dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
588 +
589 + dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
590 + dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
591 + dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
592 +
593 + dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
594 + dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
595 + dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
596 +
597 + dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
598 + dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
599 + dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
600 +
601 + dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
602 + dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
603 + dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
604 +
605 + dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
606 + dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
607 + dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
608 +
609 + dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
610 + dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
611 + dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
612 +
613 + dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
614 + dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
615 + dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
616 +
617 + # Symbol specifications
618 +
619 + dotnet_naming_symbols.interfaces.applicable_kinds = interface
620 + dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
621 + dotnet_naming_symbols.interfaces.required_modifiers =
622 +
623 + dotnet_naming_symbols.enums.applicable_kinds = enum
624 + dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
625 + dotnet_naming_symbols.enums.required_modifiers =
626 +
627 + dotnet_naming_symbols.events.applicable_kinds = event
628 + dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
629 + dotnet_naming_symbols.events.required_modifiers =
630 +
631 + dotnet_naming_symbols.methods.applicable_kinds = method
632 + dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
633 + dotnet_naming_symbols.methods.required_modifiers =
634 +
635 + dotnet_naming_symbols.properties.applicable_kinds = property
636 + dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
637 + dotnet_naming_symbols.properties.required_modifiers =
638 +
639 + dotnet_naming_symbols.public_fields.applicable_kinds = field
640 + dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
641 + dotnet_naming_symbols.public_fields.required_modifiers =
642 +
643 + dotnet_naming_symbols.private_fields.applicable_kinds = field
644 + dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
645 + dotnet_naming_symbols.private_fields.required_modifiers =
646 +
647 + dotnet_naming_symbols.private_static_fields.applicable_kinds = field
648 + dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
649 + dotnet_naming_symbols.private_static_fields.required_modifiers = static
650 +
651 + dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
652 + dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
653 + dotnet_naming_symbols.types_and_namespaces.required_modifiers =
654 +
655 + dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
656 + dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
657 + dotnet_naming_symbols.non_field_members.required_modifiers =
658 +
659 + dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
660 + dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
661 + dotnet_naming_symbols.type_parameters.required_modifiers =
662 +
663 + dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
664 + dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
665 + dotnet_naming_symbols.private_constant_fields.required_modifiers = const
666 +
667 + dotnet_naming_symbols.local_variables.applicable_kinds = local
668 + dotnet_naming_symbols.local_variables.applicable_accessibilities = local
669 + dotnet_naming_symbols.local_variables.required_modifiers =
670 +
671 + dotnet_naming_symbols.local_constants.applicable_kinds = local
672 + dotnet_naming_symbols.local_constants.applicable_accessibilities = local
673 + dotnet_naming_symbols.local_constants.required_modifiers = const
674 +
675 + dotnet_naming_symbols.parameters.applicable_kinds = parameter
676 + dotnet_naming_symbols.parameters.applicable_accessibilities = *
677 + dotnet_naming_symbols.parameters.required_modifiers =
678 +
679 + dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
680 + dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
681 + dotnet_naming_symbols.public_constant_fields.required_modifiers = const
682 +
683 + dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
684 + dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
685 + dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
686 +
687 + dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
688 + dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
689 + dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
690 +
691 + dotnet_naming_symbols.local_functions.applicable_kinds = local_function
692 + dotnet_naming_symbols.local_functions.applicable_accessibilities = *
693 + dotnet_naming_symbols.local_functions.required_modifiers =
694 +
695 + # Naming styles
696 +
697 + dotnet_naming_style.pascalcase.required_prefix =
698 + dotnet_naming_style.pascalcase.required_suffix =
699 + dotnet_naming_style.pascalcase.word_separator =
700 + dotnet_naming_style.pascalcase.capitalization = pascal_case
701 +
702 + dotnet_naming_style.ipascalcase.required_prefix = I
703 + dotnet_naming_style.ipascalcase.required_suffix =
704 + dotnet_naming_style.ipascalcase.word_separator =
705 + dotnet_naming_style.ipascalcase.capitalization = pascal_case
415 706
416 - dotnet_naming_rule.private_field_should_be_camel_case_with_underscore.severity = warning
417 - dotnet_naming_rule.private_field_should_be_camel_case_with_underscore.symbols = private_field
418 - dotnet_naming_rule.private_field_should_be_camel_case_with_underscore.style = camel_case_with_underscore
707 + dotnet_naming_style.tpascalcase.required_prefix = T
708 + dotnet_naming_style.tpascalcase.required_suffix =
709 + dotnet_naming_style.tpascalcase.word_separator =
710 + dotnet_naming_style.tpascalcase.capitalization = pascal_case
419 711
420 - dotnet_naming_symbols.interface.applicable_kinds = interface
421 - dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
712 + dotnet_naming_style._camelcase.required_prefix = _
713 + dotnet_naming_style._camelcase.required_suffix =
714 + dotnet_naming_style._camelcase.word_separator =
715 + dotnet_naming_style._camelcase.capitalization = camel_case
422 716
423 - dotnet_naming_symbols.private_field.applicable_kinds = field
424 - dotnet_naming_symbols.private_field.applicable_accessibilities = private, private_protected
717 + dotnet_naming_style.camelcase.required_prefix =
718 + dotnet_naming_style.camelcase.required_suffix =
719 + dotnet_naming_style.camelcase.word_separator =
720 + dotnet_naming_style.camelcase.capitalization = camel_case
425 721
426 - dotnet_naming_style.begins_with_i.required_prefix = I
427 - dotnet_naming_style.begins_with_i.capitalization = pascal_case
722 + dotnet_naming_style.s_camelcase.required_prefix = s_
723 + dotnet_naming_style.s_camelcase.required_suffix =
724 + dotnet_naming_style.s_camelcase.word_separator =
725 + dotnet_naming_style.s_camelcase.capitalization = camel_case
428 726
429 - dotnet_naming_style.camel_case_with_underscore.required_prefix = _
430 - dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
727 + dotnet_style_namespace_match_folder = true:suggestion
431 728 ```
432 729
433 730 ### `.dockerignore`

weehong 已修改 1 month ago. 還原成這個修訂版本

1 file changed, 14 insertions, 14 deletions

dotnet-10-clean-architecture-boilerplate-guide.md

@@ -285,10 +285,10 @@ Enables Central Package Management (CPM). Every NuGet package version is declare
285 285
286 286 <ItemGroup>
287 287 <!-- Web & API -->
288 - <PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1"/>
289 - <PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1"/>
288 + <PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0-preview.2"/>
289 + <PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0-preview.2"/>
290 290 <PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0"/>
291 - <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3"/>
291 + <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5"/>
292 292
293 293 <!-- Logging -->
294 294 <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0"/>
@@ -299,26 +299,26 @@ Enables Central Package Management (CPM). Every NuGet package version is declare
299 299 <PackageVersion Include="FluentValidation" Version="12.1.1"/>
300 300 <PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1"/>
301 301 <PackageVersion Include="MediatR" Version="14.1.0"/>
302 - <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3"/>
302 + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5"/>
303 303
304 304 <!-- Infrastructure -->
305 - <PackageVersion Include="Newtonsoft.Json" Version="13.0.3"/>
306 - <PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1"/>
305 + <PackageVersion Include="Newtonsoft.Json" Version="13.0.4"/>
306 + <PackageVersion Include="Quartz.Extensions.Hosting" Version="3.16.1"/>
307 307
308 308 <!-- Entity Framework Core -->
309 - <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.3"/>
310 - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3"/>
311 - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3"/>
312 - <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
309 + <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.5"/>
310 + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5"/>
311 + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5"/>
312 + <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1"/>
313 313
314 314 <!-- Testing -->
315 - <PackageVersion Include="coverlet.collector" Version="6.0.4"/>
315 + <PackageVersion Include="coverlet.collector" Version="8.0.0"/>
316 316 <PackageVersion Include="FluentAssertions" Version="8.8.0"/>
317 - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3"/>
318 - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
317 + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
318 + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
319 319 <PackageVersion Include="Moq" Version="4.20.72"/>
320 320 <PackageVersion Include="xunit" Version="2.9.3"/>
321 - <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"/>
321 + <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5"/>
322 322 </ItemGroup>
323 323
324 324 </Project>

weehong 已修改 1 month ago. 還原成這個修訂版本

1 file changed, 158 insertions, 156 deletions

dotnet-10-clean-architecture-boilerplate-guide.md

@@ -4,8 +4,8 @@ This guide is an exhaustive, detail-oriented manual for recreating this .NET 10
4 4
5 5 ## How to Use This Guide
6 6
7 - - **Prose** uses `{ProjectName}` as a placeholder. Replace it with your actual project name (e.g., `Upmatches`, `Inventory`, `Payments`).
8 - - **Code blocks** use `Upmatches` as the concrete example — the actual project this guide was written for.
7 + - **Prose** uses `{ProjectName}` as a placeholder. Replace it with your actual project name (e.g., `{Projects}`, `Inventory`, `Payments`).
8 + - **Code blocks** use `{Projects}` as the concrete example — the actual project this guide was written for.
9 9 - Sections are ordered by dependency: set up root config first, then work inward from Domain → Application → Infrastructure → API.
10 10
11 11 ---
@@ -187,35 +187,35 @@ mkdir {projectname}-api
187 187 cd {projectname}-api
188 188
189 189 # Create the solution (slnx = lightweight XML format, cleaner diffs than .sln)
190 - dotnet new slnx -n Upmatches
190 + dotnet new slnx -n {Projects}
191 191
192 192 # Create the source projects
193 - dotnet new webapi -n Upmatches.Api -o src/Upmatches.Api
194 - dotnet new classlib -n Upmatches.Application -o src/Upmatches.Application
195 - dotnet new classlib -n Upmatches.Domain -o src/Upmatches.Domain
196 - dotnet new classlib -n Upmatches.Infrastructure -o src/Upmatches.Infrastructure
193 + dotnet new webapi -n {Projects}.Api -o src/{Projects}.Api
194 + dotnet new classlib -n {Projects}.Application -o src/{Projects}.Application
195 + dotnet new classlib -n {Projects}.Domain -o src/{Projects}.Domain
196 + dotnet new classlib -n {Projects}.Infrastructure -o src/{Projects}.Infrastructure
197 197
198 198 # Create the test projects
199 - dotnet new xunit -n Upmatches.Application.Tests -o tests/Upmatches.Application.Tests
200 - dotnet new xunit -n Upmatches.Domain.Tests -o tests/Upmatches.Domain.Tests
201 - dotnet new xunit -n Upmatches.IntegrationTests -o tests/Upmatches.IntegrationTests
199 + dotnet new xunit -n {Projects}.Application.Tests -o tests/{Projects}.Application.Tests
200 + dotnet new xunit -n {Projects}.Domain.Tests -o tests/{Projects}.Domain.Tests
201 + dotnet new xunit -n {Projects}.IntegrationTests -o tests/{Projects}.IntegrationTests
202 202
203 203 # Add source projects to the solution
204 - dotnet sln Upmatches.slnx add src/Upmatches.Api/Upmatches.Api.csproj --solution-folder src
205 - dotnet sln Upmatches.slnx add src/Upmatches.Application/Upmatches.Application.csproj --solution-folder src
206 - dotnet sln Upmatches.slnx add src/Upmatches.Domain/Upmatches.Domain.csproj --solution-folder src
207 - dotnet sln Upmatches.slnx add src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj --solution-folder src
204 + dotnet sln {Projects}.slnx add src/{Projects}.Api/{Projects}.Api.csproj --solution-folder src
205 + dotnet sln {Projects}.slnx add src/{Projects}.Application/{Projects}.Application.csproj --solution-folder src
206 + dotnet sln {Projects}.slnx add src/{Projects}.Domain/{Projects}.Domain.csproj --solution-folder src
207 + dotnet sln {Projects}.slnx add src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj --solution-folder src
208 208
209 209 # Add test projects to the solution
210 - dotnet sln Upmatches.slnx add tests/Upmatches.Application.Tests/Upmatches.Application.Tests.csproj --solution-folder tests
211 - dotnet sln Upmatches.slnx add tests/Upmatches.Domain.Tests/Upmatches.Domain.Tests.csproj --solution-folder tests
212 - dotnet sln Upmatches.slnx add tests/Upmatches.IntegrationTests/Upmatches.IntegrationTests.csproj --solution-folder tests
210 + dotnet sln {Projects}.slnx add tests/{Projects}.Application.Tests/{Projects}.Application.Tests.csproj --solution-folder tests
211 + dotnet sln {Projects}.slnx add tests/{Projects}.Domain.Tests/{Projects}.Domain.Tests.csproj --solution-folder tests
212 + dotnet sln {Projects}.slnx add tests/{Projects}.IntegrationTests/{Projects}.IntegrationTests.csproj --solution-folder tests
213 213
214 214 # Configure Clean Architecture Dependencies
215 - dotnet add src/Upmatches.Application/Upmatches.Application.csproj reference src/Upmatches.Domain/Upmatches.Domain.csproj
216 - dotnet add src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj reference src/Upmatches.Application/Upmatches.Application.csproj
217 - dotnet add src/Upmatches.Api/Upmatches.Api.csproj reference src/Upmatches.Application/Upmatches.Application.csproj
218 - dotnet add src/Upmatches.Api/Upmatches.Api.csproj reference src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj
215 + dotnet add src/{Projects}.Application/{Projects}.Application.csproj reference src/{Projects}.Domain/{Projects}.Domain.csproj
216 + dotnet add src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj reference src/{Projects}.Application/{Projects}.Application.csproj
217 + dotnet add src/{Projects}.Api/{Projects}.Api.csproj reference src/{Projects}.Application/{Projects}.Application.csproj
218 + dotnet add src/{Projects}.Api/{Projects}.Api.csproj reference src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj
219 219 ```
220 220
221 221 ### Resulting `{ProjectName}.slnx`
@@ -225,15 +225,15 @@ The `dotnet new slnx` command creates the lightweight XML-based solution format
225 225 ```xml
226 226 <Solution>
227 227 <Folder Name="/src/">
228 - <Project Path="src/Upmatches.Api/Upmatches.Api.csproj"/>
229 - <Project Path="src/Upmatches.Application/Upmatches.Application.csproj"/>
230 - <Project Path="src/Upmatches.Domain/Upmatches.Domain.csproj"/>
231 - <Project Path="src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj"/>
228 + <Project Path="src/{Projects}.Api/{Projects}.Api.csproj"/>
229 + <Project Path="src/{Projects}.Application/{Projects}.Application.csproj"/>
230 + <Project Path="src/{Projects}.Domain/{Projects}.Domain.csproj"/>
231 + <Project Path="src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj"/>
232 232 </Folder>
233 233 <Folder Name="/tests/">
234 - <Project Path="tests/Upmatches.Application.Tests/Upmatches.Application.Tests.csproj"/>
235 - <Project Path="tests/Upmatches.Domain.Tests/Upmatches.Domain.Tests.csproj"/>
236 - <Project Path="tests/Upmatches.IntegrationTests/Upmatches.IntegrationTests.csproj"/>
234 + <Project Path="tests/{Projects}.Application.Tests/{Projects}.Application.Tests.csproj"/>
235 + <Project Path="tests/{Projects}.Domain.Tests/{Projects}.Domain.Tests.csproj"/>
236 + <Project Path="tests/{Projects}.IntegrationTests/{Projects}.IntegrationTests.csproj"/>
237 237 </Folder>
238 238 </Solution>
239 239 ```
@@ -467,7 +467,7 @@ Template for environment variables consumed by `compose.yml`. Developers copy th
467 467 # ──────────────────────────────────────────────
468 468 # Docker image
469 469 # ──────────────────────────────────────────────
470 - DOCKER_IMAGE=your-dockerhub-username/upmatches-api
470 + DOCKER_IMAGE=your-dockerhub-username/{projects}-api
471 471 IMAGE_TAG=latest
472 472
473 473 # ──────────────────────────────────────────────
@@ -479,7 +479,7 @@ API_PORT=5212
479 479 # ──────────────────────────────────────────────
480 480 # Database configuration
481 481 # ──────────────────────────────────────────────
482 - POSTGRES_DB=upmatches
482 + POSTGRES_DB={projects}
483 483 POSTGRES_USER=postgres
484 484 POSTGRES_PASSWORD=change-me-to-a-strong-password
485 485 DB_PORT=5432
@@ -487,7 +487,7 @@ DB_PORT=5432
487 487 # ──────────────────────────────────────────────
488 488 # Connection string (must match DB settings above)
489 489 # ──────────────────────────────────────────────
490 - CONNECTION_STRING=Host=db;Port=5432;Database=upmatches;Username=postgres;Password=change-me-to-a-strong-password
490 + CONNECTION_STRING=Host=db;Port=5432;Database={projects};Username=postgres;Password=change-me-to-a-strong-password
491 491 ```
492 492
493 493 ### Install Packages into Projects
@@ -496,50 +496,52 @@ With CPM, running `dotnet add package` registers the package in the `.csproj` (w
496 496
497 497 ```bash
498 498 # Application Layer
499 - dotnet add src/Upmatches.Application package MediatR
500 - dotnet add src/Upmatches.Application package FluentValidation
501 - dotnet add src/Upmatches.Application package FluentValidation.DependencyInjectionExtensions
502 - dotnet add src/Upmatches.Application package Microsoft.Extensions.Logging.Abstractions
499 + dotnet add src/{ProjectName}.Application package MediatR
500 + dotnet add src/{ProjectName}.Application package FluentValidation
501 + dotnet add src/{ProjectName}.Application package FluentValidation.DependencyInjectionExtensions
502 + dotnet add src/{ProjectName}.Application package Microsoft.Extensions.Logging.Abstractions
503 503
504 504 # Infrastructure Layer
505 - dotnet add src/Upmatches.Infrastructure package Microsoft.EntityFrameworkCore
506 - dotnet add src/Upmatches.Infrastructure package Microsoft.EntityFrameworkCore.Design
507 - dotnet add src/Upmatches.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
508 - dotnet add src/Upmatches.Infrastructure package Newtonsoft.Json
509 - dotnet add src/Upmatches.Infrastructure package Quartz.Extensions.Hosting
505 + dotnet add src/{ProjectName}.Infrastructure package Microsoft.EntityFrameworkCore
506 + dotnet add src/{ProjectName}.Infrastructure package Microsoft.EntityFrameworkCore.Design
507 + dotnet add src/{ProjectName}.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
508 + dotnet add src/{ProjectName}.Infrastructure package Newtonsoft.Json
509 + dotnet add src/{ProjectName}.Infrastructure package Quartz.Extensions.Hosting
510 510
511 511 # API Layer
512 - dotnet add src/Upmatches.Api package Serilog.AspNetCore
513 - dotnet add src/Upmatches.Api package Serilog.Enrichers.Environment
514 - dotnet add src/Upmatches.Api package Serilog.Enrichers.Thread
515 - dotnet add src/Upmatches.Api package AspNetCore.HealthChecks.NpgSql
516 - dotnet add src/Upmatches.Api package Microsoft.AspNetCore.OpenApi
517 - dotnet add src/Upmatches.Api package Microsoft.EntityFrameworkCore.Tools
518 - dotnet add src/Upmatches.Api package Asp.Versioning.Mvc
519 - dotnet add src/Upmatches.Api package Asp.Versioning.Mvc.ApiExplorer
520 -
521 - # Test Projects (same packages for all test projects)
522 - dotnet add tests/Upmatches.Domain.Tests package Microsoft.NET.Test.Sdk
523 - dotnet add tests/Upmatches.Domain.Tests package xunit
524 - dotnet add tests/Upmatches.Domain.Tests package xunit.runner.visualstudio
525 - dotnet add tests/Upmatches.Domain.Tests package coverlet.collector
526 - dotnet add tests/Upmatches.Domain.Tests package FluentAssertions
527 - dotnet add tests/Upmatches.Domain.Tests package Moq
528 -
529 - dotnet add tests/Upmatches.Application.Tests package Microsoft.NET.Test.Sdk
530 - dotnet add tests/Upmatches.Application.Tests package xunit
531 - dotnet add tests/Upmatches.Application.Tests package xunit.runner.visualstudio
532 - dotnet add tests/Upmatches.Application.Tests package coverlet.collector
533 - dotnet add tests/Upmatches.Application.Tests package FluentAssertions
534 - dotnet add tests/Upmatches.Application.Tests package Moq
535 -
536 - dotnet add tests/Upmatches.IntegrationTests package Microsoft.NET.Test.Sdk
537 - dotnet add tests/Upmatches.IntegrationTests package xunit
538 - dotnet add tests/Upmatches.IntegrationTests package xunit.runner.visualstudio
539 - dotnet add tests/Upmatches.IntegrationTests package coverlet.collector
540 - dotnet add tests/Upmatches.IntegrationTests package FluentAssertions
541 - dotnet add tests/Upmatches.IntegrationTests package Moq
542 - dotnet add tests/Upmatches.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing
512 + dotnet add src/{ProjectName}.Api package Serilog.AspNetCore
513 + dotnet add src/{ProjectName}.Api package Serilog.Enrichers.Environment
514 + dotnet add src/{ProjectName}.Api package Serilog.Enrichers.Thread
515 + dotnet add src/{ProjectName}.Api package AspNetCore.HealthChecks.NpgSql
516 + dotnet add src/{ProjectName}.Api package Microsoft.AspNetCore.OpenApi
517 + dotnet add src/{ProjectName}.Api package Microsoft.EntityFrameworkCore.Tools
518 + dotnet add src/{ProjectName}.Api package Asp.Versioning.Mvc
519 + dotnet add src/{ProjectName}.Api package Asp.Versioning.Mvc.ApiExplorer
520 +
521 + # Test Projects (Domain)
522 + dotnet add tests/{ProjectName}.Domain.Tests package Microsoft.NET.Test.Sdk
523 + dotnet add tests/{ProjectName}.Domain.Tests package xunit
524 + dotnet add tests/{ProjectName}.Domain.Tests package xunit.runner.visualstudio
525 + dotnet add tests/{ProjectName}.Domain.Tests package coverlet.collector
526 + dotnet add tests/{ProjectName}.Domain.Tests package FluentAssertions
527 + dotnet add tests/{ProjectName}.Domain.Tests package Moq
528 +
529 + # Test Projects (Application)
530 + dotnet add tests/{ProjectName}.Application.Tests package Microsoft.NET.Test.Sdk
531 + dotnet add tests/{ProjectName}.Application.Tests package xunit
532 + dotnet add tests/{ProjectName}.Application.Tests package xunit.runner.visualstudio
533 + dotnet add tests/{ProjectName}.Application.Tests package coverlet.collector
534 + dotnet add tests/{ProjectName}.Application.Tests package FluentAssertions
535 + dotnet add tests/{ProjectName}.Application.Tests package Moq
536 +
537 + # Test Projects (Integration)
538 + dotnet add tests/{ProjectName}.IntegrationTests package Microsoft.NET.Test.Sdk
539 + dotnet add tests/{ProjectName}.IntegrationTests package xunit
540 + dotnet add tests/{ProjectName}.IntegrationTests package xunit.runner.visualstudio
541 + dotnet add tests/{ProjectName}.IntegrationTests package coverlet.collector
542 + dotnet add tests/{ProjectName}.IntegrationTests package FluentAssertions
543 + dotnet add tests/{ProjectName}.IntegrationTests package Moq
544 + dotnet add tests/{ProjectName}.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing
543 545 ```
544 546
545 547 ---
@@ -568,7 +570,7 @@ References Domain and adds MediatR, FluentValidation, and logging abstractions.
568 570 <Project Sdk="Microsoft.NET.Sdk">
569 571
570 572 <ItemGroup>
571 - <ProjectReference Include="..\Upmatches.Domain\Upmatches.Domain.csproj"/>
573 + <ProjectReference Include="..\{Projects}.Domain\{Projects}.Domain.csproj"/>
572 574 </ItemGroup>
573 575
574 576 <ItemGroup>
@@ -589,7 +591,7 @@ References Application and adds EF Core with PostgreSQL, Newtonsoft.Json, and Qu
589 591 <Project Sdk="Microsoft.NET.Sdk">
590 592
591 593 <ItemGroup>
592 - <ProjectReference Include="..\Upmatches.Application\Upmatches.Application.csproj"/>
594 + <ProjectReference Include="..\{Projects}.Application\{Projects}.Application.csproj"/>
593 595 </ItemGroup>
594 596
595 597 <ItemGroup>
@@ -630,8 +632,8 @@ The web application project. Uses `Microsoft.NET.Sdk.Web` (not `Microsoft.NET.Sd
630 632 </ItemGroup>
631 633
632 634 <ItemGroup>
633 - <ProjectReference Include="..\Upmatches.Application\Upmatches.Application.csproj"/>
634 - <ProjectReference Include="..\Upmatches.Infrastructure\Upmatches.Infrastructure.csproj"/>
635 + <ProjectReference Include="..\{Projects}.Application\{Projects}.Application.csproj"/>
636 + <ProjectReference Include="..\{Projects}.Infrastructure\{Projects}.Infrastructure.csproj"/>
635 637 </ItemGroup>
636 638
637 639 </Project>
@@ -663,7 +665,7 @@ All test projects share the same structure: `IsPackable` set to `false` (prevent
663 665 </ItemGroup>
664 666
665 667 <ItemGroup>
666 - <ProjectReference Include="..\..\src\Upmatches.Domain\Upmatches.Domain.csproj"/>
668 + <ProjectReference Include="..\..\src\{Projects}.Domain\{Projects}.Domain.csproj"/>
667 669 </ItemGroup>
668 670
669 671 </Project>
@@ -691,7 +693,7 @@ All test projects share the same structure: `IsPackable` set to `false` (prevent
691 693 </ItemGroup>
692 694
693 695 <ItemGroup>
694 - <ProjectReference Include="..\..\src\Upmatches.Application\Upmatches.Application.csproj"/>
696 + <ProjectReference Include="..\..\src\{Projects}.Application\{Projects}.Application.csproj"/>
695 697 </ItemGroup>
696 698
697 699 </Project>
@@ -723,7 +725,7 @@ Integration tests reference the API project (which transitively brings in everyt
723 725 </ItemGroup>
724 726
725 727 <ItemGroup>
726 - <ProjectReference Include="..\..\src\Upmatches.Api\Upmatches.Api.csproj"/>
728 + <ProjectReference Include="..\..\src\{Projects}.Api\{Projects}.Api.csproj"/>
727 729 </ItemGroup>
728 730
729 731 </Project>
@@ -740,8 +742,8 @@ Sets up the API and a PostgreSQL database. All values are configurable via `.env
740 742 ```yaml
741 743 services:
742 744 api:
743 - container_name: upmatches-api
744 - image: ${DOCKER_IMAGE:-upmatches-api}:${IMAGE_TAG:-latest}
745 + container_name: {projects}-api
746 + image: ${DOCKER_IMAGE:-{projects}-api}:${IMAGE_TAG:-latest}
745 747 build:
746 748 context: .
747 749 dockerfile: Dockerfile
@@ -749,18 +751,18 @@ services:
749 751 - "${API_PORT:-5212}:8080"
750 752 environment:
751 753 - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
752 - - ConnectionStrings__DefaultConnection=${CONNECTION_STRING:-Host=db;Port=5432;Database=upmatches_dev;Username=postgres;Password=postgres}
754 + - ConnectionStrings__DefaultConnection=${CONNECTION_STRING:-Host=db;Port=5432;Database={projects}_dev;Username=postgres;Password=postgres}
753 755 depends_on:
754 756 db:
755 757 condition: service_healthy
756 758
757 759 db:
758 - container_name: upmatches-db
760 + container_name: {projects}-db
759 761 image: postgres:17-alpine
760 762 ports:
761 763 - "${DB_PORT:-5432}:5432"
762 764 environment:
763 - POSTGRES_DB: ${POSTGRES_DB:-upmatches_dev}
765 + POSTGRES_DB: ${POSTGRES_DB:-{projects}_dev}
764 766 POSTGRES_USER: ${POSTGRES_USER:-postgres}
765 767 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
766 768 volumes:
@@ -792,24 +794,24 @@ COPY global.json .
792 794 COPY nuget.config .
793 795 COPY Directory.Build.props .
794 796 COPY Directory.Packages.props .
795 - COPY src/Upmatches.Api/Upmatches.Api.csproj src/Upmatches.Api/
796 - COPY src/Upmatches.Application/Upmatches.Application.csproj src/Upmatches.Application/
797 - COPY src/Upmatches.Domain/Upmatches.Domain.csproj src/Upmatches.Domain/
798 - COPY src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj src/Upmatches.Infrastructure/
797 + COPY src/{Projects}.Api/{Projects}.Api.csproj src/{Projects}.Api/
798 + COPY src/{Projects}.Application/{Projects}.Application.csproj src/{Projects}.Application/
799 + COPY src/{Projects}.Domain/{Projects}.Domain.csproj src/{Projects}.Domain/
800 + COPY src/{Projects}.Infrastructure/{Projects}.Infrastructure.csproj src/{Projects}.Infrastructure/
799 801
800 - RUN dotnet restore src/Upmatches.Api/Upmatches.Api.csproj
802 + RUN dotnet restore src/{Projects}.Api/{Projects}.Api.csproj
801 803
802 804 COPY . .
803 - RUN dotnet build src/Upmatches.Api -c $BUILD_CONFIGURATION --no-restore
805 + RUN dotnet build src/{Projects}.Api -c $BUILD_CONFIGURATION --no-restore
804 806
805 807 FROM build AS publish
806 808 ARG BUILD_CONFIGURATION=Release
807 - RUN dotnet publish src/Upmatches.Api -c $BUILD_CONFIGURATION --no-build -o /app/publish /p:UseAppHost=false
809 + RUN dotnet publish src/{Projects}.Api -c $BUILD_CONFIGURATION --no-build -o /app/publish /p:UseAppHost=false
808 810
809 811 FROM base AS final
810 812 WORKDIR /app
811 813 COPY --from=publish /app/publish .
812 - ENTRYPOINT ["dotnet", "Upmatches.Api.dll"]
814 + ENTRYPOINT ["dotnet", "{Projects}.Api.dll"]
813 815 ```
814 816
815 817 ### `src/{ProjectName}.Api/Properties/launchSettings.json`
@@ -858,7 +860,7 @@ Configures how `dotnet run` launches the API locally. Two profiles are defined:
858 860 All entities inherit from this. It provides a GUID primary key and a domain events collection. Domain events are raised by entities during business operations and dispatched after `SaveChanges` by the `DomainEventInterceptor` in the Infrastructure layer.
859 861
860 862 ```csharp
861 - namespace Upmatches.Domain.Common;
863 + namespace {Projects}.Domain.Common;
862 864
863 865 public abstract class BaseEntity
864 866 {
@@ -877,7 +879,7 @@ public abstract class BaseEntity
877 879 Extends `BaseEntity` with `CreatedAt` and `UpdatedAt` timestamps. The setters are accessed by the `AuditableEntityInterceptor` — entities themselves don't need to worry about setting timestamps.
878 880
879 881 ```csharp
880 - namespace Upmatches.Domain.Common;
882 + namespace {Projects}.Domain.Common;
881 883
882 884 public abstract class AuditableEntity : BaseEntity
883 885 {
@@ -894,7 +896,7 @@ public abstract class AuditableEntity : BaseEntity
894 896 Marker interface for domain events. Events carry a timestamp so consumers know when the event occurred.
895 897
896 898 ```csharp
897 - namespace Upmatches.Domain.Common;
899 + namespace {Projects}.Domain.Common;
898 900
899 901 public interface IDomainEvent
900 902 {
@@ -907,7 +909,7 @@ public interface IDomainEvent
907 909 Base class for value objects (DDD concept). Value objects are compared by their component values, not by identity. Two `Money(100, "USD")` instances are equal regardless of reference identity.
908 910
909 911 ```csharp
910 - namespace Upmatches.Domain.Common;
912 + namespace {Projects}.Domain.Common;
911 913
912 914 public abstract class ValueObject : IEquatable<ValueObject>
913 915 {
@@ -936,7 +938,7 @@ public abstract class ValueObject : IEquatable<ValueObject>
936 938 Defines the `Error` record and `ErrorType` enum used throughout the Result pattern. Pre-defined static errors cover the most common failure cases. Domain-specific errors are defined alongside their entities (e.g., `User.Errors.EmailTaken`).
937 939
938 940 ```csharp
939 - namespace Upmatches.Domain.Common;
941 + namespace {Projects}.Domain.Common;
940 942
941 943 public enum ErrorType { None = 0, Failure = 1, Validation = 2, NotFound = 3, Conflict = 4 }
942 944
@@ -958,7 +960,7 @@ The Result pattern implementation. Key design points:
958 960 - `Result<T>` provides an implicit conversion from `T` for ergonomic returns.
959 961
960 962 ```csharp
961 - namespace Upmatches.Domain.Common;
963 + namespace {Projects}.Domain.Common;
962 964
963 965 /// <summary>
964 966 /// Defines a contract for creating a failure result of a specific type.
@@ -1044,7 +1046,7 @@ public class Result<T> : Result, IValidationResult
1044 1046 Abstracts the "save all pending changes" operation. In the Infrastructure layer, `ApplicationDbContext` implements this interface directly.
1045 1047
1046 1048 ```csharp
1047 - namespace Upmatches.Domain.Abstractions;
1049 + namespace {Projects}.Domain.Abstractions;
1048 1050
1049 1051 public interface IUnitOfWork
1050 1052 {
@@ -1070,9 +1072,9 @@ These interfaces wrap MediatR's `IRequest` and `IRequestHandler` to enforce that
1070 1072 **`ICommand.cs`**
1071 1073 ```csharp
1072 1074 using MediatR;
1073 - using Upmatches.Domain.Common;
1075 + using {Projects}.Domain.Common;
1074 1076
1075 - namespace Upmatches.Application.Abstractions.Messaging;
1077 + namespace {Projects}.Application.Abstractions.Messaging;
1076 1078
1077 1079 /// <summary>
1078 1080 /// Marker interface for commands that do not return a value.
@@ -1088,9 +1090,9 @@ public interface ICommand<TResponse> : IRequest<Result<TResponse>>;
1088 1090 **`ICommandHandler.cs`**
1089 1091 ```csharp
1090 1092 using MediatR;
1091 - using Upmatches.Domain.Common;
1093 + using {Projects}.Domain.Common;
1092 1094
1093 - namespace Upmatches.Application.Abstractions.Messaging;
1095 + namespace {Projects}.Application.Abstractions.Messaging;
1094 1096
1095 1097 /// <summary>
1096 1098 /// Handler for commands that do not return a value.
@@ -1108,9 +1110,9 @@ public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TComm
1108 1110 **`IQuery.cs`**
1109 1111 ```csharp
1110 1112 using MediatR;
1111 - using Upmatches.Domain.Common;
1113 + using {Projects}.Domain.Common;
1112 1114
1113 - namespace Upmatches.Application.Abstractions.Messaging;
1115 + namespace {Projects}.Application.Abstractions.Messaging;
1114 1116
1115 1117 /// <summary>
1116 1118 /// Marker interface for queries that return a value wrapped in Result.
@@ -1121,9 +1123,9 @@ public interface IQuery<TResponse> : IRequest<Result<TResponse>>;
1121 1123 **`IQueryHandler.cs`**
1122 1124 ```csharp
1123 1125 using MediatR;
1124 - using Upmatches.Domain.Common;
1126 + using {Projects}.Domain.Common;
1125 1127
1126 - namespace Upmatches.Application.Abstractions.Messaging;
1128 + namespace {Projects}.Application.Abstractions.Messaging;
1127 1129
1128 1130 /// <summary>
1129 1131 /// Handler for queries that return a value wrapped in Result.
@@ -1138,9 +1140,9 @@ Bridges domain events to MediatR's notification pipeline. `DomainEventNotificati
1138 1140
1139 1141 ```csharp
1140 1142 using MediatR;
1141 - using Upmatches.Domain.Common;
1143 + using {Projects}.Domain.Common;
1142 1144
1143 - namespace Upmatches.Application.Abstractions;
1145 + namespace {Projects}.Application.Abstractions;
1144 1146
1145 1147 /// <summary>
1146 1148 /// Wraps a domain event as a MediatR notification so it can be published
@@ -1174,7 +1176,7 @@ using System.Diagnostics;
1174 1176 using MediatR;
1175 1177 using Microsoft.Extensions.Logging;
1176 1178
1177 - namespace Upmatches.Application.Behaviors;
1179 + namespace {Projects}.Application.Behaviors;
1178 1180
1179 1181 public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
1180 1182 {
@@ -1201,9 +1203,9 @@ The key innovation is the `where TResponse : Result, IValidationResult` constrai
1201 1203 using FluentValidation;
1202 1204 using MediatR;
1203 1205 using Microsoft.Extensions.Logging;
1204 - using Upmatches.Domain.Common;
1206 + using {Projects}.Domain.Common;
1205 1207
1206 - namespace Upmatches.Application.Behaviors;
1208 + namespace {Projects}.Application.Behaviors;
1207 1209
1208 1210 public sealed class ValidationBehavior<TRequest, TResponse>(
1209 1211 IEnumerable<IValidator<TRequest>> validators,
@@ -1260,9 +1262,9 @@ Registers MediatR (with pipeline behaviors in order) and FluentValidation valida
1260 1262 using FluentValidation;
1261 1263 using MediatR;
1262 1264 using Microsoft.Extensions.DependencyInjection;
1263 - using Upmatches.Application.Behaviors;
1265 + using {Projects}.Application.Behaviors;
1264 1266
1265 - namespace Upmatches.Application;
1267 + namespace {Projects}.Application;
1266 1268
1267 1269 public static class DependencyInjection
1268 1270 {
@@ -1336,7 +1338,7 @@ Use **static extension methods** for mapping between domain entities and respons
1336 1338
1337 1339 ```csharp
1338 1340 // Features/Items/Mappings/ItemMappings.cs
1339 - namespace Upmatches.Application.Features.Items.Mappings;
1341 + namespace {Projects}.Application.Features.Items.Mappings;
1340 1342
1341 1343 public static class ItemMappings
1342 1344 {
@@ -1405,9 +1407,9 @@ Automatically sets `CreatedAt` (on insert) and `UpdatedAt` (on update) for any e
1405 1407 ```csharp
1406 1408 using Microsoft.EntityFrameworkCore;
1407 1409 using Microsoft.EntityFrameworkCore.Diagnostics;
1408 - using Upmatches.Domain.Common;
1410 + using {Projects}.Domain.Common;
1409 1411
1410 - namespace Upmatches.Infrastructure.Persistence.Interceptors;
1412 + namespace {Projects}.Infrastructure.Persistence.Interceptors;
1411 1413
1412 1414 public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
1413 1415 {
@@ -1438,10 +1440,10 @@ Dispatches domain events **after** `SaveChanges` completes successfully. This en
1438 1440 using MediatR;
1439 1441 using Microsoft.EntityFrameworkCore;
1440 1442 using Microsoft.EntityFrameworkCore.Diagnostics;
1441 - using Upmatches.Application.Abstractions;
1442 - using Upmatches.Domain.Common;
1443 + using {Projects}.Application.Abstractions;
1444 + using {Projects}.Domain.Common;
1443 1445
1444 - namespace Upmatches.Infrastructure.Persistence.Interceptors;
1446 + namespace {Projects}.Infrastructure.Persistence.Interceptors;
1445 1447
1446 1448 public sealed class DomainEventInterceptor(IPublisher publisher) : SaveChangesInterceptor
1447 1449 {
@@ -1474,9 +1476,9 @@ The EF Core `DbContext`. It also implements `IUnitOfWork` — calling `SaveChang
1474 1476
1475 1477 ```csharp
1476 1478 using Microsoft.EntityFrameworkCore;
1477 - using Upmatches.Domain.Abstractions;
1479 + using {Projects}.Domain.Abstractions;
1478 1480
1479 - namespace Upmatches.Infrastructure.Persistence;
1481 + namespace {Projects}.Infrastructure.Persistence;
1480 1482
1481 1483 public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options), IUnitOfWork
1482 1484 {
@@ -1496,11 +1498,11 @@ Registers the interceptors, `DbContext` (with PostgreSQL and interceptors wired
1496 1498 using Microsoft.EntityFrameworkCore;
1497 1499 using Microsoft.Extensions.Configuration;
1498 1500 using Microsoft.Extensions.DependencyInjection;
1499 - using Upmatches.Domain.Abstractions;
1500 - using Upmatches.Infrastructure.Persistence;
1501 - using Upmatches.Infrastructure.Persistence.Interceptors;
1501 + using {Projects}.Domain.Abstractions;
1502 + using {Projects}.Infrastructure.Persistence;
1503 + using {Projects}.Infrastructure.Persistence.Interceptors;
1502 1504
1503 - namespace Upmatches.Infrastructure;
1505 + namespace {Projects}.Infrastructure;
1504 1506
1505 1507 public static class DependencyInjection
1506 1508 {
@@ -1579,7 +1581,7 @@ Development overrides. Lowers the minimum log level to `Debug` for richer output
1579 1581 ```json
1580 1582 {
1581 1583 "ConnectionStrings": {
1582 - "DefaultConnection": "Host=localhost;Port=5432;Database=upmatches_dev;Username=postgres;Password=postgres"
1584 + "DefaultConnection": "Host=localhost;Port=5432;Database={projects}_dev;Username=postgres;Password=postgres"
1583 1585 },
1584 1586 "Serilog": {
1585 1587 "MinimumLevel": {
@@ -1601,7 +1603,7 @@ Development overrides. Lowers the minimum log level to `Debug` for richer output
1601 1603 Strongly-typed options class for the request logging middleware. Bound from the `RequestLogging` section of `appsettings.json` via `IOptions<RequestLoggingOptions>`. All values have sensible defaults so the middleware works out of the box even without explicit configuration.
1602 1604
1603 1605 ```csharp
1604 - namespace Upmatches.Api.Configuration;
1606 + namespace {Projects}.Api.Configuration;
1605 1607
1606 1608 public sealed class RequestLoggingOptions
1607 1609 {
@@ -1691,9 +1693,9 @@ using System.Text.Json.Nodes;
1691 1693 using System.Text.RegularExpressions;
1692 1694 using Microsoft.Extensions.Logging;
1693 1695 using Microsoft.Extensions.Options;
1694 - using Upmatches.Api.Configuration;
1696 + using {Projects}.Api.Configuration;
1695 1697
1696 - namespace Upmatches.Api.Middleware;
1698 + namespace {Projects}.Api.Middleware;
1697 1699
1698 1700 /// <summary>
1699 1701 /// Redacts sensitive field values from content before it is written to logs.
@@ -1857,9 +1859,9 @@ using System.Text;
1857 1859 using System.Text.RegularExpressions;
1858 1860 using Microsoft.Extensions.Options;
1859 1861 using Serilog.Context;
1860 - using Upmatches.Api.Configuration;
1862 + using {Projects}.Api.Configuration;
1861 1863
1862 - namespace Upmatches.Api.Middleware;
1864 + namespace {Projects}.Api.Middleware;
1863 1865
1864 1866 /// <summary>
1865 1867 /// Middleware that provides comprehensive HTTP request/response logging including:
@@ -2308,7 +2310,7 @@ using System.Net;
2308 2310 using Microsoft.AspNetCore.Diagnostics;
2309 2311 using Microsoft.AspNetCore.Mvc;
2310 2312
2311 - namespace Upmatches.Api.Middleware;
2313 + namespace {Projects}.Api.Middleware;
2312 2314
2313 2315 public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
2314 2316 {
@@ -2346,12 +2348,12 @@ The composition root's service registration. Wires together:
2346 2348 ```csharp
2347 2349 using Asp.Versioning;
2348 2350 using Serilog;
2349 - using Upmatches.Api.Configuration;
2350 - using Upmatches.Api.Middleware;
2351 - using Upmatches.Application;
2352 - using Upmatches.Infrastructure;
2351 + using {Projects}.Api.Configuration;
2352 + using {Projects}.Api.Middleware;
2353 + using {Projects}.Application;
2354 + using {Projects}.Infrastructure;
2353 2355
2354 - namespace Upmatches.Api.Extensions;
2356 + namespace {Projects}.Api.Extensions;
2355 2357
2356 2358 public static class ServiceCollectionExtensions
2357 2359 {
@@ -2409,9 +2411,9 @@ Configures the HTTP request pipeline (middleware order matters):
2409 2411 7. Health check endpoint
2410 2412
2411 2413 ```csharp
2412 - using Upmatches.Api.Middleware;
2414 + using {Projects}.Api.Middleware;
2413 2415
2414 - namespace Upmatches.Api.Extensions;
2416 + namespace {Projects}.Api.Extensions;
2415 2417
2416 2418 public static class WebApplicationExtensions
2417 2419 {
@@ -2440,7 +2442,7 @@ The `public partial class Program;` declaration at the end enables `WebApplicati
2440 2442
2441 2443 ```csharp
2442 2444 using Serilog;
2443 - using Upmatches.Api.Extensions;
2445 + using {Projects}.Api.Extensions;
2444 2446
2445 2447 var builder = WebApplication.CreateBuilder(args);
2446 2448
@@ -2452,7 +2454,7 @@ app.ConfigurePipeline();
2452 2454
2453 2455 try
2454 2456 {
2455 - Log.Information("Starting Upmatches API in {Environment} environment", app.Environment.EnvironmentName);
2457 + Log.Information("Starting {Projects} API in {Environment} environment", app.Environment.EnvironmentName);
2456 2458 app.Run();
2457 2459 }
2458 2460 catch (Exception ex)
@@ -2535,7 +2537,7 @@ ide: QDNET
2535 2537
2536 2538 #Specify the .NET solution to analyze
2537 2539 dotnet:
2538 - solution: Upmatches.slnx
2540 + solution: {Projects}.slnx
2539 2541
2540 2542 #Specify inspection profile for code analysis
2541 2543 profile:
@@ -2587,7 +2589,7 @@ on:
2587 2589 env:
2588 2590 DOTNET_VERSION: "10.0.x"
2589 2591 JAVA_VERSION: "17"
2590 - DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/upmatches-api
2592 + DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/{projects}-api
2591 2593 ```
2592 2594
2593 2595 #### Job 1: Build & Test
@@ -2607,7 +2609,7 @@ jobs:
2607 2609 postgres:
2608 2610 image: postgres:17-alpine
2609 2611 env:
2610 - POSTGRES_DB: upmatches_test
2612 + POSTGRES_DB: {projects}_test
2611 2613 POSTGRES_USER: postgres
2612 2614 POSTGRES_PASSWORD: postgres
2613 2615 ports:
@@ -2642,7 +2644,7 @@ jobs:
2642 2644 --collect:"XPlat Code Coverage"
2643 2645 --results-directory ./TestResults
2644 2646 env:
2645 - ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=upmatches_test;Username=postgres;Password=postgres"
2647 + ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database={projects}_test;Username=postgres;Password=postgres"
2646 2648
2647 2649 - name: Upload test results
2648 2650 uses: actions/upload-artifact@v4
@@ -2670,7 +2672,7 @@ Runs after tests pass. Performs static analysis and code coverage reporting via
2670 2672 postgres:
2671 2673 image: postgres:17-alpine
2672 2674 env:
2673 - POSTGRES_DB: upmatches_test
2675 + POSTGRES_DB: {projects}_test
2674 2676 POSTGRES_USER: postgres
2675 2677 POSTGRES_PASSWORD: postgres
2676 2678 ports:
@@ -2725,7 +2727,7 @@ Runs after tests pass. Performs static analysis and code coverage reporting via
2725 2727 --collect:"XPlat Code Coverage;Format=opencover"
2726 2728 --results-directory ./TestResults
2727 2729 env:
2728 - ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=upmatches_test;Username=postgres;Password=postgres"
2730 + ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database={projects}_test;Username=postgres;Password=postgres"
2729 2731
2730 2732 - name: End SonarCloud analysis
2731 2733 env:
@@ -2910,7 +2912,7 @@ This file is automatically picked up by Copilot in VS Code and GitHub.com, provi
2910 2912 docker compose up db -d
2911 2913
2912 2914 # Run the API
2913 - dotnet run --project src/Upmatches.Api
2915 + dotnet run --project src/{Projects}.Api
2914 2916
2915 2917 # API available at http://localhost:5212
2916 2918 # Health check: http://localhost:5212/health
@@ -2934,9 +2936,9 @@ docker compose up --build -d
2934 2936 dotnet test
2935 2937
2936 2938 # Specific test project
2937 - dotnet test tests/Upmatches.Domain.Tests
2938 - dotnet test tests/Upmatches.Application.Tests
2939 - dotnet test tests/Upmatches.IntegrationTests
2939 + dotnet test tests/{Projects}.Domain.Tests
2940 + dotnet test tests/{Projects}.Application.Tests
2941 + dotnet test tests/{Projects}.IntegrationTests
2940 2942
2941 2943 # Single test by name
2942 2944 dotnet test --filter "FullyQualifiedName~MyTestMethod"
@@ -2947,13 +2949,13 @@ dotnet test --filter "FullyQualifiedName~MyTestMethod"
2947 2949 ```bash
2948 2950 # Add a new migration
2949 2951 dotnet ef migrations add <MigrationName> \
2950 - --project src/Upmatches.Infrastructure \
2951 - --startup-project src/Upmatches.Api
2952 + --project src/{Projects}.Infrastructure \
2953 + --startup-project src/{Projects}.Api
2952 2954
2953 2955 # Apply migrations
2954 2956 dotnet ef database update \
2955 - --project src/Upmatches.Infrastructure \
2956 - --startup-project src/Upmatches.Api
2957 + --project src/{Projects}.Infrastructure \
2958 + --startup-project src/{Projects}.Api
2957 2959 ```
2958 2960
2959 2961 ---
@@ -3060,4 +3062,4 @@ public sealed class ProcessOutboxMessagesJob(
3060 3062 - You need at-least-once delivery guarantees
3061 3063 - You're in a microservices architecture where services communicate via events
3062 3064
3063 - For purely in-process event handling (the common case in a monolith), the existing `DomainEventInterceptor` approach is simpler and sufficient.
3065 + For purely in-process event handling (the common case in a monolith), the existing `DomainEventInterceptor` approach is simpler and sufficient.

weehong 已修改 1 month ago. 還原成這個修訂版本

1 file changed, 3063 insertions

dotnet-10-clean-architecture-boilerplate-guide.md(檔案已創建)

@@ -0,0 +1,3063 @@
1 + # The Ultimate .NET 10 Clean Architecture Boilerplate: Step-by-Step Implementation Guide
2 +
3 + This guide is an exhaustive, detail-oriented manual for recreating this .NET 10 Clean Architecture and CQRS boilerplate from scratch. It documents every file in the repository — from root-level configuration to Docker setups, middleware, and test scaffolding — so you can understand not just *what* each file does, but *why* it exists.
4 +
5 + ## How to Use This Guide
6 +
7 + - **Prose** uses `{ProjectName}` as a placeholder. Replace it with your actual project name (e.g., `Upmatches`, `Inventory`, `Payments`).
8 + - **Code blocks** use `Upmatches` as the concrete example — the actual project this guide was written for.
9 + - Sections are ordered by dependency: set up root config first, then work inward from Domain → Application → Infrastructure → API.
10 +
11 + ---
12 +
13 + ## Table of Contents
14 +
15 + 1. [Architecture Overview](#1-architecture-overview)
16 + 2. [Design Decisions & Trade-offs](#2-design-decisions--trade-offs)
17 + 3. [Prerequisites](#3-prerequisites)
18 + 4. [Solution & Project Scaffolding](#4-solution--project-scaffolding)
19 + 5. [Root Configuration Files](#5-root-configuration-files)
20 + 6. [Project Files (.csproj)](#6-project-files-csproj)
21 + 7. [Docker & Dev Environment](#7-docker--dev-environment)
22 + 8. [Layer 1: Domain](#8-layer-1-domain)
23 + 9. [Layer 2: Application](#9-layer-2-application)
24 + 10. [Layer 3: Infrastructure](#10-layer-3-infrastructure)
25 + 11. [Layer 4: API / Presentation](#11-layer-4-api--presentation)
26 + 12. [Test Projects](#12-test-projects)
27 + 13. [CI/CD Pipeline](#13-cicd-pipeline)
28 + 14. [AI-Assisted Development](#14-ai-assisted-development)
29 + 15. [Running the Project](#15-running-the-project)
30 +
31 + ---
32 +
33 + ## 1. Architecture Overview
34 +
35 + ### Clean Architecture
36 +
37 + Clean Architecture organises code into concentric layers where dependencies **always point inward**. The innermost layer (Domain) has zero external dependencies; each outer layer may only reference layers closer to the core.
38 +
39 + ```
40 + ┌─────────────────────────────────────────────────────────────┐
41 + │ API / Presentation │
42 + │ Controllers · Middleware · Serilog · OpenAPI · Program.cs │
43 + │ │
44 + │ ┌─────────────────────────────────────────────────────┐ │
45 + │ │ Infrastructure │ │
46 + │ │ EF Core DbContext · Repositories · Interceptors │ │
47 + │ │ │ │
48 + │ │ ┌─────────────────────────────────────────────┐ │ │
49 + │ │ │ Application │ │ │
50 + │ │ │ Commands · Queries · Handlers · Behaviors │ │ │
51 + │ │ │ Validators · Mapping · DI Registration │ │ │
52 + │ │ │ │ │ │
53 + │ │ │ ┌──────────────────────────────────────┐ │ │ │
54 + │ │ │ │ Domain │ │ │ │
55 + │ │ │ │ Entities · Value Objects · Events │ │ │ │
56 + │ │ │ │ Result · Error · Abstractions │ │ │ │
57 + │ │ │ │ (ZERO external dependencies) │ │ │ │
58 + │ │ │ └──────────────────────────────────────┘ │ │ │
59 + │ │ └─────────────────────────────────────────────┘ │ │
60 + │ └─────────────────────────────────────────────────────┘ │
61 + └─────────────────────────────────────────────────────────────┘
62 + ```
63 +
64 + **The Dependency Rule:** Source-code dependencies must point inward. Nothing in an inner circle can reference anything in an outer circle. Concretely:
65 +
66 + - **Domain** references nothing.
67 + - **Application** references Domain only.
68 + - **Infrastructure** references Application (and transitively, Domain).
69 + - **API** references Application and Infrastructure.
70 +
71 + This means the Domain and Application layers are fully testable without a database, web server, or any framework.
72 +
73 + ### Project Dependency Graph
74 +
75 + ```
76 + {ProjectName}.Api
77 + ├── {ProjectName}.Application
78 + │ └── {ProjectName}.Domain (zero NuGet deps)
79 + └── {ProjectName}.Infrastructure
80 + └── {ProjectName}.Application
81 + └── {ProjectName}.Domain
82 +
83 + {ProjectName}.Domain.Tests
84 + └── {ProjectName}.Domain
85 +
86 + {ProjectName}.Application.Tests
87 + └── {ProjectName}.Application
88 +
89 + {ProjectName}.IntegrationTests
90 + └── {ProjectName}.Api
91 + ```
92 +
93 + ### CQRS (Command Query Responsibility Segregation)
94 +
95 + CQRS separates read operations (Queries) from write operations (Commands). Each operation is a standalone class that carries all the data it needs, and each has a dedicated handler. This gives you:
96 +
97 + - **Single Responsibility** — every handler does exactly one thing.
98 + - **Explicit contracts** — the request shape *is* the documentation.
99 + - **Pipeline behaviors** — cross-cutting concerns (logging, validation) are applied uniformly via MediatR's pipeline, not scattered through service classes.
100 +
101 + In this boilerplate, CQRS is implemented through MediatR:
102 +
103 + ```
104 + Controller Handler
105 + │ ▲
106 + │ IMediator.Send(command) │
107 + ▼ │
108 + ┌──────────────────────────────────────────────────┐
109 + │ MediatR Pipeline │
110 + │ │
111 + │ ┌─────────────────┐ ┌──────────────────────┐ │
112 + │ │ LoggingBehavior │──▶│ ValidationBehavior │──┼──▶ Handler
113 + │ └─────────────────┘ └──────────────────────┘ │
114 + └──────────────────────────────────────────────────┘
115 + ```
116 +
117 + Controllers never call services directly. They send a command or query to `IMediator`, which routes it through the pipeline behaviors and into the correct handler.
118 +
119 + ### The Result Pattern
120 +
121 + Instead of throwing exceptions for expected failures (validation errors, not-found, conflicts), every handler returns a `Result` or `Result<T>`. This makes the success/failure path explicit in the type system:
122 +
123 + ```csharp
124 + // Handler returns Result<Guid>, not just Guid
125 + public async Task<Result<Guid>> Handle(CreateMatchCommand request, CancellationToken ct)
126 + {
127 + // Failure path — no exception thrown
128 + if (exists) return Result.Failure<Guid>(Error.Conflict);
129 +
130 + // Success path
131 + return Result.Success(match.Id);
132 + }
133 + ```
134 +
135 + The `IValidationResult` interface with a `static abstract` method enables the `ValidationBehavior` to create typed failure results without reflection — a zero-reflection, high-performance validation pipeline.
136 +
137 + ### MediatR Pipeline Behaviors
138 +
139 + Pipeline behaviors are middleware that wrap every MediatR request. They execute in registration order, forming a chain:
140 +
141 + 1. **LoggingBehavior** — logs the request name and execution time.
142 + 2. **ValidationBehavior** — runs all FluentValidation validators for the request. If any fail, it short-circuits and returns a `Result.Failure` without ever reaching the handler.
143 +
144 + You can add more behaviors (e.g., authorization, caching, transaction management) by registering additional `IPipelineBehavior<,>` implementations.
145 +
146 + ---
147 +
148 + ## 2. Design Decisions & Trade-offs
149 +
150 + | Decision | Why | Alternatives Considered |
151 + |---|---|---|
152 + | **Central Package Management (CPM)** | Single `Directory.Packages.props` controls all NuGet versions — no version drift between projects | Per-project `<PackageVersion>` attributes |
153 + | **Result pattern over exceptions** | Makes success/failure explicit in return types; eliminates try/catch ceremony in callers; no stack-trace overhead for expected failures | Throwing domain exceptions; `OneOf<T>` discriminated unions |
154 + | **MediatR for CQRS** | Decouples controllers from handlers; pipeline behaviors give free cross-cutting concerns; widely adopted in .NET ecosystem | Hand-rolled mediator; direct service injection; Wolverine |
155 + | **`static abstract` on IValidationResult** | Enables `ValidationBehavior` to create typed `Result.Failure` without reflection or `Activator.CreateInstance` | Reflection-based factory; generic constraints with `new()` |
156 + | **Manual mapping (extension methods)** | Zero magic, fully debuggable, no hidden runtime behavior; keeps mapping close to the feature that uses it | AutoMapper; Mapster |
157 + | **FluentValidation** | Declarative, composable rules; integrates cleanly with MediatR pipeline | Data Annotations; hand-rolled validation |
158 + | **Serilog** | Structured logging with rich sink ecosystem; configuration-driven via `appsettings.json` | Built-in `ILogger` with console provider; NLog; log4net |
159 + | **EF Core Interceptors** | Keeps audit (`CreatedAt`/`UpdatedAt`) and domain event dispatch out of the DbContext, making them composable and testable | Overriding `SaveChangesAsync` directly; domain event outbox pattern |
160 + | **API Versioning (URL path segment)** | Non-breaking evolution of APIs; URL path (`/api/v1/`) is the most explicit and cache-friendly strategy; `Asp.Versioning.Mvc` is the official Microsoft-maintained library | Query string versioning; header versioning; no versioning |
161 + | **C# 13 (via .NET 10)** | Latest language version: collection expressions, `static abstract` interfaces, primary constructors, file-scoped namespaces, etc. | Pinning an older `LangVersion` |
162 + | **`.slnx` (XML solution file)** | New lightweight format; cleaner diffs than `.sln`; created directly via `dotnet new slnx` | Traditional `.sln` |
163 + | **PostgreSQL** | Open-source, production-grade RDBMS; excellent JSON support; strong EF Core provider | SQL Server; SQLite (dev-only); MySQL |
164 + | **Quartz.NET** | Mature, cron-capable job scheduler for background tasks (email, cleanup, etc.) | Hangfire; `IHostedService` with `Timer`; custom `BackgroundService` |
165 + | **`compose.yml` (not `docker-compose.yml`)** | Docker Compose V2 standard; shorter name; `docker compose` CLI (no hyphen) | Legacy `docker-compose.yml` |
166 + | **Multi-stage Dockerfile** | Separates build and runtime images; final image contains only published output (~200 MB vs ~1.5 GB) | Single-stage build; publishing locally and copying artifacts |
167 + | **Correlation ID middleware** | Traces a request across services and log entries; accepts client-supplied IDs or generates new ones | W3C Trace Context; OpenTelemetry baggage (heavier) |
168 + | **Sensitive data redaction** | Prevents passwords, tokens, and PII from appearing in logs; JSON DOM + regex fallback handles truncated bodies | Manual redaction per log call; not logging bodies at all |
169 +
170 + ---
171 +
172 + ## 3. Prerequisites
173 +
174 + Ensure you have the following installed:
175 + - **.NET 10 SDK** (Version 10.0.103 or higher)
176 + - **Docker & Docker Compose** (for PostgreSQL and containerization)
177 + - **IDE**: Visual Studio, Rider, or VS Code
178 +
179 + ---
180 +
181 + ## 4. Solution & Project Scaffolding
182 +
183 + Create the root directory and initialize the solution and projects:
184 +
185 + ```bash
186 + mkdir {projectname}-api
187 + cd {projectname}-api
188 +
189 + # Create the solution (slnx = lightweight XML format, cleaner diffs than .sln)
190 + dotnet new slnx -n Upmatches
191 +
192 + # Create the source projects
193 + dotnet new webapi -n Upmatches.Api -o src/Upmatches.Api
194 + dotnet new classlib -n Upmatches.Application -o src/Upmatches.Application
195 + dotnet new classlib -n Upmatches.Domain -o src/Upmatches.Domain
196 + dotnet new classlib -n Upmatches.Infrastructure -o src/Upmatches.Infrastructure
197 +
198 + # Create the test projects
199 + dotnet new xunit -n Upmatches.Application.Tests -o tests/Upmatches.Application.Tests
200 + dotnet new xunit -n Upmatches.Domain.Tests -o tests/Upmatches.Domain.Tests
201 + dotnet new xunit -n Upmatches.IntegrationTests -o tests/Upmatches.IntegrationTests
202 +
203 + # Add source projects to the solution
204 + dotnet sln Upmatches.slnx add src/Upmatches.Api/Upmatches.Api.csproj --solution-folder src
205 + dotnet sln Upmatches.slnx add src/Upmatches.Application/Upmatches.Application.csproj --solution-folder src
206 + dotnet sln Upmatches.slnx add src/Upmatches.Domain/Upmatches.Domain.csproj --solution-folder src
207 + dotnet sln Upmatches.slnx add src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj --solution-folder src
208 +
209 + # Add test projects to the solution
210 + dotnet sln Upmatches.slnx add tests/Upmatches.Application.Tests/Upmatches.Application.Tests.csproj --solution-folder tests
211 + dotnet sln Upmatches.slnx add tests/Upmatches.Domain.Tests/Upmatches.Domain.Tests.csproj --solution-folder tests
212 + dotnet sln Upmatches.slnx add tests/Upmatches.IntegrationTests/Upmatches.IntegrationTests.csproj --solution-folder tests
213 +
214 + # Configure Clean Architecture Dependencies
215 + dotnet add src/Upmatches.Application/Upmatches.Application.csproj reference src/Upmatches.Domain/Upmatches.Domain.csproj
216 + dotnet add src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj reference src/Upmatches.Application/Upmatches.Application.csproj
217 + dotnet add src/Upmatches.Api/Upmatches.Api.csproj reference src/Upmatches.Application/Upmatches.Application.csproj
218 + dotnet add src/Upmatches.Api/Upmatches.Api.csproj reference src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj
219 + ```
220 +
221 + ### Resulting `{ProjectName}.slnx`
222 +
223 + The `dotnet new slnx` command creates the lightweight XML-based solution format directly — no migration from `.sln` needed. The resulting file is concise:
224 +
225 + ```xml
226 + <Solution>
227 + <Folder Name="/src/">
228 + <Project Path="src/Upmatches.Api/Upmatches.Api.csproj"/>
229 + <Project Path="src/Upmatches.Application/Upmatches.Application.csproj"/>
230 + <Project Path="src/Upmatches.Domain/Upmatches.Domain.csproj"/>
231 + <Project Path="src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj"/>
232 + </Folder>
233 + <Folder Name="/tests/">
234 + <Project Path="tests/Upmatches.Application.Tests/Upmatches.Application.Tests.csproj"/>
235 + <Project Path="tests/Upmatches.Domain.Tests/Upmatches.Domain.Tests.csproj"/>
236 + <Project Path="tests/Upmatches.IntegrationTests/Upmatches.IntegrationTests.csproj"/>
237 + </Folder>
238 + </Solution>
239 + ```
240 +
241 + ---
242 +
243 + ## 5. Root Configuration Files
244 +
245 + These files enforce consistency, lock SDK versions, and centrally manage NuGet packages across all projects. Create them at the repository root (next to the `.slnx` file).
246 +
247 + ### `global.json`
248 +
249 + Locks the .NET SDK version so every developer and CI runner uses the same toolchain. The `rollForward: latestFeature` policy allows patch updates within the 10.0.1xx band but prevents major/minor surprises.
250 +
251 + ```json
252 + {
253 + "sdk": {
254 + "rollForward": "latestFeature",
255 + "version": "10.0.103"
256 + }
257 + }
258 + ```
259 +
260 + ### `Directory.Build.props`
261 +
262 + MSBuild imports this file automatically into every `.csproj` in the repo tree. It sets the target framework, enables nullable reference types, implicit usings, and treats warnings as errors — so no project can accidentally diverge from these defaults.
263 +
264 + ```xml
265 + <Project>
266 + <PropertyGroup>
267 + <TargetFramework>net10.0</TargetFramework>
268 + <Nullable>enable</Nullable>
269 + <ImplicitUsings>enable</ImplicitUsings>
270 + <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
271 + </PropertyGroup>
272 + </Project>
273 + ```
274 +
275 + ### `Directory.Packages.props`
276 +
277 + Enables Central Package Management (CPM). Every NuGet package version is declared once here. Individual `.csproj` files reference packages by name only (no `Version` attribute). This eliminates version drift across projects and makes upgrades a single-file change.
278 +
279 + ```xml
280 + <Project>
281 +
282 + <PropertyGroup>
283 + <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
284 + </PropertyGroup>
285 +
286 + <ItemGroup>
287 + <!-- Web & API -->
288 + <PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1"/>
289 + <PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1"/>
290 + <PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0"/>
291 + <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3"/>
292 +
293 + <!-- Logging -->
294 + <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0"/>
295 + <PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1"/>
296 + <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0"/>
297 +
298 + <!-- Application -->
299 + <PackageVersion Include="FluentValidation" Version="12.1.1"/>
300 + <PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1"/>
301 + <PackageVersion Include="MediatR" Version="14.1.0"/>
302 + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3"/>
303 +
304 + <!-- Infrastructure -->
305 + <PackageVersion Include="Newtonsoft.Json" Version="13.0.3"/>
306 + <PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1"/>
307 +
308 + <!-- Entity Framework Core -->
309 + <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.3"/>
310 + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3"/>
311 + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3"/>
312 + <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
313 +
314 + <!-- Testing -->
315 + <PackageVersion Include="coverlet.collector" Version="6.0.4"/>
316 + <PackageVersion Include="FluentAssertions" Version="8.8.0"/>
317 + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3"/>
318 + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
319 + <PackageVersion Include="Moq" Version="4.20.72"/>
320 + <PackageVersion Include="xunit" Version="2.9.3"/>
321 + <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"/>
322 + </ItemGroup>
323 +
324 + </Project>
325 + ```
326 +
327 + ### `nuget.config`
328 +
329 + Explicitly clears any inherited package sources and sets the official NuGet feed as the only source. This prevents builds from silently pulling packages from unexpected feeds (e.g., a corporate proxy or local cache).
330 +
331 + ```xml
332 + <?xml version="1.0" encoding="utf-8"?>
333 + <configuration>
334 + <packageSources>
335 + <clear />
336 + <add key="nuget" value="https://api.nuget.org/v3/index.json" />
337 + </packageSources>
338 + </configuration>
339 + ```
340 +
341 + ### `.editorconfig`
342 +
343 + Enforces consistent code style across all editors and IDEs. The C# section is particularly important — it mandates file-scoped namespaces, `var` usage, and naming conventions (e.g., `_camelCase` for private fields, `I` prefix for interfaces). These rules integrate with Roslyn analyzers, so violations appear as warnings during build.
344 +
345 + ```editorconfig
346 + root = true
347 +
348 + # All files
349 + [*]
350 + indent_style = space
351 + indent_size = 4
352 + end_of_line = lf
353 + charset = utf-8
354 + trim_trailing_whitespace = true
355 + insert_final_newline = true
356 +
357 + # XML project files
358 + [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,props,targets}]
359 + indent_size = 2
360 +
361 + # XML files
362 + [*.{xml,config,nuspec,resx}]
363 + indent_size = 2
364 +
365 + # JSON files
366 + [*.json]
367 + indent_size = 2
368 +
369 + # YAML files
370 + [*.{yml,yaml}]
371 + indent_size = 2
372 +
373 + # Markdown files
374 + [*.md]
375 + trim_trailing_whitespace = false
376 +
377 + # C# files
378 + [*.cs]
379 +
380 + # Organize usings
381 + dotnet_sort_system_directives_first = true
382 + dotnet_separate_import_directive_groups = false
383 +
384 + # Namespace settings
385 + csharp_style_namespace_declarations = file_scoped:warning
386 +
387 + # var preferences
388 + csharp_style_var_for_built_in_types = true:suggestion
389 + csharp_style_var_when_type_is_apparent = true:suggestion
390 + csharp_style_var_elsewhere = true:suggestion
391 +
392 + # Expression-level preferences
393 + csharp_prefer_simple_using_statement = true:warning
394 + csharp_style_prefer_switch_expression = true:suggestion
395 + csharp_style_prefer_pattern_matching = true:suggestion
396 +
397 + # Null-checking preferences
398 + csharp_style_throw_expression = true:suggestion
399 + csharp_style_conditional_delegate_call = true:suggestion
400 +
401 + # New line preferences
402 + csharp_new_line_before_open_brace = all
403 + csharp_new_line_before_else = true
404 + csharp_new_line_before_catch = true
405 + csharp_new_line_before_finally = true
406 +
407 + # Indentation preferences
408 + csharp_indent_case_contents = true
409 + csharp_indent_switch_labels = true
410 +
411 + # Naming conventions
412 + dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning
413 + dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
414 + dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
415 +
416 + dotnet_naming_rule.private_field_should_be_camel_case_with_underscore.severity = warning
417 + dotnet_naming_rule.private_field_should_be_camel_case_with_underscore.symbols = private_field
418 + dotnet_naming_rule.private_field_should_be_camel_case_with_underscore.style = camel_case_with_underscore
419 +
420 + dotnet_naming_symbols.interface.applicable_kinds = interface
421 + dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
422 +
423 + dotnet_naming_symbols.private_field.applicable_kinds = field
424 + dotnet_naming_symbols.private_field.applicable_accessibilities = private, private_protected
425 +
426 + dotnet_naming_style.begins_with_i.required_prefix = I
427 + dotnet_naming_style.begins_with_i.capitalization = pascal_case
428 +
429 + dotnet_naming_style.camel_case_with_underscore.required_prefix = _
430 + dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
431 + ```
432 +
433 + ### `.dockerignore`
434 +
435 + Tells Docker which files to exclude from the build context. Keeping the context small speeds up `docker build` significantly. Test projects, CI config, docs, and IDE metadata are not needed in the production image.
436 +
437 + ```
438 + **/.git
439 + **/.vs
440 + **/.vscode
441 + **/.idea
442 + **/bin
443 + **/obj
444 + **/logs
445 + **/.DS_Store
446 + **/node_modules
447 + tests/
448 + .github/
449 + *.md
450 + .editorconfig
451 + .gitignore
452 + .env
453 + .env.example
454 + qodana.yaml
455 + compose.yml
456 + ```
457 +
458 + ### `.gitignore`
459 +
460 + The `.gitignore` file is a standard .NET template that excludes build output (`bin/`, `obj/`), IDE-specific directories (`.vs/`, `.idea/`, `.vscode/`), user-specific files (`*.user`, `launchSettings.json` overrides), and environment files (`.env`). It is ~300 lines and is generated via `dotnet new gitignore` — not reproduced here for brevity.
461 +
462 + ### `.env.example`
463 +
464 + Template for environment variables consumed by `compose.yml`. Developers copy this to `.env` and customize. The `.env` file is gitignored; this `.example` file is committed so new developers know what variables are needed.
465 +
466 + ```bash
467 + # ──────────────────────────────────────────────
468 + # Docker image
469 + # ──────────────────────────────────────────────
470 + DOCKER_IMAGE=your-dockerhub-username/upmatches-api
471 + IMAGE_TAG=latest
472 +
473 + # ──────────────────────────────────────────────
474 + # API configuration
475 + # ──────────────────────────────────────────────
476 + ASPNETCORE_ENVIRONMENT=Production
477 + API_PORT=5212
478 +
479 + # ──────────────────────────────────────────────
480 + # Database configuration
481 + # ──────────────────────────────────────────────
482 + POSTGRES_DB=upmatches
483 + POSTGRES_USER=postgres
484 + POSTGRES_PASSWORD=change-me-to-a-strong-password
485 + DB_PORT=5432
486 +
487 + # ──────────────────────────────────────────────
488 + # Connection string (must match DB settings above)
489 + # ──────────────────────────────────────────────
490 + CONNECTION_STRING=Host=db;Port=5432;Database=upmatches;Username=postgres;Password=change-me-to-a-strong-password
491 + ```
492 +
493 + ### Install Packages into Projects
494 +
495 + With CPM, running `dotnet add package` registers the package in the `.csproj` (without a version). The version is resolved from `Directory.Packages.props`.
496 +
497 + ```bash
498 + # Application Layer
499 + dotnet add src/Upmatches.Application package MediatR
500 + dotnet add src/Upmatches.Application package FluentValidation
501 + dotnet add src/Upmatches.Application package FluentValidation.DependencyInjectionExtensions
502 + dotnet add src/Upmatches.Application package Microsoft.Extensions.Logging.Abstractions
503 +
504 + # Infrastructure Layer
505 + dotnet add src/Upmatches.Infrastructure package Microsoft.EntityFrameworkCore
506 + dotnet add src/Upmatches.Infrastructure package Microsoft.EntityFrameworkCore.Design
507 + dotnet add src/Upmatches.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
508 + dotnet add src/Upmatches.Infrastructure package Newtonsoft.Json
509 + dotnet add src/Upmatches.Infrastructure package Quartz.Extensions.Hosting
510 +
511 + # API Layer
512 + dotnet add src/Upmatches.Api package Serilog.AspNetCore
513 + dotnet add src/Upmatches.Api package Serilog.Enrichers.Environment
514 + dotnet add src/Upmatches.Api package Serilog.Enrichers.Thread
515 + dotnet add src/Upmatches.Api package AspNetCore.HealthChecks.NpgSql
516 + dotnet add src/Upmatches.Api package Microsoft.AspNetCore.OpenApi
517 + dotnet add src/Upmatches.Api package Microsoft.EntityFrameworkCore.Tools
518 + dotnet add src/Upmatches.Api package Asp.Versioning.Mvc
519 + dotnet add src/Upmatches.Api package Asp.Versioning.Mvc.ApiExplorer
520 +
521 + # Test Projects (same packages for all test projects)
522 + dotnet add tests/Upmatches.Domain.Tests package Microsoft.NET.Test.Sdk
523 + dotnet add tests/Upmatches.Domain.Tests package xunit
524 + dotnet add tests/Upmatches.Domain.Tests package xunit.runner.visualstudio
525 + dotnet add tests/Upmatches.Domain.Tests package coverlet.collector
526 + dotnet add tests/Upmatches.Domain.Tests package FluentAssertions
527 + dotnet add tests/Upmatches.Domain.Tests package Moq
528 +
529 + dotnet add tests/Upmatches.Application.Tests package Microsoft.NET.Test.Sdk
530 + dotnet add tests/Upmatches.Application.Tests package xunit
531 + dotnet add tests/Upmatches.Application.Tests package xunit.runner.visualstudio
532 + dotnet add tests/Upmatches.Application.Tests package coverlet.collector
533 + dotnet add tests/Upmatches.Application.Tests package FluentAssertions
534 + dotnet add tests/Upmatches.Application.Tests package Moq
535 +
536 + dotnet add tests/Upmatches.IntegrationTests package Microsoft.NET.Test.Sdk
537 + dotnet add tests/Upmatches.IntegrationTests package xunit
538 + dotnet add tests/Upmatches.IntegrationTests package xunit.runner.visualstudio
539 + dotnet add tests/Upmatches.IntegrationTests package coverlet.collector
540 + dotnet add tests/Upmatches.IntegrationTests package FluentAssertions
541 + dotnet add tests/Upmatches.IntegrationTests package Moq
542 + dotnet add tests/Upmatches.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing
543 + ```
544 +
545 + ---
546 +
547 + ## 6. Project Files (.csproj)
548 +
549 + With CPM and `Directory.Build.props`, individual `.csproj` files are minimal. They declare only package references (no versions) and project references (enforcing the dependency rule).
550 +
551 + ### `src/{ProjectName}.Domain/{ProjectName}.Domain.csproj`
552 +
553 + The Domain project has **no NuGet dependencies at all**. This is intentional — the Domain layer must be pure C# with zero framework coupling.
554 +
555 + ```xml
556 + <Project Sdk="Microsoft.NET.Sdk">
557 +
558 + </Project>
559 + ```
560 +
561 + > Note: `TargetFramework`, `Nullable`, `ImplicitUsings`, and `TreatWarningsAsErrors` are inherited from `Directory.Build.props` — no need to repeat them.
562 +
563 + ### `src/{ProjectName}.Application/{ProjectName}.Application.csproj`
564 +
565 + References Domain and adds MediatR, FluentValidation, and logging abstractions.
566 +
567 + ```xml
568 + <Project Sdk="Microsoft.NET.Sdk">
569 +
570 + <ItemGroup>
571 + <ProjectReference Include="..\Upmatches.Domain\Upmatches.Domain.csproj"/>
572 + </ItemGroup>
573 +
574 + <ItemGroup>
575 + <PackageReference Include="FluentValidation"/>
576 + <PackageReference Include="FluentValidation.DependencyInjectionExtensions"/>
577 + <PackageReference Include="MediatR"/>
578 + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
579 + </ItemGroup>
580 +
581 + </Project>
582 + ```
583 +
584 + ### `src/{ProjectName}.Infrastructure/{ProjectName}.Infrastructure.csproj`
585 +
586 + References Application and adds EF Core with PostgreSQL, Newtonsoft.Json, and Quartz for background jobs.
587 +
588 + ```xml
589 + <Project Sdk="Microsoft.NET.Sdk">
590 +
591 + <ItemGroup>
592 + <ProjectReference Include="..\Upmatches.Application\Upmatches.Application.csproj"/>
593 + </ItemGroup>
594 +
595 + <ItemGroup>
596 + <PackageReference Include="Microsoft.EntityFrameworkCore"/>
597 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design">
598 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
599 + <PrivateAssets>all</PrivateAssets>
600 + </PackageReference>
601 + <PackageReference Include="Newtonsoft.Json"/>
602 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL"/>
603 + <PackageReference Include="Quartz.Extensions.Hosting"/>
604 + </ItemGroup>
605 +
606 + </Project>
607 + ```
608 +
609 + > `EF Core Design` is marked as a development-only dependency (`PrivateAssets: all`) — it's used by `dotnet ef` tooling at design time, not at runtime.
610 +
611 + ### `src/{ProjectName}.Api/{ProjectName}.Api.csproj`
612 +
613 + The web application project. Uses `Microsoft.NET.Sdk.Web` (not `Microsoft.NET.Sdk`). References both Application and Infrastructure to wire everything together at the composition root.
614 +
615 + ```xml
616 + <Project Sdk="Microsoft.NET.Sdk.Web">
617 +
618 + <ItemGroup>
619 + <PackageReference Include="Asp.Versioning.Mvc"/>
620 + <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer"/>
621 + <PackageReference Include="AspNetCore.HealthChecks.NpgSql"/>
622 + <PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
623 + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
624 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
625 + <PrivateAssets>all</PrivateAssets>
626 + </PackageReference>
627 + <PackageReference Include="Serilog.AspNetCore"/>
628 + <PackageReference Include="Serilog.Enrichers.Environment"/>
629 + <PackageReference Include="Serilog.Enrichers.Thread"/>
630 + </ItemGroup>
631 +
632 + <ItemGroup>
633 + <ProjectReference Include="..\Upmatches.Application\Upmatches.Application.csproj"/>
634 + <ProjectReference Include="..\Upmatches.Infrastructure\Upmatches.Infrastructure.csproj"/>
635 + </ItemGroup>
636 +
637 + </Project>
638 + ```
639 +
640 + ### Test `.csproj` Files
641 +
642 + All test projects share the same structure: `IsPackable` set to `false` (prevents accidentally publishing test assemblies as NuGet packages), common test packages, a global `Using` for xUnit, and a single project reference to the layer under test.
643 +
644 + **`tests/{ProjectName}.Domain.Tests/{ProjectName}.Domain.Tests.csproj`**
645 + ```xml
646 + <Project Sdk="Microsoft.NET.Sdk">
647 +
648 + <PropertyGroup>
649 + <IsPackable>false</IsPackable>
650 + </PropertyGroup>
651 +
652 + <ItemGroup>
653 + <PackageReference Include="coverlet.collector"/>
654 + <PackageReference Include="FluentAssertions"/>
655 + <PackageReference Include="Microsoft.NET.Test.Sdk"/>
656 + <PackageReference Include="Moq"/>
657 + <PackageReference Include="xunit"/>
658 + <PackageReference Include="xunit.runner.visualstudio"/>
659 + </ItemGroup>
660 +
661 + <ItemGroup>
662 + <Using Include="Xunit"/>
663 + </ItemGroup>
664 +
665 + <ItemGroup>
666 + <ProjectReference Include="..\..\src\Upmatches.Domain\Upmatches.Domain.csproj"/>
667 + </ItemGroup>
668 +
669 + </Project>
670 + ```
671 +
672 + **`tests/{ProjectName}.Application.Tests/{ProjectName}.Application.Tests.csproj`**
673 + ```xml
674 + <Project Sdk="Microsoft.NET.Sdk">
675 +
676 + <PropertyGroup>
677 + <IsPackable>false</IsPackable>
678 + </PropertyGroup>
679 +
680 + <ItemGroup>
681 + <PackageReference Include="coverlet.collector"/>
682 + <PackageReference Include="FluentAssertions"/>
683 + <PackageReference Include="Microsoft.NET.Test.Sdk"/>
684 + <PackageReference Include="Moq"/>
685 + <PackageReference Include="xunit"/>
686 + <PackageReference Include="xunit.runner.visualstudio"/>
687 + </ItemGroup>
688 +
689 + <ItemGroup>
690 + <Using Include="Xunit"/>
691 + </ItemGroup>
692 +
693 + <ItemGroup>
694 + <ProjectReference Include="..\..\src\Upmatches.Application\Upmatches.Application.csproj"/>
695 + </ItemGroup>
696 +
697 + </Project>
698 + ```
699 +
700 + **`tests/{ProjectName}.IntegrationTests/{ProjectName}.IntegrationTests.csproj`**
701 +
702 + Integration tests reference the API project (which transitively brings in everything) and add `Microsoft.AspNetCore.Mvc.Testing` for `WebApplicationFactory<Program>` support.
703 +
704 + ```xml
705 + <Project Sdk="Microsoft.NET.Sdk">
706 +
707 + <PropertyGroup>
708 + <IsPackable>false</IsPackable>
709 + </PropertyGroup>
710 +
711 + <ItemGroup>
712 + <PackageReference Include="coverlet.collector"/>
713 + <PackageReference Include="FluentAssertions"/>
714 + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
715 + <PackageReference Include="Microsoft.NET.Test.Sdk"/>
716 + <PackageReference Include="Moq"/>
717 + <PackageReference Include="xunit"/>
718 + <PackageReference Include="xunit.runner.visualstudio"/>
719 + </ItemGroup>
720 +
721 + <ItemGroup>
722 + <Using Include="Xunit"/>
723 + </ItemGroup>
724 +
725 + <ItemGroup>
726 + <ProjectReference Include="..\..\src\Upmatches.Api\Upmatches.Api.csproj"/>
727 + </ItemGroup>
728 +
729 + </Project>
730 + ```
731 +
732 + ---
733 +
734 + ## 7. Docker & Dev Environment
735 +
736 + ### `compose.yml` (Root)
737 +
738 + Sets up the API and a PostgreSQL database. All values are configurable via `.env` with sensible defaults for local development.
739 +
740 + ```yaml
741 + services:
742 + api:
743 + container_name: upmatches-api
744 + image: ${DOCKER_IMAGE:-upmatches-api}:${IMAGE_TAG:-latest}
745 + build:
746 + context: .
747 + dockerfile: Dockerfile
748 + ports:
749 + - "${API_PORT:-5212}:8080"
750 + environment:
751 + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
752 + - ConnectionStrings__DefaultConnection=${CONNECTION_STRING:-Host=db;Port=5432;Database=upmatches_dev;Username=postgres;Password=postgres}
753 + depends_on:
754 + db:
755 + condition: service_healthy
756 +
757 + db:
758 + container_name: upmatches-db
759 + image: postgres:17-alpine
760 + ports:
761 + - "${DB_PORT:-5432}:5432"
762 + environment:
763 + POSTGRES_DB: ${POSTGRES_DB:-upmatches_dev}
764 + POSTGRES_USER: ${POSTGRES_USER:-postgres}
765 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
766 + volumes:
767 + - postgres_data:/var/lib/postgresql/data
768 + healthcheck:
769 + test: ["CMD-SHELL", "pg_isready -U postgres"]
770 + interval: 5s
771 + timeout: 5s
772 + retries: 5
773 +
774 + volumes:
775 + postgres_data:
776 + ```
777 +
778 + ### `Dockerfile` (Root)
779 +
780 + Optimized multi-stage build. The key optimization is copying `.csproj` files first and running `dotnet restore` before copying source code — this means the NuGet restore layer is cached and only invalidated when dependencies change, not when code changes.
781 +
782 + ```dockerfile
783 + FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
784 + WORKDIR /app
785 + EXPOSE 8080
786 +
787 + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
788 + ARG BUILD_CONFIGURATION=Release
789 + WORKDIR /src
790 +
791 + COPY global.json .
792 + COPY nuget.config .
793 + COPY Directory.Build.props .
794 + COPY Directory.Packages.props .
795 + COPY src/Upmatches.Api/Upmatches.Api.csproj src/Upmatches.Api/
796 + COPY src/Upmatches.Application/Upmatches.Application.csproj src/Upmatches.Application/
797 + COPY src/Upmatches.Domain/Upmatches.Domain.csproj src/Upmatches.Domain/
798 + COPY src/Upmatches.Infrastructure/Upmatches.Infrastructure.csproj src/Upmatches.Infrastructure/
799 +
800 + RUN dotnet restore src/Upmatches.Api/Upmatches.Api.csproj
801 +
802 + COPY . .
803 + RUN dotnet build src/Upmatches.Api -c $BUILD_CONFIGURATION --no-restore
804 +
805 + FROM build AS publish
806 + ARG BUILD_CONFIGURATION=Release
807 + RUN dotnet publish src/Upmatches.Api -c $BUILD_CONFIGURATION --no-build -o /app/publish /p:UseAppHost=false
808 +
809 + FROM base AS final
810 + WORKDIR /app
811 + COPY --from=publish /app/publish .
812 + ENTRYPOINT ["dotnet", "Upmatches.Api.dll"]
813 + ```
814 +
815 + ### `src/{ProjectName}.Api/Properties/launchSettings.json`
816 +
817 + Configures how `dotnet run` launches the API locally. Two profiles are defined: HTTP-only (port 5212) and HTTPS (ports 7031 + 5212). `launchBrowser` is disabled — APIs don't need a browser window.
818 +
819 + ```json
820 + {
821 + "$schema": "https://json.schemastore.org/launchsettings.json",
822 + "profiles": {
823 + "http": {
824 + "commandName": "Project",
825 + "dotnetRunMessages": true,
826 + "launchBrowser": false,
827 + "applicationUrl": "http://localhost:5212",
828 + "environmentVariables": {
829 + "ASPNETCORE_ENVIRONMENT": "Development"
830 + }
831 + },
832 + "https": {
833 + "commandName": "Project",
834 + "dotnetRunMessages": true,
835 + "launchBrowser": false,
836 + "applicationUrl": "https://localhost:7031;http://localhost:5212",
837 + "environmentVariables": {
838 + "ASPNETCORE_ENVIRONMENT": "Development"
839 + }
840 + }
841 + }
842 + }
843 + ```
844 +
845 + ---
846 +
847 + ## 8. Layer 1: Domain
848 +
849 + *The innermost layer. Contains entities, value objects, domain events, the Result pattern, and repository abstractions. It has **zero** NuGet dependencies — pure C# only.*
850 +
851 + **Why a dependency-free Domain?** The Domain layer encodes business rules. By keeping it free of frameworks (no EF Core attributes, no MediatR, no JSON serializers), it remains:
852 + - Testable with plain unit tests (no mocking infrastructure).
853 + - Portable — you can swap EF Core for Dapper or PostgreSQL for MongoDB without touching a single Domain file.
854 + - Focused — developers reading Domain code see only business logic, not framework ceremony.
855 +
856 + ### `src/{ProjectName}.Domain/Common/BaseEntity.cs`
857 +
858 + All entities inherit from this. It provides a GUID primary key and a domain events collection. Domain events are raised by entities during business operations and dispatched after `SaveChanges` by the `DomainEventInterceptor` in the Infrastructure layer.
859 +
860 + ```csharp
861 + namespace Upmatches.Domain.Common;
862 +
863 + public abstract class BaseEntity
864 + {
865 + private readonly List<IDomainEvent> _domainEvents = [];
866 + public Guid Id { get; private init; } = Guid.NewGuid();
867 + public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
868 +
869 + public void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
870 + public void RemoveDomainEvent(IDomainEvent domainEvent) => _domainEvents.Remove(domainEvent);
871 + public void ClearDomainEvents() => _domainEvents.Clear();
872 + }
873 + ```
874 +
875 + ### `src/{ProjectName}.Domain/Common/AuditableEntity.cs`
876 +
877 + Extends `BaseEntity` with `CreatedAt` and `UpdatedAt` timestamps. The setters are accessed by the `AuditableEntityInterceptor` — entities themselves don't need to worry about setting timestamps.
878 +
879 + ```csharp
880 + namespace Upmatches.Domain.Common;
881 +
882 + public abstract class AuditableEntity : BaseEntity
883 + {
884 + public DateTime CreatedAt { get; private set; }
885 + public DateTime? UpdatedAt { get; private set; }
886 +
887 + public void SetCreatedAt(DateTime createdAt) => CreatedAt = createdAt;
888 + public void SetUpdatedAt(DateTime updatedAt) => UpdatedAt = updatedAt;
889 + }
890 + ```
891 +
892 + ### `src/{ProjectName}.Domain/Common/IDomainEvent.cs`
893 +
894 + Marker interface for domain events. Events carry a timestamp so consumers know when the event occurred.
895 +
896 + ```csharp
897 + namespace Upmatches.Domain.Common;
898 +
899 + public interface IDomainEvent
900 + {
901 + DateTime OccurredOn { get; }
902 + }
903 + ```
904 +
905 + ### `src/{ProjectName}.Domain/Common/ValueObject.cs`
906 +
907 + Base class for value objects (DDD concept). Value objects are compared by their component values, not by identity. Two `Money(100, "USD")` instances are equal regardless of reference identity.
908 +
909 + ```csharp
910 + namespace Upmatches.Domain.Common;
911 +
912 + public abstract class ValueObject : IEquatable<ValueObject>
913 + {
914 + protected abstract IEnumerable<object?> GetEqualityComponents();
915 +
916 + public override bool Equals(object? obj)
917 + {
918 + if (obj is null || obj.GetType() != GetType()) return false;
919 + return Equals((ValueObject)obj);
920 + }
921 +
922 + public bool Equals(ValueObject? other)
923 + {
924 + if (other is null || other.GetType() != GetType()) return false;
925 + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
926 + }
927 +
928 + public override int GetHashCode() => GetEqualityComponents().Aggregate(0, (current, obj) => HashCode.Combine(current, obj?.GetHashCode() ?? 0));
929 + public static bool operator ==(ValueObject? left, ValueObject? right) => left is null && right is null || (left is not null && right is not null && left.Equals(right));
930 + public static bool operator !=(ValueObject? left, ValueObject? right) => !(left == right);
931 + }
932 + ```
933 +
934 + ### `src/{ProjectName}.Domain/Common/Error.cs`
935 +
936 + Defines the `Error` record and `ErrorType` enum used throughout the Result pattern. Pre-defined static errors cover the most common failure cases. Domain-specific errors are defined alongside their entities (e.g., `User.Errors.EmailTaken`).
937 +
938 + ```csharp
939 + namespace Upmatches.Domain.Common;
940 +
941 + public enum ErrorType { None = 0, Failure = 1, Validation = 2, NotFound = 3, Conflict = 4 }
942 +
943 + public sealed record Error(string Code, string Description, ErrorType Type)
944 + {
945 + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None);
946 + public static readonly Error NullValue = new("Error.NullValue", "A null value was provided.", ErrorType.Failure);
947 + public static readonly Error NotFound = new("Error.NotFound", "The requested resource was not found.", ErrorType.NotFound);
948 + public static readonly Error Conflict = new("Error.Conflict", "A conflict occurred with the current state.", ErrorType.Conflict);
949 + public static readonly Error Validation = new("Error.Validation", "A validation error occurred.", ErrorType.Validation);
950 + }
951 + ```
952 +
953 + ### `src/{ProjectName}.Domain/Common/Result.cs`
954 +
955 + The Result pattern implementation. Key design points:
956 + - `IValidationResult` uses **static abstract interface members** (C# 13) — this enables `ValidationBehavior` to call `TResponse.Failure(error)` without reflection or `Activator.CreateInstance`.
957 + - Constructor guards prevent invalid states (success with error, failure without error).
958 + - `Result<T>` provides an implicit conversion from `T` for ergonomic returns.
959 +
960 + ```csharp
961 + namespace Upmatches.Domain.Common;
962 +
963 + /// <summary>
964 + /// Defines a contract for creating a failure result of a specific type.
965 + /// Used for type-safe, high-performance validation in CQRS pipelines.
966 + /// </summary>
967 + public interface IValidationResult
968 + {
969 + static abstract Result Failure(Error error);
970 + }
971 +
972 + public class Result : IValidationResult
973 + {
974 + protected Result(bool isSuccess, Error error)
975 + {
976 + if (isSuccess && error != Error.None)
977 + throw new InvalidOperationException("A successful result cannot have an error.");
978 +
979 + if (!isSuccess && error == Error.None)
980 + throw new InvalidOperationException("A failed result must have an error.");
981 +
982 + IsSuccess = isSuccess;
983 + Error = error;
984 + }
985 +
986 + public bool IsSuccess { get; }
987 + public bool IsFailure => !IsSuccess;
988 + public Error Error { get; }
989 +
990 + public static Result Success()
991 + {
992 + return new Result(true, Error.None);
993 + }
994 +
995 + public static Result<T> Success<T>(T value)
996 + {
997 + return Result<T>.Success(value);
998 + }
999 +
1000 + public static Result Failure(Error error)
1001 + {
1002 + return new Result(false, error);
1003 + }
1004 +
1005 + public static Result<T> Failure<T>(Error error)
1006 + {
1007 + return Result<T>.Failure(error);
1008 + }
1009 + }
1010 +
1011 + public class Result<T> : Result, IValidationResult
1012 + {
1013 + private readonly T? _value;
1014 +
1015 + private Result(T? value, bool isSuccess, Error error)
1016 + : base(isSuccess, error)
1017 + {
1018 + _value = value;
1019 + }
1020 +
1021 + public T Value => IsSuccess
1022 + ? _value!
1023 + : throw new InvalidOperationException("Cannot access the value of a failed result.");
1024 +
1025 + public static Result<T> Success(T value)
1026 + {
1027 + return new Result<T>(value, true, Error.None);
1028 + }
1029 +
1030 + public new static Result<T> Failure(Error error)
1031 + {
1032 + return new Result<T>(default, false, error);
1033 + }
1034 +
1035 + public static implicit operator Result<T>(T value)
1036 + {
1037 + return Success(value);
1038 + }
1039 + }
1040 + ```
1041 +
1042 + ### `src/{ProjectName}.Domain/Abstractions/IUnitOfWork.cs`
1043 +
1044 + Abstracts the "save all pending changes" operation. In the Infrastructure layer, `ApplicationDbContext` implements this interface directly.
1045 +
1046 + ```csharp
1047 + namespace Upmatches.Domain.Abstractions;
1048 +
1049 + public interface IUnitOfWork
1050 + {
1051 + Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
1052 + }
1053 + ```
1054 +
1055 + ---
1056 +
1057 + ## 9. Layer 2: Application
1058 +
1059 + *Contains use cases (commands, queries, handlers), validation, mapping, and MediatR pipeline behaviors. References Domain only.*
1060 +
1061 + **Why a separate Application layer?** The Application layer orchestrates business operations without knowing *how* data is persisted or *how* HTTP requests arrive. This means:
1062 + - Handlers are testable by mocking `ApplicationDbContext` and `IUnitOfWork` — no database needed.
1063 + - The same handlers can serve a REST API, gRPC service, or message queue consumer.
1064 + - Validation is co-located with the command/query it validates.
1065 +
1066 + ### Messaging Interfaces (`src/{ProjectName}.Application/Abstractions/Messaging/`)
1067 +
1068 + These interfaces wrap MediatR's `IRequest` and `IRequestHandler` to enforce that all commands and queries return `Result` or `Result<T>`. This guarantees the Result pattern is used consistently throughout the application.
1069 +
1070 + **`ICommand.cs`**
1071 + ```csharp
1072 + using MediatR;
1073 + using Upmatches.Domain.Common;
1074 +
1075 + namespace Upmatches.Application.Abstractions.Messaging;
1076 +
1077 + /// <summary>
1078 + /// Marker interface for commands that do not return a value.
1079 + /// </summary>
1080 + public interface ICommand : IRequest<Result>;
1081 +
1082 + /// <summary>
1083 + /// Marker interface for commands that return a value wrapped in Result.
1084 + /// </summary>
1085 + public interface ICommand<TResponse> : IRequest<Result<TResponse>>;
1086 + ```
1087 +
1088 + **`ICommandHandler.cs`**
1089 + ```csharp
1090 + using MediatR;
1091 + using Upmatches.Domain.Common;
1092 +
1093 + namespace Upmatches.Application.Abstractions.Messaging;
1094 +
1095 + /// <summary>
1096 + /// Handler for commands that do not return a value.
1097 + /// </summary>
1098 + public interface ICommandHandler<in TCommand> : IRequestHandler<TCommand, Result>
1099 + where TCommand : ICommand;
1100 +
1101 + /// <summary>
1102 + /// Handler for commands that return a value wrapped in Result.
1103 + /// </summary>
1104 + public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
1105 + where TCommand : ICommand<TResponse>;
1106 + ```
1107 +
1108 + **`IQuery.cs`**
1109 + ```csharp
1110 + using MediatR;
1111 + using Upmatches.Domain.Common;
1112 +
1113 + namespace Upmatches.Application.Abstractions.Messaging;
1114 +
1115 + /// <summary>
1116 + /// Marker interface for queries that return a value wrapped in Result.
1117 + /// </summary>
1118 + public interface IQuery<TResponse> : IRequest<Result<TResponse>>;
1119 + ```
1120 +
1121 + **`IQueryHandler.cs`**
1122 + ```csharp
1123 + using MediatR;
1124 + using Upmatches.Domain.Common;
1125 +
1126 + namespace Upmatches.Application.Abstractions.Messaging;
1127 +
1128 + /// <summary>
1129 + /// Handler for queries that return a value wrapped in Result.
1130 + /// </summary>
1131 + public interface IQueryHandler<in TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
1132 + where TQuery : IQuery<TResponse>;
1133 + ```
1134 +
1135 + ### Domain Event Handler (`src/{ProjectName}.Application/Abstractions/IDomainEventHandler.cs`)
1136 +
1137 + Bridges domain events to MediatR's notification pipeline. `DomainEventNotification<T>` wraps any `IDomainEvent` as an `INotification`, keeping the Domain layer free of MediatR references.
1138 +
1139 + ```csharp
1140 + using MediatR;
1141 + using Upmatches.Domain.Common;
1142 +
1143 + namespace Upmatches.Application.Abstractions;
1144 +
1145 + /// <summary>
1146 + /// Wraps a domain event as a MediatR notification so it can be published
1147 + /// through the MediatR pipeline without coupling the Domain layer to MediatR.
1148 + /// </summary>
1149 + public sealed class DomainEventNotification<TDomainEvent>(TDomainEvent domainEvent)
1150 + : INotification where TDomainEvent : IDomainEvent
1151 + {
1152 + public TDomainEvent DomainEvent { get; } = domainEvent;
1153 + }
1154 +
1155 + /// <summary>
1156 + /// Convenience interface for handling domain events via MediatR.
1157 + /// Implement this instead of INotificationHandler&lt;DomainEventNotification&lt;T&gt;&gt; directly.
1158 + /// </summary>
1159 + public interface IDomainEventHandler<TDomainEvent>
1160 + : INotificationHandler<DomainEventNotification<TDomainEvent>>
1161 + where TDomainEvent : IDomainEvent;
1162 + ```
1163 +
1164 + ### Behaviors (`src/{ProjectName}.Application/Behaviors/`)
1165 +
1166 + Pipeline behaviors are MediatR middleware. They wrap every request and can inspect, modify, or short-circuit the pipeline.
1167 +
1168 + **`LoggingBehavior.cs`**
1169 +
1170 + Logs the request name before handling and the elapsed time after. Uses `Stopwatch` for high-resolution timing.
1171 +
1172 + ```csharp
1173 + using System.Diagnostics;
1174 + using MediatR;
1175 + using Microsoft.Extensions.Logging;
1176 +
1177 + namespace Upmatches.Application.Behaviors;
1178 +
1179 + public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
1180 + {
1181 + public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
1182 + {
1183 + var requestName = typeof(TRequest).Name;
1184 + logger.LogInformation("Handling {RequestName}", requestName);
1185 + var stopwatch = Stopwatch.StartNew();
1186 + var response = await next(cancellationToken);
1187 + stopwatch.Stop();
1188 + logger.LogInformation("Handled {RequestName} in {ElapsedMilliseconds}ms", requestName, stopwatch.ElapsedMilliseconds);
1189 + return response;
1190 + }
1191 + }
1192 + ```
1193 +
1194 + **`ValidationBehavior.cs`** *(Zero Reflection / High-Performance)*
1195 +
1196 + Runs all registered `IValidator<TRequest>` validators before the handler executes. If any validation fails, it short-circuits the pipeline and returns a `Result.Failure` — the handler is never invoked.
1197 +
1198 + The key innovation is the `where TResponse : Result, IValidationResult` constraint combined with the `static abstract` method on `IValidationResult`. This allows `TResponse.Failure(error)` to be called directly — no reflection, no `Activator.CreateInstance`, fully AOT-compatible.
1199 +
1200 + ```csharp
1201 + using FluentValidation;
1202 + using MediatR;
1203 + using Microsoft.Extensions.Logging;
1204 + using Upmatches.Domain.Common;
1205 +
1206 + namespace Upmatches.Application.Behaviors;
1207 +
1208 + public sealed class ValidationBehavior<TRequest, TResponse>(
1209 + IEnumerable<IValidator<TRequest>> validators,
1210 + ILogger<ValidationBehavior<TRequest, TResponse>> logger)
1211 + : IPipelineBehavior<TRequest, TResponse>
1212 + where TRequest : IRequest<TResponse>
1213 + where TResponse : Result, IValidationResult
1214 + {
1215 + public async Task<TResponse> Handle(
1216 + TRequest request,
1217 + RequestHandlerDelegate<TResponse> next,
1218 + CancellationToken cancellationToken)
1219 + {
1220 + var validatorList = validators as IReadOnlyList<IValidator<TRequest>> ?? [.. validators];
1221 +
1222 + if (validatorList.Count == 0)
1223 + return await next(cancellationToken);
1224 +
1225 + var context = new ValidationContext<TRequest>(request);
1226 +
1227 + var validationResults = await Task.WhenAll(
1228 + validatorList.Select(v => v.ValidateAsync(context, cancellationToken)));
1229 +
1230 + var failures = validationResults
1231 + .SelectMany(r => r.Errors)
1232 + .Where(f => f is not null)
1233 + .ToList();
1234 +
1235 + if (failures.Count != 0)
1236 + {
1237 + var errorMessage = string.Join("; ", failures.Select(f => f.ErrorMessage));
1238 + var error = new Error("Validation", errorMessage, ErrorType.Validation);
1239 +
1240 + logger.LogWarning(
1241 + "Validation failed for {RequestName}: {ErrorMessage}",
1242 + typeof(TRequest).Name,
1243 + errorMessage);
1244 +
1245 + // Directly call the static abstract Failure method.
1246 + // No reflection, 100% type-safe and high performance.
1247 + return (TResponse)TResponse.Failure(error);
1248 + }
1249 +
1250 + return await next(cancellationToken);
1251 + }
1252 + }
1253 + ```
1254 +
1255 + ### Dependency Injection (`src/{ProjectName}.Application/DependencyInjection.cs`)
1256 +
1257 + Registers MediatR (with pipeline behaviors in order) and FluentValidation validators (auto-discovered from the assembly).
1258 +
1259 + ```csharp
1260 + using FluentValidation;
1261 + using MediatR;
1262 + using Microsoft.Extensions.DependencyInjection;
1263 + using Upmatches.Application.Behaviors;
1264 +
1265 + namespace Upmatches.Application;
1266 +
1267 + public static class DependencyInjection
1268 + {
1269 + public static IServiceCollection AddApplication(this IServiceCollection services)
1270 + {
1271 + var assembly = typeof(DependencyInjection).Assembly;
1272 +
1273 + services.AddMediatR(cfg =>
1274 + {
1275 + cfg.RegisterServicesFromAssembly(assembly);
1276 + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
1277 + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
1278 + });
1279 +
1280 + services.AddValidatorsFromAssembly(assembly);
1281 +
1282 + return services;
1283 + }
1284 + }
1285 + ```
1286 +
1287 + ### Vertical Slice Convention
1288 +
1289 + > **Customize:** The folder structure below is a convention, not enforced by tooling. Adapt the nesting depth to your project's complexity.
1290 +
1291 + When adding features, organize the Application layer by **vertical slices** rather than by technical concern (no `Commands/`, `Queries/`, `Validators/` top-level folders). Each feature is a self-contained folder:
1292 +
1293 + ```
1294 + src/{ProjectName}.Application/
1295 + └── Features/
1296 + └── {FeatureName}/
1297 + ├── {Operation}/
1298 + │ ├── {Operation}Command.cs (or {Operation}Query.cs)
1299 + │ ├── {Operation}CommandHandler.cs (or {Operation}QueryHandler.cs)
1300 + │ ├── {Operation}CommandValidator.cs (optional)
1301 + │ └── {Operation}Response.cs (optional — only if returning data)
1302 + └── Mappings/
1303 + └── {FeatureName}Mappings.cs (static extension methods)
1304 + ```
1305 +
1306 + **Example for an "Items" feature:**
1307 +
1308 + ```
1309 + Features/
1310 + └── Items/
1311 + ├── CreateItem/
1312 + │ ├── CreateItemCommand.cs
1313 + │ ├── CreateItemCommandHandler.cs
1314 + │ └── CreateItemCommandValidator.cs
1315 + ├── GetItem/
1316 + │ ├── GetItemQuery.cs
1317 + │ ├── GetItemQueryHandler.cs
1318 + │ └── ItemResponse.cs
1319 + └── Mappings/
1320 + └── ItemMappings.cs
1321 + ```
1322 +
1323 + **Why vertical slices?**
1324 + - **Cohesion** — everything needed for a use case is in one folder; no jumping between `Commands/`, `Validators/`, `Handlers/`.
1325 + - **Discoverability** — new developers find related code immediately.
1326 + - **Safe deletion** — removing a feature means deleting one folder.
1327 + - **Reduced merge conflicts** — different developers working on different features rarely touch the same files.
1328 +
1329 + MediatR auto-discovers all `IRequestHandler<,>` and `IValidator<>` implementations from the assembly scan (configured in `DependencyInjection.cs`), so no manual registration is needed when adding new slices.
1330 +
1331 + ### Mapping Convention
1332 +
1333 + > **Customize:** If your project grows large enough to benefit from auto-mapping, you can introduce Mapster or AutoMapper later. Start with manual mapping.
1334 +
1335 + Use **static extension methods** for mapping between domain entities and response DTOs. Keep mapping logic close to the feature that uses it:
1336 +
1337 + ```csharp
1338 + // Features/Items/Mappings/ItemMappings.cs
1339 + namespace Upmatches.Application.Features.Items.Mappings;
1340 +
1341 + public static class ItemMappings
1342 + {
1343 + public static ItemResponse ToResponse(this Item entity) => new(
1344 + entity.Id,
1345 + entity.Name,
1346 + entity.CreatedAt);
1347 + }
1348 + ```
1349 +
1350 + **Why manual mapping over AutoMapper/Mapster?**
1351 + - **Zero magic** — mappings are plain C# code, fully debuggable with F12 / Go to Definition.
1352 + - **Compile-time safety** — missing properties cause build errors, not runtime surprises.
1353 + - **No hidden performance costs** — no reflection, no expression compilation, no global configuration scanning.
1354 + - **Co-located** — the mapping lives next to the feature that uses it.
1355 +
1356 + ### API Versioning Convention
1357 +
1358 + > **Customize:** The versioning strategy (URL path) is baked in. The version numbers and deprecation schedule are project-specific.
1359 +
1360 + Controllers use URL path versioning via `Asp.Versioning.Mvc`. Decorate controllers with `[ApiVersion]` and use `[Route("api/v{version:apiVersion}/[controller]")]`:
1361 +
1362 + ```csharp
1363 + using Asp.Versioning;
1364 +
1365 + [ApiVersion(1.0)]
1366 + [ApiController]
1367 + [Route("api/v{version:apiVersion}/[controller]")]
1368 + public class ItemsController : ControllerBase
1369 + {
1370 + // All endpoints in this controller are v1
1371 + // URL: /api/v1/items
1372 + }
1373 + ```
1374 +
1375 + When introducing breaking changes, add a new version:
1376 +
1377 + ```csharp
1378 + [ApiVersion(2.0)]
1379 + [ApiController]
1380 + [Route("api/v{version:apiVersion}/[controller]")]
1381 + public class ItemsV2Controller : ControllerBase
1382 + {
1383 + // URL: /api/v2/items
1384 + }
1385 + ```
1386 +
1387 + To deprecate an older version: `[ApiVersion(1.0, Deprecated = true)]`.
1388 +
1389 + ---
1390 +
1391 + ## 10. Layer 3: Infrastructure
1392 +
1393 + *Implements the abstractions defined in Domain and Application. Contains the EF Core `DbContext` and interceptors.*
1394 +
1395 + **Why Infrastructure is separate from Application:** The Application layer defines *what* operations are needed (via `IUnitOfWork` and feature-specific repository interfaces). Infrastructure provides *how* they're implemented (via EF Core, PostgreSQL, etc.). If you ever need to swap databases or add a caching layer, you change Infrastructure — Application and Domain remain untouched.
1396 +
1397 + ### Interceptors (`src/{ProjectName}.Infrastructure/Persistence/Interceptors/`)
1398 +
1399 + EF Core interceptors hook into the `SaveChanges` pipeline. They keep cross-cutting concerns out of the `DbContext` itself, making them individually testable and composable.
1400 +
1401 + **`AuditableEntityInterceptor.cs`**
1402 +
1403 + Automatically sets `CreatedAt` (on insert) and `UpdatedAt` (on update) for any entity that extends `AuditableEntity`. This means domain code never needs to manually set timestamps.
1404 +
1405 + ```csharp
1406 + using Microsoft.EntityFrameworkCore;
1407 + using Microsoft.EntityFrameworkCore.Diagnostics;
1408 + using Upmatches.Domain.Common;
1409 +
1410 + namespace Upmatches.Infrastructure.Persistence.Interceptors;
1411 +
1412 + public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
1413 + {
1414 + public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
1415 + {
1416 + UpdateAuditableEntities(eventData.Context);
1417 + return base.SavingChangesAsync(eventData, result, cancellationToken);
1418 + }
1419 +
1420 + private static void UpdateAuditableEntities(DbContext? context)
1421 + {
1422 + if (context is null) return;
1423 + var utcNow = DateTime.UtcNow;
1424 + foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
1425 + {
1426 + if (entry.State == EntityState.Added) entry.Entity.SetCreatedAt(utcNow);
1427 + if (entry.State == EntityState.Modified) entry.Entity.SetUpdatedAt(utcNow);
1428 + }
1429 + }
1430 + }
1431 + ```
1432 +
1433 + **`DomainEventInterceptor.cs`**
1434 +
1435 + Dispatches domain events **after** `SaveChanges` completes successfully. This ensures events are only published when the database transaction has committed. Events are collected from all tracked entities, the entity event lists are cleared, and each event is published through MediatR as a `DomainEventNotification<T>`.
1436 +
1437 + ```csharp
1438 + using MediatR;
1439 + using Microsoft.EntityFrameworkCore;
1440 + using Microsoft.EntityFrameworkCore.Diagnostics;
1441 + using Upmatches.Application.Abstractions;
1442 + using Upmatches.Domain.Common;
1443 +
1444 + namespace Upmatches.Infrastructure.Persistence.Interceptors;
1445 +
1446 + public sealed class DomainEventInterceptor(IPublisher publisher) : SaveChangesInterceptor
1447 + {
1448 + public override async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
1449 + {
1450 + if (eventData.Context is not null)
1451 + await PublishDomainEventsAsync(eventData.Context, cancellationToken);
1452 + return await base.SavedChangesAsync(eventData, result, cancellationToken);
1453 + }
1454 +
1455 + private async Task PublishDomainEventsAsync(DbContext context, CancellationToken cancellationToken)
1456 + {
1457 + var entities = context.ChangeTracker.Entries<BaseEntity>().Where(e => e.Entity.DomainEvents.Count != 0).Select(e => e.Entity).ToList();
1458 + var domainEvents = entities.SelectMany(e => e.DomainEvents).ToList();
1459 + entities.ForEach(e => e.ClearDomainEvents());
1460 +
1461 + foreach (var domainEvent in domainEvents)
1462 + {
1463 + var notificationType = typeof(DomainEventNotification<>).MakeGenericType(domainEvent.GetType());
1464 + var notification = Activator.CreateInstance(notificationType, domainEvent)!;
1465 + await publisher.Publish(notification, cancellationToken);
1466 + }
1467 + }
1468 + }
1469 + ```
1470 +
1471 + ### Database Context (`src/{ProjectName}.Infrastructure/Persistence/ApplicationDbContext.cs`)
1472 +
1473 + The EF Core `DbContext`. It also implements `IUnitOfWork` — calling `SaveChangesAsync` on the context fulfills the unit-of-work contract. Entity configurations are auto-discovered from the Infrastructure assembly via `ApplyConfigurationsFromAssembly`.
1474 +
1475 + ```csharp
1476 + using Microsoft.EntityFrameworkCore;
1477 + using Upmatches.Domain.Abstractions;
1478 +
1479 + namespace Upmatches.Infrastructure.Persistence;
1480 +
1481 + public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options), IUnitOfWork
1482 + {
1483 + protected override void OnModelCreating(ModelBuilder modelBuilder)
1484 + {
1485 + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
1486 + base.OnModelCreating(modelBuilder);
1487 + }
1488 + }
1489 + ```
1490 +
1491 + ### Dependency Injection (`src/{ProjectName}.Infrastructure/DependencyInjection.cs`)
1492 +
1493 + Registers the interceptors, `DbContext` (with PostgreSQL and interceptors wired in), and `IUnitOfWork`. Handlers access data directly through `ApplicationDbContext` — no generic repository abstraction. For complex data access patterns, create feature-specific repository interfaces in the Domain layer (e.g., `IMatchRepository`).
1494 +
1495 + ```csharp
1496 + using Microsoft.EntityFrameworkCore;
1497 + using Microsoft.Extensions.Configuration;
1498 + using Microsoft.Extensions.DependencyInjection;
1499 + using Upmatches.Domain.Abstractions;
1500 + using Upmatches.Infrastructure.Persistence;
1501 + using Upmatches.Infrastructure.Persistence.Interceptors;
1502 +
1503 + namespace Upmatches.Infrastructure;
1504 +
1505 + public static class DependencyInjection
1506 + {
1507 + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
1508 + {
1509 + services.AddSingleton<AuditableEntityInterceptor>();
1510 + services.AddScoped<DomainEventInterceptor>();
1511 +
1512 + services.AddDbContext<ApplicationDbContext>((sp, options) =>
1513 + {
1514 + var auditableInterceptor = sp.GetRequiredService<AuditableEntityInterceptor>();
1515 + var domainEventInterceptor = sp.GetRequiredService<DomainEventInterceptor>();
1516 +
1517 + options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))
1518 + .AddInterceptors(auditableInterceptor, domainEventInterceptor);
1519 + });
1520 +
1521 + services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<ApplicationDbContext>());
1522 +
1523 + return services;
1524 + }
1525 + }
1526 + ```
1527 +
1528 + > **Why `AuditableEntityInterceptor` is Singleton:** It has no mutable state and doesn't depend on scoped services — a single instance is reused across all requests, avoiding unnecessary allocations.
1529 + >
1530 + > **Why `DomainEventInterceptor` is Scoped:** It depends on `IPublisher` (MediatR), which is scoped to the HTTP request. Using a scoped lifetime ensures events are published through the correct scope.
1531 +
1532 + ---
1533 +
1534 + ## 11. Layer 4: API / Presentation
1535 +
1536 + *The outermost layer. Contains the ASP.NET Core web host, middleware, configuration, and the composition root where all layers are wired together.*
1537 +
1538 + **Why the API layer exists separately:** It is the composition root — the only place where all layers meet. Controllers receive HTTP requests, translate them into MediatR commands/queries, and map results back to HTTP responses. By keeping this layer thin, you ensure that business logic stays in Application and domain rules stay in Domain.
1539 +
1540 + ### `src/{ProjectName}.Api/appsettings.json`
1541 +
1542 + Main configuration file. Defines the connection string placeholder, Serilog configuration (structured console and file output with correlation ID, daily rolling log files), request logging options, and allowed hosts.
1543 +
1544 + ```json
1545 + {
1546 + "ConnectionStrings": {
1547 + "DefaultConnection": ""
1548 + },
1549 + "Serilog": {
1550 + "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
1551 + "MinimumLevel": {
1552 + "Default": "Information",
1553 + "Override": { "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" }
1554 + },
1555 + "WriteTo": [
1556 + { "Name": "Console", "Args": { "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}{CorrelationId: [{CorrelationId}]} {Message:lj}{NewLine}{Exception}" } },
1557 + { "Name": "File", "Args": { "path": "logs/log-.txt", "rollingInterval": "Day", "retainedFileCountLimit": 7, "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {SourceContext}{CorrelationId: [{CorrelationId}]} {Message:lj}{NewLine}{Exception}" } }
1558 + ],
1559 + "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
1560 + },
1561 + "RequestLogging": {
1562 + "MaxBodySizeBytes": 65536,
1563 + "SlowRequestThresholdMs": 500,
1564 + "CorrelationIdHeader": "X-Correlation-Id",
1565 + "SensitiveFields": ["password", "token", "secret", "authorization", "creditCard", "ssn", "accessToken", "refreshToken"],
1566 + "LoggableContentTypes": ["application/json"],
1567 + "EnableRequestBodyLogging": true,
1568 + "EnableResponseBodyLogging": true,
1569 + "ExcludedPaths": ["/health"]
1570 + },
1571 + "AllowedHosts": "*"
1572 + }
1573 + ```
1574 +
1575 + ### `src/{ProjectName}.Api/appsettings.Development.json`
1576 +
1577 + Development overrides. Lowers the minimum log level to `Debug` for richer output during development, raises the slow request threshold to avoid noise, and provides a local PostgreSQL connection string.
1578 +
1579 + ```json
1580 + {
1581 + "ConnectionStrings": {
1582 + "DefaultConnection": "Host=localhost;Port=5432;Database=upmatches_dev;Username=postgres;Password=postgres"
1583 + },
1584 + "Serilog": {
1585 + "MinimumLevel": {
1586 + "Default": "Debug",
1587 + "Override": {
1588 + "Microsoft.AspNetCore": "Information",
1589 + "Microsoft.EntityFrameworkCore": "Information"
1590 + }
1591 + }
1592 + },
1593 + "RequestLogging": {
1594 + "SlowRequestThresholdMs": 1000
1595 + }
1596 + }
1597 + ```
1598 +
1599 + ### `src/{ProjectName}.Api/Configuration/RequestLoggingOptions.cs`
1600 +
1601 + Strongly-typed options class for the request logging middleware. Bound from the `RequestLogging` section of `appsettings.json` via `IOptions<RequestLoggingOptions>`. All values have sensible defaults so the middleware works out of the box even without explicit configuration.
1602 +
1603 + ```csharp
1604 + namespace Upmatches.Api.Configuration;
1605 +
1606 + public sealed class RequestLoggingOptions
1607 + {
1608 + public const string SectionName = "RequestLogging";
1609 +
1610 + /// <summary>
1611 + /// Maximum request/response body size (in bytes) to capture in logs.
1612 + /// Bodies exceeding this limit are truncated. Default: 65,536 (64 KB).
1613 + /// </summary>
1614 + public int MaxBodySizeBytes { get; set; } = 65_536;
1615 +
1616 + /// <summary>
1617 + /// Requests exceeding this duration (in milliseconds) are logged at Warning level.
1618 + /// Default: 500ms.
1619 + /// </summary>
1620 + public int SlowRequestThresholdMs { get; set; } = 500;
1621 +
1622 + /// <summary>
1623 + /// The HTTP header name used for correlation ID propagation.
1624 + /// If the header is present on the incoming request, its value is reused;
1625 + /// otherwise a new GUID is generated. The correlation ID is always returned
1626 + /// in the response headers.
1627 + /// </summary>
1628 + public string CorrelationIdHeader { get; set; } = "X-Correlation-Id";
1629 +
1630 + /// <summary>
1631 + /// JSON field names whose values should be replaced with a redaction placeholder
1632 + /// before logging request/response bodies. Matching is case-insensitive.
1633 + /// </summary>
1634 + public List<string> SensitiveFields { get; set; } =
1635 + [
1636 + "password",
1637 + "token",
1638 + "secret",
1639 + "authorization",
1640 + "creditCard",
1641 + "ssn",
1642 + "accessToken",
1643 + "refreshToken"
1644 + ];
1645 +
1646 + /// <summary>
1647 + /// Content types for which request/response body logging is enabled.
1648 + /// Only JSON content types are included by default because
1649 + /// <see cref="SensitiveDataRedactor"/> only redacts JSON payloads.
1650 + /// Adding non-JSON types (XML, plain text) will cause sensitive data in
1651 + /// those formats to be logged unredacted.
1652 + /// </summary>
1653 + public List<string> LoggableContentTypes { get; set; } =
1654 + [
1655 + "application/json"
1656 + ];
1657 +
1658 + /// <summary>
1659 + /// When true, request bodies are captured and logged.
1660 + /// </summary>
1661 + public bool EnableRequestBodyLogging { get; set; } = true;
1662 +
1663 + /// <summary>
1664 + /// When true, response bodies are captured and logged.
1665 + /// </summary>
1666 + public bool EnableResponseBodyLogging { get; set; } = true;
1667 +
1668 + /// <summary>
1669 + /// Request paths that should be excluded from logging entirely.
1670 + /// Useful for high-frequency endpoints like health checks and readiness probes
1671 + /// that would otherwise generate excessive log noise.
1672 + /// </summary>
1673 + public List<string> ExcludedPaths { get; set; } =
1674 + [
1675 + "/health"
1676 + ];
1677 + }
1678 + ```
1679 +
1680 + ### `src/{ProjectName}.Api/Middleware/SensitiveDataRedactor.cs`
1681 +
1682 + Redacts sensitive field values from JSON content before it reaches the logs. This prevents passwords, tokens, and PII from being stored in log aggregation systems.
1683 +
1684 + **Dual-strategy approach:**
1685 + 1. **JSON DOM path** — for valid JSON, parses the content into a `JsonNode` tree and recursively replaces sensitive field values with `***REDACTED***`. This handles nested objects and arrays reliably.
1686 + 2. **Regex fallback** — for invalid/truncated JSON (e.g., bodies that exceeded `MaxBodySizeBytes`), uses a pre-compiled regex that matches `"sensitiveField": "value"` patterns. The regex has a 100ms timeout to prevent ReDoS attacks.
1687 +
1688 + ```csharp
1689 + using System.Text.Json;
1690 + using System.Text.Json.Nodes;
1691 + using System.Text.RegularExpressions;
1692 + using Microsoft.Extensions.Logging;
1693 + using Microsoft.Extensions.Options;
1694 + using Upmatches.Api.Configuration;
1695 +
1696 + namespace Upmatches.Api.Middleware;
1697 +
1698 + /// <summary>
1699 + /// Redacts sensitive field values from content before it is written to logs.
1700 + /// Field names to redact are configured via <see cref="RequestLoggingOptions.SensitiveFields"/>.
1701 + /// For valid JSON, uses a DOM-based approach for reliable recursive redaction.
1702 + /// For invalid/truncated JSON, falls back to regex-based pattern matching.
1703 + /// </summary>
1704 + public sealed class SensitiveDataRedactor
1705 + {
1706 + private const string RedactedPlaceholder = "***REDACTED***";
1707 +
1708 + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = false };
1709 +
1710 + private readonly ILogger<SensitiveDataRedactor> _logger;
1711 + private readonly HashSet<string> _sensitiveFields;
1712 + private readonly Regex _fallbackRegex;
1713 +
1714 + public SensitiveDataRedactor(
1715 + ILogger<SensitiveDataRedactor> logger,
1716 + IOptions<RequestLoggingOptions> options)
1717 + {
1718 + _logger = logger;
1719 + _sensitiveFields = new HashSet<string>(
1720 + options.Value.SensitiveFields,
1721 + StringComparer.OrdinalIgnoreCase);
1722 +
1723 + // Build a regex that matches JSON key-value pairs for any sensitive field name.
1724 + // Pattern: "fieldName" : "..." — matches quoted string values only.
1725 + // Non-string values (numbers, booleans, null) are not matched by this regex;
1726 + // those are handled by the JSON DOM path for valid JSON.
1727 + if (_sensitiveFields.Count > 0)
1728 + {
1729 + var escapedFields = _sensitiveFields.Select(Regex.Escape);
1730 + var alternation = string.Join("|", escapedFields);
1731 +
1732 + var pattern = $"""
1733 + (?<="(?:{alternation})"\s*:\s*)"(?:[^"\\]|\\.)*"
1734 + """;
1735 +
1736 + _fallbackRegex = new Regex(
1737 + pattern.Trim(),
1738 + RegexOptions.IgnoreCase | RegexOptions.Compiled,
1739 + matchTimeout: TimeSpan.FromMilliseconds(100));
1740 + }
1741 + else
1742 + {
1743 + // No sensitive fields configured — use a regex that never matches.
1744 + _fallbackRegex = new Regex(
1745 + "(?!)",
1746 + RegexOptions.Compiled,
1747 + matchTimeout: TimeSpan.FromMilliseconds(100));
1748 + }
1749 + }
1750 +
1751 + /// <summary>
1752 + /// Redacts values of sensitive fields in the provided content.
1753 + /// Attempts JSON DOM-based redaction first. If the content is not valid JSON
1754 + /// (e.g. truncated bodies), falls back to regex-based pattern matching.
1755 + /// </summary>
1756 + public string Redact(string content)
1757 + {
1758 + if (string.IsNullOrWhiteSpace(content))
1759 + return content;
1760 +
1761 + try
1762 + {
1763 + var node = JsonNode.Parse(content);
1764 +
1765 + if (node is null)
1766 + return content;
1767 +
1768 + RedactNode(node);
1769 +
1770 + return node.ToJsonString(SerializerOptions);
1771 + }
1772 + catch (JsonException)
1773 + {
1774 + // Content is not valid JSON (e.g. truncated). Fall back to regex redaction.
1775 + return RedactWithRegex(content);
1776 + }
1777 + }
1778 +
1779 + /// <summary>
1780 + /// Regex-based fallback for redacting sensitive values in content that is not
1781 + /// parseable as JSON (e.g. truncated bodies). Replaces quoted string values
1782 + /// following sensitive field names with the redaction placeholder.
1783 + /// </summary>
1784 + private string RedactWithRegex(string content)
1785 + {
1786 + try
1787 + {
1788 + return _fallbackRegex.Replace(content, $"\"{RedactedPlaceholder}\"");
1789 + }
1790 + catch (RegexMatchTimeoutException)
1791 + {
1792 + _logger.LogWarning(
1793 + "Sensitive data redaction regex timed out — body redacted entirely to prevent sensitive data exposure");
1794 + return "[REDACTION FAILED — BODY SUPPRESSED]";
1795 + }
1796 + }
1797 +
1798 + private void RedactNode(JsonNode node)
1799 + {
1800 + switch (node)
1801 + {
1802 + case JsonObject jsonObject:
1803 + RedactObject(jsonObject);
1804 + break;
1805 +
1806 + case JsonArray jsonArray:
1807 + RedactArray(jsonArray);
1808 + break;
1809 + }
1810 + }
1811 +
1812 + private void RedactObject(JsonObject jsonObject)
1813 + {
1814 + var propertyNames = jsonObject.Select(p => p.Key).ToList();
1815 +
1816 + foreach (var name in propertyNames)
1817 + {
1818 + if (_sensitiveFields.Contains(name))
1819 + {
1820 + jsonObject[name] = RedactedPlaceholder;
1821 + continue;
1822 + }
1823 +
1824 + var child = jsonObject[name];
1825 +
1826 + if (child is not null)
1827 + RedactNode(child);
1828 + }
1829 + }
1830 +
1831 + private void RedactArray(JsonArray jsonArray)
1832 + {
1833 + foreach (var element in jsonArray)
1834 + {
1835 + if (element is not null)
1836 + RedactNode(element);
1837 + }
1838 + }
1839 + }
1840 + ```
1841 +
1842 + ### `src/{ProjectName}.Api/Middleware/RequestLoggingMiddleware.cs`
1843 +
1844 + Comprehensive HTTP request/response logging middleware. This is the largest single file in the boilerplate (~450 lines) because it handles many concerns carefully:
1845 +
1846 + - **Correlation ID tracking** — accepts a client-supplied correlation ID (validated for safety) or generates a new GUID. The ID is pushed into Serilog's `LogContext` so all downstream log entries include it.
1847 + - **Request/response body capture** — uses `EnableBuffering()` for the request stream and a `MemoryStream` swap for the response stream. Both are size-limited to `MaxBodySizeBytes`.
1848 + - **Sensitive data redaction** — bodies are passed through `SensitiveDataRedactor` before logging.
1849 + - **Slow request warnings** — requests exceeding `SlowRequestThresholdMs` trigger a warning-level log.
1850 + - **Path exclusion** — health checks and other high-frequency endpoints can be excluded to reduce log noise.
1851 + - **Security hardening** — correlation IDs are validated against a safe character set, client IPs are sanitized to prevent log injection, and UTF-8 truncation respects character boundaries.
1852 +
1853 + ```csharp
1854 + using System.Buffers;
1855 + using System.Diagnostics;
1856 + using System.Text;
1857 + using System.Text.RegularExpressions;
1858 + using Microsoft.Extensions.Options;
1859 + using Serilog.Context;
1860 + using Upmatches.Api.Configuration;
1861 +
1862 + namespace Upmatches.Api.Middleware;
1863 +
1864 + /// <summary>
1865 + /// Middleware that provides comprehensive HTTP request/response logging including:
1866 + /// <list type="bullet">
1867 + /// <item>Correlation ID tracking (accept from header or generate)</item>
1868 + /// <item>Request and response body capture (with configurable size limits)</item>
1869 + /// <item>Sensitive data redaction in logged bodies</item>
1870 + /// <item>Slow-request performance warnings</item>
1871 + /// <item>Enriched Serilog LogContext (client IP, user agent, user identity, etc.)</item>
1872 + /// </list>
1873 + /// </summary>
1874 + public sealed partial class RequestLoggingMiddleware
1875 + {
1876 + private const int MaxCorrelationIdLength = 128;
1877 +
1878 + private readonly RequestDelegate _next;
1879 + private readonly ILogger<RequestLoggingMiddleware> _logger;
1880 + private readonly RequestLoggingOptions _options;
1881 + private readonly SensitiveDataRedactor _redactor;
1882 + private readonly List<string> _excludedPathPrefixes;
1883 +
1884 + public RequestLoggingMiddleware(
1885 + RequestDelegate next,
1886 + ILogger<RequestLoggingMiddleware> logger,
1887 + IOptions<RequestLoggingOptions> options,
1888 + SensitiveDataRedactor redactor)
1889 + {
1890 + _next = next;
1891 + _logger = logger;
1892 + _options = options.Value;
1893 + _redactor = redactor;
1894 + _excludedPathPrefixes = _options.ExcludedPaths
1895 + .Select(p => p.TrimEnd('/'))
1896 + .ToList();
1897 + }
1898 +
1899 + public async Task InvokeAsync(HttpContext context)
1900 + {
1901 + // Skip logging for excluded paths (prefix match, e.g. /health also covers /health/ready).
1902 + var requestPath = context.Request.Path.ToString();
1903 +
1904 + if (IsExcludedPath(requestPath))
1905 + {
1906 + await _next(context);
1907 + return;
1908 + }
1909 +
1910 + var correlationId = GetOrCreateCorrelationId(context);
1911 + context.Items["CorrelationId"] = correlationId;
1912 + context.Response.OnStarting(() =>
1913 + {
1914 + context.Response.Headers[_options.CorrelationIdHeader] = correlationId;
1915 + return Task.CompletedTask;
1916 + });
1917 +
1918 + var clientIp = GetClientIp(context);
1919 + var userAgent = context.Request.Headers.UserAgent.ToString();
1920 +
1921 + // Push enrichment properties into Serilog's LogContext so that all
1922 + // downstream log entries within this request scope include them automatically.
1923 + using (LogContext.PushProperty("CorrelationId", correlationId))
1924 + using (LogContext.PushProperty("ClientIp", clientIp))
1925 + using (LogContext.PushProperty("UserAgent", userAgent))
1926 + using (LogContext.PushProperty("RequestMethod", context.Request.Method))
1927 + using (LogContext.PushProperty("RequestPath", requestPath))
1928 + using (LogContext.PushProperty("QueryString", context.Request.QueryString.ToString()))
1929 + using (LogContext.PushProperty("UserIdentity", context.User.Identity?.Name ?? "anonymous"))
1930 + {
1931 + var requestBody = await CaptureRequestBodyAsync(context);
1932 +
1933 + _logger.LogDebug(
1934 + "HTTP {RequestMethod} {RequestPath}{QueryString} started",
1935 + context.Request.Method,
1936 + context.Request.Path,
1937 + context.Request.QueryString);
1938 +
1939 + if (requestBody.Content.Length > 0)
1940 + {
1941 + _logger.LogDebug(
1942 + "Request body: {RequestBody}",
1943 + FormatBodyForLog(requestBody));
1944 + }
1945 +
1946 + // Only allocate and swap the response body stream when response body logging is enabled.
1947 + Stream? originalBodyStream = null;
1948 + MemoryStream? responseBodyStream = null;
1949 +
1950 + if (_options.EnableResponseBodyLogging)
1951 + {
1952 + originalBodyStream = context.Response.Body;
1953 + responseBodyStream = new MemoryStream();
1954 + context.Response.Body = responseBodyStream;
1955 + }
1956 +
1957 + // Start timing just before calling the next middleware so the elapsed time
1958 + // reflects actual pipeline processing, not request body capture overhead.
1959 + var stopwatch = Stopwatch.StartNew();
1960 +
1961 + try
1962 + {
1963 + await _next(context);
1964 + }
1965 + finally
1966 + {
1967 + stopwatch.Stop();
1968 + var elapsedMs = stopwatch.ElapsedMilliseconds;
1969 +
1970 + var responseBody = CapturedBody.Empty;
1971 +
1972 + if (_options.EnableResponseBodyLogging
1973 + && responseBodyStream is not null
1974 + && originalBodyStream is not null)
1975 + {
1976 + responseBody = await CaptureResponseBodySafeAsync(
1977 + context, responseBodyStream, originalBodyStream);
1978 + }
1979 +
1980 + _logger.LogInformation(
1981 + "HTTP {RequestMethod} {RequestPath} completed {StatusCode} in {ElapsedMs}ms",
1982 + context.Request.Method,
1983 + context.Request.Path,
1984 + context.Response.StatusCode,
1985 + elapsedMs);
1986 +
1987 + if (responseBody.Content.Length > 0)
1988 + {
1989 + _logger.LogDebug(
1990 + "Response body: {ResponseBody}",
1991 + FormatBodyForLog(responseBody));
1992 + }
1993 +
1994 + if (elapsedMs > _options.SlowRequestThresholdMs)
1995 + {
1996 + _logger.LogWarning(
1997 + "Slow request detected: HTTP {RequestMethod} {RequestPath} took {ElapsedMs}ms (threshold: {ThresholdMs}ms)",
1998 + context.Request.Method,
1999 + context.Request.Path,
2000 + elapsedMs,
2001 + _options.SlowRequestThresholdMs);
2002 + }
2003 +
2004 + if (responseBodyStream is not null)
2005 + await responseBodyStream.DisposeAsync();
2006 + }
2007 + }
2008 + }
2009 +
2010 + /// <summary>
2011 + /// Redacts the body content first, then appends the truncation marker if needed.
2012 + /// This ensures sensitive fields are redacted even in truncated bodies.
2013 + /// </summary>
2014 + private string FormatBodyForLog(CapturedBody body)
2015 + {
2016 + var redacted = _redactor.Redact(body.Content);
2017 +
2018 + return body.IsTruncated
2019 + ? $"{redacted} ... [TRUNCATED - body exceeds {_options.MaxBodySizeBytes} bytes]"
2020 + : redacted;
2021 + }
2022 +
2023 + private bool IsExcludedPath(string path)
2024 + {
2025 + foreach (var prefix in _excludedPathPrefixes)
2026 + {
2027 + if (path.Equals(prefix, StringComparison.OrdinalIgnoreCase)
2028 + || path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase))
2029 + {
2030 + return true;
2031 + }
2032 + }
2033 +
2034 + return false;
2035 + }
2036 +
2037 + private string GetOrCreateCorrelationId(HttpContext context)
2038 + {
2039 + if (context.Request.Headers.TryGetValue(_options.CorrelationIdHeader, out var existingId)
2040 + && !string.IsNullOrWhiteSpace(existingId))
2041 + {
2042 + var candidate = existingId.ToString();
2043 +
2044 + if (candidate.Length <= MaxCorrelationIdLength && SafeCorrelationIdRegex().IsMatch(candidate))
2045 + return candidate;
2046 +
2047 + // Invalid or oversized correlation ID from client; generate a new one.
2048 + }
2049 +
2050 + return Guid.NewGuid().ToString();
2051 + }
2052 +
2053 + private async Task<CapturedBody> CaptureRequestBodyAsync(HttpContext context)
2054 + {
2055 + if (!_options.EnableRequestBodyLogging)
2056 + return CapturedBody.Empty;
2057 +
2058 + if (!IsLoggableContentType(context.Request.ContentType))
2059 + return CapturedBody.Empty;
2060 +
2061 + context.Request.EnableBuffering();
2062 +
2063 + // Read the request body at the byte level. EnableBuffering() wraps the stream
2064 + // so it supports seeking, allowing us to reset the position after reading.
2065 + var body = await ReadStreamBytesAsync(context.Request.Body, _options.MaxBodySizeBytes);
2066 +
2067 + context.Request.Body.Position = 0;
2068 +
2069 + return body;
2070 + }
2071 +
2072 + /// <summary>
2073 + /// Captures the response body from the memory stream and copies it to the original
2074 + /// response stream. Wrapped in a try/catch so that a failure here (e.g. the response
2075 + /// has already started on the original stream) does not mask the original exception.
2076 + /// </summary>
2077 + private async Task<CapturedBody> CaptureResponseBodySafeAsync(
2078 + HttpContext context,
2079 + MemoryStream responseBodyStream,
2080 + Stream originalBodyStream)
2081 + {
2082 + try
2083 + {
2084 + responseBodyStream.Position = 0;
2085 +
2086 + var body = CapturedBody.Empty;
2087 +
2088 + if (IsLoggableContentType(context.Response.ContentType))
2089 + {
2090 + body = await ReadStreamFromMemoryAsync(responseBodyStream, _options.MaxBodySizeBytes);
2091 + }
2092 +
2093 + responseBodyStream.Position = 0;
2094 + await responseBodyStream.CopyToAsync(originalBodyStream);
2095 + context.Response.Body = originalBodyStream;
2096 +
2097 + return body;
2098 + }
2099 + catch (Exception ex)
2100 + {
2101 + _logger.LogDebug(ex, "Failed to capture response body for logging");
2102 +
2103 + // Best-effort: try to restore the original body stream so the client gets a response.
2104 + try
2105 + {
2106 + context.Response.Body = originalBodyStream;
2107 + }
2108 + catch
2109 + {
2110 + // Nothing more we can do.
2111 + }
2112 +
2113 + return CapturedBody.Empty;
2114 + }
2115 + }
2116 +
2117 + /// <summary>
2118 + /// Reads a stream at the byte level, suitable for forward-only streams (e.g. request body)
2119 + /// where <c>stream.Length</c> may not be available before the stream is consumed.
2120 + /// Uses <see cref="ArrayPool{T}"/> to avoid large object heap allocations.
2121 + /// The limit is enforced in bytes to match <c>MaxBodySizeBytes</c>.
2122 + /// Truncation respects UTF-8 character boundaries.
2123 + /// </summary>
2124 + private static async Task<CapturedBody> ReadStreamBytesAsync(Stream stream, int maxBytes)
2125 + {
2126 + stream.Position = 0;
2127 +
2128 + // Read one extra byte to detect whether the stream has more data beyond the limit.
2129 + var readLimit = maxBytes + 1;
2130 + var buffer = ArrayPool<byte>.Shared.Rent(readLimit);
2131 +
2132 + try
2133 + {
2134 + var totalRead = 0;
2135 +
2136 + while (totalRead < readLimit)
2137 + {
2138 + var bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, readLimit - totalRead));
2139 +
2140 + if (bytesRead == 0)
2141 + break;
2142 +
2143 + totalRead += bytesRead;
2144 + }
2145 +
2146 + var isTruncated = totalRead > maxBytes;
2147 + var usableBytes = Math.Min(totalRead, maxBytes);
2148 +
2149 + // Adjust the truncation point to avoid splitting a multi-byte UTF-8 sequence.
2150 + if (isTruncated)
2151 + usableBytes = FindUtf8SafeTruncationPoint(buffer, usableBytes);
2152 +
2153 + var content = Encoding.UTF8.GetString(buffer, 0, usableBytes);
2154 +
2155 + return new CapturedBody(content, isTruncated);
2156 + }
2157 + finally
2158 + {
2159 + ArrayPool<byte>.Shared.Return(buffer);
2160 + }
2161 + }
2162 +
2163 + /// <summary>
2164 + /// Reads a MemoryStream where <c>stream.Length</c> is reliable.
2165 + /// Used for response body capture. Uses <see cref="ArrayPool{T}"/> to avoid
2166 + /// per-request heap allocations.
2167 + /// Truncation respects UTF-8 character boundaries.
2168 + /// </summary>
2169 + private static async Task<CapturedBody> ReadStreamFromMemoryAsync(MemoryStream stream, int maxBytes)
2170 + {
2171 + stream.Position = 0;
2172 +
2173 + var length = stream.Length;
2174 + var bytesToRead = (int)Math.Min(maxBytes, length);
2175 + var buffer = ArrayPool<byte>.Shared.Rent(bytesToRead);
2176 +
2177 + try
2178 + {
2179 + var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, bytesToRead));
2180 +
2181 + var isTruncated = length > maxBytes;
2182 +
2183 + var usableBytes = bytesRead;
2184 +
2185 + // Adjust the truncation point to avoid splitting a multi-byte UTF-8 sequence.
2186 + if (isTruncated)
2187 + usableBytes = FindUtf8SafeTruncationPoint(buffer, bytesRead);
2188 +
2189 + var content = Encoding.UTF8.GetString(buffer, 0, usableBytes);
2190 +
2191 + return new CapturedBody(content, isTruncated);
2192 + }
2193 + finally
2194 + {
2195 + ArrayPool<byte>.Shared.Return(buffer);
2196 + }
2197 + }
2198 +
2199 + /// <summary>
2200 + /// Walks backwards from <paramref name="length"/> to find a byte position that
2201 + /// does not split a multi-byte UTF-8 character. UTF-8 continuation bytes have the
2202 + /// bit pattern <c>10xxxxxx</c> (0x80..0xBF). If the byte at the truncation point
2203 + /// is a continuation byte, we step back until we reach the leading byte of that
2204 + /// character and exclude the incomplete sequence.
2205 + /// </summary>
2206 + private static int FindUtf8SafeTruncationPoint(byte[] buffer, int length)
2207 + {
2208 + if (length == 0)
2209 + return 0;
2210 +
2211 + // Walk backwards over any continuation bytes (10xxxxxx).
2212 + var i = length - 1;
2213 + while (i > 0 && (buffer[i] & 0xC0) == 0x80)
2214 + i--;
2215 +
2216 + // i now points at a leading byte (or byte 0). Determine the expected
2217 + // character length from the leading byte.
2218 + var leadByte = buffer[i];
2219 + int expectedCharBytes;
2220 +
2221 + if ((leadByte & 0x80) == 0)
2222 + expectedCharBytes = 1; // 0xxxxxxx — ASCII
2223 + else if ((leadByte & 0xE0) == 0xC0)
2224 + expectedCharBytes = 2; // 110xxxxx
2225 + else if ((leadByte & 0xF0) == 0xE0)
2226 + expectedCharBytes = 3; // 1110xxxx
2227 + else if ((leadByte & 0xF8) == 0xF0)
2228 + expectedCharBytes = 4; // 11110xxx
2229 + else
2230 + return i; // Invalid leading byte — truncate before it.
2231 +
2232 + // If the full character fits within the buffer, keep it; otherwise drop it.
2233 + return i + expectedCharBytes <= length ? length : i;
2234 + }
2235 +
2236 + private bool IsLoggableContentType(string? contentType)
2237 + {
2238 + if (string.IsNullOrWhiteSpace(contentType))
2239 + return false;
2240 +
2241 + return _options.LoggableContentTypes.Exists(
2242 + ct => contentType.Contains(ct, StringComparison.OrdinalIgnoreCase));
2243 + }
2244 +
2245 + private static string GetClientIp(HttpContext context)
2246 + {
2247 + // Check for forwarded headers first (reverse proxy scenarios).
2248 + var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
2249 +
2250 + if (!string.IsNullOrWhiteSpace(forwardedFor))
2251 + {
2252 + // X-Forwarded-For may contain multiple IPs; the first is the original client.
2253 + var ip = forwardedFor.Split(',', StringSplitOptions.TrimEntries)[0];
2254 + return SanitizeForLog(ip);
2255 + }
2256 +
2257 + return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
2258 + }
2259 +
2260 + /// <summary>
2261 + /// Strips control characters and newlines from a string to prevent log injection.
2262 + /// Limits length to avoid unbounded values in log output.
2263 + /// </summary>
2264 + private static string SanitizeForLog(string value)
2265 + {
2266 + const int maxLength = 45; // Max length of an IPv6 address with zone ID
2267 +
2268 + if (value.Length > maxLength)
2269 + value = value[..maxLength];
2270 +
2271 + return LogSanitizeRegex().Replace(value, string.Empty);
2272 + }
2273 +
2274 + /// <summary>
2275 + /// Matches control characters, newlines, and other non-printable characters
2276 + /// that could be used for log injection.
2277 + /// </summary>
2278 + [GeneratedRegex(@"[\x00-\x1F\x7F]")]
2279 + private static partial Regex LogSanitizeRegex();
2280 +
2281 + /// <summary>
2282 + /// Matches safe correlation ID values: alphanumeric characters, hyphens, underscores,
2283 + /// periods, and colons. Rejects control characters, braces (Serilog template injection),
2284 + /// and other unsafe characters.
2285 + /// </summary>
2286 + [GeneratedRegex(@"^[\w\-.:]+$")]
2287 + private static partial Regex SafeCorrelationIdRegex();
2288 +
2289 + /// <summary>
2290 + /// Represents a captured request or response body along with a flag indicating
2291 + /// whether the body was truncated to fit within the configured size limit.
2292 + /// Separating the content from the truncation flag allows the redactor to operate
2293 + /// on valid (non-truncated) content before the truncation marker is appended.
2294 + /// </summary>
2295 + private readonly record struct CapturedBody(string Content, bool IsTruncated)
2296 + {
2297 + public static readonly CapturedBody Empty = new(string.Empty, false);
2298 + }
2299 + }
2300 + ```
2301 +
2302 + ### `src/{ProjectName}.Api/Middleware/GlobalExceptionHandler.cs`
2303 +
2304 + Catches all unhandled exceptions and returns a standardized `ProblemDetails` response. In development, the exception message is included for debugging; in production, a generic message is returned to avoid leaking internals.
2305 +
2306 + ```csharp
2307 + using System.Net;
2308 + using Microsoft.AspNetCore.Diagnostics;
2309 + using Microsoft.AspNetCore.Mvc;
2310 +
2311 + namespace Upmatches.Api.Middleware;
2312 +
2313 + public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
2314 + {
2315 + public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
2316 + {
2317 + logger.LogError(exception, "An unhandled exception occurred: {Message}", exception.Message);
2318 +
2319 + var problemDetails = new ProblemDetails
2320 + {
2321 + Status = (int)HttpStatusCode.InternalServerError,
2322 + Title = "An unexpected error occurred",
2323 + Detail = httpContext.RequestServices.GetRequiredService<IHostEnvironment>().IsDevelopment() ? exception.Message : "An internal server error has occurred.",
2324 + Instance = httpContext.Request.Path
2325 + };
2326 +
2327 + httpContext.Response.StatusCode = problemDetails.Status.Value;
2328 + httpContext.Response.ContentType = "application/problem+json";
2329 + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken: cancellationToken);
2330 + return true;
2331 + }
2332 + }
2333 + ```
2334 +
2335 + ### `src/{ProjectName}.Api/Extensions/ServiceCollectionExtensions.cs`
2336 +
2337 + The composition root's service registration. Wires together:
2338 + - Serilog (reads config from `appsettings.json`)
2339 + - Request logging options and redactor
2340 + - Controllers, OpenAPI, exception handler, problem details
2341 + - API versioning (URL path segment: `/api/v1/`)
2342 + - Application layer (MediatR + behaviors + validators)
2343 + - Infrastructure layer (EF Core + interceptors)
2344 + - Health checks (PostgreSQL connectivity)
2345 +
2346 + ```csharp
2347 + using Asp.Versioning;
2348 + using Serilog;
2349 + using Upmatches.Api.Configuration;
2350 + using Upmatches.Api.Middleware;
2351 + using Upmatches.Application;
2352 + using Upmatches.Infrastructure;
2353 +
2354 + namespace Upmatches.Api.Extensions;
2355 +
2356 + public static class ServiceCollectionExtensions
2357 + {
2358 + public static WebApplicationBuilder AddServices(this WebApplicationBuilder builder)
2359 + {
2360 + builder.Host.UseSerilog((context, loggerConfiguration) =>
2361 + loggerConfiguration.ReadFrom.Configuration(context.Configuration));
2362 +
2363 + builder.Services.Configure<RequestLoggingOptions>(
2364 + builder.Configuration.GetSection(RequestLoggingOptions.SectionName));
2365 + builder.Services.AddSingleton<SensitiveDataRedactor>();
2366 +
2367 + builder.Services.AddControllers();
2368 + builder.Services.AddOpenApi();
2369 + builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
2370 + builder.Services.AddProblemDetails();
2371 +
2372 + builder.Services.AddApiVersioning(options =>
2373 + {
2374 + options.DefaultApiVersion = new ApiVersion(1, 0);
2375 + options.AssumeDefaultVersionWhenUnspecified = true;
2376 + options.ReportApiVersions = true;
2377 + options.ApiVersionReader = new UrlSegmentApiVersionReader();
2378 + })
2379 + .AddApiExplorer(options =>
2380 + {
2381 + options.GroupNameFormat = "'v'VVV";
2382 + options.SubstituteApiVersionInUrl = true;
2383 + });
2384 +
2385 + builder.Services.AddApplication();
2386 + builder.Services.AddInfrastructure(builder.Configuration);
2387 +
2388 + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
2389 + ?? throw new InvalidOperationException(
2390 + "Connection string 'DefaultConnection' is not configured.");
2391 +
2392 + builder.Services.AddHealthChecks()
2393 + .AddNpgSql(connectionString);
2394 +
2395 + return builder;
2396 + }
2397 + }
2398 + ```
2399 +
2400 + ### `src/{ProjectName}.Api/Extensions/WebApplicationExtensions.cs`
2401 +
2402 + Configures the HTTP request pipeline (middleware order matters):
2403 + 1. OpenAPI endpoint (development only)
2404 + 2. `RequestLoggingMiddleware` — must come early to capture the full request lifecycle
2405 + 3. Exception handler — catches exceptions from downstream middleware
2406 + 4. HTTPS redirection
2407 + 5. Authorization
2408 + 6. Controller mapping
2409 + 7. Health check endpoint
2410 +
2411 + ```csharp
2412 + using Upmatches.Api.Middleware;
2413 +
2414 + namespace Upmatches.Api.Extensions;
2415 +
2416 + public static class WebApplicationExtensions
2417 + {
2418 + public static WebApplication ConfigurePipeline(this WebApplication app)
2419 + {
2420 + if (app.Environment.IsDevelopment())
2421 + app.MapOpenApi();
2422 +
2423 + app.UseMiddleware<RequestLoggingMiddleware>();
2424 + app.UseExceptionHandler();
2425 + app.UseHttpsRedirection();
2426 + app.UseAuthorization();
2427 + app.MapControllers();
2428 + app.MapHealthChecks("/health");
2429 +
2430 + return app;
2431 + }
2432 + }
2433 + ```
2434 +
2435 + ### `src/{ProjectName}.Api/Program.cs`
2436 +
2437 + The application entry point. Deliberately minimal — all setup is delegated to extension methods. The try/catch/finally ensures Serilog captures fatal startup errors and flushes all buffered log events on shutdown.
2438 +
2439 + The `public partial class Program;` declaration at the end enables `WebApplicationFactory<Program>` in integration tests.
2440 +
2441 + ```csharp
2442 + using Serilog;
2443 + using Upmatches.Api.Extensions;
2444 +
2445 + var builder = WebApplication.CreateBuilder(args);
2446 +
2447 + builder.AddServices();
2448 +
2449 + var app = builder.Build();
2450 +
2451 + app.ConfigurePipeline();
2452 +
2453 + try
2454 + {
2455 + Log.Information("Starting Upmatches API in {Environment} environment", app.Environment.EnvironmentName);
2456 + app.Run();
2457 + }
2458 + catch (Exception ex)
2459 + {
2460 + Log.Fatal(ex, "Application terminated unexpectedly");
2461 + }
2462 + finally
2463 + {
2464 + Log.CloseAndFlush();
2465 + }
2466 +
2467 + public partial class Program;
2468 + ```
2469 +
2470 + ---
2471 +
2472 + ## 12. Test Projects
2473 +
2474 + ### Test Strategy
2475 +
2476 + The boilerplate scaffolds three test projects, each targeting a different layer and testing style:
2477 +
2478 + | Project | Tests | Style |
2479 + |---|---|---|
2480 + | `{ProjectName}.Domain.Tests` | Entities, value objects, Result pattern, domain logic | Pure unit tests — no mocks needed (Domain has zero dependencies) |
2481 + | `{ProjectName}.Application.Tests` | Command/query handlers, validators, pipeline behaviors | Unit tests with mocked `ApplicationDbContext` and `IUnitOfWork` |
2482 + | `{ProjectName}.IntegrationTests` | Full HTTP request/response cycle through the API | Integration tests using `WebApplicationFactory<Program>` with a real PostgreSQL instance |
2483 +
2484 + **Shared test tooling across all projects:**
2485 + - **xUnit** — test framework (`[Fact]`, `[Theory]`)
2486 + - **FluentAssertions** — expressive assertions (`result.Should().Be(...)`)
2487 + - **Moq** — mocking framework for isolating dependencies
2488 + - **coverlet.collector** — code coverage collection (used by CI pipeline)
2489 +
2490 + **Integration test additions:**
2491 + - **`Microsoft.AspNetCore.Mvc.Testing`** — provides `WebApplicationFactory<Program>` for in-process HTTP testing
2492 +
2493 + ### Test File Naming Convention
2494 +
2495 + Test files follow the pattern `{ClassUnderTest}Tests.cs`. For example:
2496 + - `ResultTests.cs` tests `Result.cs`
2497 + - `ValidationBehaviorTests.cs` tests `ValidationBehavior.cs`
2498 +
2499 + ### Project References
2500 +
2501 + Each test project references only the layer it tests:
2502 + - `Domain.Tests` → `Domain`
2503 + - `Application.Tests` → `Application` (which transitively includes Domain)
2504 + - `IntegrationTests` → `Api` (which transitively includes everything)
2505 +
2506 + This mirrors the Clean Architecture dependency rule — test projects never reach across layers.
2507 +
2508 + ---
2509 +
2510 + ## 13. CI/CD Pipeline
2511 +
2512 + The CI/CD pipeline is split into two conceptual sections:
2513 +
2514 + 1. **Standard CI** (test, SonarCloud, Qodana) — reusable as-is across projects. Just update repository variables.
2515 + 2. **Deployment** (Docker build/push, SSH deploy) — project-specific. Customize the deployment target, Tailscale config, and SSH details.
2516 +
2517 + ### `qodana.yaml`
2518 +
2519 + Configuration for JetBrains Qodana static analysis. Points to the `.slnx` solution file and uses the starter inspection profile.
2520 +
2521 + ```yaml
2522 + #-------------------------------------------------------------------------------#
2523 + # Qodana analysis is configured by qodana.yaml file #
2524 + # https://www.jetbrains.com/help/qodana/qodana-yaml.html #
2525 + #-------------------------------------------------------------------------------#
2526 +
2527 + #################################################################################
2528 + # WARNING: Do not store sensitive information in this file, #
2529 + # as its contents will be included in the Qodana report. #
2530 + #################################################################################
2531 + version: "1.0"
2532 +
2533 + #Specify IDE code to run analysis without container (Applied in CI/CD pipeline)
2534 + ide: QDNET
2535 +
2536 + #Specify the .NET solution to analyze
2537 + dotnet:
2538 + solution: Upmatches.slnx
2539 +
2540 + #Specify inspection profile for code analysis
2541 + profile:
2542 + name: qodana.starter
2543 +
2544 + #Enable inspections
2545 + #include:
2546 + # - name: <SomeEnabledInspectionId>
2547 +
2548 + #Disable inspections
2549 + #exclude:
2550 + # - name: <SomeDisabledInspectionId>
2551 + # paths:
2552 + # - <path/where/not/run/inspection>
2553 +
2554 + #Execute shell command before Qodana execution (Applied in CI/CD pipeline)
2555 + #bootstrap: sh ./prepare-qodana.sh
2556 +
2557 + #Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
2558 + #plugins:
2559 + # - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
2560 +
2561 + # Quality gate. Will fail the CI/CD pipeline if any condition is not met
2562 + # severityThresholds - configures maximum thresholds for different problem severities
2563 + # testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
2564 + # Code Coverage is available in Ultimate and Ultimate Plus plans
2565 + #failureConditions:
2566 + # severityThresholds:
2567 + # any: 15
2568 + # critical: 5
2569 + # testCoverageThresholds:
2570 + # fresh: 70
2571 + # total: 50
2572 + ```
2573 +
2574 + ### `.github/workflows/ci.yml`
2575 +
2576 + Full CI/CD pipeline with four jobs:
2577 +
2578 + ```yaml
2579 + name: CI/CD Pipeline
2580 +
2581 + on:
2582 + push:
2583 + branches: [main, dev]
2584 + pull_request:
2585 + branches: [main, dev]
2586 +
2587 + env:
2588 + DOTNET_VERSION: "10.0.x"
2589 + JAVA_VERSION: "17"
2590 + DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/upmatches-api
2591 + ```
2592 +
2593 + #### Job 1: Build & Test
2594 +
2595 + Runs on every push and PR. Spins up a PostgreSQL service container, restores, builds, and runs all tests. Test results and coverage reports are uploaded as artifacts.
2596 +
2597 + ```yaml
2598 + jobs:
2599 + # ──────────────────────────────────────────────
2600 + # Job 1: Build, test, and collect coverage
2601 + # ──────────────────────────────────────────────
2602 + test:
2603 + name: Build & Test
2604 + runs-on: ubuntu-latest
2605 +
2606 + services:
2607 + postgres:
2608 + image: postgres:17-alpine
2609 + env:
2610 + POSTGRES_DB: upmatches_test
2611 + POSTGRES_USER: postgres
2612 + POSTGRES_PASSWORD: postgres
2613 + ports:
2614 + - 5432:5432
2615 + options: >-
2616 + --health-cmd "pg_isready -U postgres"
2617 + --health-interval 10s
2618 + --health-timeout 5s
2619 + --health-retries 5
2620 +
2621 + steps:
2622 + - name: Checkout repository
2623 + uses: actions/checkout@v4
2624 +
2625 + - name: Setup .NET
2626 + uses: actions/setup-dotnet@v4
2627 + with:
2628 + dotnet-version: ${{ env.DOTNET_VERSION }}
2629 +
2630 + - name: Restore dependencies
2631 + run: dotnet restore
2632 +
2633 + - name: Build solution
2634 + run: dotnet build --no-restore --configuration Release
2635 +
2636 + - name: Run tests
2637 + run: >-
2638 + dotnet test
2639 + --no-build
2640 + --configuration Release
2641 + --logger "trx;LogFileName=test-results.trx"
2642 + --collect:"XPlat Code Coverage"
2643 + --results-directory ./TestResults
2644 + env:
2645 + ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=upmatches_test;Username=postgres;Password=postgres"
2646 +
2647 + - name: Upload test results
2648 + uses: actions/upload-artifact@v4
2649 + if: always()
2650 + with:
2651 + name: test-results
2652 + path: ./TestResults
2653 + retention-days: 7
2654 + ```
2655 +
2656 + #### Job 2: SonarCloud Analysis
2657 +
2658 + Runs after tests pass. Performs static analysis and code coverage reporting via SonarCloud. Requires `SONAR_TOKEN` secret and `SONAR_PROJECT_KEY` / `SONAR_ORGANIZATION_KEY` variables.
2659 +
2660 + ```yaml
2661 + # ──────────────────────────────────────────────
2662 + # Job 2: SonarCloud analysis
2663 + # ──────────────────────────────────────────────
2664 + sonar:
2665 + name: SonarCloud Analysis
2666 + runs-on: ubuntu-latest
2667 + needs: test
2668 +
2669 + services:
2670 + postgres:
2671 + image: postgres:17-alpine
2672 + env:
2673 + POSTGRES_DB: upmatches_test
2674 + POSTGRES_USER: postgres
2675 + POSTGRES_PASSWORD: postgres
2676 + ports:
2677 + - 5432:5432
2678 + options: >-
2679 + --health-cmd "pg_isready -U postgres"
2680 + --health-interval 10s
2681 + --health-timeout 5s
2682 + --health-retries 5
2683 +
2684 + steps:
2685 + - name: Checkout repository
2686 + uses: actions/checkout@v4
2687 + with:
2688 + fetch-depth: 0
2689 +
2690 + - name: Setup .NET
2691 + uses: actions/setup-dotnet@v4
2692 + with:
2693 + dotnet-version: ${{ env.DOTNET_VERSION }}
2694 +
2695 + - name: Setup Java
2696 + uses: actions/setup-java@v4
2697 + with:
2698 + distribution: "temurin"
2699 + java-version: ${{ env.JAVA_VERSION }}
2700 +
2701 + - name: Install SonarScanner
2702 + run: dotnet tool install --global dotnet-sonarscanner
2703 +
2704 + - name: Begin SonarCloud analysis
2705 + env:
2706 + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
2707 + run: >-
2708 + dotnet sonarscanner begin
2709 + /k:"${{ vars.SONAR_PROJECT_KEY }}"
2710 + /o:"${{ vars.SONAR_ORGANIZATION_KEY }}"
2711 + /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
2712 + /d:sonar.host.url="https://sonarcloud.io"
2713 + /d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml"
2714 + /d:sonar.exclusions="**/obj/**,**/bin/**"
2715 + /d:sonar.coverage.exclusions="**/obj/**,**/bin/**,**/Migrations/**"
2716 +
2717 + - name: Build solution
2718 + run: dotnet build --configuration Release
2719 +
2720 + - name: Run tests with coverage
2721 + run: >-
2722 + dotnet test
2723 + --no-build
2724 + --configuration Release
2725 + --collect:"XPlat Code Coverage;Format=opencover"
2726 + --results-directory ./TestResults
2727 + env:
2728 + ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=upmatches_test;Username=postgres;Password=postgres"
2729 +
2730 + - name: End SonarCloud analysis
2731 + env:
2732 + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
2733 + run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
2734 +
2735 + - name: Check SonarCloud Quality Gate
2736 + uses: sonarsource/sonarqube-quality-gate-action@v1.2.0
2737 + timeout-minutes: 5
2738 + continue-on-error: true
2739 + with:
2740 + scanMetadataReportFile: .sonarqube/out/.sonar/report-task.txt
2741 + env:
2742 + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
2743 + ```
2744 +
2745 + #### Job 3: Qodana Analysis
2746 +
2747 + Runs in parallel with SonarCloud (both depend on `test`). Uses JetBrains Qodana for .NET static analysis. Requires `QODANA_TOKEN` secret and `contents: write` permissions for PR annotations.
2748 +
2749 + ```yaml
2750 + # ──────────────────────────────────────────────
2751 + # Job 3: Qodana analysis (parallel with SonarCloud)
2752 + # ──────────────────────────────────────────────
2753 + qodana:
2754 + name: Qodana Analysis
2755 + runs-on: ubuntu-latest
2756 + needs: test
2757 +
2758 + permissions:
2759 + contents: write
2760 + pull-requests: write
2761 + checks: write
2762 +
2763 + steps:
2764 + - name: Checkout repository
2765 + uses: actions/checkout@v4
2766 + with:
2767 + fetch-depth: 0
2768 +
2769 + - name: Qodana Scan
2770 + uses: JetBrains/qodana-action@v2025.1
2771 + env:
2772 + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
2773 + ```
2774 +
2775 + #### Job 4: Deploy to Server
2776 +
2777 + Only runs on pushes to `dev` (not PRs, not `main`). Builds a Docker image, pushes it to Docker Hub, then deploys to a remote server via SSH over Tailscale. This job is project-specific — customize the deployment target, Tailscale OAuth credentials, and SSH details.
2778 +
2779 + ```yaml
2780 + # ──────────────────────────────────────────────
2781 + # Job 4: Build, push, and deploy
2782 + # ──────────────────────────────────────────────
2783 + deploy:
2784 + name: Deploy to Server
2785 + runs-on: ubuntu-latest
2786 + needs: [sonar, qodana]
2787 + if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
2788 + environment: Development
2789 +
2790 + steps:
2791 + - name: Checkout repository
2792 + uses: actions/checkout@v4
2793 +
2794 + - name: Login to Docker Hub
2795 + uses: docker/login-action@v3
2796 + with:
2797 + username: ${{ vars.DOCKERHUB_USERNAME }}
2798 + password: ${{ secrets.DOCKERHUB_TOKEN }}
2799 +
2800 + - name: Set up Docker Buildx
2801 + uses: docker/setup-buildx-action@v3
2802 +
2803 + - name: Build and push Docker image
2804 + uses: docker/build-push-action@v6
2805 + with:
2806 + context: .
2807 + push: true
2808 + tags: |
2809 + ${{ env.DOCKER_IMAGE }}:latest
2810 + ${{ env.DOCKER_IMAGE }}:${{ github.sha }}
2811 + cache-from: type=gha
2812 + cache-to: type=gha,mode=max
2813 +
2814 + - name: Connect to Tailscale
2815 + uses: tailscale/github-action@v4
2816 + with:
2817 + oauth-client-id: ${{ vars.TS_OAUTH_CLIENT_ID }}
2818 + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
2819 + tags: tag:ci
2820 +
2821 + - name: Copy compose file to server
2822 + uses: appleboy/scp-action@v1.0.0
2823 + with:
2824 + host: ${{ vars.SSH_HOST }}
2825 + username: ${{ vars.SSH_USERNAME }}
2826 + key: ${{ secrets.SSH_KEY }}
2827 + port: ${{ vars.SSH_PORT }}
2828 + source: "compose.yml,.env.example"
2829 + target: ${{ vars.DEPLOY_PATH }}
2830 +
2831 + - name: Deploy via SSH
2832 + uses: appleboy/ssh-action@v1.2.0
2833 + with:
2834 + host: ${{ vars.SSH_HOST }}
2835 + username: ${{ vars.SSH_USERNAME }}
2836 + key: ${{ secrets.SSH_KEY }}
2837 + port: ${{ vars.SSH_PORT }}
2838 + envs: DOCKER_IMAGE,IMAGE_TAG
2839 + script: |
2840 + cd ${{ vars.DEPLOY_PATH }}
2841 + if [ ! -f .env ]; then
2842 + cp .env.example .env
2843 + chmod 600 .env
2844 + fi
2845 +
2846 + # Update DOCKER_IMAGE and IMAGE_TAG in .env with values from CI
2847 + sed -i "s|^DOCKER_IMAGE=.*|DOCKER_IMAGE=${DOCKER_IMAGE}|" .env
2848 + sed -i "s|^IMAGE_TAG=.*|IMAGE_TAG=${IMAGE_TAG}|" .env
2849 +
2850 + docker compose pull
2851 + docker compose up -d
2852 + sleep 10
2853 +
2854 + # Verify all expected services are running
2855 + EXPECTED=2
2856 + RUNNING=$(docker compose ps --status running --quiet | wc -l)
2857 + if [ "$RUNNING" -lt "$EXPECTED" ]; then
2858 + echo "Expected $EXPECTED services, found $RUNNING running"
2859 + docker compose logs --tail=50
2860 + exit 1
2861 + fi
2862 + env:
2863 + DOCKER_IMAGE: ${{ env.DOCKER_IMAGE }}
2864 + IMAGE_TAG: ${{ github.sha }}
2865 + ```
2866 +
2867 + ### Required GitHub Secrets and Variables
2868 +
2869 + | Type | Name | Description |
2870 + |---|---|---|
2871 + | **Secret** | `SONAR_TOKEN` | SonarCloud authentication token |
2872 + | **Secret** | `QODANA_TOKEN` | Qodana Cloud authentication token |
2873 + | **Secret** | `DOCKERHUB_TOKEN` | Docker Hub access token |
2874 + | **Secret** | `SSH_KEY` | Private SSH key for deployment server |
2875 + | **Secret** | `TS_OAUTH_SECRET` | Tailscale OAuth secret |
2876 + | **Variable** | `DOCKERHUB_USERNAME` | Docker Hub username |
2877 + | **Variable** | `SONAR_PROJECT_KEY` | SonarCloud project key |
2878 + | **Variable** | `SONAR_ORGANIZATION_KEY` | SonarCloud organization key |
2879 + | **Variable** | `SSH_HOST` | Deployment server hostname (Tailscale IP) |
2880 + | **Variable** | `SSH_USERNAME` | SSH username on deployment server |
2881 + | **Variable** | `SSH_PORT` | SSH port on deployment server |
2882 + | **Variable** | `TS_OAUTH_CLIENT_ID` | Tailscale OAuth client ID |
2883 + | **Variable** | `DEPLOY_PATH` | Path on server where app is deployed |
2884 +
2885 + ---
2886 +
2887 + ## 14. AI-Assisted Development
2888 +
2889 + ### `.github/copilot-instructions.md`
2890 +
2891 + This file provides project-specific context to GitHub Copilot (and other AI assistants that read it). It documents:
2892 +
2893 + - **Git commit conventions** — execute commits directly, no `Co-authored-by` trailers
2894 + - **Build & run commands** — `dotnet build`, `dotnet run`, `dotnet test`, EF Core migrations
2895 + - **Architecture** — Clean Architecture dependency flow, layer responsibilities
2896 + - **Key conventions** — CQRS with MediatR, Result pattern, Domain layer rules
2897 + - **Testing conventions** — xUnit, Moq, FluentAssertions, `WebApplicationFactory<Program>`
2898 + - **Tech stack** — .NET 10, PostgreSQL, MediatR 14, FluentValidation 12, Serilog
2899 +
2900 + This file is automatically picked up by Copilot in VS Code and GitHub.com, providing contextual suggestions that align with the project's architecture and conventions.
2901 +
2902 + ---
2903 +
2904 + ## 15. Running the Project
2905 +
2906 + ### Local Development (without Docker)
2907 +
2908 + ```bash
2909 + # Start PostgreSQL (via Docker or locally)
2910 + docker compose up db -d
2911 +
2912 + # Run the API
2913 + dotnet run --project src/Upmatches.Api
2914 +
2915 + # API available at http://localhost:5212
2916 + # Health check: http://localhost:5212/health
2917 + # OpenAPI spec: http://localhost:5212/openapi/v1.json (dev only)
2918 + ```
2919 +
2920 + ### Docker Compose (full stack)
2921 +
2922 + ```bash
2923 + # Build and start everything
2924 + docker compose up --build -d
2925 +
2926 + # API available at http://localhost:5212
2927 + # Health check: http://localhost:5212/health
2928 + ```
2929 +
2930 + ### Running Tests
2931 +
2932 + ```bash
2933 + # All tests
2934 + dotnet test
2935 +
2936 + # Specific test project
2937 + dotnet test tests/Upmatches.Domain.Tests
2938 + dotnet test tests/Upmatches.Application.Tests
2939 + dotnet test tests/Upmatches.IntegrationTests
2940 +
2941 + # Single test by name
2942 + dotnet test --filter "FullyQualifiedName~MyTestMethod"
2943 + ```
2944 +
2945 + ### EF Core Migrations
2946 +
2947 + ```bash
2948 + # Add a new migration
2949 + dotnet ef migrations add <MigrationName> \
2950 + --project src/Upmatches.Infrastructure \
2951 + --startup-project src/Upmatches.Api
2952 +
2953 + # Apply migrations
2954 + dotnet ef database update \
2955 + --project src/Upmatches.Infrastructure \
2956 + --startup-project src/Upmatches.Api
2957 + ```
2958 +
2959 + ---
2960 +
2961 + ## Appendix A: Outbox Pattern (Optional)
2962 +
2963 + > **This section is an optional enhancement.** The boilerplate uses EF Core's `DomainEventInterceptor` to dispatch domain events in-process during `SaveChanges`. The outbox pattern is the next evolution — use it when you need **guaranteed delivery** of domain events to external systems (message brokers, other microservices) with at-least-once semantics.
2964 +
2965 + ### The Problem
2966 +
2967 + The current `DomainEventInterceptor` dispatches events via MediatR *inside* the `SaveChanges` pipeline. This works well for in-process handlers (updating read models, sending notifications, etc.), but has two limitations:
2968 +
2969 + 1. **No guarantee of delivery to external systems** — if the application crashes after `SaveChanges` but before an external message is sent, the event is lost.
2970 + 2. **Coupling to the transaction boundary** — if an event handler calls an external API or publishes to a message broker, you're mixing I/O with the database transaction.
2971 +
2972 + ### The Outbox Pattern Solution
2973 +
2974 + Instead of dispatching events immediately, write them as rows in an `OutboxMessages` table within the **same database transaction** as the business data. A background job (Quartz.NET, which is already included in this boilerplate) polls the table and publishes events to external consumers.
2975 +
2976 + **Flow:**
2977 + 1. Handler modifies entity + raises domain event
2978 + 2. `SaveChanges` interceptor serializes domain events into `OutboxMessages` table (same transaction)
2979 + 3. Transaction commits — business data and outbox messages are atomically consistent
2980 + 4. Quartz.NET background job polls `OutboxMessages`, publishes to message broker, marks as processed
2981 +
2982 + ### Implementation Sketch
2983 +
2984 + **1. Outbox entity (Domain or Infrastructure layer):**
2985 +
2986 + ```csharp
2987 + public sealed class OutboxMessage
2988 + {
2989 + public Guid Id { get; init; }
2990 + public string Type { get; init; } = string.Empty; // Event CLR type name
2991 + public string Content { get; init; } = string.Empty; // Serialized event payload (JSON)
2992 + public DateTime OccurredOnUtc { get; init; }
2993 + public DateTime? ProcessedOnUtc { get; set; }
2994 + public string? Error { get; set; }
2995 + }
2996 + ```
2997 +
2998 + **2. Modified interceptor (writes to outbox instead of dispatching):**
2999 +
3000 + ```csharp
3001 + // In SaveChangesInterceptor, replace MediatR Publish with:
3002 + var outboxMessages = domainEvents.Select(e => new OutboxMessage
3003 + {
3004 + Id = Guid.NewGuid(),
3005 + Type = e.GetType().Name,
3006 + Content = JsonConvert.SerializeObject(e, new JsonSerializerSettings
3007 + {
3008 + TypeNameHandling = TypeNameHandling.All
3009 + }),
3010 + OccurredOnUtc = DateTime.UtcNow
3011 + });
3012 +
3013 + dbContext.Set<OutboxMessage>().AddRange(outboxMessages);
3014 + // Events are persisted in the same SaveChanges transaction
3015 + ```
3016 +
3017 + **3. Quartz.NET background job:**
3018 +
3019 + ```csharp
3020 + [DisallowConcurrentExecution]
3021 + public sealed class ProcessOutboxMessagesJob(
3022 + ApplicationDbContext dbContext,
3023 + IPublisher publisher) : IJob
3024 + {
3025 + public async Task Execute(IJobExecutionContext context)
3026 + {
3027 + var messages = await dbContext.Set<OutboxMessage>()
3028 + .Where(m => m.ProcessedOnUtc == null)
3029 + .OrderBy(m => m.OccurredOnUtc)
3030 + .Take(20)
3031 + .ToListAsync(context.CancellationToken);
3032 +
3033 + foreach (var message in messages)
3034 + {
3035 + try
3036 + {
3037 + var domainEvent = JsonConvert.DeserializeObject<IDomainEvent>(
3038 + message.Content,
3039 + new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All });
3040 +
3041 + if (domainEvent is not null)
3042 + await publisher.Publish(domainEvent, context.CancellationToken);
3043 +
3044 + message.ProcessedOnUtc = DateTime.UtcNow;
3045 + }
3046 + catch (Exception ex)
3047 + {
3048 + message.Error = ex.ToString();
3049 + }
3050 + }
3051 +
3052 + await dbContext.SaveChangesAsync(context.CancellationToken);
3053 + }
3054 + }
3055 + ```
3056 +
3057 + ### When to Adopt This
3058 +
3059 + - You're publishing events to a message broker (RabbitMQ, Azure Service Bus, Kafka)
3060 + - You need at-least-once delivery guarantees
3061 + - You're in a microservices architecture where services communicate via events
3062 +
3063 + For purely in-process event handling (the common case in a monolith), the existing `DomainEventInterceptor` approach is simpler and sufficient.

weehong 已修改 1 month ago. 還原成這個修訂版本

1 file changed, 3 insertions, 3 deletions

README.md

@@ -5,8 +5,8 @@ Run the following command in your terminal. Replace `MyProjectName` with your de
5 5
6 6 ```bash
7 7 # Syntax: curl [url] | bash -s -- [ProjectName]
8 - curl -fsSL https://gist.githubusercontent.com/weehong-1/69b63249165a4373204e82c88b211a78/raw/scaffold.sh | bash -s -- MyCleanApp
9 - bash -c "$(curl -fsSL https://gist.githubusercontent.com/weehong-1/69b63249165a4373204e82c88b211a78/raw/scaffold_install.sh)"
8 + curl -fsSL https://opengist.rmrf.online/weehong/7548f80484b4419b9ba93ca45e784638/raw/HEAD/scaffold.sh | bash -s -- MyCleanApp
9 + bash -c "$(curl -fsSL https://opengist.rmrf.online/weehong/7548f80484b4419b9ba93ca45e784638/raw/HEAD/scaffold_project.sh)"
10 10 ```
11 11
12 12 ### 🪟 Windows (PowerShell)
@@ -14,5 +14,5 @@ Run this command to scaffold the solution.
14 14
15 15 ```powershell
16 16 # Syntax: irm [url] | % { & ([scriptblock]::Create($_)) [ProjectName] }
17 - irm "https://gist.githubusercontent.com/weehong-1/69b63249165a4373204e82c88b211a78/raw/scaffold.ps1" | % { & ([scriptblock]::Create($_)) Tidverse }
17 + irm "https://opengist.rmrf.online/weehong/7548f80484b4419b9ba93ca45e784638/raw/HEAD/scaffold.ps1" | % { & ([scriptblock]::Create($_)) Tidverse }
18 18 ```

weehong 已修改 1 month ago. 還原成這個修訂版本

4 files changed, 563 insertions

README.md(檔案已創建)

@@ -0,0 +1,18 @@
1 + ## How to use
2 +
3 + ### 🍎 Linux / macOS
4 + Run the following command in your terminal. Replace `MyProjectName` with your desired solution name.
5 +
6 + ```bash
7 + # Syntax: curl [url] | bash -s -- [ProjectName]
8 + curl -fsSL https://gist.githubusercontent.com/weehong-1/69b63249165a4373204e82c88b211a78/raw/scaffold.sh | bash -s -- MyCleanApp
9 + bash -c "$(curl -fsSL https://gist.githubusercontent.com/weehong-1/69b63249165a4373204e82c88b211a78/raw/scaffold_install.sh)"
10 + ```
11 +
12 + ### 🪟 Windows (PowerShell)
13 + Run this command to scaffold the solution.
14 +
15 + ```powershell
16 + # Syntax: irm [url] | % { & ([scriptblock]::Create($_)) [ProjectName] }
17 + irm "https://gist.githubusercontent.com/weehong-1/69b63249165a4373204e82c88b211a78/raw/scaffold.ps1" | % { & ([scriptblock]::Create($_)) Tidverse }
18 + ```

scaffold.ps1(檔案已創建)

@@ -0,0 +1,168 @@
1 + param(
2 + [string]$ProjectName,
3 + [ValidateSet("slnx", "sln")]
4 + [string]$Format = "slnx",
5 + [switch]$SkipPackages = $false
6 + )
7 +
8 + # --- 1. Setup & Validation ---
9 + if ([string]::IsNullOrWhiteSpace($ProjectName)) {
10 + $ProjectName = Read-Host "Please enter a project name"
11 + }
12 +
13 + if ([string]::IsNullOrWhiteSpace($ProjectName)) {
14 + Write-Host "❌ Name required." -ForegroundColor Red; exit 1
15 + }
16 +
17 + if (Test-Path $ProjectName) {
18 + Write-Host "❌ Directory '$ProjectName' already exists. Aborting to prevent overwrite." -ForegroundColor Red; exit 1
19 + }
20 +
21 + $SlnFile = "$ProjectName.$Format"
22 + Write-Host "🚀 Scaffolding Clean Architecture (DDD) for: $ProjectName" -ForegroundColor Cyan
23 +
24 + # Create Root Directory
25 + New-Item -ItemType Directory -Path $ProjectName | Out-Null
26 + Set-Location $ProjectName
27 +
28 + # --- 2. Smart SDK Detection ---
29 + $LatestSdk = dotnet --list-sdks | Select-Object -Last 1 | ForEach-Object { $_.Split(' ')[0] }
30 +
31 + if ($LatestSdk) {
32 + Write-Host "ℹ️ Detected SDK: $LatestSdk. Pinning global.json..." -ForegroundColor Gray
33 + dotnet new globaljson --sdk-version $LatestSdk --roll-forward latestFeature
34 + }
35 +
36 + dotnet new gitignore
37 +
38 + # --- 3. Create Solution & Fix NuGet ---
39 + if ($Format -eq "slnx") { dotnet new sln -n $ProjectName --format slnx }
40 + else { dotnet new sln -n $ProjectName }
41 +
42 + Write-Host "📦 Configuring NuGet sources..." -ForegroundColor Cyan
43 + dotnet new nugetconfig --force
44 + $CurrentSources = dotnet nuget list source --configfile "nuget.config"
45 + if ($CurrentSources -notmatch "nuget.org") {
46 + dotnet nuget add source "https://api.nuget.org/v3/index.json" -n "nuget.org" --configfile "nuget.config"
47 + }
48 +
49 + # --- 4. Create Projects ---
50 + Write-Host "🔨 Creating projects..." -ForegroundColor Cyan
51 +
52 + # Source Projects
53 + dotnet new classlib -n "$ProjectName.Domain" -o "src/$ProjectName.Domain"
54 + dotnet new classlib -n "$ProjectName.Application" -o "src/$ProjectName.Application"
55 + dotnet new classlib -n "$ProjectName.Infrastructure" -o "src/$ProjectName.Infrastructure"
56 + dotnet new webapi -n "$ProjectName.Api" -o "src/$ProjectName.Api" --use-controllers
57 +
58 + # Test Projects
59 + dotnet new xunit -n "$ProjectName.Domain.Tests" -o "tests/$ProjectName.Domain.Tests"
60 + dotnet new xunit -n "$ProjectName.Application.Tests" -o "tests/$ProjectName.Application.Tests"
61 + dotnet new xunit -n "$ProjectName.IntegrationTests" -o "tests/$ProjectName.IntegrationTests"
62 +
63 + # --- 4.1 CLEANUP BOILERPLATE ---
64 + Write-Host "🧹 Removing default template files..." -ForegroundColor Cyan
65 +
66 + $FilesToRemove = @(
67 + "src/$ProjectName.Domain/Class1.cs",
68 + "src/$ProjectName.Application/Class1.cs",
69 + "src/$ProjectName.Infrastructure/Class1.cs",
70 + "tests/$ProjectName.Domain.Tests/UnitTest1.cs",
71 + "tests/$ProjectName.Application.Tests/UnitTest1.cs",
72 + "tests/$ProjectName.IntegrationTests/UnitTest1.cs",
73 + "src/$ProjectName.Api/WeatherForecast.cs",
74 + "src/$ProjectName.Api/Controllers/WeatherForecastController.cs",
75 + "src/$ProjectName.Api/$ProjectName.Api.http"
76 + )
77 +
78 + foreach ($File in $FilesToRemove) {
79 + if (Test-Path $File) {
80 + Remove-Item $File -Force
81 + }
82 + }
83 +
84 + # --- 5. Add to Solution ---
85 + Write-Host "📂 Organizing solution structure..." -ForegroundColor Cyan
86 +
87 + # Add Src
88 + dotnet sln $SlnFile add "src/$ProjectName.Domain/$ProjectName.Domain.csproj" -s "src"
89 + dotnet sln $SlnFile add "src/$ProjectName.Application/$ProjectName.Application.csproj" -s "src"
90 + dotnet sln $SlnFile add "src/$ProjectName.Infrastructure/$ProjectName.Infrastructure.csproj" -s "src"
91 + dotnet sln $SlnFile add "src/$ProjectName.Api/$ProjectName.Api.csproj" -s "src"
92 +
93 + # Add Tests
94 + dotnet sln $SlnFile add "tests/$ProjectName.Domain.Tests/$ProjectName.Domain.Tests.csproj" -s "tests"
95 + dotnet sln $SlnFile add "tests/$ProjectName.Application.Tests/$ProjectName.Application.Tests.csproj" -s "tests"
96 + dotnet sln $SlnFile add "tests/$ProjectName.IntegrationTests/$ProjectName.IntegrationTests.csproj" -s "tests"
97 +
98 + # --- 6. Add References ---
99 + Write-Host "🔗 Wiring up dependencies..." -ForegroundColor Cyan
100 +
101 + # Application -> Domain
102 + dotnet add "src/$ProjectName.Application/$ProjectName.Application.csproj" reference "src/$ProjectName.Domain/$ProjectName.Domain.csproj"
103 +
104 + # Infrastructure -> Application & Domain
105 + dotnet add "src/$ProjectName.Infrastructure/$ProjectName.Infrastructure.csproj" reference "src/$ProjectName.Application/$ProjectName.Application.csproj"
106 + dotnet add "src/$ProjectName.Infrastructure/$ProjectName.Infrastructure.csproj" reference "src/$ProjectName.Domain/$ProjectName.Domain.csproj"
107 +
108 + # API -> Application & Infrastructure
109 + dotnet add "src/$ProjectName.Api/$ProjectName.Api.csproj" reference "src/$ProjectName.Application/$ProjectName.Application.csproj"
110 + dotnet add "src/$ProjectName.Api/$ProjectName.Api.csproj" reference "src/$ProjectName.Infrastructure/$ProjectName.Infrastructure.csproj"
111 +
112 + # Tests
113 + dotnet add "tests/$ProjectName.Domain.Tests/$ProjectName.Domain.Tests.csproj" reference "src/$ProjectName.Domain/$ProjectName.Domain.csproj"
114 +
115 + dotnet add "tests/$ProjectName.Application.Tests/$ProjectName.Application.Tests.csproj" reference "src/$ProjectName.Application/$ProjectName.Application.csproj"
116 + # NOTE: Application tests usually need Domain too for entities
117 + dotnet add "tests/$ProjectName.Application.Tests/$ProjectName.Application.Tests.csproj" reference "src/$ProjectName.Domain/$ProjectName.Domain.csproj"
118 +
119 + dotnet add "tests/$ProjectName.IntegrationTests/$ProjectName.IntegrationTests.csproj" reference "src/$ProjectName.Api/$ProjectName.Api.csproj"
120 + dotnet add "tests/$ProjectName.IntegrationTests/$ProjectName.IntegrationTests.csproj" reference "src/$ProjectName.Infrastructure/$ProjectName.Infrastructure.csproj"
121 + dotnet add "tests/$ProjectName.IntegrationTests/$ProjectName.IntegrationTests.csproj" reference "src/$ProjectName.Application/$ProjectName.Application.csproj"
122 + dotnet add "tests/$ProjectName.IntegrationTests/$ProjectName.IntegrationTests.csproj" reference "src/$ProjectName.Domain/$ProjectName.Domain.csproj"
123 +
124 + # --- 7. Install Nuget Packages (Optional) ---
125 + if (-not $SkipPackages) {
126 + Write-Host "📦 Installing standard Clean Architecture packages..." -ForegroundColor Cyan
127 +
128 + function Add-Package {
129 + param ($Project, $Package)
130 + Write-Host " + Adding $Package..." -ForegroundColor Gray
131 + dotnet add $Project package $Package
132 + }
133 +
134 + # Application Layer
135 + Add-Package "src/$ProjectName.Application/$ProjectName.Application.csproj" "MediatR"
136 + Add-Package "src/$ProjectName.Application/$ProjectName.Application.csproj" "FluentValidation"
137 + Add-Package "src/$ProjectName.Application/$ProjectName.Application.csproj" "FluentValidation.DependencyInjectionExtensions"
138 + Add-Package "src/$ProjectName.Application/$ProjectName.Application.csproj" "Microsoft.Extensions.Logging.Abstractions"
139 +
140 + # Infrastructure Layer
141 + Add-Package "src/$ProjectName.Infrastructure/$ProjectName.Infrastructure.csproj" "Microsoft.EntityFrameworkCore"
142 + Add-Package "src/$ProjectName.Infrastructure/$ProjectName.Infrastructure.csproj" "Microsoft.EntityFrameworkCore.SqlServer"
143 + Add-Package "src/$ProjectName.Infrastructure/$ProjectName.Infrastructure.csproj" "Microsoft.EntityFrameworkCore.Design"
144 +
145 + # API Layer
146 + Add-Package "src/$ProjectName.Api/$ProjectName.Api.csproj" "Microsoft.EntityFrameworkCore.Tools"
147 +
148 + # Test Projects
149 + $TestProjects = @(
150 + "tests/$ProjectName.Domain.Tests/$ProjectName.Domain.Tests.csproj",
151 + "tests/$ProjectName.Application.Tests/$ProjectName.Application.Tests.csproj",
152 + "tests/$ProjectName.IntegrationTests/$ProjectName.IntegrationTests.csproj"
153 + )
154 + foreach ($proj in $TestProjects) {
155 + Add-Package $proj "FluentAssertions"
156 + Add-Package $proj "Moq"
157 + }
158 +
159 + Add-Package "tests/$ProjectName.IntegrationTests/$ProjectName.IntegrationTests.csproj" "Microsoft.AspNetCore.Mvc.Testing"
160 + }
161 +
162 + # --- 8. Final Verification ---
163 + Write-Host "🏗️ Verifying build..." -ForegroundColor Cyan
164 + dotnet build
165 + if ($LASTEXITCODE -eq 0) {
166 + Write-Host "✅ $ProjectName scaffolded successfully!" -ForegroundColor Green
167 + Write-Host " 👉 cd $ProjectName" -ForegroundColor Gray
168 + }

scaffold.sh(檔案已創建)

@@ -0,0 +1,184 @@
1 + #!/bin/bash
2 +
3 + # Usage: ./scaffold.sh MyProjectName [slnx|sln] [true|false for packages]
4 + # Example: ./scaffold.sh MyCleanApp slnx
5 +
6 + # --- 1. Setup & Validation ---
7 + if [ -z "$1" ]; then
8 + echo "❌ Please provide a project name."
9 + echo "Usage: ./scaffold.sh MyProjectName [slnx|sln]"
10 + exit 1
11 + fi
12 +
13 + PROJECT_NAME=$1
14 + FORMAT=${2:-slnx}
15 + INSTALL_PACKAGES=${3:-true} # Default to true
16 + SLN_FILE="$PROJECT_NAME.$FORMAT"
17 +
18 + # Check if directory exists to avoid overwriting
19 + if [ -d "$PROJECT_NAME" ]; then
20 + echo "❌ Directory '$PROJECT_NAME' already exists. Aborting."
21 + exit 1
22 + fi
23 +
24 + echo "🚀 Scaffolding $FORMAT solution for: $PROJECT_NAME (Clean Architecture + DDD)"
25 +
26 + # Create Root Directory and enter it
27 + mkdir "$PROJECT_NAME"
28 + cd "$PROJECT_NAME" || exit
29 +
30 + # --- 2. Smart SDK Detection ---
31 + # Get the latest installed SDK version (e.g. 10.0.102)
32 + LATEST_SDK=$(dotnet --list-sdks | tail -n 1 | awk '{print $1}')
33 +
34 + if [ -n "$LATEST_SDK" ]; then
35 + echo "ℹ️ Detected SDK: $LATEST_SDK. Pinning global.json..."
36 + dotnet new globaljson --sdk-version "$LATEST_SDK" --roll-forward latestFeature
37 + else
38 + echo "⚠️ No SDK detected. Skipping global.json."
39 + fi
40 +
41 + dotnet new gitignore
42 +
43 + # --- 3. Create Solution & Fix NuGet ---
44 + if [ "$FORMAT" == "slnx" ]; then
45 + dotnet new sln -n "$PROJECT_NAME" --format slnx
46 + else
47 + dotnet new sln -n "$PROJECT_NAME"
48 + fi
49 +
50 + # [FIX] Create a local nuget.config to ensure we can find packages
51 + echo "📦 Configuring NuGet sources..."
52 + dotnet new nugetconfig --force
53 +
54 + # Check if nuget.org is already there
55 + if ! dotnet nuget list source --configfile "nuget.config" | grep -q "nuget.org"; then
56 + dotnet nuget add source "https://api.nuget.org/v3/index.json" -n "nuget.org" --configfile "nuget.config"
57 + fi
58 +
59 + # --- 4. Create Projects ---
60 + echo "🔨 Creating projects..."
61 + dotnet new classlib -n "$PROJECT_NAME.Domain" -o "src/$PROJECT_NAME.Domain"
62 + dotnet new classlib -n "$PROJECT_NAME.Application" -o "src/$PROJECT_NAME.Application"
63 + dotnet new classlib -n "$PROJECT_NAME.Infrastructure" -o "src/$PROJECT_NAME.Infrastructure"
64 + dotnet new webapi -n "$PROJECT_NAME.Api" -o "src/$PROJECT_NAME.Api" --use-controllers
65 +
66 + mkdir -p tests
67 + dotnet new xunit -n "$PROJECT_NAME.Domain.Tests" -o "tests/$PROJECT_NAME.Domain.Tests"
68 + dotnet new xunit -n "$PROJECT_NAME.Application.Tests" -o "tests/$PROJECT_NAME.Application.Tests"
69 + dotnet new xunit -n "$PROJECT_NAME.IntegrationTests" -o "tests/$PROJECT_NAME.IntegrationTests"
70 +
71 + # --- 4.1 CLEANUP BOILERPLATE ---
72 + echo "🧹 Removing default template files..."
73 +
74 + # Define array of files to remove relative to solution root
75 + FILES_TO_REMOVE=(
76 + "src/$PROJECT_NAME.Domain/Class1.cs"
77 + "src/$PROJECT_NAME.Application/Class1.cs"
78 + "src/$PROJECT_NAME.Infrastructure/Class1.cs"
79 + "tests/$PROJECT_NAME.Domain.Tests/UnitTest1.cs"
80 + "tests/$PROJECT_NAME.Application.Tests/UnitTest1.cs"
81 + "tests/$PROJECT_NAME.IntegrationTests/UnitTest1.cs"
82 + "src/$PROJECT_NAME.Api/WeatherForecast.cs"
83 + "src/$PROJECT_NAME.Api/Controllers/WeatherForecastController.cs"
84 + "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.http"
85 + )
86 +
87 + for file in "${FILES_TO_REMOVE[@]}"; do
88 + if [ -f "$file" ]; then
89 + rm "$file"
90 + echo " - Deleted $file"
91 + fi
92 + done
93 +
94 + # --- 5. Add to Solution (With Visual Folders) ---
95 + echo "📂 Organizing solution structure..."
96 + # Source
97 + dotnet sln "$SLN_FILE" add "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj" -s "src"
98 + dotnet sln "$SLN_FILE" add "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" -s "src"
99 + dotnet sln "$SLN_FILE" add "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" -s "src"
100 + dotnet sln "$SLN_FILE" add "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" -s "src"
101 +
102 + # Tests
103 + dotnet sln "$SLN_FILE" add "tests/$PROJECT_NAME.Domain.Tests/$PROJECT_NAME.Domain.Tests.csproj" -s "tests"
104 + dotnet sln "$SLN_FILE" add "tests/$PROJECT_NAME.Application.Tests/$PROJECT_NAME.Application.Tests.csproj" -s "tests"
105 + dotnet sln "$SLN_FILE" add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" -s "tests"
106 +
107 + # --- 6. Add References ---
108 + echo "🔗 Wiring up dependencies..."
109 +
110 + # Application -> Domain
111 + dotnet add "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
112 +
113 + # Infrastructure -> Application AND Domain
114 + dotnet add "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" reference "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj"
115 + dotnet add "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
116 +
117 + # API -> Application AND Infrastructure
118 + dotnet add "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" reference "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj"
119 + dotnet add "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" reference "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj"
120 +
121 + # Tests
122 + dotnet add "tests/$PROJECT_NAME.Domain.Tests/$PROJECT_NAME.Domain.Tests.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
123 +
124 + dotnet add "tests/$PROJECT_NAME.Application.Tests/$PROJECT_NAME.Application.Tests.csproj" reference "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj"
125 + dotnet add "tests/$PROJECT_NAME.Application.Tests/$PROJECT_NAME.Application.Tests.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
126 +
127 + dotnet add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" reference "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj"
128 + dotnet add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" reference "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj"
129 + dotnet add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" reference "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj"
130 + dotnet add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
131 +
132 + # --- 7. Install Nuget Packages (Optional) ---
133 + if [ "$INSTALL_PACKAGES" = true ]; then
134 + echo "📦 Installing standard Clean Architecture packages..."
135 +
136 + # Helper function to add packages safely
137 + add_package() {
138 + local proj=$1
139 + local pkg=$2
140 + echo " + Adding $pkg..."
141 + dotnet add "$proj" package "$pkg" > /dev/null 2>&1
142 + if [ $? -ne 0 ]; then
143 + echo " ⚠️ Failed to add $pkg. Check connection."
144 + fi
145 + }
146 +
147 + # Application Layer
148 + add_package "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" "MediatR"
149 + add_package "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" "FluentValidation"
150 + add_package "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" "FluentValidation.DependencyInjectionExtensions"
151 + add_package "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" "Microsoft.Extensions.Logging.Abstractions"
152 +
153 + # Infrastructure Layer
154 + add_package "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" "Microsoft.EntityFrameworkCore"
155 + add_package "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" "Microsoft.EntityFrameworkCore.SqlServer"
156 + add_package "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" "Microsoft.EntityFrameworkCore.Design"
157 +
158 + # API Layer
159 + add_package "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" "Microsoft.EntityFrameworkCore.Tools"
160 +
161 + # Tests
162 + TEST_PROJECTS=(
163 + "tests/$PROJECT_NAME.Domain.Tests/$PROJECT_NAME.Domain.Tests.csproj"
164 + "tests/$PROJECT_NAME.Application.Tests/$PROJECT_NAME.Application.Tests.csproj"
165 + "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj"
166 + )
167 +
168 + for proj in "${TEST_PROJECTS[@]}"; do
169 + add_package "$proj" "FluentAssertions"
170 + add_package "$proj" "Moq"
171 + done
172 +
173 + add_package "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" "Microsoft.AspNetCore.Mvc.Testing"
174 + fi
175 +
176 + # --- 8. Final Verification ---
177 + echo "🏗️ Verifying build..."
178 + dotnet build
179 + if [ $? -eq 0 ]; then
180 + echo "✅ $PROJECT_NAME scaffolded successfully!"
181 + echo "👉 cd $PROJECT_NAME"
182 + else
183 + echo "⚠️ Scaffolding finished, but build failed. Run 'dotnet restore' manually."
184 + fi

scaffold_project.sh(檔案已創建)

@@ -0,0 +1,193 @@
1 + #!/bin/bash
2 +
3 + # --- 0. Determine Project Name ---
4 + # Grab the name of the current directory
5 + PROJECT_NAME=$(basename "$PWD")
6 +
7 + echo "📂 Using current directory name as project name: $PROJECT_NAME"
8 +
9 + # --- 1. Configuration ---
10 + FORMAT="slnx" # Options: "sln" or "slnx"
11 + INSTALL_PACKAGES=true # Set to true to install NuGet packages
12 +
13 + echo "🚀 Initializing $PROJECT_NAME (Format: $FORMAT)..."
14 +
15 + # --- 1.5 Database Selection ---
16 + echo ""
17 + echo "🗄️ Select your Database Provider for $PROJECT_NAME:"
18 + echo " 1) PostgreSQL (Npgsql) [Default]"
19 + echo " 2) SQL Server"
20 + echo " 3) SQLite"
21 + # < /dev/tty allows interactive prompt when piping via curl
22 + read -p "Enter choice [1-3] (Default: 1): " DB_CHOICE < /dev/tty
23 +
24 + case $DB_CHOICE in
25 + 2)
26 + DB_PACKAGE="Microsoft.EntityFrameworkCore.SqlServer"
27 + echo " Selected: SQL Server"
28 + ;;
29 + 3)
30 + DB_PACKAGE="Microsoft.EntityFrameworkCore.Sqlite"
31 + echo " Selected: SQLite"
32 + ;;
33 + *)
34 + DB_PACKAGE="Npgsql.EntityFrameworkCore.PostgreSQL"
35 + echo " Selected: PostgreSQL"
36 + ;;
37 + esac
38 + echo ""
39 +
40 + # --- 2. Smart SDK Detection ---
41 + # Get the latest installed SDK version
42 + LATEST_SDK=$(dotnet --list-sdks | tail -n 1 | awk '{print $1}')
43 +
44 + if [ -n "$LATEST_SDK" ]; then
45 + echo "ℹ️ Detected SDK: $LATEST_SDK. Pinning global.json..."
46 + dotnet new globaljson --sdk-version "$LATEST_SDK" --roll-forward latestFeature
47 + else
48 + echo "⚠️ No SDK detected. Skipping global.json."
49 + fi
50 +
51 + dotnet new gitignore
52 +
53 + # --- 3. Create Solution & Fix NuGet ---
54 + if [ "$FORMAT" = "slnx" ]; then
55 + echo "📄 Creating .slnx solution..."
56 + dotnet new sln -n "$PROJECT_NAME" --format slnx
57 + SLN_FILE="$PROJECT_NAME.slnx"
58 + else
59 + echo "📄 Creating standard .sln solution..."
60 + dotnet new sln -n "$PROJECT_NAME"
61 + SLN_FILE="$PROJECT_NAME.sln"
62 + fi
63 +
64 + # Create a local nuget.config to ensure we can find packages
65 + echo "📦 Configuring NuGet sources..."
66 + dotnet new nugetconfig --force
67 +
68 + # Check if nuget.org is already there, if not, add it
69 + if ! dotnet nuget list source --configfile "nuget.config" | grep -q "nuget.org"; then
70 + dotnet nuget add source "https://api.nuget.org/v3/index.json" -n "nuget.org" --configfile "nuget.config"
71 + fi
72 +
73 + # --- 4. Create Projects ---
74 + echo "🔨 Creating projects..."
75 + dotnet new classlib -n "$PROJECT_NAME.Domain" -o "src/$PROJECT_NAME.Domain"
76 + dotnet new classlib -n "$PROJECT_NAME.Application" -o "src/$PROJECT_NAME.Application"
77 + dotnet new classlib -n "$PROJECT_NAME.Infrastructure" -o "src/$PROJECT_NAME.Infrastructure"
78 + dotnet new webapi -n "$PROJECT_NAME.Api" -o "src/$PROJECT_NAME.Api" --use-controllers
79 +
80 + mkdir -p tests
81 + dotnet new xunit -n "$PROJECT_NAME.Domain.Tests" -o "tests/$PROJECT_NAME.Domain.Tests"
82 + dotnet new xunit -n "$PROJECT_NAME.Application.Tests" -o "tests/$PROJECT_NAME.Application.Tests"
83 + dotnet new xunit -n "$PROJECT_NAME.IntegrationTests" -o "tests/$PROJECT_NAME.IntegrationTests"
84 +
85 + # --- 4.1 CLEANUP BOILERPLATE ---
86 + echo "🧹 Removing default template files..."
87 +
88 + FILES_TO_REMOVE=(
89 + "src/$PROJECT_NAME.Domain/Class1.cs"
90 + "src/$PROJECT_NAME.Application/Class1.cs"
91 + "src/$PROJECT_NAME.Infrastructure/Class1.cs"
92 + "tests/$PROJECT_NAME.Domain.Tests/UnitTest1.cs"
93 + "tests/$PROJECT_NAME.Application.Tests/UnitTest1.cs"
94 + "tests/$PROJECT_NAME.IntegrationTests/UnitTest1.cs"
95 + "src/$PROJECT_NAME.Api/WeatherForecast.cs"
96 + "src/$PROJECT_NAME.Api/Controllers/WeatherForecastController.cs"
97 + "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.http"
98 + )
99 +
100 + for file in "${FILES_TO_REMOVE[@]}"; do
101 + if [ -f "$file" ]; then
102 + rm "$file"
103 + echo " - Deleted $file"
104 + fi
105 + done
106 +
107 + # --- 5. Add to Solution (With Visual Folders) ---
108 + echo "📂 Organizing solution structure in $SLN_FILE..."
109 +
110 + dotnet sln "$SLN_FILE" add "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj" -s "src"
111 + dotnet sln "$SLN_FILE" add "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" -s "src"
112 + dotnet sln "$SLN_FILE" add "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" -s "src"
113 + dotnet sln "$SLN_FILE" add "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" -s "src"
114 +
115 + dotnet sln "$SLN_FILE" add "tests/$PROJECT_NAME.Domain.Tests/$PROJECT_NAME.Domain.Tests.csproj" -s "tests"
116 + dotnet sln "$SLN_FILE" add "tests/$PROJECT_NAME.Application.Tests/$PROJECT_NAME.Application.Tests.csproj" -s "tests"
117 + dotnet sln "$SLN_FILE" add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" -s "tests"
118 +
119 + # --- 6. Add References ---
120 + echo "🔗 Wiring up dependencies..."
121 +
122 + # Clean Architecture Flow
123 + dotnet add "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
124 +
125 + dotnet add "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" reference "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj"
126 + dotnet add "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
127 +
128 + dotnet add "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" reference "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj"
129 + dotnet add "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" reference "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj"
130 +
131 + # Test References
132 + dotnet add "tests/$PROJECT_NAME.Domain.Tests/$PROJECT_NAME.Domain.Tests.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
133 +
134 + dotnet add "tests/$PROJECT_NAME.Application.Tests/$PROJECT_NAME.Application.Tests.csproj" reference "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj"
135 + dotnet add "tests/$PROJECT_NAME.Application.Tests/$PROJECT_NAME.Application.Tests.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
136 +
137 + dotnet add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" reference "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj"
138 + dotnet add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" reference "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj"
139 + dotnet add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" reference "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj"
140 + dotnet add "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" reference "src/$PROJECT_NAME.Domain/$PROJECT_NAME.Domain.csproj"
141 +
142 + # --- 7. Install Nuget Packages (Optional) ---
143 + if [ "$INSTALL_PACKAGES" = true ]; then
144 + echo "📦 Installing standard Clean Architecture packages..."
145 +
146 + add_package() {
147 + local proj=$1
148 + local pkg=$2
149 + echo " + Adding $pkg..."
150 + dotnet add "$proj" package "$pkg" > /dev/null 2>&1
151 + if [ $? -ne 0 ]; then
152 + echo " ⚠️ Failed to add $pkg. Check connection."
153 + fi
154 + }
155 +
156 + # Application Layer
157 + add_package "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" "MediatR"
158 + add_package "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" "FluentValidation"
159 + add_package "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" "FluentValidation.DependencyInjectionExtensions"
160 + add_package "src/$PROJECT_NAME.Application/$PROJECT_NAME.Application.csproj" "Microsoft.Extensions.Logging.Abstractions"
161 +
162 + # Infrastructure Layer
163 + add_package "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" "Microsoft.EntityFrameworkCore"
164 + add_package "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" "$DB_PACKAGE"
165 + add_package "src/$PROJECT_NAME.Infrastructure/$PROJECT_NAME.Infrastructure.csproj" "Microsoft.EntityFrameworkCore.Design"
166 +
167 + # API Layer
168 + add_package "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" "Microsoft.EntityFrameworkCore.Tools"
169 + add_package "src/$PROJECT_NAME.Api/$PROJECT_NAME.Api.csproj" "Serilog.AspNetCore"
170 +
171 + # Test Projects
172 + TEST_PROJECTS=(
173 + "tests/$PROJECT_NAME.Domain.Tests/$PROJECT_NAME.Domain.Tests.csproj"
174 + "tests/$PROJECT_NAME.Application.Tests/$PROJECT_NAME.Application.Tests.csproj"
175 + "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj"
176 + )
177 +
178 + for proj in "${TEST_PROJECTS[@]}"; do
179 + add_package "$proj" "FluentAssertions"
180 + add_package "$proj" "Moq"
181 + done
182 +
183 + add_package "tests/$PROJECT_NAME.IntegrationTests/$PROJECT_NAME.IntegrationTests.csproj" "Microsoft.AspNetCore.Mvc.Testing"
184 + fi
185 +
186 + # --- 8. Final Verification ---
187 + echo "🏗️ Verifying build..."
188 + dotnet build
189 + if [ $? -eq 0 ]; then
190 + echo "✅ $PROJECT_NAME scaffolded successfully!"
191 + else
192 + echo "⚠️ Scaffolding finished, but build failed. Run 'dotnet restore' manually."
193 + fi
上一頁 下一頁