first commit

This commit is contained in:
2026-02-26 14:04:18 +07:00
parent 57ac80a666
commit 4b7236493f
92 changed files with 4999 additions and 0 deletions

82
.editorconfig Normal file
View File

@@ -0,0 +1,82 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# All files
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# C# files
[*.cs]
# Organize usings
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
# this. preferences
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_qualification_for_event = false:suggestion
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
# var preferences
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Expression-level preferences
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
# Null-checking preferences
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
# New line preferences
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
# Indentation preferences
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_keywords_in_control_flow_statements = true
# Naming conventions
dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore_style
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.camel_case_underscore_style.required_prefix = _
dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case
# JSON files
[*.json]
indent_size = 2
# XML files
[*.{xml,csproj,props,targets}]
indent_size = 2
# YAML files
[*.{yml,yaml}]
indent_size = 2

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Build artifacts
bin/
obj/
# IDE
.vs/
.vscode/
*.user
*.suo
*.userosscache
*.sln.docstates
# NuGet
*.nupkg
**/packages/*
!**/packages/build/
# Test Results
TestResults/
*.trx
# Rider
.idea/
*.sln.iml
# macOS
.DS_Store
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Logs
*.log
logs/
# Environment files
.env
.env.local
.env.*.local
appsettings.*.local.json
# Database
*.mdf
*.ldf
*.db
# Published output
publish/

10
Directory.Build.props Normal file
View File

@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
</Project>

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy csproj files and restore
COPY ["iYHCT360.Domain/iYHCT360.Domain.csproj", "iYHCT360.Domain/"]
COPY ["iYHCT360.Contracts/iYHCT360.Contracts.csproj", "iYHCT360.Contracts/"]
COPY ["iYHCT360.Application/iYHCT360.Application.csproj", "iYHCT360.Application/"]
COPY ["iYHCT360.Infrastructure/iYHCT360.Infrastructure.csproj", "iYHCT360.Infrastructure/"]
COPY ["iYHCT360.WebAPI/iYHCT360.WebAPI.csproj", "iYHCT360.WebAPI/"]
RUN dotnet restore "iYHCT360.WebAPI/iYHCT360.WebAPI.csproj"
# Copy everything else and build
COPY . .
WORKDIR "/src/iYHCT360.WebAPI"
RUN dotnet build "iYHCT360.WebAPI.csproj" -c Release -o /app/build
# Publish stage
FROM build AS publish
RUN dotnet publish "iYHCT360.WebAPI.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Final stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "iYHCT360.WebAPI.dll"]

30
Dockerfile.admin Normal file
View File

@@ -0,0 +1,30 @@
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy csproj files and restore
COPY ["iYHCT360.Domain/iYHCT360.Domain.csproj", "iYHCT360.Domain/"]
COPY ["iYHCT360.Contracts/iYHCT360.Contracts.csproj", "iYHCT360.Contracts/"]
COPY ["iYHCT360.Application/iYHCT360.Application.csproj", "iYHCT360.Application/"]
COPY ["iYHCT360.Infrastructure/iYHCT360.Infrastructure.csproj", "iYHCT360.Infrastructure/"]
COPY ["iYHCT360.AdminAPI/iYHCT360.AdminAPI.csproj", "iYHCT360.AdminAPI/"]
RUN dotnet restore "iYHCT360.AdminAPI/iYHCT360.AdminAPI.csproj"
# Copy everything else and build
COPY . .
WORKDIR "/src/iYHCT360.AdminAPI"
RUN dotnet build "iYHCT360.AdminAPI.csproj" -c Release -o /app/build
# Publish stage
FROM build AS publish
RUN dotnet publish "iYHCT360.AdminAPI.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Final stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "iYHCT360.AdminAPI.dll"]

View File

@@ -0,0 +1,16 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace MyNewProjectName.AdminAPI.Controllers;
/// <summary>
/// Base API controller with common functionality
/// </summary>
[ApiController]
[Route("api/admin/[controller]")]
public abstract class BaseApiController : ControllerBase
{
private ISender? _mediator;
protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService<ISender>();
}

View File

@@ -0,0 +1,36 @@
using Serilog.Context;
namespace MyNewProjectName.AdminAPI.Middleware;
/// <summary>
/// Middleware to generate and track correlation ID for each request
/// This ID is added to HTTP headers and Serilog log context for easy tracing
/// </summary>
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeaderName = "X-Correlation-ID";
private const string CorrelationIdLogPropertyName = "CorrelationId";
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Get correlation ID from request header, or generate a new one
var correlationId = context.Request.Headers[CorrelationIdHeaderName].FirstOrDefault()
?? $"req-{Guid.NewGuid():N}";
// Add correlation ID to response header
context.Response.Headers[CorrelationIdHeaderName] = correlationId;
// Add correlation ID to Serilog log context
// All logs within this request will automatically include this correlation ID
using (LogContext.PushProperty(CorrelationIdLogPropertyName, correlationId))
{
await _next(context);
}
}
}

View File

@@ -0,0 +1,114 @@
using System.Net;
using System.Text.Json;
using MyNewProjectName.Contracts.DTOs.Responses;
using MyNewProjectName.Domain.Exceptions;
using Serilog;
namespace MyNewProjectName.AdminAPI.Middleware;
/// <summary>
/// Global exception handling middleware
/// Catches all unhandled exceptions, logs them to Serilog with full details,
/// and returns a standardized error response without exposing stack traces
/// </summary>
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly Serilog.ILogger _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, Serilog.ILogger logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
// Get correlation ID from header (set by CorrelationIdMiddleware)
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() ?? "unknown";
// Log full exception details to Serilog (including stack trace)
// This will be searchable by CorrelationId
_logger.Error(
exception,
"Unhandled exception occurred. CorrelationId: {CorrelationId}, Path: {Path}, Method: {Method}",
correlationId,
context.Request.Path,
context.Request.Method
);
// Safety check: If response has already started, we cannot modify headers/status code
// This prevents InvalidOperationException: "Headers are read-only, response has already started"
if (context.Response.HasStarted)
{
_logger.Warning(
"Response has already started. Cannot modify HTTP status code or headers. CorrelationId: {CorrelationId}",
correlationId
);
return;
}
var response = context.Response;
// Clear any existing response data
response.Clear();
response.ContentType = "application/json";
// Determine status code and user-friendly message
var (statusCode, message, errors) = exception switch
{
ValidationException validationEx => (
HttpStatusCode.BadRequest,
"Validation failed",
validationEx.Errors.SelectMany(e => e.Value).ToList()),
NotFoundException => (
HttpStatusCode.NotFound,
exception.Message,
new List<string>()),
DomainException => (
HttpStatusCode.BadRequest,
exception.Message,
new List<string>()),
UnauthorizedAccessException => (
HttpStatusCode.Unauthorized,
"Unauthorized",
new List<string>()),
_ => (
HttpStatusCode.InternalServerError,
"An error occurred while processing your request",
new List<string>())
};
response.StatusCode = (int)statusCode;
// Return standardized error response (NO stack trace exposed to client)
var result = JsonSerializer.Serialize(new ApiResponse
{
Success = false,
Message = message,
Errors = errors.Any() ? errors : null
}, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await response.WriteAsync(result);
}
}

View File

@@ -0,0 +1,133 @@
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Configuration;
using Serilog;
namespace MyNewProjectName.AdminAPI.Middleware;
/// <summary>
/// Middleware to log request and response bodies
/// WARNING: This can generate large log files. Use with caution in production.
/// Consider enabling only for specific environments or endpoints.
/// </summary>
public class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly Serilog.ILogger _logger;
private readonly bool _enableRequestLogging;
private readonly bool _enableResponseLogging;
// Paths that should NOT be logged (e.g., health checks, metrics)
private static readonly string[] ExcludedPaths = new[]
{
"/health",
"/metrics",
"/favicon.ico"
};
public RequestResponseLoggingMiddleware(
RequestDelegate next,
Serilog.ILogger logger,
IConfiguration configuration)
{
_next = next;
_logger = logger;
_enableRequestLogging = configuration.GetValue<bool>("Logging:EnableRequestLogging", false);
_enableResponseLogging = configuration.GetValue<bool>("Logging:EnableResponseLogging", false);
}
public async Task InvokeAsync(HttpContext context)
{
// Skip logging for excluded paths
if (ExcludedPaths.Any(path => context.Request.Path.StartsWithSegments(path)))
{
await _next(context);
return;
}
var stopwatch = Stopwatch.StartNew();
var requestBody = string.Empty;
var responseBody = string.Empty;
// Log request
if (_enableRequestLogging)
{
requestBody = await ReadRequestBodyAsync(context.Request);
_logger.Information(
"Request: {Method} {Path} {QueryString} | Body: {RequestBody}",
context.Request.Method,
context.Request.Path,
context.Request.QueryString,
requestBody
);
}
// Capture response body
var originalBodyStream = context.Response.Body;
using var responseBodyStream = new MemoryStream();
context.Response.Body = responseBodyStream;
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
// Log response
if (_enableResponseLogging)
{
responseBody = await ReadResponseBodyAsync(context.Response);
await responseBodyStream.CopyToAsync(originalBodyStream);
_logger.Information(
"Response: {StatusCode} | Duration: {Duration}ms | Body: {ResponseBody}",
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds,
responseBody
);
}
else
{
await responseBodyStream.CopyToAsync(originalBodyStream);
}
context.Response.Body = originalBodyStream;
}
}
private static async Task<string> ReadRequestBodyAsync(HttpRequest request)
{
// Enable buffering to allow reading the body multiple times
request.EnableBuffering();
using var reader = new StreamReader(
request.Body,
encoding: Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0; // Reset position for next middleware
// Truncate very long bodies to avoid huge logs
return body.Length > 10000 ? body[..10000] + "... (truncated)" : body;
}
private static async Task<string> ReadResponseBodyAsync(HttpResponse response)
{
response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(
response.Body,
encoding: Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
var body = await reader.ReadToEndAsync();
response.Body.Seek(0, SeekOrigin.Begin); // Reset position
// Truncate very long bodies to avoid huge logs
return body.Length > 10000 ? body[..10000] + "... (truncated)" : body;
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<!-- Serilog for structured logging -->
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<!-- OpenTelemetry for distributed tracing -->
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyNewProjectName.Infrastructure\MyNewProjectName.Infrastructure.csproj" />
<ProjectReference Include="..\MyNewProjectName.Application\MyNewProjectName.Application.csproj" />
<ProjectReference Include="..\MyNewProjectName.Contracts\MyNewProjectName.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@MyNewProjectName.AdminAPI_HostAddress = http://localhost:5011
GET {{MyNewProjectName.AdminAPI_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,76 @@
using Microsoft.Extensions.Hosting;
using MyNewProjectName.Application;
using MyNewProjectName.Infrastructure;
using MyNewProjectName.Infrastructure.Extensions;
using MyNewProjectName.AdminAPI.Middleware;
using Serilog;
// Create host builder with Serilog
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
builder.Host.UseSerilogLogging(builder.Configuration);
// Add OpenTelemetry distributed tracing
builder.Services.AddOpenTelemetryTracing("MyNewProjectName.AdminAPI");
// Add services to the container.
builder.Services.AddControllers();
// Add Application Layer
builder.Services.AddApplication();
// Add Infrastructure Layer
builder.Services.AddInfrastructure(builder.Configuration);
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// Add Swagger
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// Middleware pipeline order is critical:
// 1. CorrelationIdMiddleware - Must be first to track all requests
app.UseMiddleware<CorrelationIdMiddleware>();
// 2. RequestResponseLoggingMiddleware - Optional, enable only when needed
// WARNING: This can generate large log files. Enable only for specific environments.
// For AdminAPI, you might want to enable this more often for audit purposes.
// Configure in appsettings.json: "Logging:EnableRequestLogging" and "Logging:EnableResponseLogging"
app.UseMiddleware<RequestResponseLoggingMiddleware>();
app.UseHttpsRedirection();
// 3. Authentication (built-in)
app.UseAuthentication();
// 4. Authorization (built-in)
app.UseAuthorization();
// 6. ExceptionHandlingMiddleware - Must be last to catch all exceptions
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapControllers();
try
{
Log.Information("Starting MyNewProjectName.AdminAPI");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
throw;
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5011",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7298;http://localhost:5011",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,43 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MyNewProjectNameDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
},
"Jwt": {
"SecretKey": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "MyNewProjectName",
"Audience": "MyNewProjectName",
"ExpirationInMinutes": 60,
"RefreshTokenExpirationInDays": 7
},
"Redis": {
"ConnectionString": "localhost:6379",
"InstanceName": "MyNewProjectName:",
"DefaultExpirationInMinutes": 30
},
"Serilog": {
"MinimumLevel": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
},
"WriteToConsole": true,
"WriteToFile": true,
"FilePath": "logs/log-.txt",
"RollingInterval": "Day",
"RetainedFileCountLimit": 31,
"SeqUrl": null,
"ElasticsearchUrl": null
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
},
"EnableRequestLogging": false,
"EnableResponseLogging": false
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,49 @@
using System.Diagnostics;
using MediatR;
using Microsoft.Extensions.Logging;
namespace MyNewProjectName.Application.Behaviors;
/// <summary>
/// Logging behavior for MediatR pipeline
/// </summary>
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation("Handling {RequestName}", requestName);
var stopwatch = Stopwatch.StartNew();
try
{
var response = await next();
stopwatch.Stop();
_logger.LogInformation("Handled {RequestName} in {ElapsedMilliseconds}ms",
requestName, stopwatch.ElapsedMilliseconds);
return response;
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Error handling {RequestName} after {ElapsedMilliseconds}ms",
requestName, stopwatch.ElapsedMilliseconds);
throw;
}
}
}

View File

@@ -0,0 +1,47 @@
using FluentValidation;
using MediatR;
namespace MyNewProjectName.Application.Behaviors;
/// <summary>
/// Validation behavior for MediatR pipeline
/// </summary>
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!_validators.Any())
{
return await next();
}
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.Where(r => r.Errors.Any())
.SelectMany(r => r.Errors)
.ToList();
if (failures.Any())
{
var errors = failures
.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
.ToDictionary(g => g.Key, g => g.ToArray());
throw new MyNewProjectName.Domain.Exceptions.ValidationException(errors);
}
return await next();
}
}

View File

@@ -0,0 +1,36 @@
using System.Reflection;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using MyNewProjectName.Application.Behaviors;
namespace MyNewProjectName.Application;
/// <summary>
/// Dependency Injection for Application Layer
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
var assembly = Assembly.GetExecutingAssembly();
// Register AutoMapper
services.AddAutoMapper(assembly);
// Register MediatR
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(assembly);
// Logging phải đứng đầu tiên để ghi nhận request đến
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
// Sau đó mới đến Validation
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
// Register FluentValidation validators
services.AddValidatorsFromAssembly(assembly);
return services;
}
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
/// <summary>
/// Command to create a new sample
/// </summary>
public record CreateSampleCommand(string Name, string? Description) : IRequest<Guid>;

View File

