weehong revidoval tento gist 1 month ago. Přejít na revizi
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 revidoval tento gist 1 month ago. Přejít na revizi
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<DomainEventNotification<T>> 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 revidoval tento gist 1 month ago. Přejít na revizi
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 revidoval tento gist 1 month ago. Přejít na revizi
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 revidoval tento gist 1 month ago. Přejít na revizi
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 revidoval tento gist 1 month ago. Přejít na revizi
1 file changed, 3063 insertions
dotnet-10-clean-architecture-boilerplate-guide.md(vytvořil soubor)
| @@ -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<DomainEventNotification<T>> 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 revidoval tento gist 1 month ago. Přejít na revizi
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 revidoval tento gist 1 month ago. Přejít na revizi
4 files changed, 563 insertions
README.md(vytvořil soubor)
| @@ -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(vytvořil soubor)
| @@ -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(vytvořil soubor)
| @@ -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(vytvořil soubor)
| @@ -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 | |