@@ -0,0 +1,37 @@
using MyNewProjectName.Domain.Entities;
using MyNewProjectName.Domain.Interfaces;
using MediatR;
namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
/// <summary>
/// Handler for CreateSampleCommand
/// </summary>
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, Guid>
{
private readonly IRepository<SampleEntity> _repository;
private readonly IUnitOfWork _unitOfWork;
public CreateSampleCommandHandler(IRepository<SampleEntity> repository, IUnitOfWork unitOfWork)
{
_repository = repository;
_unitOfWork = unitOfWork;
}
public async Task<Guid> Handle(CreateSampleCommand request, CancellationToken cancellationToken)
{
var entity = new SampleEntity
{
Id = Guid.NewGuid(),
Name = request.Name,
Description = request.Description,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _repository.AddAsync(entity, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
namespace MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
/// <summary>
/// Validator for CreateSampleCommand
/// </summary>
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
{
public CreateSampleCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required.")
.MaximumLength(200).WithMessage("Name must not exceed 200 characters.");
RuleFor(x => x.Description)
.MaximumLength(1000).WithMessage("Description must not exceed 1000 characters.");
}
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples;
/// <summary>
/// Query to get all samples
/// </summary>
public record GetSamplesQuery : IRequest<List<SampleDto>>;

View File

@@ -0,0 +1,27 @@
using AutoMapper;
using MyNewProjectName.Domain.Entities;
using MyNewProjectName.Domain.Interfaces;
using MediatR;
namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples;
/// <summary>
/// Handler for GetSamplesQuery
/// </summary>
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, List<SampleDto>>
{
private readonly IRepository<SampleEntity> _repository;
private readonly IMapper _mapper;
public GetSamplesQueryHandler(IRepository<SampleEntity> repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<List<SampleDto>> Handle(GetSamplesQuery request, CancellationToken cancellationToken)
{
var entities = await _repository.GetAllAsync(cancellationToken);
return _mapper.Map<List<SampleDto>>(entities);
}
}

View File

@@ -0,0 +1,22 @@
using AutoMapper;
using MyNewProjectName.Application.Mappings;
using MyNewProjectName.Domain.Entities;
namespace MyNewProjectName.Application.Features.Sample.Queries.GetSamples;
/// <summary>
/// Sample DTO
/// </summary>
public class SampleDto : IMapFrom<SampleEntity>
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public void Mapping(Profile profile)
{
profile.CreateMap<SampleEntity, SampleDto>();
}
}

View File

@@ -0,0 +1,14 @@
// using MyNewProjectName.Domain.Entities;
// using System.Security.Claims;
// namespace MyNewProjectName.Application.Interfaces.Common;
// public interface IJwtTokenGenerator
// {
// string GenerateAccessToken(User user, List<string> roles, Guid tenantId);
// string GenerateRefreshToken();
// ClaimsPrincipal GetPrincipalFromExpiredToken(string token);
// }

View File

@@ -0,0 +1,8 @@
namespace MyNewProjectName.Application.Interfaces.Common;
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string password, string hashedPassword);
}

View File

@@ -0,0 +1,12 @@
namespace MyNewProjectName.Application.Interfaces;
/// <summary>
/// Interface for getting current user information
/// </summary>
public interface ICurrentUserService
{
string? UserId { get; }
string? UserName { get; }
bool? IsAuthenticated { get; }
string? Role { get; }
}

View File

@@ -0,0 +1,10 @@
namespace MyNewProjectName.Application.Interfaces;
/// <summary>
/// Interface for date time operations (for testability)
/// </summary>
public interface IDateTimeService
{
DateTime Now { get; }
DateTime UtcNow { get; }
}

View File

@@ -0,0 +1,11 @@
using AutoMapper;
namespace MyNewProjectName.Application.Mappings;
/// <summary>
/// Interface for auto mapping registration
/// </summary>
public interface IMapFrom<T>
{
void Mapping(Profile profile) => profile.CreateMap(typeof(T), GetType());
}

View File

@@ -0,0 +1,55 @@
using System.Reflection;
using AutoMapper;
namespace MyNewProjectName.Application.Mappings;
/// <summary>
/// AutoMapper profile that auto-registers mappings from IMapFrom interface
/// </summary>
public class MappingProfile : Profile
{
public MappingProfile()
{
ApplyMappingsFromAssembly(Assembly.GetExecutingAssembly());
}
private void ApplyMappingsFromAssembly(Assembly assembly)
{
var mapFromType = typeof(IMapFrom<>);
var mappingMethodName = nameof(IMapFrom<object>.Mapping);
bool HasInterface(Type t) => t.IsGenericType && t.GetGenericTypeDefinition() == mapFromType;
var types = assembly.GetExportedTypes()
.Where(t => t.GetInterfaces().Any(HasInterface))
.ToList();
var argumentTypes = new Type[] { typeof(Profile) };
foreach (var type in types)
{
var instance = Activator.CreateInstance(type);
var methodInfo = type.GetMethod(mappingMethodName);
if (methodInfo != null)
{
methodInfo.Invoke(instance, new object[] { this });
}
else
{
var interfaces = type.GetInterfaces().Where(HasInterface).ToList();
if (interfaces.Count > 0)
{
foreach (var @interface in interfaces)
{
var interfaceMethodInfo = @interface.GetMethod(mappingMethodName, argumentTypes);
interfaceMethodInfo?.Invoke(instance, new object[] { this });
}
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MyNewProjectName.Domain\MyNewProjectName.Domain.csproj" />
<ProjectReference Include="..\MyNewProjectName.Contracts\MyNewProjectName.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,37 @@
namespace MyNewProjectName.Contracts.Common
{
// PagedList model to support pagination in API responses
public class PagedList<T>
{
// Items in the current page
public List<T> Items { get; set; } = new List<T>();
// Total count of items across all pages
public int TotalCount { get; set; }
// Current page number
public int PageNumber { get; set; }
// Page size (items per page)
public int PageSize { get; set; }
// Total number of pages
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
// Flag indicating if there is a previous page
public bool HasPreviousPage => PageNumber > 1;
// Flag indicating if there is a next page
public bool HasNextPage => PageNumber < TotalPages;
public PagedList(List<T> items, int totalCount, int pageNumber, int pageSize)
{
Items = items;
TotalCount = totalCount;
PageNumber = pageNumber;
PageSize = pageSize;
}
// Default constructor
public PagedList() { }
}
}

View File

@@ -0,0 +1,19 @@
namespace MyNewProjectName.Contracts.Common
{
// Parameters for handling pagination in requests
public class PaginationParams
{
private const int MaxPageSize = 1000;
private int _pageSize = 50;
// Page number (default is 1)
public int PageNumber { get; set; } = 1;
// Page size with validation to ensure it doesn't exceed MaxPageSize
public int PageSize
{
get => _pageSize;
set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;
}
}
}

View File

@@ -0,0 +1,21 @@
namespace MyNewProjectName.Contracts.Common
{
// Generic response model for all API endpoints
public class ServiceResponse<T>
{
// Success status of the request
public bool Success { get; set; } = true;
// Response message
public string Message { get; set; } = string.Empty;
// Response data
public T? Data { get; set; }
// Error details if any
public List<string>? Errors { get; set; }
// Field-specific validation errors for frontend form validation
public Dictionary<string, List<string>>? FieldErrors { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
namespace MyNewProjectName.Contracts.DTOs.Requests;
/// <summary>
/// Request to create a sample
/// </summary>
public class CreateSampleRequest
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace MyNewProjectName.Contracts.DTOs.Requests;
/// <summary>
/// Base request for paginated queries
/// </summary>
public class PaginatedRequest
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
public string? SearchTerm { get; set; }
public string? SortBy { get; set; }
public bool SortDescending { get; set; } = false;
}

View File

@@ -0,0 +1,68 @@
namespace MyNewProjectName.Contracts.DTOs.Responses;
/// <summary>
/// Standard API response wrapper
/// </summary>
public class ApiResponse<T>
{
public bool Success { get; set; }
public string? Message { get; set; }
public T? Data { get; set; }
public List<string>? Errors { get; set; }
public static ApiResponse<T> SuccessResponse(T data, string? message = null)
{
return new ApiResponse<T>
{
Success = true,
Data = data,
Message = message
};
}
public static ApiResponse<T> ErrorResponse(string error)
{
return new ApiResponse<T>
{
Success = false,
Errors = new List<string> { error }
};
}
public static ApiResponse<T> ErrorResponse(List<string> errors)
{
return new ApiResponse<T>
{
Success = false,
Errors = errors
};
}
}
/// <summary>
/// Non-generic API response
/// </summary>
public class ApiResponse
{
public bool Success { get; set; }
public string? Message { get; set; }
public List<string>? Errors { get; set; }
public static ApiResponse SuccessResponse(string? message = null)
{
return new ApiResponse
{
Success = true,
Message = message
};
}
public static ApiResponse ErrorResponse(string error)
{
return new ApiResponse
{
Success = false,
Errors = new List<string> { error }
};
}
}

View File

@@ -0,0 +1,14 @@
namespace MyNewProjectName.Contracts.DTOs.Responses;
/// <summary>
/// Paginated response wrapper
/// </summary>
public class PaginatedResponse<T>
{
public List<T> Items { get; set; } = new();
public int PageNumber { get; set; }
public int TotalPages { get; set; }
public int TotalCount { get; set; }
public bool HasPreviousPage { get; set; }
public bool HasNextPage { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace MyNewProjectName.Contracts.DTOs.Responses;
/// <summary>
/// Sample response DTO
/// </summary>
public class SampleResponse
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,12 @@
namespace MyNewProjectName.Domain.Common;
/// <summary>
/// Base entity with audit properties
/// </summary>
public abstract class AuditableEntity : BaseEntity
{
public DateTime CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}

View File

@@ -0,0 +1,36 @@
namespace MyNewProjectName.Domain.Common;
/// <summary>
/// Base entity with common properties for all entities
/// </summary>
public abstract class BaseEntity
{
public Guid Id { get; set; }
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
public void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void RemoveDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Remove(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
/// <summary>
/// Marker interface for domain events
/// </summary>
public interface IDomainEvent
{
DateTime OccurredOn { get; }
}

View File

@@ -0,0 +1,11 @@
namespace MyNewProjectName.Domain.Common;
/// <summary>
/// Interface for soft delete functionality
/// </summary>
public interface ISoftDelete
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}

View File

@@ -0,0 +1,13 @@
using MyNewProjectName.Domain.Common;
namespace MyNewProjectName.Domain.Entities;
/// <summary>
/// Sample entity - Replace with your actual entity
/// </summary>
public class SampleEntity : AuditableEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsActive { get; set; } = true;
}

View File

@@ -0,0 +1,11 @@
using MyNewProjectName.Domain.Common;
namespace MyNewProjectName.Domain.Events;
/// <summary>
/// Base domain event
/// </summary>
public abstract class BaseDomainEvent : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,21 @@
namespace MyNewProjectName.Domain.Exceptions;
/// <summary>
/// Base exception for domain layer
/// </summary>
public class DomainException : Exception
{
public DomainException()
{
}
public DomainException(string message)
: base(message)
{
}
public DomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,27 @@
namespace MyNewProjectName.Domain.Exceptions;
/// <summary>
/// Exception thrown when an entity is not found
/// </summary>
public class NotFoundException : DomainException
{
public NotFoundException()
: base()
{
}
public NotFoundException(string message)
: base(message)
{
}
public NotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}
public NotFoundException(string name, object key)
: base($"Entity \"{name}\" ({key}) was not found.")
{
}
}

View File

@@ -0,0 +1,27 @@
namespace MyNewProjectName.Domain.Exceptions;
/// <summary>
/// Exception thrown when validation fails
/// </summary>
public class ValidationException : DomainException
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException()
: base("One or more validation failures have occurred.")
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(string message)
: base(message)
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(IDictionary<string, string[]> errors)
: base("One or more validation failures have occurred.")
{
Errors = errors;
}
}

View File

@@ -0,0 +1,24 @@
using System.Linq.Expressions;
using MyNewProjectName.Domain.Common;
namespace MyNewProjectName.Domain.Interfaces;
/// <summary>
/// Generic repository interface
/// </summary>
/// <typeparam name="T">Entity type</typeparam>
public interface IRepository<T> where T : BaseEntity
{
Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null, CancellationToken cancellationToken = default);
Task AddAsync(T entity, CancellationToken cancellationToken = default);
Task AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
void Update(T entity);
void UpdateRange(IEnumerable<T> entities);
void Remove(T entity);
void RemoveRange(IEnumerable<T> entities);
}

View File

@@ -0,0 +1,12 @@
namespace MyNewProjectName.Domain.Interfaces;
/// <summary>
/// Unit of Work pattern interface
/// </summary>
public interface IUnitOfWork : IDisposable
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,43 @@
namespace MyNewProjectName.Domain.ValueObjects;
/// <summary>
/// Base class for value objects
/// </summary>
public abstract class ValueObject
{
protected abstract IEnumerable<object?> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != GetType())
{
return false;
}
var other = (ValueObject)obj;
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x ^ y);
}
public static bool operator ==(ValueObject? left, ValueObject? right)
{
if (left is null && right is null)
return true;
if (left is null || right is null)
return false;
return left.Equals(right);
}
public static bool operator !=(ValueObject? left, ValueObject? right)
{
return !(left == right);
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using MyNewProjectName.Application.Interfaces;
using MyNewProjectName.Domain.Interfaces;
using MyNewProjectName.Infrastructure.Options;
using MyNewProjectName.Infrastructure.Persistence.Context;
using MyNewProjectName.Infrastructure.Persistence.Repositories;
using MyNewProjectName.Infrastructure.Services;
namespace MyNewProjectName.Infrastructure;
/// <summary>
/// Dependency Injection for Infrastructure Layer
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// Register Options Pattern
services.Configure<DatabaseOptions>(configuration.GetSection(DatabaseOptions.SectionName));
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionName));
services.Configure<SerilogOptions>(configuration.GetSection(SerilogOptions.SectionName));
// Validate required Options on startup
services.AddOptions<DatabaseOptions>()
.Bind(configuration.GetSection(DatabaseOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Validate JwtOptions only if section exists (optional for now)
var jwtSection = configuration.GetSection(JwtOptions.SectionName);
if (jwtSection.Exists() && jwtSection.GetChildren().Any())
{
services.AddOptions<JwtOptions>()
.Bind(jwtSection)
.ValidateDataAnnotations()
.ValidateOnStart();
}
// Register DbContext using Options Pattern
services.AddDbContext<ApplicationDbContext>((serviceProvider, options) =>
{
var dbOptions = serviceProvider.GetRequiredService<IOptions<DatabaseOptions>>().Value;
options.UseSqlServer(
dbOptions.DefaultConnection,
b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName));
});
// Register repositories
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Register HttpContextAccessor (required for CurrentUserService)
services.AddHttpContextAccessor();
// Register services
services.AddScoped<IDateTimeService, DateTimeService>();
services.AddScoped<ICurrentUserService, CurrentUserService>();
return services;
}
}

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Reflection;
namespace MyNewProjectName.Infrastructure.Extensions;
/// <summary>
/// Extension methods for configuring OpenTelemetry
/// </summary>
public static class OpenTelemetryExtensions
{
/// <summary>
/// Add OpenTelemetry distributed tracing
/// </summary>
public static IServiceCollection AddOpenTelemetryTracing(
this IServiceCollection services,
string serviceName = "MyNewProjectName")
{
services.AddOpenTelemetry()
.WithTracing(builder =>
{
builder
.SetResourceBuilder(ResourceBuilder
.CreateDefault()
.AddService(serviceName)
.AddAttributes(new Dictionary<string, object>
{
["service.version"] = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion ?? "1.0.0"
}))
.AddAspNetCoreInstrumentation(options =>
{
// Capture HTTP request/response details
options.RecordException = true;
options.EnrichWithHttpRequest = (activity, request) =>
{
activity.SetTag("http.request.method", request.Method);
activity.SetTag("http.request.path", request.Path);
activity.SetTag("http.request.query_string", request.QueryString);
};
options.EnrichWithHttpResponse = (activity, response) =>
{
activity.SetTag("http.response.status_code", response.StatusCode);
};
})
.AddHttpClientInstrumentation(options =>
{
options.RecordException = true;
options.EnrichWithHttpRequestMessage = (activity, request) =>
{
activity.SetTag("http.client.request.method", request.Method?.ToString());
activity.SetTag("http.client.request.uri", request.RequestUri?.ToString());
};
})
.AddEntityFrameworkCoreInstrumentation(options =>
{
options.SetDbStatementForText = true;
options.EnrichWithIDbCommand = (activity, command) =>
{
activity.SetTag("db.command.text", command.CommandText);
};
})
.AddSource(serviceName)
// Export to console (for development)
.AddConsoleExporter()
// Export to OTLP (for production - requires OTLP collector)
.AddOtlpExporter(options =>
{
// Configure OTLP endpoint if needed
// options.Endpoint = new Uri("http://localhost:4317");
});
});
return services;
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using MyNewProjectName.Infrastructure.Options;
using Serilog;
using Serilog.Events;
using Serilog.Formatting.Compact;
namespace MyNewProjectName.Infrastructure.Extensions;
/// <summary>
/// Extension methods for configuring Serilog
/// </summary>
public static class SerilogExtensions
{
/// <summary>
/// Configure Serilog with structured JSON logging
/// </summary>
public static IHostBuilder UseSerilogLogging(this IHostBuilder hostBuilder, IConfiguration configuration)
{
var serilogOptions = configuration.GetSection(SerilogOptions.SectionName).Get<SerilogOptions>()
?? new SerilogOptions();
var loggerConfiguration = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.Enrich.WithProperty("Application", "MyNewProjectName");
// Set minimum level from configuration
if (Enum.TryParse<LogEventLevel>(serilogOptions.MinimumLevel, out var minLevel))
{
loggerConfiguration.MinimumLevel.Is(minLevel);
}
// Apply overrides from configuration
foreach (var overrideConfig in serilogOptions.Override)
{
if (Enum.TryParse<LogEventLevel>(overrideConfig.Value, out var overrideLevel))
{
loggerConfiguration.MinimumLevel.Override(overrideConfig.Key, overrideLevel);
}
}
// Console sink with JSON formatting (Compact JSON format)
if (serilogOptions.WriteToConsole)
{
loggerConfiguration.WriteTo.Console(new CompactJsonFormatter());
}
// File sink with rolling
if (serilogOptions.WriteToFile)
{
var rollingInterval = Enum.TryParse<RollingInterval>(serilogOptions.RollingInterval, out var interval)
? interval
: RollingInterval.Day;
loggerConfiguration.WriteTo.File(
new CompactJsonFormatter(),
serilogOptions.FilePath,
rollingInterval: rollingInterval,
retainedFileCountLimit: serilogOptions.RetainedFileCountLimit,
shared: true);
}
// Seq sink (optional)
if (!string.IsNullOrWhiteSpace(serilogOptions.SeqUrl))
{
loggerConfiguration.WriteTo.Seq(serilogOptions.SeqUrl);
}
// Elasticsearch sink (optional)
if (!string.IsNullOrWhiteSpace(serilogOptions.ElasticsearchUrl))
{
// Note: Add Serilog.Sinks.Elasticsearch package if needed
// loggerConfiguration.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri(serilogOptions.ElasticsearchUrl)));
}
Log.Logger = loggerConfiguration.CreateLogger();
return hostBuilder.UseSerilog();
}
}

View File

@@ -0,0 +1,85 @@
// using MyNewProjectName.Application.Interfaces;
// using MyNewProjectName.Domain.Entities;
// using Microsoft.Extensions.Options;
// using Microsoft.IdentityModel.Tokens;
// using System.IdentityModel.Tokens.Jwt;
// using System.Security.Claims;
// using System.Security.Cryptography;
// using System.Text;
// using MyNewProjectName.Application.Interfaces.Common;
// namespace MyNewProjectName.Infrastructure.Identity;
// public class JwtTokenGenerator : IJwtTokenGenerator
// {
// private readonly JwtSettings _jwtSettings;
// public JwtTokenGenerator(IOptions<JwtSettings> jwtOptions)
// {
// _jwtSettings = jwtOptions.Value;
// }
// public string GenerateAccessToken(User user, List<string> roles, Guid tenantId)
// {
// var tokenHandler = new JwtSecurityTokenHandler();
// var key = Encoding.UTF8.GetBytes(_jwtSettings.Secret);
// var claims = new List<Claim>
// {
// new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
// new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
// new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
// new("id", user.Id.ToString()),
// new("tenantId", tenantId.ToString())
// };
// foreach (var role in roles)
// {
// claims.Add(new Claim(ClaimTypes.Role, role));
// }
// var tokenDescriptor = new SecurityTokenDescriptor
// {
// Subject = new ClaimsIdentity(claims),
// Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpirationMinutes),
// Issuer = _jwtSettings.Issuer,
// Audience = _jwtSettings.Audience,
// SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
// };
// var token = tokenHandler.CreateToken(tokenDescriptor);
// return tokenHandler.WriteToken(token);
// }
// public string GenerateRefreshToken()
// {
// var randomNumber = new byte[32];
// using var rng = RandomNumberGenerator.Create();
// rng.GetBytes(randomNumber);
// return Convert.ToBase64String(randomNumber);
// }
// public ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
// {
// var tokenValidationParameters = new TokenValidationParameters
// {
// ValidateAudience = false,
// ValidateIssuer = false,
// ValidateIssuerSigningKey = true,
// IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)),
// ValidateLifetime = false
// };
// var tokenHandler = new JwtSecurityTokenHandler();
// var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken);
// if (securityToken is not JwtSecurityToken jwtSecurityToken ||
// !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
// {
// throw new SecurityTokenException("Invalid token");
// }
// return principal;
// }
// }

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MyNewProjectName.Application\MyNewProjectName.Application.csproj" />
<ProjectReference Include="..\MyNewProjectName.Domain\MyNewProjectName.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.0" />
<!-- Serilog packages for extension methods -->
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" />
<!-- OpenTelemetry packages for extension methods -->
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.7" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace MyNewProjectName.Infrastructure.Options;
/// <summary>
/// Database connection configuration options
/// </summary>
public class DatabaseOptions
{
public const string SectionName = "ConnectionStrings";
/// <summary>
/// Default database connection string
/// </summary>
[Required(ErrorMessage = "DefaultConnection is required")]
public string DefaultConnection { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace MyNewProjectName.Infrastructure.Options;
/// <summary>
/// JWT authentication configuration options
/// </summary>
public class JwtOptions
{
public const string SectionName = "Jwt";
/// <summary>
/// Secret key for signing JWT tokens
/// </summary>
[Required(ErrorMessage = "JWT SecretKey is required")]
[MinLength(32, ErrorMessage = "JWT SecretKey must be at least 32 characters long")]
public string SecretKey { get; set; } = string.Empty;
/// <summary>
/// Token issuer
/// </summary>
[Required(ErrorMessage = "JWT Issuer is required")]
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// Token audience
/// </summary>
[Required(ErrorMessage = "JWT Audience is required")]
public string Audience { get; set; } = string.Empty;
/// <summary>
/// Token expiration time in minutes
/// </summary>
[Range(1, 1440, ErrorMessage = "ExpirationInMinutes must be between 1 and 1440 (24 hours)")]
public int ExpirationInMinutes { get; set; } = 60;
/// <summary>
/// Refresh token expiration time in days
/// </summary>
[Range(1, 365, ErrorMessage = "RefreshTokenExpirationInDays must be between 1 and 365")]
public int RefreshTokenExpirationInDays { get; set; } = 7;
}

View File

@@ -0,0 +1,24 @@
namespace MyNewProjectName.Infrastructure.Options;
/// <summary>
/// Redis cache configuration options
/// </summary>
public class RedisOptions
{
public const string SectionName = "Redis";
/// <summary>
/// Redis connection string
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>
/// Redis instance name for key prefixing
/// </summary>
public string InstanceName { get; set; } = string.Empty;
/// <summary>
/// Default cache expiration time in minutes
/// </summary>
public int DefaultExpirationInMinutes { get; set; } = 30;
}

View File

@@ -0,0 +1,54 @@
namespace MyNewProjectName.Infrastructure.Options;
/// <summary>
/// Serilog logging configuration options
/// </summary>
public class SerilogOptions
{
public const string SectionName = "Serilog";
/// <summary>
/// Minimum log level
/// </summary>
public string MinimumLevel { get; set; } = "Information";
/// <summary>
/// Override log levels for specific namespaces
/// </summary>
public Dictionary<string, string> Override { get; set; } = new();
/// <summary>
/// Write to console
/// </summary>
public bool WriteToConsole { get; set; } = true;
/// <summary>
/// Write to file
/// </summary>
public bool WriteToFile { get; set; } = true;
/// <summary>
/// File path for logs (relative to application root)
/// </summary>
public string FilePath { get; set; } = "logs/log-.txt";
/// <summary>
/// Rolling interval for log files
/// </summary>
public string RollingInterval { get; set; } = "Day";
/// <summary>
/// Retained file count limit
/// </summary>
public int RetainedFileCountLimit { get; set; } = 31;
/// <summary>
/// Seq server URL (optional)
/// </summary>
public string? SeqUrl { get; set; }
/// <summary>
/// Elasticsearch URL (optional)
/// </summary>
public string? ElasticsearchUrl { get; set; }
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MyNewProjectName.Domain.Entities;
namespace MyNewProjectName.Infrastructure.Persistence.Configurations;
/// <summary>
/// Entity configuration for SampleEntity
/// </summary>
public class SampleEntityConfiguration : IEntityTypeConfiguration<SampleEntity>
{
public void Configure(EntityTypeBuilder<SampleEntity> builder)
{
builder.ToTable("Samples");
builder.HasKey(e => e.Id);
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.Description)
.HasMaxLength(1000);
builder.Property(e => e.CreatedBy)
.HasMaxLength(100);
builder.Property(e => e.UpdatedBy)
.HasMaxLength(100);
// Index for common queries
builder.HasIndex(e => e.Name);
builder.HasIndex(e => e.IsActive);
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using MyNewProjectName.Domain.Common;
using MyNewProjectName.Domain.Entities;
using System.Reflection;
namespace MyNewProjectName.Infrastructure.Persistence.Context;
/// <summary>
/// Application database context
/// </summary>
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<SampleEntity> Samples => Set<SampleEntity>();
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = DateTime.UtcNow;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = DateTime.UtcNow;
break;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,90 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using MyNewProjectName.Domain.Common;
using MyNewProjectName.Domain.Interfaces;
using MyNewProjectName.Infrastructure.Persistence.Context;
namespace MyNewProjectName.Infrastructure.Persistence.Repositories;
/// <summary>
/// Generic repository implementation
/// </summary>
public class Repository<T> : IRepository<T> where T : BaseEntity
{
protected readonly ApplicationDbContext _context;
protected readonly DbSet<T> _dbSet;
public Repository(ApplicationDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public virtual async Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
}
public virtual async Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _dbSet.ToListAsync(cancellationToken);
}
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.Where(predicate).ToListAsync(cancellationToken);
}
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken);
}
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.AnyAsync(predicate, cancellationToken);
}
public virtual async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null, CancellationToken cancellationToken = default)
{
if (predicate == null)
return await _dbSet.CountAsync(cancellationToken);
return await _dbSet.CountAsync(predicate, cancellationToken);
}
public virtual async Task AddAsync(T entity, CancellationToken cancellationToken = default)
{
await _dbSet.AddAsync(entity, cancellationToken);
}
public virtual async Task AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
{
await _dbSet.AddRangeAsync(entities, cancellationToken);
}
public virtual void Update(T entity)
{
_dbSet.Attach(entity);
_context.Entry(entity).State = EntityState.Modified;
}
public virtual void UpdateRange(IEnumerable<T> entities)
{
_dbSet.AttachRange(entities);
foreach (var entity in entities)
{
_context.Entry(entity).State = EntityState.Modified;
}
}
public virtual void Remove(T entity)
{
_dbSet.Remove(entity);
}
public virtual void RemoveRange(IEnumerable<T> entities)
{
_dbSet.RemoveRange(entities);
}
}

View File

@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Storage;
using MyNewProjectName.Domain.Interfaces;
using MyNewProjectName.Infrastructure.Persistence.Context;
namespace MyNewProjectName.Infrastructure.Persistence.Repositories;
/// <summary>
/// Unit of Work implementation
/// </summary>
public class UnitOfWork : IUnitOfWork
{
private readonly ApplicationDbContext _context;
private IDbContextTransaction? _transaction;
public UnitOfWork(ApplicationDbContext context)
{
_context = context;
}
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
public async Task BeginTransactionAsync(CancellationToken cancellationToken = default)
{
_transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
}
public async Task CommitTransactionAsync(CancellationToken cancellationToken = default)
{
try
{
await _context.SaveChangesAsync(cancellationToken);
if (_transaction != null)
{
await _transaction.CommitAsync(cancellationToken);
}
}
catch
{
await RollbackTransactionAsync(cancellationToken);
throw;
}
finally
{
if (_transaction != null)
{
await _transaction.DisposeAsync();
_transaction = null;
}
}
}
public async Task RollbackTransactionAsync(CancellationToken cancellationToken = default)
{
if (_transaction != null)
{
await _transaction.RollbackAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}

View File

@@ -0,0 +1,69 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using MyNewProjectName.Application.Interfaces;
namespace MyNewProjectName.Infrastructure.Services;
/// <summary>
/// Implementation of ICurrentUserService
/// Automatically extracts user information from HttpContext using IHttpContextAccessor
/// This follows Clean Architecture principles - no dependency on concrete middleware
/// </summary>
public class CurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string? UserId
{
get
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;
return user.FindFirstValue(ClaimTypes.NameIdentifier)
?? user.FindFirstValue("sub")
?? user.FindFirstValue("userId");
}
}
public string? UserName
{
get
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;
return user.FindFirstValue(ClaimTypes.Name)
?? user.FindFirstValue("name")
?? user.FindFirstValue("username");
}
}
public bool? IsAuthenticated
{
get
{
return _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated;
}
}
public string? Role
{
get
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;
return user.FindFirstValue(ClaimTypes.Role)
?? user.FindFirstValue("role");
}
}
}

View File

@@ -0,0 +1,12 @@
using MyNewProjectName.Application.Interfaces;
namespace MyNewProjectName.Infrastructure.Services;
/// <summary>
/// DateTime service implementation
/// </summary>
public class DateTimeService : IDateTimeService
{
public DateTime Now => DateTime.Now;
public DateTime UtcNow => DateTime.UtcNow;
}

View File

@@ -0,0 +1,82 @@
using FluentAssertions;
using FluentValidation.TestHelper;
using MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
namespace MyNewProjectName.UnitTest.Application;
public class CreateSampleCommandValidatorTests
{
private readonly CreateSampleCommandValidator _validator;
public CreateSampleCommandValidatorTests()
{
_validator = new CreateSampleCommandValidator();
}
[Fact]
public void Validate_WithValidCommand_ShouldNotHaveErrors()
{
// Arrange
var command = new CreateSampleCommand("Test Name", "Test Description");
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_WithEmptyName_ShouldHaveError()
{
// Arrange
var command = new CreateSampleCommand("", "Test Description");
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Fact]
public void Validate_WithNameExceeding200Characters_ShouldHaveError()
{
// Arrange
var longName = new string('a', 201);
var command = new CreateSampleCommand(longName, "Test Description");
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Fact]
public void Validate_WithDescriptionExceeding1000Characters_ShouldHaveError()
{
// Arrange
var longDescription = new string('a', 1001);
var command = new CreateSampleCommand("Test Name", longDescription);
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Description);
}
[Fact]
public void Validate_WithNullDescription_ShouldNotHaveError()
{
// Arrange
var command = new CreateSampleCommand("Test Name", null);
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Description);
}
}

View File

@@ -0,0 +1,59 @@
using FluentAssertions;
using MyNewProjectName.Domain.Common;
namespace MyNewProjectName.UnitTest.Domain;
public class BaseEntityTests
{
private class TestEntity : BaseEntity { }
private class TestDomainEvent : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
[Fact]
public void AddDomainEvent_ShouldAddEventToCollection()
{
// Arrange
var entity = new TestEntity();
var domainEvent = new TestDomainEvent();
// Act
entity.AddDomainEvent(domainEvent);
// Assert
entity.DomainEvents.Should().Contain(domainEvent);
entity.DomainEvents.Should().HaveCount(1);
}
[Fact]
public void RemoveDomainEvent_ShouldRemoveEventFromCollection()
{
// Arrange
var entity = new TestEntity();
var domainEvent = new TestDomainEvent();
entity.AddDomainEvent(domainEvent);
// Act
entity.RemoveDomainEvent(domainEvent);
// Assert
entity.DomainEvents.Should().BeEmpty();
}
[Fact]
public void ClearDomainEvents_ShouldRemoveAllEvents()
{
// Arrange
var entity = new TestEntity();
entity.AddDomainEvent(new TestDomainEvent());
entity.AddDomainEvent(new TestDomainEvent());
// Act
entity.ClearDomainEvents();
// Assert
entity.DomainEvents.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,60 @@
using FluentAssertions;
using MyNewProjectName.Domain.ValueObjects;
namespace MyNewProjectName.UnitTest.Domain;
public class ValueObjectTests
{
private class TestValueObject : ValueObject
{
public string Value1 { get; }
public int Value2 { get; }
public TestValueObject(string value1, int value2)
{
Value1 = value1;
Value2 = value2;
}
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Value1;
yield return Value2;
}
}
[Fact]
public void ValueObjects_WithSameValues_ShouldBeEqual()
{
// Arrange
var vo1 = new TestValueObject("test", 123);
var vo2 = new TestValueObject("test", 123);
// Act & Assert
vo1.Should().Be(vo2);
(vo1 == vo2).Should().BeTrue();
}
[Fact]
public void ValueObjects_WithDifferentValues_ShouldNotBeEqual()
{
// Arrange
var vo1 = new TestValueObject("test", 123);
var vo2 = new TestValueObject("test", 456);
// Act & Assert
vo1.Should().NotBe(vo2);
(vo1 != vo2).Should().BeTrue();
}
[Fact]
public void GetHashCode_ShouldBeSameForEqualObjects()
{
// Arrange
var vo1 = new TestValueObject("test", 123);
var vo2 = new TestValueObject("test", 123);
// Act & Assert
vo1.GetHashCode().Should().Be(vo2.GetHashCode());
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyNewProjectName.Domain\MyNewProjectName.Domain.csproj" />
<ProjectReference Include="..\MyNewProjectName.Application\MyNewProjectName.Application.csproj" />
<ProjectReference Include="..\MyNewProjectName.Infrastructure\MyNewProjectName.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace MyNewProjectName.WebAPI.Controllers;
/// <summary>
/// Base API controller with common functionality
/// </summary>
[ApiController]
[Route("api/[controller]")]
public abstract class BaseApiController : ControllerBase
{
private ISender? _mediator;
protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService<ISender>();
}

View File

@@ -0,0 +1,37 @@
using MyNewProjectName.Application.Features.Sample.Commands.CreateSample;
using MyNewProjectName.Application.Features.Sample.Queries.GetSamples;
using MyNewProjectName.Contracts.DTOs.Requests;
using MyNewProjectName.Contracts.DTOs.Responses;
using Microsoft.AspNetCore.Mvc;
namespace MyNewProjectName.WebAPI.Controllers;
/// <summary>
/// Sample controller to demonstrate CQRS pattern
/// </summary>
public class SamplesController : BaseApiController
{
/// <summary>
/// Get all samples
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<List<SampleResponse>>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAll()
{
var result = await Mediator.Send(new GetSamplesQuery());
return Ok(ApiResponse<List<SampleDto>>.SuccessResponse(result));
}
/// <summary>
/// Create a new sample
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<Guid>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create([FromBody] CreateSampleRequest request)
{
var command = new CreateSampleCommand(request.Name, request.Description);
var id = await Mediator.Send(command);
return CreatedAtAction(nameof(GetAll), new { id }, ApiResponse<Guid>.SuccessResponse(id, "Sample created successfully"));
}
}

View File

@@ -0,0 +1,36 @@
using Serilog.Context;
namespace MyNewProjectName.WebAPI.Middleware;
/// <summary>
/// Middleware to generate and track correlation ID for each request
/// This ID is added to HTTP headers and Serilog log context for easy tracing
/// </summary>
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeaderName = "X-Correlation-ID";
private const string CorrelationIdLogPropertyName = "CorrelationId";
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Get correlation ID from request header, or generate a new one
var correlationId = context.Request.Headers[CorrelationIdHeaderName].FirstOrDefault()
?? $"req-{Guid.NewGuid():N}";
// Add correlation ID to response header
context.Response.Headers[CorrelationIdHeaderName] = correlationId;
// Add correlation ID to Serilog log context
// All logs within this request will automatically include this correlation ID
using (LogContext.PushProperty(CorrelationIdLogPropertyName, correlationId))
{
await _next(context);
}
}
}

View File

@@ -0,0 +1,114 @@
using System.Net;
using System.Text.Json;
using MyNewProjectName.Contracts.DTOs.Responses;
using MyNewProjectName.Domain.Exceptions;
using Serilog;
namespace MyNewProjectName.WebAPI.Middleware;
/// <summary>
/// Global exception handling middleware
/// Catches all unhandled exceptions, logs them to Serilog with full details,
/// and returns a standardized error response without exposing stack traces
/// </summary>
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly Serilog.ILogger _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, Serilog.ILogger logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
// Get correlation ID from header (set by CorrelationIdMiddleware)
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() ?? "unknown";
// Log full exception details to Serilog (including stack trace)
// This will be searchable by CorrelationId
_logger.Error(
exception,
"Unhandled exception occurred. CorrelationId: {CorrelationId}, Path: {Path}, Method: {Method}",
correlationId,
context.Request.Path,
context.Request.Method
);
// Safety check: If response has already started, we cannot modify headers/status code
// This prevents InvalidOperationException: "Headers are read-only, response has already started"
if (context.Response.HasStarted)
{
_logger.Warning(
"Response has already started. Cannot modify HTTP status code or headers. CorrelationId: {CorrelationId}",
correlationId
);
return;
}
var response = context.Response;
// Clear any existing response data
response.Clear();
response.ContentType = "application/json";
// Determine status code and user-friendly message
var (statusCode, message, errors) = exception switch
{
ValidationException validationEx => (
HttpStatusCode.BadRequest,
"Validation failed",
validationEx.Errors.SelectMany(e => e.Value).ToList()),
NotFoundException => (
HttpStatusCode.NotFound,
exception.Message,
new List<string>()),
DomainException => (
HttpStatusCode.BadRequest,
exception.Message,
new List<string>()),
UnauthorizedAccessException => (
HttpStatusCode.Unauthorized,
"Unauthorized",
new List<string>()),
_ => (
HttpStatusCode.InternalServerError,
"An error occurred while processing your request",
new List<string>())
};
response.StatusCode = (int)statusCode;
// Return standardized error response (NO stack trace exposed to client)
var result = JsonSerializer.Serialize(new ApiResponse
{
Success = false,
Message = message,
Errors = errors.Any() ? errors : null
}, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await response.WriteAsync(result);
}
}

View File

@@ -0,0 +1,133 @@
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Configuration;
using Serilog;
namespace MyNewProjectName.WebAPI.Middleware;
/// <summary>
/// Middleware to log request and response bodies
/// WARNING: This can generate large log files. Use with caution in production.
/// Consider enabling only for specific environments or endpoints.
/// </summary>
public class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly Serilog.ILogger _logger;
private readonly bool _enableRequestLogging;
private readonly bool _enableResponseLogging;
// Paths that should NOT be logged (e.g., health checks, metrics)
private static readonly string[] ExcludedPaths = new[]
{
"/health",
"/metrics",
"/favicon.ico"
};
public RequestResponseLoggingMiddleware(
RequestDelegate next,
Serilog.ILogger logger,
IConfiguration configuration)
{
_next = next;
_logger = logger;
_enableRequestLogging = configuration.GetValue<bool>("Logging:EnableRequestLogging", false);
_enableResponseLogging = configuration.GetValue<bool>("Logging:EnableResponseLogging", false);
}
public async Task InvokeAsync(HttpContext context)
{
// Skip logging for excluded paths
if (ExcludedPaths.Any(path => context.Request.Path.StartsWithSegments(path)))
{
await _next(context);
return;
}
var stopwatch = Stopwatch.StartNew();
var requestBody = string.Empty;
var responseBody = string.Empty;
// Log request
if (_enableRequestLogging)
{
requestBody = await ReadRequestBodyAsync(context.Request);
_logger.Information(
"Request: {Method} {Path} {QueryString} | Body: {RequestBody}",
context.Request.Method,
context.Request.Path,
context.Request.QueryString,
requestBody
);
}
// Capture response body
var originalBodyStream = context.Response.Body;
using var responseBodyStream = new MemoryStream();
context.Response.Body = responseBodyStream;
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
// Log response
if (_enableResponseLogging)
{
responseBody = await ReadResponseBodyAsync(context.Response);
await responseBodyStream.CopyToAsync(originalBodyStream);
_logger.Information(
"Response: {StatusCode} | Duration: {Duration}ms | Body: {ResponseBody}",
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds,
responseBody
);
}
else
{
await responseBodyStream.CopyToAsync(originalBodyStream);
}
context.Response.Body = originalBodyStream;
}
}
private static async Task<string> ReadRequestBodyAsync(HttpRequest request)
{
// Enable buffering to allow reading the body multiple times
request.EnableBuffering();
using var reader = new StreamReader(
request.Body,
encoding: Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0; // Reset position for next middleware
// Truncate very long bodies to avoid huge logs
return body.Length > 10000 ? body[..10000] + "... (truncated)" : body;
}
private static async Task<string> ReadResponseBodyAsync(HttpResponse response)
{
response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(
response.Body,
encoding: Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
var body = await reader.ReadToEndAsync();
response.Body.Seek(0, SeekOrigin.Begin); // Reset position
// Truncate very long bodies to avoid huge logs
return body.Length > 10000 ? body[..10000] + "... (truncated)" : body;
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<!-- Serilog for structured logging -->
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<!-- OpenTelemetry for distributed tracing -->
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyNewProjectName.Infrastructure\MyNewProjectName.Infrastructure.csproj" />
<ProjectReference Include="..\MyNewProjectName.Application\MyNewProjectName.Application.csproj" />
<ProjectReference Include="..\MyNewProjectName.Contracts\MyNewProjectName.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@MyNewProjectName.WebAPI_HostAddress = http://localhost:5044
GET {{MyNewProjectName.WebAPI_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,75 @@
using Microsoft.Extensions.Hosting;
using MyNewProjectName.Application;
using MyNewProjectName.Infrastructure;
using MyNewProjectName.Infrastructure.Extensions;
using MyNewProjectName.WebAPI.Middleware;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
builder.Host.UseSerilogLogging(builder.Configuration);
// Add OpenTelemetry distributed tracing
builder.Services.AddOpenTelemetryTracing("MyNewProjectName.WebAPI");
// Add services to the container.
builder.Services.AddControllers();
// Add Application Layer
builder.Services.AddApplication();
// Add Infrastructure Layer
builder.Services.AddInfrastructure(builder.Configuration);
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// Add Swagger
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// Middleware pipeline order is critical:
// 1. CorrelationIdMiddleware - Must be first to track all requests
app.UseMiddleware<CorrelationIdMiddleware>();
// 2. RequestResponseLoggingMiddleware - Optional, enable only when needed
// WARNING: This can generate large log files. Enable only for specific environments.
// Configure in appsettings.json: "Logging:EnableRequestLogging" and "Logging:EnableResponseLogging"
app.UseMiddleware<RequestResponseLoggingMiddleware>();
app.UseHttpsRedirection();
// 3. Authentication (built-in)
app.UseAuthentication();
// 4. Authorization (built-in)
app.UseAuthorization();
// 6. ExceptionHandlingMiddleware - Must be last to catch all exceptions
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapControllers();
try
{
Log.Information("Starting MyNewProjectName.WebAPI");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
throw;
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5044",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7285;http://localhost:5044",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,43 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MyNewProjectNameDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
},
"Jwt": {
"SecretKey": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "MyNewProjectName",
"Audience": "MyNewProjectName",
"ExpirationInMinutes": 60,
"RefreshTokenExpirationInDays": 7
},
"Redis": {
"ConnectionString": "localhost:6379",
"InstanceName": "MyNewProjectName:",
"DefaultExpirationInMinutes": 30
},
"Serilog": {
"MinimumLevel": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
},
"WriteToConsole": true,
"WriteToFile": true,
"FilePath": "logs/log-.txt",
"RollingInterval": "Day",
"RetainedFileCountLimit": 31,
"SeqUrl": null,
"ElasticsearchUrl": null
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
},
"EnableRequestLogging": false,
"EnableResponseLogging": false
},
"AllowedHosts": "*"
}

118
MyNewProjectName.sln Normal file
View File

@@ -0,0 +1,118 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.Domain", "MyNewProjectName.Domain\MyNewProjectName.Domain.csproj", "{40B880AC-5B91-4B47-8244-B32357283A12}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.Contracts", "MyNewProjectName.Contracts\MyNewProjectName.Contracts.csproj", "{EFA800B5-A636-4779-A488-F481C2520D94}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.Application", "MyNewProjectName.Application\MyNewProjectName.Application.csproj", "{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.Infrastructure", "MyNewProjectName.Infrastructure\MyNewProjectName.Infrastructure.csproj", "{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.WebAPI", "MyNewProjectName.WebAPI\MyNewProjectName.WebAPI.csproj", "{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.AdminAPI", "MyNewProjectName.AdminAPI\MyNewProjectName.AdminAPI.csproj", "{DF5C0545-C453-47C5-8AAC-22655434DDFB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNewProjectName.UnitTest", "MyNewProjectName.UnitTest\MyNewProjectName.UnitTest.csproj", "{6381CB4D-1AE7-420F-A2C1-0621EB659614}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|x64.ActiveCfg = Debug|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|x64.Build.0 = Debug|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|x86.ActiveCfg = Debug|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Debug|x86.Build.0 = Debug|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|Any CPU.Build.0 = Release|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|x64.ActiveCfg = Release|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|x64.Build.0 = Release|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|x86.ActiveCfg = Release|Any CPU
{40B880AC-5B91-4B47-8244-B32357283A12}.Release|x86.Build.0 = Release|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|x64.ActiveCfg = Debug|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|x64.Build.0 = Debug|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|x86.ActiveCfg = Debug|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Debug|x86.Build.0 = Debug|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|Any CPU.Build.0 = Release|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|x64.ActiveCfg = Release|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|x64.Build.0 = Release|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|x86.ActiveCfg = Release|Any CPU
{EFA800B5-A636-4779-A488-F481C2520D94}.Release|x86.Build.0 = Release|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|x64.ActiveCfg = Debug|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|x64.Build.0 = Debug|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|x86.ActiveCfg = Debug|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Debug|x86.Build.0 = Debug|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|Any CPU.Build.0 = Release|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|x64.ActiveCfg = Release|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|x64.Build.0 = Release|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|x86.ActiveCfg = Release|Any CPU
{344EDD3C-D5B8-4A7F-9262-7C134B278EB5}.Release|x86.Build.0 = Release|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|x64.ActiveCfg = Debug|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|x64.Build.0 = Debug|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|x86.ActiveCfg = Debug|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Debug|x86.Build.0 = Debug|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|Any CPU.Build.0 = Release|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|x64.ActiveCfg = Release|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|x64.Build.0 = Release|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|x86.ActiveCfg = Release|Any CPU
{6C7C7C6D-CA2D-4D65-AF8D-5FA023291B66}.Release|x86.Build.0 = Release|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|x64.ActiveCfg = Debug|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|x64.Build.0 = Debug|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|x86.ActiveCfg = Debug|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Debug|x86.Build.0 = Debug|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|Any CPU.Build.0 = Release|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|x64.ActiveCfg = Release|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|x64.Build.0 = Release|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|x86.ActiveCfg = Release|Any CPU
{AC37D921-C0CD-4780-9DAF-46BCCEFCE108}.Release|x86.Build.0 = Release|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|x64.ActiveCfg = Debug|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|x64.Build.0 = Debug|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|x86.ActiveCfg = Debug|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Debug|x86.Build.0 = Debug|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|Any CPU.Build.0 = Release|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|x64.ActiveCfg = Release|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|x64.Build.0 = Release|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|x86.ActiveCfg = Release|Any CPU
{DF5C0545-C453-47C5-8AAC-22655434DDFB}.Release|x86.Build.0 = Release|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|x64.ActiveCfg = Debug|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|x64.Build.0 = Debug|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|x86.ActiveCfg = Debug|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Debug|x86.Build.0 = Debug|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|Any CPU.Build.0 = Release|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|x64.ActiveCfg = Release|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|x64.Build.0 = Release|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|x86.ActiveCfg = Release|Any CPU
{6381CB4D-1AE7-420F-A2C1-0621EB659614}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

50
docker-compose.yml Normal file
View File

@@ -0,0 +1,50 @@
services:
webapi:
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:8080"
- "5001:8081"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=iYHCT360Db;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True
depends_on:
- sqlserver
networks:
- app-network
adminapi:
build:
context: .
dockerfile: Dockerfile.admin
ports:
- "5002:8080"
- "5003:8081"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=iYHCT360Db;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True
depends_on:
- sqlserver
networks:
- app-network
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
ports:
- "1433:1433"
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=YourStrong!Passw0rd
- MSSQL_PID=Developer
volumes:
- sqlserver-data:/var/opt/mssql
networks:
- app-network
volumes:
sqlserver-data:
networks:
app-network:
driver: bridge

334
docs/GitBranch.md Normal file
View File

@@ -0,0 +1,334 @@
# Huong Dan Dat Ten Git Branch Trong Du An
> **Tham khao:** [Git Branch Naming Conventions - Codiga](https://codiga.io/blog/git-branch-naming-conventions)
---
## Muc Luc
1. [Nguyen Tac Chung](#1-nguyen-tac-chung)
2. [Cau Truc Ten Branch](#2-cau-truc-ten-branch)
3. [Cac Loai Branch (Branch Types)](#3-cac-loai-branch-branch-types)
4. [Bang Mau Ten Branch Theo Chuc Nang](#4-bang-mau-ten-branch-theo-chuc-nang)
5. [Quy Tac Dat Ten (Good Practices)](#5-quy-tac-dat-ten-good-practices)
6. [Mo Hinh Git Flow](#6-mo-hinh-git-flow)
7. [Vi Du Thuc Te Trong Du An](#7-vi-du-thuc-te-trong-du-an)
8. [Checklist Truoc Khi Tao Branch](#8-checklist-truoc-khi-tao-branch)
---
## 1. Nguyen Tac Chung
Theo bai viet tu Codiga, mot quy uoc dat ten branch tot giup:
| # | Loi ich | Mo ta |
|---|---------|-------|
| 1 | **Truy vet tac gia** | Biet ai da tao branch (developer nao) |
| 2 | **Lien ket voi issue tracker** | De dang trace branch voi task/ticket tren JIRA, Trello, GitHub Issues... |
| 3 | **Hieu muc dich branch** | Nhanh chong biet branch la bugfix, feature, hay hotfix |
| 4 | **To chuc workflow** | Giu cho quy trinh lam viec co trat tu va hieu qua |
---
## 2. Cau Truc Ten Branch
### Format chung
```
<type>/<ticket-id>-<short-description>
```
Trong do:
| Thanh phan | Bat buoc | Mo ta | Vi du |
|-----------|----------|-------|-------|
| `type` | Co | Loai branch (feature, bugfix, hotfix...) | `feature` |
| `ticket-id` | Co (neu co) | Ma ticket/issue tu issue tracker | `PROJ-1234` |
| `short-description` | Co | Mo ta ngan 3-6 tu, phan cach bang dau `-` | `add-user-authentication` |
### Vi du day du
```
feature/PROJ-1234-add-user-authentication
bugfix/PROJ-5678-fix-login-redirect
hotfix/PROJ-9012-patch-security-vulnerability
```
### Format mo rong (co ten tac gia)
Neu team co nhieu nguoi lam chung mot ticket, them ten tac gia:
```
<author>/<type>/<ticket-id>-<short-description>
```
Vi du:
```
julien/feature/1234-new-dashboard
david/feature/1234-new-dashboard
```
Dieu nay giup phan biet ro rang code cua tung developer cho cung mot task.
---
## 3. Cac Loai Branch (Branch Types)
### Branch chinh (Long-lived branches)
| Branch | Muc dich | Duoc merge tu | Ghi chu |
|--------|---------|---------------|---------|
| `main` (hoac `master`) | Code production, luon o trang thai stable | `release`, `hotfix` | Khong bao gio commit truc tiep |
| `develop` | Code moi nhat cho phien ban tiep theo | `feature`, `bugfix` | Nhanh tich hop chinh |
| `staging` | Moi truong test truoc khi len production | `develop` | Tuy chon, tuy du an |
### Branch tam thoi (Short-lived branches)
| Prefix | Muc dich | Tao tu | Merge vao | Vi du |
|--------|---------|--------|----------|-------|
| `feature/` | Tinh nang moi | `develop` | `develop` | `feature/PROJ-101-add-login-page` |
| `bugfix/` | Sua loi trong qua trinh phat trien | `develop` | `develop` | `bugfix/PROJ-202-fix-null-reference` |
| `hotfix/` | Sua loi khan cap tren production | `main` | `main` va `develop` | `hotfix/PROJ-303-fix-payment-crash` |
| `release/` | Chuan bi phien ban moi | `develop` | `main` va `develop` | `release/v1.2.0` |
| `chore/` | Cong viec bao tri, refactor, CI/CD | `develop` | `develop` | `chore/update-dependencies` |
| `docs/` | Cap nhat tai lieu | `develop` | `develop` | `docs/update-api-documentation` |
| `test/` | Viet test hoac cai thien test | `develop` | `develop` | `test/add-unit-tests-user-service` |
| `refactor/` | Tai cau truc code, khong thay doi chuc nang | `develop` | `develop` | `refactor/clean-up-user-repository` |
---
## 4. Bang Mau Ten Branch Theo Chuc Nang
### Authentication & Authorization
```
feature/PROJ-101-add-jwt-authentication
feature/PROJ-102-implement-refresh-token
feature/PROJ-103-add-role-based-access
bugfix/PROJ-104-fix-token-expiration
hotfix/PROJ-105-patch-auth-bypass
```
### CRUD Entity
```
feature/PROJ-201-create-product-entity
feature/PROJ-202-add-product-api-endpoints
feature/PROJ-203-implement-product-search
bugfix/PROJ-204-fix-product-update-validation
```
### Infrastructure & DevOps
```
chore/PROJ-301-setup-docker-compose
chore/PROJ-302-configure-ci-cd-pipeline
chore/PROJ-303-add-redis-caching
chore/PROJ-304-setup-logging-serilog
```
### Database & Migration
```
feature/PROJ-401-add-migration-user-table
feature/PROJ-402-seed-initial-data
bugfix/PROJ-403-fix-migration-conflict
```
### Documentation
```
docs/PROJ-501-update-readme
docs/PROJ-502-add-api-swagger-docs
docs/PROJ-503-create-deployment-guide
```
---
## 5. Quy Tac Dat Ten (Good Practices)
### Nen lam
| Quy tac | Chi tiet | Vi du |
|---------|---------|-------|
| **Dung ten mo ta** | Ten branch phai phan anh ro noi dung thay doi | `feature/PROJ-101-add-user-authentication` |
| **Giu ngan gon** | Chi 3-6 tu khoa, phan cach bang dau `-` | `bugfix/PROJ-202-fix-null-ref` |
| **Viet thuong toan bo** | Khong viet hoa | `feature/add-login` |
| **Dung dau `-` phan cach tu** | Khong dung dau cach, underscore, hoac camelCase | `fix-login-redirect` |
| **Bat dau bang type prefix** | Luon co prefix xac dinh loai branch | `feature/`, `bugfix/`, `hotfix/` |
| **Lien ket ticket ID** | Giup trace nguon goc thay doi | `PROJ-1234-...` |
### Khong nen lam
| Quy tac | Vi du sai | Vi du dung |
|---------|----------|-----------|
| **Khong dung ky tu dac biet** | `feature/add@user#auth` | `feature/add-user-auth` |
| **Khong dung dau cach** | `feature/add user auth` | `feature/add-user-auth` |
| **Khong viet hoa** | `Feature/Add-User-Auth` | `feature/add-user-auth` |
| **Khong dat ten chung chung** | `feature/new-stuff` | `feature/PROJ-101-add-payment-gateway` |
| **Khong dat ten qua dai** | `feature/PROJ-101-add-new-user-authentication-with-jwt-and-refresh-token-support-for-all-roles` | `feature/PROJ-101-add-jwt-auth` |
| **Khong dung so thuong** | `feature/123` | `feature/PROJ-123-add-login` |
| **Khong commit truc tiep vao main/develop** | — | Luon tao branch rieng |
---
## 6. Mo Hinh Git Flow
### So do tong quat
```
main (production)
|
|--- hotfix/PROJ-xxx-fix-critical-bug
| |
| v
| (merge vao main VA develop)
|
|--- release/v1.2.0
| |
| v
| (merge vao main VA develop)
|
develop (integration)
|
|--- feature/PROJ-xxx-new-feature
| |
| v
| (merge vao develop qua Pull Request)
|
|--- bugfix/PROJ-xxx-fix-bug
| |
| v
| (merge vao develop qua Pull Request)
|
|--- chore/update-packages
|
v
(merge vao develop qua Pull Request)
```
### Quy trinh lam viec
1. **Tao branch** tu `develop` (hoac `main` cho hotfix)
2. **Commit** thuong xuyen voi message ro rang
3. **Push** branch len remote
4. **Tao Pull Request** (PR) de review code
5. **Review & Approve** boi it nhat 1 thanh vien khac
6. **Merge** vao branch dich (squash merge hoac merge commit)
7. **Xoa branch** sau khi merge thanh cong
### Lenh Git mau
```bash
# Tao branch feature moi tu develop
git checkout develop
git pull origin develop
git checkout -b feature/PROJ-101-add-login-page
# Lam viec va commit
git add .
git commit -m "feat(PROJ-101): add login page UI"
# Push len remote
git push origin feature/PROJ-101-add-login-page
# Sau khi merge PR, xoa branch local
git checkout develop
git pull origin develop
git branch -d feature/PROJ-101-add-login-page
```
---
## 7. Vi Du Thuc Te Trong Du An
### Ap dung cho du an Clean Architecture (MyNewProjectName)
#### Sprint 1: Khoi tao du an
```bash
# Cau hinh co ban
chore/PROJ-001-setup-clean-architecture
chore/PROJ-002-configure-dependency-injection
chore/PROJ-003-setup-ef-core-database
chore/PROJ-004-add-serilog-logging
chore/PROJ-005-setup-docker-compose
```
#### Sprint 2: Authentication
```bash
# Tinh nang xac thuc
feature/PROJ-010-add-user-entity
feature/PROJ-011-implement-jwt-authentication
feature/PROJ-012-add-refresh-token-flow
feature/PROJ-013-implement-role-authorization
bugfix/PROJ-014-fix-token-validation-error
```
#### Sprint 3: CRUD cho SampleEntity
```bash
# Tinh nang CRUD
feature/PROJ-020-add-sample-entity-crud
feature/PROJ-021-add-pagination-support
feature/PROJ-022-implement-search-filter
bugfix/PROJ-023-fix-sample-delete-cascade
```
#### Hotfix khan cap
```bash
# Sua loi tren production
hotfix/PROJ-099-fix-sql-injection-vulnerability
hotfix/PROJ-100-patch-cors-configuration
```
### Quy uoc Commit Message (di kem voi branch)
De dong bo voi quy uoc branch, nen dung [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
```
| Type | Muc dich | Vi du |
|------|---------|-------|
| `feat` | Tinh nang moi | `feat(PROJ-101): add login page` |
| `fix` | Sua loi | `fix(PROJ-202): resolve null reference in UserService` |
| `chore` | Bao tri | `chore: update NuGet packages` |
| `docs` | Tai lieu | `docs: update API documentation` |
| `refactor` | Tai cau truc | `refactor: simplify UserRepository queries` |
| `test` | Them/sua test | `test: add unit tests for AuthService` |
| `style` | Format code | `style: apply editorconfig rules` |
| `ci` | CI/CD | `ci: add GitHub Actions workflow` |
---
## 8. Checklist Truoc Khi Tao Branch
- [ ] Ten branch co bat dau bang type prefix khong? (`feature/`, `bugfix/`, `hotfix/`...)
- [ ] Ten branch co chua ticket/issue ID khong? (`PROJ-1234`)
- [ ] Mo ta co ngan gon va ro rang khong? (3-6 tu)
- [ ] Chi dung chu thuong, so, dau `-` va dau `/`?
- [ ] Khong co ky tu dac biet, dau cach, hoac chu viet hoa?
- [ ] Branch duoc tao tu dung branch nguon? (`develop` hoac `main`)
- [ ] Da pull code moi nhat tu branch nguon truoc khi tao?
---
## Tom Tat Nhanh
```
Format: <type>/<ticket-id>-<short-description>
Vi du: feature/PROJ-101-add-user-authentication
Type: feature | bugfix | hotfix | release | chore | docs | test | refactor
Chu y: - Viet thuong toan bo
- Dung dau `-` phan cach tu
- Giu ngan gon (3-6 tu)
- Khong ky tu dac biet
- Lien ket ticket ID
- Xoa branch sau khi merge
```

468
docs/GitCommit.md Normal file
View File

@@ -0,0 +1,468 @@
# Huong Dan Viet Git Commit Message Trong Du An
> **Tham khao:** [Conventional Commits](https://www.conventionalcommits.org/)
---
## Muc Luc
1. [Nguyen Tac Chung](#1-nguyen-tac-chung)
2. [Cau Truc Commit Message](#2-cau-truc-commit-message)
3. [Cac Loai Type](#3-cac-loai-type)
4. [Scope - Pham Vi Thay Doi](#4-scope---pham-vi-thay-doi)
5. [Quy Tac Viet Description](#5-quy-tac-viet-description)
6. [Commit Message Voi Body Va Footer](#6-commit-message-voi-body-va-footer)
7. [Bang Vi Du Day Du](#7-bang-vi-du-day-du)
8. [Vi Du Thuc Te Trong Du An](#8-vi-du-thuc-te-trong-du-an)
9. [Nhung Loi Thuong Gap](#9-nhung-loi-thuong-gap)
10. [Checklist Truoc Khi Commit](#10-checklist-truoc-khi-commit)
---
## 1. Nguyen Tac Chung
Viet commit message chuan giup:
| # | Loi ich | Mo ta |
|---|---------|-------|
| 1 | **Doc lich su de dang** | Nhin vao git log biet ngay thay doi gi |
| 2 | **Tu dong tao changelog** | Cac tool co the tu dong tao changelog tu commit message |
| 3 | **Lien ket voi issue tracker** | De dang trace commit voi task/ticket |
| 4 | **Review code hieu qua** | Nguoi review hieu nhanh muc dich cua commit |
| 5 | **Tu dong versioning** | Xac dinh phien ban tu dong (semantic versioning) dua tren type |
---
## 2. Cau Truc Commit Message
### Format chung
```
<type>(<scope>): <description>
```
Trong do:
| Thanh phan | Bat buoc | Mo ta | Vi du |
|-----------|----------|-------|-------|
| `type` | Co | Loai thay doi (feat, fix, chore...) | `feat` |
| `scope` | Khong | Pham vi/module bi anh huong | `auth`, `api`, `user` |
| `description` | Co | Mo ta ngan, duoi 50 ky tu, viet hoa dau cau, khong dau cham cuoi | `add Google login` |
### Format day du (voi body va footer)
```
<type>(<scope>): <description>
<body>
<footer>
```
### Vi du nhanh
```
feat(auth): add Google login
fix(api): resolve 404 error
docs(readme): update install guide
chore: update dependencies
```
---
## 3. Cac Loai Type
### Type chinh (thuong dung)
| Type | Muc dich | Anh huong version | Vi du |
|------|---------|-------------------|-------|
| `feat` | Them tinh nang moi | MINOR (1.x.0) | `feat(auth): add Google OAuth` |
| `fix` | Sua loi | PATCH (1.0.x) | `fix(api): resolve 404 error` |
| `docs` | Cap nhat tai lieu | Khong | `docs(readme): update install guide` |
| `style` | Doi format/UI khong anh huong logic | Khong | `style: apply prettier formatting` |
| `refactor` | Tai cau truc code, khong thay doi chuc nang | Khong | `refactor: simplify UserService logic` |
| `perf` | Toi uu hieu nang | PATCH (1.0.x) | `perf: optimize database queries` |
| `test` | Them hoac sua test | Khong | `test: add unit tests for AuthService` |
| `chore` | Thay doi nho, bao tri, CI/CD | Khong | `chore: update dependencies` |
### Type bo sung (it dung hon)
| Type | Muc dich | Vi du |
|------|---------|-------|
| `build` | Thay doi build system hoac dependencies | `build: upgrade to .NET 8` |
| `ci` | Thay doi CI/CD pipeline | `ci: add GitHub Actions workflow` |
| `revert` | Hoan tac commit truoc do | `revert: revert feat(auth): add Google login` |
---
## 4. Scope - Pham Vi Thay Doi
Scope la phan **tuy chon** nam trong dau ngoac `()`, xac dinh module/file/module cu the bi anh huong.
### Danh sach scope khuyen nghi cho du an Clean Architecture
| Scope | Layer/Module | Vi du |
|-------|-------------|-------|
| `domain` | MyNewProjectName.Domain | `feat(domain): add Order entity` |
| `app` | MyNewProjectName.Application | `feat(app): add CreateUserCommand` |
| `infra` | MyNewProjectName.Infrastructure | `feat(infra): configure EF Core DbContext` |
| `api` | MyNewProjectName.WebAPI | `fix(api): resolve CORS issue` |
| `admin` | MyNewProjectName.AdminAPI | `feat(admin): add dashboard endpoint` |
| `contract` | MyNewProjectName.Contracts | `feat(contract): add UserDto` |
| `test` | MyNewProjectName.UnitTest | `test(test): add UserService tests` |
| `auth` | Module xac thuc | `feat(auth): implement JWT refresh token` |
| `cache` | Module caching/Redis | `feat(cache): add Redis caching layer` |
| `db` | Database/Migration | `feat(db): add migration for User table` |
| `docker` | Docker/Container | `chore(docker): update docker-compose` |
| `deps` | Dependencies/NuGet | `chore(deps): update MediatR to v12` |
### Quy tac scope
- Viet thuong toan bo
- Ngan gon, 1-2 tu
- Phai nhat quan trong toan du an
- Co the bo qua neu thay doi anh huong nhieu module
---
## 5. Quy Tac Viet Description
### Nen lam
| Quy tac | Chi tiet | Vi du |
|---------|---------|-------|
| **Viet hoa chu dau** | Chu dau tien cua description viet hoa | `feat: Add login page` |
| **Duoi 50 ky tu** | Giu description ngan gon | `fix: Resolve null reference in UserService` |
| **Dung dong tu menh lenh** | Bat dau bang dong tu (add, fix, update, remove...) | `feat: Add user authentication` |
| **Khong dau cham cuoi** | Khong ket thuc bang dau `.` | `docs: Update README` |
| **Mo ta "lam gi"** | Tap trung vao ket qua, khong phai qua trinh | `fix: Resolve 404 on login redirect` |
### Khong nen lam
| Quy tac | Vi du sai | Vi du dung |
|---------|----------|-----------|
| **Khong viet chung chung** | `fix: Fix bug` | `fix(auth): Resolve token expiration error` |
| **Khong qua dai** | `feat: Add new user authentication with JWT and refresh token and role-based access control` | `feat(auth): Add JWT authentication` |
| **Khong dung qua khu** | `feat: Added login page` | `feat: Add login page` |
| **Khong ghi ten file** | `fix: Fix UserService.cs` | `fix(app): Resolve null ref in user creation` |
| **Khong dung tieng Viet trong type** | `feat: Them trang dang nhap` | `feat: Add login page` |
### Danh sach dong tu khuyen dung
| Dong tu | Khi nao dung | Vi du |
|---------|-------------|-------|
| `add` | Them moi | `feat: Add payment gateway` |
| `remove` | Xoa bo | `refactor: Remove unused imports` |
| `update` | Cap nhat | `docs: Update API documentation` |
| `fix` | Sua loi | `fix: Fix null reference exception` |
| `resolve` | Giai quyet | `fix: Resolve race condition in checkout` |
| `implement` | Hien thuc | `feat: Implement search filter` |
| `refactor` | Tai cau truc | `refactor: Refactor UserRepository` |
| `optimize` | Toi uu | `perf: Optimize query performance` |
| `configure` | Cau hinh | `chore: Configure Serilog logging` |
| `migrate` | Di chuyen/migration | `feat: Migrate user table schema` |
| `replace` | Thay the | `refactor: Replace raw SQL with EF Core` |
| `rename` | Doi ten | `refactor: Rename UserDto to UserResponse` |
| `move` | Di chuyen | `refactor: Move validators to shared folder` |
| `simplify` | Don gian hoa | `refactor: Simplify error handling logic` |
| `extract` | Tach ra | `refactor: Extract email service interface` |
---
## 6. Commit Message Voi Body Va Footer
Khi commit phuc tap, can giai thich them, su dung body va footer:
### Format
```
<type>(<scope>): <description>
<-- dong trong bat buoc
<body>
<-- dong trong bat buoc
<footer>
```
### Vi du 1: Commit co body
```
feat(auth): Add JWT authentication
Implement JWT-based authentication using IdentityServer.
Include access token and refresh token flow.
Configure token expiration to 15 minutes for access token
and 7 days for refresh token.
```
### Vi du 2: Commit co body va footer (lien ket ticket)
```
fix(api): Resolve 500 error on user creation
The API was returning 500 when creating a user with an existing email.
Added proper validation check before inserting into database.
Return 409 Conflict instead of 500 Internal Server Error.
Resolves: PROJ-1234
```
### Vi du 3: Breaking change
```
feat(api)!: Change response format for all endpoints
BREAKING CHANGE: All API responses now follow the new standard format:
{
"success": true,
"data": {},
"message": "",
"errors": []
}
Previous format with flat response body is no longer supported.
Clients must update to handle the new wrapper format.
Resolves: PROJ-5678
```
### Footer keywords
| Keyword | Muc dich | Vi du |
|---------|---------|-------|
| `Resolves:` | Dong issue/ticket | `Resolves: PROJ-1234` |
| `Closes:` | Dong issue tren GitHub | `Closes: #123` |
| `Related:` | Lien quan den issue khac | `Related: PROJ-5678` |
| `BREAKING CHANGE:` | Thay doi khong tuong thich nguoc | `BREAKING CHANGE: API response format changed` |
| `Co-authored-by:` | Dong tac gia | `Co-authored-by: Name <email>` |
| `Reviewed-by:` | Nguoi review | `Reviewed-by: Name <email>` |
---
## 7. Bang Vi Du Day Du
### feat - Them tinh nang moi
```bash
feat(auth): Add Google OAuth login
feat(api): Add pagination support for product list
feat(domain): Add Order entity with value objects
feat(app): Add CreateUserCommand with validation
feat(infra): Add Redis caching for product queries
feat(admin): Add dashboard statistics endpoint
feat(contract): Add OrderResponseDto
```
### fix - Sua loi
```bash
fix(api): Resolve 404 error on login redirect
fix(auth): Fix token expiration calculation
fix(infra): Resolve database connection timeout
fix(app): Fix null reference in GetUserQuery
fix(domain): Fix value object equality comparison
```
### docs - Tai lieu
```bash
docs(readme): Update installation guide
docs(api): Add Swagger annotations for OrderController
docs: Add contributing guidelines
docs: Update environment variables documentation
```
### style - Format code
```bash
style: Apply EditorConfig formatting rules
style(api): Fix indentation in controllers
style: Remove trailing whitespace
```
### refactor - Tai cau truc
```bash
refactor(app): Simplify UserService error handling
refactor(infra): Extract IEmailService interface
refactor: Move validation logic to domain layer
refactor(api): Replace manual mapping with AutoMapper
```
### perf - Toi uu hieu nang
```bash
perf(infra): Optimize database queries with projection
perf(api): Add response compression middleware
perf(cache): Reduce Redis round trips with pipeline
```
### test - Them/sua test
```bash
test(app): Add unit tests for CreateUserCommand
test(domain): Add tests for Order entity validation
test(infra): Add integration tests for UserRepository
test: Increase code coverage to 80%
```
### chore - Bao tri
```bash
chore: Update NuGet packages
chore(deps): Upgrade to .NET 8
chore(docker): Update docker-compose configuration
chore(ci): Add GitHub Actions build workflow
chore: Update .gitignore
```
### build - Build system
```bash
build: Upgrade to .NET 8 SDK
build: Add Directory.Build.props for shared config
build: Configure multi-stage Docker build
```
### ci - CI/CD
```bash
ci: Add GitHub Actions workflow for PR checks
ci: Configure automatic deployment to staging
ci: Add SonarQube code analysis step
```
---
## 8. Vi Du Thuc Te Trong Du An
### Luong lam viec mot feature hoan chinh
Gia su lam task PROJ-101: Them tinh nang dang nhap
```bash
# 1. Tao branch
git checkout -b feature/PROJ-101-add-login
# 2. Them entity va domain logic
git commit -m "feat(domain): Add User entity with email and password"
# 3. Them command/query
git commit -m "feat(app): Add LoginCommand with FluentValidation"
# 4. Them infrastructure
git commit -m "feat(infra): Implement UserRepository with EF Core"
git commit -m "feat(infra): Add password hashing service"
# 5. Them API endpoint
git commit -m "feat(api): Add AuthController with login endpoint"
# 6. Them test
git commit -m "test(app): Add unit tests for LoginCommand handler"
# 7. Cap nhat tai lieu
git commit -m "docs(api): Add Swagger docs for auth endpoints"
# 8. Push va tao PR
git push origin feature/PROJ-101-add-login
```
### Luong sua loi
```bash
# 1. Tao branch
git checkout -b bugfix/PROJ-202-fix-login-error
# 2. Sua loi
git commit -m "fix(auth): Resolve incorrect password validation logic"
# 3. Them test cho truong hop loi
git commit -m "test(auth): Add test for invalid password scenario"
# 4. Push va tao PR
git push origin bugfix/PROJ-202-fix-login-error
```
### Luong hotfix khan cap
```bash
# 1. Tao branch tu main
git checkout main
git checkout -b hotfix/PROJ-303-fix-sql-injection
# 2. Sua loi
git commit -m "fix(infra): Sanitize SQL parameters to prevent injection
The raw SQL query in SearchRepository was concatenating user input
directly into the query string. Replaced with parameterized query
using EF Core's FromSqlInterpolated method.
Resolves: PROJ-303"
# 3. Push va tao PR vao main
git push origin hotfix/PROJ-303-fix-sql-injection
```
---
## 9. Nhung Loi Thuong Gap
| # | Loi sai | Vi du sai | Vi du dung |
|---|--------|----------|-----------|
| 1 | **Message qua chung chung** | `fix: Fix bug` | `fix(auth): Resolve token expiration error` |
| 2 | **Khong co type** | `Add login page` | `feat: Add login page` |
| 3 | **Dung qua khu** | `feat: Added new feature` | `feat: Add new feature` |
| 4 | **Qua nhieu thay doi trong 1 commit** | `feat: Add login, register, forgot password` | Tach thanh 3 commit rieng |
| 5 | **Commit file khong lien quan** | Commit ca file config lan feature | Chi commit file lien quan |
| 6 | **Message tieng Viet** | `feat: Them trang dang nhap` | `feat: Add login page` |
| 7 | **Dau cham cuoi** | `feat: Add login page.` | `feat: Add login page` |
| 8 | **Khong co scope khi can thiet** | `fix: Fix null reference` | `fix(app): Resolve null ref in GetUserQuery` |
| 9 | **Type sai** | `feat: Fix bug` | `fix: Resolve login error` |
| 10 | **Description qua dai** | 100+ ky tu tren 1 dong | Giu duoi 50 ky tu, dung body cho chi tiet |
---
## 10. Checklist Truoc Khi Commit
- [ ] Commit message co dung format `<type>(<scope>): <description>` khong?
- [ ] Type co chinh xac khong? (feat, fix, docs, style, refactor, perf, test, chore)
- [ ] Scope co phan anh dung module bi anh huong khong?
- [ ] Description co duoi 50 ky tu khong?
- [ ] Description co bat dau bang dong tu menh lenh khong? (add, fix, update...)
- [ ] Description co viet hoa chu dau khong?
- [ ] Khong co dau cham cuoi trong description?
- [ ] Moi commit chi chua 1 thay doi logic duy nhat?
- [ ] Commit co lien ket ticket ID khong? (trong scope hoac footer)
- [ ] Neu la breaking change, da danh dau `!` va them `BREAKING CHANGE:` trong footer?
---
## Tom Tat Nhanh
```
Format: <type>(<scope>): <description>
Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
Scope: domain | app | infra | api | admin | contract | test | auth | cache | db | docker | deps
Vi du:
feat(auth): Add Google OAuth login # scope = "auth" (module authentication)
fix(api): Resolve 404 error # scope = "api" (API endpoints)
docs(readme): Update install guide # scope = "readme" (file/module cu the)
feat: Them tinh nang moi (VD: feat(login): Add Google OAuth).
fix: Sua loi (VD: fix(api): Resolve 404 error).
docs: Cap nhat tai lieu.
style: Doi format/UI khong anh huong logic.
refactor: Tai cau truc code.
perf: Toi uu hieu nang.
chore: Thay doi nho (VD: chore: Update dependencies).
Quy tac:
- Type va scope viet thuong
- Description viet hoa chu dau, duoi 50 ky tu
- Dung dong tu menh lenh (add, fix, update, remove, implement...)
- Khong dau cham cuoi
- 1 commit = 1 thay doi logic
- Lien ket ticket ID khi co the
```

254
docs/Observability-Guide.md Normal file
View File

@@ -0,0 +1,254 @@
# Observability Guide - Serilog & OpenTelemetry
## Tổng quan
Dự án đã được tích hợp **Serilog** (Structured Logging) và **OpenTelemetry** (Distributed Tracing) để hỗ trợ giám sát và debug ứng dụng khi chạy trên production/Docker.
## Serilog - Structured Logging
### Tính năng
- **JSON Format**: Log được ghi dưới dạng JSON, dễ dàng parse và filter
- **Multiple Sinks**: Console, File, Seq, Elasticsearch
- **Enrichment**: Tự động thêm thông tin như Environment, MachineName, ThreadId
- **Rolling Files**: Tự động rotate log files theo ngày/tuần/tháng
### Cấu hình trong appsettings.json
```json
{
"Serilog": {
"MinimumLevel": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
},
"WriteToConsole": true,
"WriteToFile": true,
"FilePath": "logs/log-.txt",
"RollingInterval": "Day",
"RetainedFileCountLimit": 31,
"SeqUrl": "http://localhost:5341",
"ElasticsearchUrl": null
}
}
```
### Sử dụng trong Code
```csharp
using Serilog;
public class MyService
{
private readonly ILogger _logger;
public MyService(ILogger logger)
{
_logger = logger;
}
public void DoSomething()
{
// Structured logging với properties
_logger.Information(
"Processing order {OrderId} for user {UserId}",
orderId,
userId
);
// Log với exception
try
{
// ...
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to process order {OrderId}", orderId);
}
}
}
```
### Log Levels
- **Verbose**: Chi tiết nhất, dùng cho debug
- **Debug**: Thông tin debug
- **Information**: Thông tin thông thường (mặc định)
- **Warning**: Cảnh báo
- **Error**: Lỗi
- **Fatal**: Lỗi nghiêm trọng, ứng dụng có thể crash
### Tích hợp với Seq (Development)
1. Chạy Seq trong Docker:
```bash
docker run -d --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq:latest
```
2. Cập nhật appsettings.json:
```json
{
"Serilog": {
"SeqUrl": "http://localhost:5341"
}
}
```
3. Xem logs tại: http://localhost:5341
### Tích hợp với ELK Stack (Production)
1. Cài đặt Elasticsearch và Kibana
2. Cập nhật appsettings.json với ElasticsearchUrl
3. Xem logs trong Kibana dashboard
## OpenTelemetry - Distributed Tracing
### Tính năng
- **Distributed Tracing**: Theo dõi request qua nhiều services
- **Performance Monitoring**: Đo thời gian thực thi từng operation
- **Automatic Instrumentation**: Tự động instrument ASP.NET Core, HTTP Client, EF Core
- **OTLP Export**: Export traces đến các hệ thống như Jaeger, Zipkin, Grafana
### Cấu hình
OpenTelemetry đã được cấu hình tự động trong `Program.cs`:
```csharp
builder.Services.AddOpenTelemetryTracing("MyNewProjectName.WebAPI");
```
### Instrumentation tự động
- **ASP.NET Core**: Tự động trace HTTP requests/responses
- **HTTP Client**: Trace các HTTP calls đến external APIs
- **Entity Framework Core**: Trace database queries
### Xem Traces
#### Option 1: Console (Development)
Traces được export ra console, có thể thấy trong log output.
#### Option 2: OTLP Collector (Production)
1. Chạy OTLP Collector:
```yaml
# docker-compose.yml
services:
otlp-collector:
image: otel/opentelemetry-collector:latest
ports:
- "4317:4317" # OTLP gRPC receiver
- "4318:4318" # OTLP HTTP receiver
volumes:
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml
```
2. Cấu hình export trong code (nếu cần):
```csharp
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri("http://otlp-collector:4317");
})
```
3. Export đến Jaeger, Zipkin, hoặc Grafana Tempo
### Tích hợp với Jaeger
1. Chạy Jaeger:
```bash
docker run -d --name jaeger \
-p 16686:16686 \
-p 4317:4317 \
jaegertracing/all-in-one:latest
```
2. Xem traces tại: http://localhost:16686
## Best Practices
### 1. Structured Logging
**Tốt**: Sử dụng structured logging với properties
```csharp
_logger.Information("User {UserId} created order {OrderId}", userId, orderId);
```
**Không tốt**: String interpolation
```csharp
_logger.Information($"User {userId} created order {orderId}");
```
### 2. Log Levels
- **Information**: Business events (user login, order created)
- **Warning**: Unexpected situations (retry, fallback)
- **Error**: Exceptions và failures
- **Debug**: Chi tiết kỹ thuật (chỉ trong development)
### 3. Correlation ID
Sử dụng correlation ID để link logs và traces:
```csharp
// Trong middleware
var correlationId = Guid.NewGuid().ToString();
LogContext.PushProperty("CorrelationId", correlationId);
```
### 4. Sensitive Data
**Không log**:
- Passwords
- Credit card numbers
- API keys
- Personal information (PII)
**Log**:
- User IDs (không phải username/email)
- Order IDs
- Request/Response metadata (không có sensitive data)
## Monitoring trong Docker
Khi chạy trong Docker, logs được ghi vào:
- **Console**: `docker logs <container-name>`
- **Files**: `logs/log-*.txt` trong container
- **Seq/ELK**: Nếu đã cấu hình
### Xem logs trong Docker
```bash
# Xem logs real-time
docker logs -f webapi
# Xem logs với timestamp
docker logs -t webapi
# Xem logs của 100 dòng cuối
docker logs --tail 100 webapi
```
## Troubleshooting
### Logs không xuất hiện
1. Kiểm tra `Serilog:WriteToConsole` trong appsettings.json
2. Kiểm tra `Serilog:MinimumLevel` có quá cao không
3. Kiểm tra Docker logs: `docker logs <container-name>`
### Traces không hiển thị
1. Kiểm tra OpenTelemetry đã được add trong Program.cs
2. Kiểm tra OTLP endpoint nếu dùng collector
3. Kiểm tra network connectivity đến collector
### Performance Impact
- Serilog: Minimal impact, async logging
- OpenTelemetry: ~1-2% overhead, có thể disable trong development nếu cần

View File

@@ -0,0 +1,157 @@
# Options Pattern - Hướng dẫn sử dụng
## Tổng quan
Options Pattern là cách tiếp cận **Strongly-Typed** để quản lý cấu hình trong .NET, thay vì sử dụng `IConfiguration` trực tiếp với magic strings.
## Lợi ích
1. **Type Safety**: IntelliSense hỗ trợ, tránh lỗi chính tả
2. **Validation**: Validate cấu hình ngay khi khởi động ứng dụng
3. **Separation of Concerns**: Tách biệt Infrastructure (config) khỏi Application logic
4. **Testability**: Dễ dàng mock và test
## Cách sử dụng trong Service
### Ví dụ: Sử dụng JwtOptions trong Service
```csharp
using Microsoft.Extensions.Options;
using MyNewProjectName.Infrastructure.Options;
public class JwtTokenService
{
private readonly JwtOptions _jwtOptions;
// Inject IOptions<JwtOptions> thay vì IConfiguration
public JwtTokenService(IOptions<JwtOptions> jwtOptions)
{
_jwtOptions = jwtOptions.Value;
}
public string GenerateToken()
{
// Sử dụng với IntelliSense hỗ trợ
var secretKey = _jwtOptions.SecretKey;
var issuer = _jwtOptions.Issuer;
var expiration = _jwtOptions.ExpirationInMinutes;
// ... logic tạo token
return token;
}
}
```
### Ví dụ: Sử dụng DatabaseOptions
```csharp
using Microsoft.Extensions.Options;
using MyNewProjectName.Infrastructure.Options;
public class DatabaseService
{
private readonly DatabaseOptions _dbOptions;
public DatabaseService(IOptions<DatabaseOptions> dbOptions)
{
_dbOptions = dbOptions.Value;
}
public string GetConnectionString()
{
return _dbOptions.DefaultConnection;
}
}
```
## Các Options Classes có sẵn
1. **DatabaseOptions** - Cấu hình kết nối database
2. **JwtOptions** - Cấu hình JWT authentication
3. **RedisOptions** - Cấu hình Redis cache
4. **SerilogOptions** - Cấu hình Serilog logging
## Thêm Options Class mới
### Bước 1: Tạo Options Class
```csharp
// MyNewProjectName.Infrastructure/Options/EmailOptions.cs
using System.ComponentModel.DataAnnotations;
namespace MyNewProjectName.Infrastructure.Options;
public class EmailOptions
{
public const string SectionName = "Email";
[Required]
public string SmtpServer { get; set; } = string.Empty;
[Range(1, 65535)]
public int SmtpPort { get; set; } = 587;
[Required]
[EmailAddress]
public string FromAddress { get; set; } = string.Empty;
}
```
### Bước 2: Đăng ký trong DependencyInjection.cs
```csharp
// Trong MyNewProjectName.Infrastructure/DependencyInjection.cs
services.Configure<EmailOptions>(configuration.GetSection(EmailOptions.SectionName));
// Validate (optional)
services.AddOptions<EmailOptions>()
.Bind(configuration.GetSection(EmailOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
```
### Bước 3: Thêm vào appsettings.json
```json
{
"Email": {
"SmtpServer": "smtp.gmail.com",
"SmtpPort": 587,
"FromAddress": "noreply@example.com"
}
}
```
### Bước 4: Sử dụng trong Service
```csharp
public class EmailService
{
private readonly EmailOptions _emailOptions;
public EmailService(IOptions<EmailOptions> emailOptions)
{
_emailOptions = emailOptions.Value;
}
}
```
## Validation
Options classes sử dụng Data Annotations để validate:
- `[Required]` - Bắt buộc phải có giá trị
- `[MinLength]` - Độ dài tối thiểu
- `[Range]` - Giá trị trong khoảng
- `[EmailAddress]` - Định dạng email
- Và nhiều attributes khác...
Nếu validation fail, ứng dụng sẽ **không khởi động** và báo lỗi rõ ràng.
## IOptions vs IOptionsSnapshot vs IOptionsMonitor
- **IOptions<T>**: Giá trị được cache, không tự động reload khi config thay đổi
- **IOptionsSnapshot<T>**: Tự động reload khi config thay đổi (scoped)
- **IOptionsMonitor<T>**: Tự động reload và có thể subscribe để nhận thông báo (singleton)
Thông thường, sử dụng **IOptions<T>** là đủ.

391
docs/Redis.md Normal file
View File

@@ -0,0 +1,391 @@
# Hướng Dẫn Đặt Tên Redis Key Trong Dự Án
> **Tham khảo:** [Redis Namespace and Other Keys to Developing with Redis](https://redis.io/blog/5-key-takeaways-for-developing-with-redis/)
---
## Mục Lục
1. [Nguyên Tắc Chung](#1-nguyên-tắc-chung)
2. [Quy Ước Đặt Tên Key (Naming Convention)](#2-quy-ước-đặt-tên-key-naming-convention)
3. [Cấu Trúc Key Theo Namespace](#3-cấu-trúc-key-theo-namespace)
4. [Bảng Mẫu Key Theo Chức Năng](#4-bảng-mẫu-key-theo-chức-năng)
5. [Quy Tắc Về Độ Dài Key](#5-quy-tắc-về-độ-dài-key)
6. [Chọn Đúng Data Structure](#6-chọn-đúng-data-structure)
7. [Quản Lý Key: SCAN thay vì KEYS](#7-quản-lý-key-scan-thay-vì-keys)
8. [Chiến Lược TTL & Expiration](#8-chiến-lược-ttl--expiration)
9. [Cache Invalidation](#9-cache-invalidation)
10. [Ví Dụ Thực Tế Trong Dự Án](#10-ví-dụ-thực-tế-trong-dự-án)
---
## 1. Nguyên Tắc Chung
Theo bài viết từ Redis, có **5 điểm quan trọng** khi phát triển với Redis:
| # | Nguyên tắc | Mô tả |
|---|-----------|-------|
| 1 | **Namespace cho key** | Sử dụng dấu `:` để phân tách các phần của tên key, giúp dễ quản lý và tìm kiếm |
| 2 | **Giữ key ngắn gọn** | Key name cũng chiếm bộ nhớ — key dài 12 ký tự tốn thêm ~15% RAM so với key 6 ký tự (trên 1 triệu key) |
| 3 | **Dùng đúng data structure** | Hash, List, Set, Sorted Set — mỗi loại phù hợp với một use case khác nhau |
| 4 | **Dùng SCAN, không dùng KEYS** | Lệnh `KEYS` có thể block server, `SCAN` an toàn hơn cho production |
| 5 | **Sử dụng Lua Scripts** | Xử lý logic phía server để giảm latency và tối ưu hiệu suất |
---
## 2. Quy Ước Đặt Tên Key (Naming Convention)
### Format chung
```
{project}:{service}:{entity}:{identifier}
```
- **Dấu phân cách:** Luôn dùng dấu hai chấm `:` (colon) — đây là **convention chuẩn** của Redis
- **Chữ thường:** Tất cả các phần của key đều viết **lowercase**
- **Không dấu cách, không ký tự đặc biệt:** Chỉ dùng chữ cái, số, dấu `:` và dấu `-` hoặc `_`
### Đúng
```
myapp:user:profile:12345
myapp:order:detail:ORD-001
myapp:cache:product:list:page:1
```
### Sai
```
MyApp_User_Profile_12345 # Dùng underscore thay vì colon, viết hoa
user profile 12345 # Có dấu cách
MYAPP:USER:PROFILE:12345 # Viết hoa toàn bộ (lãng phí bộ nhớ)
```
---
## 3. Cấu Trúc Key Theo Namespace
Áp dụng cho dự án **Clean Architecture**, cấu trúc key nên phản ánh rõ layer và module:
```
{app}:{layer}:{entity}:{action/scope}:{identifier}
```
### Các prefix theo layer
| Prefix | Ý nghĩa | Ví dụ |
|--------|---------|-------|
| `app:cache` | Cache dữ liệu | `app:cache:product:list` |
| `app:session` | Quản lý session | `app:session:user:abc123` |
| `app:rate` | Rate limiting | `app:rate:api:login:192.168.1.1` |
| `app:lock` | Distributed lock | `app:lock:order:process:ORD-001` |
| `app:queue` | Message queue | `app:queue:email:pending` |
| `app:temp` | Dữ liệu tạm thời | `app:temp:otp:user:12345` |
| `app:pub` | Pub/Sub channels | `app:pub:notifications:user:12345` |
| `app:counter` | Bộ đếm | `app:counter:visit:page:home` |
---
## 4. Bảng Mẫu Key Theo Chức Năng
### Authentication & Authorization
| Key Pattern | Data Type | TTL | Mô tả |
|------------|-----------|-----|--------|
| `app:session:{sessionId}` | Hash | 30 phút | Thông tin session người dùng |
| `app:token:refresh:{userId}` | String | 7 ngày | Refresh token |
| `app:token:blacklist:{jti}` | String | Thời gian còn lại của token | JWT bị thu hồi |
| `app:temp:otp:{userId}` | String | 5 phút | Mã OTP xác thực |
| `app:rate:login:{ip}` | String (counter) | 15 phút | Giới hạn số lần đăng nhập |
### Cache Dữ Liệu (CRUD)
| Key Pattern | Data Type | TTL | Mô tả |
|------------|-----------|-----|--------|
| `app:cache:{entity}:detail:{id}` | Hash/String | 10 phút | Cache chi tiết 1 entity |
| `app:cache:{entity}:list:{hash}` | String (JSON) | 5 phút | Cache danh sách có phân trang/filter |
| `app:cache:{entity}:count` | String | 5 phút | Cache tổng số record |
| `app:cache:{entity}:ids:all` | Set | 10 phút | Tập hợp tất cả ID của entity |
> **`{hash}`** là hash MD5/SHA256 của query parameters (page, filter, sort) để tạo key unique cho mỗi truy vấn khác nhau.
### 🔔 Real-time & Pub/Sub
| Key Pattern | Data Type | TTL | Mô tả |
|------------|-----------|-----|--------|
| `app:pub:notification:{userId}` | Channel | — | Kênh thông báo realtime |
| `app:queue:email:pending` | List | — | Hàng đợi gửi email |
| `app:counter:online:users` | String | — | Đếm user đang online |
### 🔒 Distributed Locking
| Key Pattern | Data Type | TTL | Mô tả |
|------------|-----------|-----|--------|
| `app:lock:{resource}:{id}` | String | 30 giây | Lock tài nguyên để tránh race condition |
---
## 5. Quy Tắc Về Độ Dài Key
Theo bài viết từ Redis:
> *"Storing 1,000,000 keys, each set with a 32-character value, will consume about **96MB** when using 6-character key names, and **111MB** with 12-character names. This overhead of more than **15%** becomes quite significant as your number of keys grows."*
### Hướng dẫn cân bằng
| Quy tắc | Chi tiết |
|---------|---------|
| **Tối đa 50 ký tự** | Giữ tổng chiều dài key không quá 50 ký tự |
| **Viết tắt hợp lý** | `usr` thay vì `user`, `prod` thay vì `product` — nhưng phải có **bảng chú giải** |
| **Không lạm dụng viết tắt** | Key phải đọc được, tránh như `a:b:c:d:1` |
| **Ưu tiên rõ ràng nếu < 10K keys** | Nếu dataset nhỏ, ưu tiên key dễ đọc hơn key ngắn |
### Bảng viết tắt chuẩn (nếu cần tối ưu)
| Viết tắt | Đầy đủ |
|----------|--------|
| `usr` | user |
| `prod` | product |
| `ord` | order |
| `sess` | session |
| `notif` | notification |
| `cfg` | config |
| `inv` | inventory |
| `txn` | transaction |
---
## 6. Chọn Đúng Data Structure
Theo bài viết, việc chọn đúng cấu trúc dữ liệu giúp **tối ưu bộ nhớ và hiệu suất**:
| Data Structure | Khi nào dùng | Ví dụ trong dự án |
|---------------|-------------|-------------------|
| **String** | Giá trị đơn giản, counter, cache JSON | `app:cache:product:detail:123` → JSON string |
| **Hash** | Object có nhiều field, profile user | `app:session:abc123``{userId, role, name, exp}` |
| **List** | Queue, danh sách có thứ tự, cho phép trùng | `app:queue:email:pending` → FIFO queue |
| **Set** | Tập hợp unique, kiểm tra membership | `app:cache:user:ids:all` → tập hợp user IDs |
| **Sorted Set** | Leaderboard, ranking, timeline | `app:rank:score:board:game1` → ranking theo điểm |
| **Bitmap** | Track true/false cho lượng lớn, analytics | `app:analytics:daily:login:2026-02-23` → bit per user |
### 💡 Tips quan trọng:
- **Hash thay vì nhiều String**: Nhóm dữ liệu liên quan vào 1 Hash thay vì tạo nhiều key String riêng lẻ → tiết kiệm bộ nhớ đáng kể
- **List thay vì Set**: Nếu không cần kiểm tra uniqueness, List nhanh hơn và tốn ít RAM hơn
- **Tránh Sorted Set nếu không cần ranking**: Sorted Set tốn nhiều bộ nhớ và phức tạp nhất
---
## 7. Quản Lý Key: SCAN thay vì KEYS
### ⚠️ KHÔNG BAO GIỜ dùng `KEYS` trong production!
Lệnh `KEYS *` sẽ:
- **Block toàn bộ Redis server** cho đến khi hoàn thành
- **Tiêu tốn RAM** nguy hiểm
- Gây **downtime** nếu dataset lớn
### Dùng `SCAN` để duyệt key an toàn
```bash
# Cú pháp
SCAN cursor [MATCH pattern] [COUNT count]
# Ví dụ: Tìm tất cả cache key của product
SCAN 0 MATCH "app:cache:product:*" COUNT 100
# Dùng HSCAN cho Hash
HSCAN app:session:abc123 0 MATCH "*"
# Dùng SSCAN cho Set
SSCAN app:cache:user:ids:all 0 COUNT 50
```
### Trong code C# (StackExchange.Redis):
```csharp
// Đúng: Dùng SCAN
var server = redis.GetServer(endpoint);
var keys = server.Keys(pattern: "app:cache:product:*", pageSize: 100);
// Sai: Dùng KEYS (block server)
// var keys = server.Keys(pattern: "app:cache:product:*", pageSize: int.MaxValue);
```
---
## 8. Chiến Lược TTL & Expiration
| Loại dữ liệu | TTL khuyến nghị | Lý do |
|--------------|----------------|-------|
| **Cache API response** | 5 15 phút | Đảm bảo data tương đối fresh |
| **Session** | 30 phút 2 giờ | Theo session timeout của app |
| **OTP / Verification** | 3 10 phút | Bảo mật |
| **Refresh Token** | 7 30 ngày | Theo chính sách auth |
| **Rate Limit Counter** | 1 60 phút | Theo window rate limit |
| **Distributed Lock** | 10 60 giây | Tránh deadlock |
| **Analytics / Counter** | Không expire hoặc 24h | Tùy yêu cầu business |
### ⚡ Luôn đặt TTL cho mọi cache key!
```csharp
// Luôn set expiration khi SET
await db.StringSetAsync("app:cache:product:detail:123", jsonData, TimeSpan.FromMinutes(10));
// Hoặc set TTL riêng
await db.KeyExpireAsync("app:cache:product:detail:123", TimeSpan.FromMinutes(10));
```
> ⚠️ **Cảnh báo:** Key không có TTL sẽ tồn tại mãi mãi → nguy cơ memory leak!
---
## 9. Cache Invalidation
Khi dữ liệu thay đổi trong database chính (SQL, MongoDB...), cần **xóa cache Redis tương ứng**:
### Pattern: Tag-based Invalidation
```
# Khi tạo cache, thêm key vào một Set quản lý
SADD app:tags:product app:cache:product:detail:123
SADD app:tags:product app:cache:product:list:abc
SADD app:tags:product app:cache:product:count
# Khi invalidate, duyệt Set và xóa tất cả
SMEMBERS app:tags:product → lấy tất cả key liên quan
DEL app:cache:product:detail:123 app:cache:product:list:abc ...
DEL app:tags:product
```
### Trong code C#:
```csharp
public async Task InvalidateCacheByTagAsync(string tag)
{
var db = _redis.GetDatabase();
var tagKey = $"app:tags:{tag}";
// Lấy tất cả cache key thuộc tag này
var members = await db.SetMembersAsync(tagKey);
if (members.Length > 0)
{
// Xóa tất cả cache key
var keys = members.Select(m => (RedisKey)m.ToString()).ToArray();
await db.KeyDeleteAsync(keys);
}
// Xóa luôn tag set
await db.KeyDeleteAsync(tagKey);
}
```
---
## 10. Ví Dụ Thực Tế Trong Dự Án
Áp dụng quy ước cho dự án **MyNewProjectName** (Clean Architecture):
### Entity: `SampleEntity`
```
# Cache chi tiết
app:cache:sample:detail:{id}
# Cache danh sách (hash = MD5 của query params)
app:cache:sample:list:{queryHash}
# Cache count
app:cache:sample:count
# Tag để invalidation
app:tags:sample → Set chứa tất cả key cache liên quan
# Lock khi cập nhật
app:lock:sample:update:{id}
```
### Authentication Flow
```
# Session sau khi login
app:session:{sessionId} → Hash { userId, role, loginAt, ip }
# Refresh token
app:token:refresh:{userId} → "eyJhbGciOi..."
# OTP xác thực email
app:temp:otp:{userId} → "123456" (TTL: 5 phút)
# Blacklist JWT đã revoke
app:token:blacklist:{jti} → "1" (TTL: thời gian còn lại của token)
# Rate limit login
app:rate:login:{ip} → counter (TTL: 15 phút, max: 5 lần)
```
### Constant class trong C#
```csharp
/// <summary>
/// Định nghĩa tất cả Redis key patterns sử dụng trong dự án.
/// Sử dụng dấu ':' làm namespace separator theo convention chuẩn Redis.
/// </summary>
public static class RedisKeyPatterns
{
private const string Prefix = "app";
// ── Cache ──────────────────────────────────
public static string CacheDetail(string entity, string id)
=> $"{Prefix}:cache:{entity}:detail:{id}";
public static string CacheList(string entity, string queryHash)
=> $"{Prefix}:cache:{entity}:list:{queryHash}";
public static string CacheCount(string entity)
=> $"{Prefix}:cache:{entity}:count";
// ── Tags (cho cache invalidation) ──────────
public static string Tag(string entity)
=> $"{Prefix}:tags:{entity}";
// ── Session ────────────────────────────────
public static string Session(string sessionId)
=> $"{Prefix}:session:{sessionId}";
// ── Token ──────────────────────────────────
public static string RefreshToken(string userId)
=> $"{Prefix}:token:refresh:{userId}";
public static string BlacklistToken(string jti)
=> $"{Prefix}:token:blacklist:{jti}";
// ── Temporary ──────────────────────────────
public static string Otp(string userId)
=> $"{Prefix}:temp:otp:{userId}";
// ── Rate Limiting ──────────────────────────
public static string RateLimit(string action, string identifier)
=> $"{Prefix}:rate:{action}:{identifier}";
// ── Distributed Lock ───────────────────────
public static string Lock(string resource, string id)
=> $"{Prefix}:lock:{resource}:{id}";
}
```
---
## 📋 Checklist Trước Khi Tạo Key Mới
- [ ] Key có sử dụng namespace với dấu `:` không?
- [ ] Key có phản ánh đúng layer/module không?
- [ ] Key có ngắn gọn nhưng vẫn dễ hiểu không? (< 50 ký tự)
- [ ] Đã chọn đúng data structure (String/Hash/List/Set/Sorted Set)?
- [ ] Đã đặt TTL phù hợp cho key?
- [ ] Đã có chiến lược invalidation khi data thay đổi?
- [ ] Đã thêm key pattern vào class `RedisKeyPatterns`?
- [ ] Không dùng `KEYS` command trong code production?
---
> **💡 Ghi nhớ:** *"Luôn namespace key bằng dấu `:`, giữ key ngắn gọn, chọn đúng data structure, dùng SCAN thay vì KEYS, và luôn đặt TTL!"*

BIN
docs/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

67
rename-project.ps1 Normal file
View File

@@ -0,0 +1,67 @@
# PowerShell script to rename the project from iYHCT360 to a new name
# Usage: .\rename-project.ps1 -NewName "NewProjectName"
param(
[Parameter(Mandatory=$true)]
[string]$NewName
)
$OldName = "iYHCT360"
Write-Host "🔄 Renaming project from '$OldName' to '$NewName'..." -ForegroundColor Cyan
# Clean build artifacts first
Write-Host "🧹 Cleaning build artifacts..." -ForegroundColor Yellow
Get-ChildItem -Path . -Include bin,obj -Recurse -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Replace content in files
Write-Host "📝 Replacing content in files..." -ForegroundColor Yellow
$extensions = @("*.cs", "*.csproj", "*.sln", "*.json", "*.xml", "*.md", "*.props", "*.targets", "*.http")
$excludePaths = @("*\bin\*", "*\obj\*", "*\.git\*")
foreach ($ext in $extensions) {
Get-ChildItem -Path . -Filter $ext -Recurse -File |
Where-Object {
$path = $_.FullName
-not ($excludePaths | Where-Object { $path -like $_ })
} |
ForEach-Object {
$content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
if ($content -and $content -match $OldName) {
Write-Host " Updating: $($_.FullName)" -ForegroundColor Gray
$newContent = $content -replace $OldName, $NewName
Set-Content -Path $_.FullName -Value $newContent -NoNewline
}
}
}
# Rename files
Write-Host "📁 Renaming files..." -ForegroundColor Yellow
Get-ChildItem -Path . -Recurse -File |
Where-Object { $_.Name -match $OldName } |
ForEach-Object {
$newFileName = $_.Name -replace $OldName, $NewName
$newPath = Join-Path $_.Directory.FullName $newFileName
Write-Host " Renaming: $($_.Name) -> $newFileName" -ForegroundColor Gray
Rename-Item -Path $_.FullName -NewName $newFileName
}
# Rename directories (from deepest to shallowest)
Write-Host "📁 Renaming directories..." -ForegroundColor Yellow
Get-ChildItem -Path . -Recurse -Directory |
Where-Object { $_.Name -match $OldName } |
Sort-Object { $_.FullName.Length } -Descending |
ForEach-Object {
$newDirName = $_.Name -replace $OldName, $NewName
Write-Host " Renaming: $($_.Name) -> $newDirName" -ForegroundColor Gray
Rename-Item -Path $_.FullName -NewName $newDirName
}
Write-Host ""
Write-Host " Project renamed successfully from '$OldName' to '$NewName'!" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Cyan
Write-Host " 1. Review the changes"
Write-Host " 2. Update your connection string in appsettings.json"
Write-Host " 3. Run 'dotnet restore'"
Write-Host " 4. Run 'dotnet build'"

81
rename-project.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/bin/bash
# Script to rename the project from iYHCT360 to a new name
# Usage: ./rename-project.sh NewProjectName
set -e
if [ -z "$1" ]; then
echo "Usage: ./rename-project.sh NewProjectName"
echo "Example: ./rename-project.sh MyAwesomeApp"
exit 1
fi
OLD_NAME="iYHCT360"
NEW_NAME="$1"
echo "🔄 Renaming project from '$OLD_NAME' to '$NEW_NAME'..."
# Function to rename files and directories
rename_files() {
# Rename directories (from deepest to shallowest)
find . -depth -type d -name "*${OLD_NAME}*" 2>/dev/null | while read dir; do
new_dir=$(echo "$dir" | sed "s/${OLD_NAME}/${NEW_NAME}/g")
if [ "$dir" != "$new_dir" ]; then
echo " Renaming directory: $dir -> $new_dir"
mv "$dir" "$new_dir"
fi
done
# Rename files
find . -type f -name "*${OLD_NAME}*" 2>/dev/null | while read file; do
new_file=$(echo "$file" | sed "s/${OLD_NAME}/${NEW_NAME}/g")
if [ "$file" != "$new_file" ]; then
echo " Renaming file: $file -> $new_file"
mv "$file" "$new_file"
fi
done
}
# Function to replace content in files
replace_content() {
echo "📝 Replacing content in files..."
# Find all text files and replace content
find . -type f \( \
-name "*.cs" -o \
-name "*.csproj" -o \
-name "*.sln" -o \
-name "*.json" -o \
-name "*.xml" -o \
-name "*.md" -o \
-name "*.props" -o \
-name "*.targets" -o \
-name "*.http" \
\) ! -path "*/bin/*" ! -path "*/obj/*" ! -path "*/.git/*" 2>/dev/null | while read file; do
if grep -q "${OLD_NAME}" "$file" 2>/dev/null; then
echo " Updating: $file"
sed -i "s/${OLD_NAME}/${NEW_NAME}/g" "$file"
fi
done
}
# Clean build artifacts first
echo "🧹 Cleaning build artifacts..."
find . -type d \( -name "bin" -o -name "obj" \) -exec rm -rf {} + 2>/dev/null || true
# Replace content first (before renaming directories)
replace_content
# Then rename files and directories
echo "📁 Renaming files and directories..."
rename_files
echo ""
echo " Project renamed successfully from '$OLD_NAME' to '$NEW_NAME'!"
echo ""
echo "Next steps:"
echo " 1. Review the changes"
echo " 2. Update your connection string in appsettings.json"
echo " 3. Run 'dotnet restore'"
echo " 4. Run 'dotnet build'"