chore: Update compatibility for skills to .NET 10+

- Revised compatibility specifications in multiple skill documentation files to reflect support for .NET 10+, ensuring alignment with the latest development standards.
- Updated files include API design, error handling patterns, project rules, repository pattern, security, and testing patterns.
- This change enhances the relevance and usability of the skills for current and future development projects.
This commit is contained in:
Ho Ngoc Hai
2026-01-14 11:37:28 +07:00
parent 0138fc75f9
commit df007cafde
12 changed files with 3206 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
---
name: api-design
description: RESTful API design standards for GoodGo microservices. Use for new API endpoints, DTOs, controllers, OpenAPI documentation, or standardized responses.
compatibility: ".NET 8+, ASP.NET Core, MediatR, Swashbuckle, Asp.Versioning"
compatibility: ".NET 10+, ASP.NET Core, MediatR, Swashbuckle, Asp.Versioning"
metadata:
author: Velik Ho
version: "2.0"

View File

@@ -0,0 +1,454 @@
---
name: cqrs-mediatr
description: CQRS pattern với MediatR. Use for Commands, Queries, Handlers, Pipeline Behaviors, và Idempotency.
compatibility: ".NET 10+, MediatR 12+, FluentValidation"
metadata:
author: Velik Ho
version: "1.0"
---
# CQRS & MediatR Patterns / Mẫu CQRS & MediatR
CQRS pattern với MediatR cho GoodGo microservices.
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Separating read/write operations / Tách biệt operations đọc/ghi
- Creating Commands and Queries / Tạo Commands và Queries
- Implementing MediatR handlers / Triển khai MediatR handlers
- Adding cross-cutting concerns via Behaviors / Thêm behaviors xuyên suốt
- Ensuring idempotency / Đảm bảo tính idempotent
## Core Concepts / Khái Niệm Cốt Lõi
### CQRS Overview / Tổng Quan CQRS
```
┌─────────────────────────────────────────────────────────────┐
│ API Controller │
└─────────────────────┬───────────────────────────┬───────────┘
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ Commands │ │ Queries │
│ (Write/Ghi) │ │ (Read/Đọc) │
└───────┬───────┘ └───────┬───────┘
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ MediatR │ │ MediatR │
│ Handler │ │ Handler │
└───────┬───────┘ └───────┬───────┘
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ Domain Model │ │ Dapper │
│ EF Core │ │ Raw SQL │
└───────┬───────┘ └───────┬───────┘
│ │
└───────────────┬───────────────────┘
┌────────▼────────┐
│ Database │
└─────────────────┘
```
### Command vs Query / Lệnh vs Truy Vấn
| Aspect | Command | Query |
|--------|---------|-------|
| **Purpose** | Modify state | Read data |
| **Returns** | Result/void | Data/DTO |
| **Model** | Full Domain Model | Lightweight DTO |
| **ORM** | EF Core | Dapper/Raw SQL |
| **Validation** | Full business rules | Minimal |
### MediatR Flow / Luồng MediatR
```
Request → Pipeline Behaviors → Handler → Response
├── LoggingBehavior
├── ValidationBehavior
└── TransactionBehavior
```
## Key Patterns / Mẫu Chính
### Command Definition / Định Nghĩa Command
```csharp
/// <summary>
/// EN: Command to create a new order.
/// VI: Command tạo order mới.
/// </summary>
public record CreateOrderCommand(
string UserId,
Address ShippingAddress,
List<OrderItemDto> Items) : IRequest<OrderResult>;
/// <summary>
/// EN: Command result.
/// VI: Kết quả command.
/// </summary>
public record OrderResult(Guid OrderId);
```
### Query Definition / Định Nghĩa Query
```csharp
/// <summary>
/// EN: Query to get user orders.
/// VI: Query lấy orders của user.
/// </summary>
public record GetUserOrdersQuery(
string UserId,
int Skip = 0,
int Take = 20) : IRequest<PagedResult<OrderSummaryDto>>;
/// <summary>
/// EN: Lightweight DTO for query results.
/// VI: DTO nhẹ cho kết quả query.
/// </summary>
public record OrderSummaryDto(
Guid Id,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
int ItemCount);
```
### Command Handler / Handler Command
```csharp
/// <summary>
/// EN: Handler for CreateOrderCommand.
/// VI: Handler cho CreateOrderCommand.
/// </summary>
public class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, OrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<CreateOrderCommandHandler> _logger;
public CreateOrderCommandHandler(
IOrderRepository orderRepository,
ILogger<CreateOrderCommandHandler> logger)
{
_orderRepository = orderRepository;
_logger = logger;
}
public async Task<OrderResult> Handle(
CreateOrderCommand request,
CancellationToken ct)
{
// EN: Create order through domain model
// VI: Tạo order qua domain model
var order = new Order(request.UserId, request.ShippingAddress);
foreach (var item in request.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
}
await _orderRepository.AddAsync(order, ct);
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
_logger.LogInformation(
"EN: Order created / VI: Order đã tạo: {OrderId}",
order.Id);
return new OrderResult(order.Id);
}
}
```
### Query Handler with Dapper / Handler Query với Dapper
```csharp
/// <summary>
/// EN: Query handler using Dapper for optimized reads.
/// VI: Query handler dùng Dapper cho đọc tối ưu.
/// </summary>
public class GetUserOrdersQueryHandler
: IRequestHandler<GetUserOrdersQuery, PagedResult<OrderSummaryDto>>
{
private readonly IDbConnection _connection;
public GetUserOrdersQueryHandler(IDbConnection connection)
{
_connection = connection;
}
public async Task<PagedResult<OrderSummaryDto>> Handle(
GetUserOrdersQuery request,
CancellationToken ct)
{
const string countSql = "SELECT COUNT(*) FROM Orders WHERE UserId = @UserId";
var total = await _connection.ExecuteScalarAsync<int>(countSql, new { request.UserId });
const string sql = @"
SELECT o.Id, o.Status, o.TotalAmount, o.CreatedAt,
COUNT(oi.Id) as ItemCount
FROM Orders o
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
WHERE o.UserId = @UserId
GROUP BY o.Id, o.Status, o.TotalAmount, o.CreatedAt
ORDER BY o.CreatedAt DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY";
var orders = await _connection.QueryAsync<OrderSummaryDto>(sql, new
{
request.UserId,
request.Skip,
request.Take
});
return new PagedResult<OrderSummaryDto>(orders.ToList(), total);
}
}
```
### Validation Behavior / Behavior Validation
```csharp
/// <summary>
/// EN: Pipeline behavior for FluentValidation.
/// VI: Pipeline behavior cho FluentValidation.
/// </summary>
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
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 ct)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, ct))))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
```
### Logging Behavior / Behavior Logging
```csharp
/// <summary>
/// EN: Pipeline behavior for logging.
/// VI: Pipeline behavior cho logging.
/// </summary>
public class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
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 ct)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation(
"EN: Handling {RequestName} / VI: Xử lý {RequestName}",
requestName);
var stopwatch = Stopwatch.StartNew();
var response = await next();
stopwatch.Stop();
_logger.LogInformation(
"EN: Handled {RequestName} in {ElapsedMs}ms / VI: Đã xử lý {RequestName} trong {ElapsedMs}ms",
requestName, stopwatch.ElapsedMilliseconds);
return response;
}
}
```
### Controller with MediatR / Controller với MediatR
```csharp
/// <summary>
/// EN: Slim controller using MediatR.
/// VI: Controller gọn nhẹ với MediatR.
/// </summary>
[ApiController]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<ActionResult<ApiResponse<OrderResult>>> CreateOrder(
CreateOrderRequest request,
CancellationToken ct)
{
var command = new CreateOrderCommand(
GetUserId(),
request.ShippingAddress,
request.Items);
var result = await _mediator.Send(command, ct);
return CreatedAtAction(
nameof(GetOrder),
new { orderId = result.OrderId },
ApiResponse<OrderResult>.Ok(result));
}
[HttpGet]
public async Task<ActionResult<ApiResponse<PagedResult<OrderSummaryDto>>>> GetOrders(
[FromQuery] int skip = 0,
[FromQuery] int take = 20,
CancellationToken ct = default)
{
var query = new GetUserOrdersQuery(GetUserId(), skip, take);
var result = await _mediator.Send(query, ct);
return Ok(ApiResponse<PagedResult<OrderSummaryDto>>.Ok(result));
}
private string GetUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new UnauthorizedAccessException();
}
```
## Common Mistakes / Lỗi Thường Gặp
### 1. Using Domain Model for Queries
```csharp
// ❌ BAD: Using EF Core for simple reads
public async Task<IEnumerable<Order>> Handle(GetOrdersQuery query, CancellationToken ct)
{
return await _context.Orders
.Include(o => o.OrderItems)
.Where(o => o.UserId == query.UserId)
.ToListAsync(ct);
}
// ✅ GOOD: Using Dapper for optimized reads
public async Task<IEnumerable<OrderDto>> Handle(GetOrdersQuery query, CancellationToken ct)
{
const string sql = "SELECT Id, Status, TotalAmount FROM Orders WHERE UserId = @UserId";
return await _connection.QueryAsync<OrderDto>(sql, new { query.UserId });
}
```
### 2. Missing Pipeline Behaviors
```csharp
// ❌ BAD: Validation in handler
public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
{
if (string.IsNullOrEmpty(request.UserId))
throw new ValidationException("UserId required");
// ...
}
// ✅ GOOD: Validation via Behavior
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
```
### 3. Fat Controllers
```csharp
// ❌ BAD: Logic in controller
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var order = new Order(request.UserId, request.Address);
foreach (var item in request.Items)
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
await _repository.AddAsync(order);
await _repository.UnitOfWork.SaveChangesAsync();
return Ok(order.Id);
}
// ✅ GOOD: Delegate to MediatR
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var command = request.ToCommand(GetUserId());
var result = await _mediator.Send(command);
return CreatedAtAction(nameof(GetOrder), new { orderId = result.OrderId }, result);
}
```
## Quick Reference / Tham Chiếu Nhanh
### MediatR Request Types
| Interface | Purpose | Example |
|-----------|---------|---------|
| `IRequest<T>` | Request with response | Commands, Queries |
| `IRequest` | Request without response | Fire-and-forget |
| `INotification` | Event notification | Domain events |
### Pipeline Behavior Order
```csharp
// EN: Registration order = execution order
// VI: Thứ tự đăng ký = thứ tự thực thi
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); // 1st
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); // 2nd
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>)); // 3rd
```
### DI Registration
```csharp
// EN: Register MediatR with behaviors
// VI: Đăng ký MediatR với behaviors
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
// EN: Register FluentValidation
// VI: Đăng ký FluentValidation
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
```
## Resources / Tài Nguyên
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access
- [Error Handling](../error-handling-patterns/SKILL.md) - Validation errors
- [Testing Patterns](../testing-patterns/SKILL.md) - Handler testing
- [API Design](../api-design/SKILL.md) - Controller patterns

View File

@@ -0,0 +1,764 @@
# CQRS-MediatR - Detailed Reference
Detailed code examples for CQRS pattern với MediatR trong ASP.NET Core.
## Table of Contents
1. [Project Setup](#project-setup)
2. [Command Patterns](#command-patterns)
3. [Query Patterns](#query-patterns)
4. [Pipeline Behaviors](#pipeline-behaviors)
5. [Idempotency](#idempotency)
6. [Domain Events](#domain-events)
7. [Transaction Management](#transaction-management)
---
## Project Setup
### Package Dependencies
```xml
<ItemGroup>
<PackageReference Include="MediatR" Version="12.2.0" />
<PackageReference Include="FluentValidation" Version="11.9.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
<PackageReference Include="Dapper" Version="2.1.24" />
</ItemGroup>
```
### DI Registration
```csharp
/// <summary>
/// EN: Configure MediatR with all behaviors.
/// VI: Cấu hình MediatR với tất cả behaviors.
/// </summary>
// EN: Register MediatR / VI: Đăng ký MediatR
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
// EN: Pipeline behaviors (order matters!)
// VI: Pipeline behaviors (thứ tự quan trọng!)
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
});
// EN: Register FluentValidation validators
// VI: Đăng ký FluentValidation validators
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
// EN: Register Dapper connection for queries
// VI: Đăng ký Dapper connection cho queries
builder.Services.AddScoped<IDbConnection>(_ =>
new NpgsqlConnection(builder.Configuration.GetConnectionString("DefaultConnection")));
```
---
## Command Patterns
### Immutable Command Records
```csharp
/// <summary>
/// EN: Command to create an order. Commands should be immutable.
/// VI: Command tạo order. Commands nên là immutable.
/// </summary>
public record CreateOrderCommand : IRequest<OrderResult>
{
public string UserId { get; init; }
public Address ShippingAddress { get; init; }
public IReadOnlyList<OrderItemDto> Items { get; init; }
public CreateOrderCommand(
string userId,
Address shippingAddress,
IReadOnlyList<OrderItemDto> items)
{
UserId = userId;
ShippingAddress = shippingAddress;
Items = items;
}
}
/// <summary>
/// EN: Command to update order status.
/// VI: Command cập nhật trạng thái order.
/// </summary>
public record UpdateOrderStatusCommand(
Guid OrderId,
string UserId,
OrderStatus NewStatus) : IRequest<OrderResult>;
/// <summary>
/// EN: Command to cancel order.
/// VI: Command hủy order.
/// </summary>
public record CancelOrderCommand(
Guid OrderId,
string UserId,
string Reason) : IRequest<OrderResult>;
```
### Command Handler with Full Domain Logic
```csharp
/// <summary>
/// EN: Handler for CreateOrderCommand with domain validation.
/// VI: Handler cho CreateOrderCommand với domain validation.
/// </summary>
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly IProductService _productService;
private readonly ILogger<CreateOrderCommandHandler> _logger;
public CreateOrderCommandHandler(
IOrderRepository orderRepository,
IProductService productService,
ILogger<CreateOrderCommandHandler> logger)
{
_orderRepository = orderRepository;
_productService = productService;
_logger = logger;
}
public async Task<OrderResult> Handle(
CreateOrderCommand request,
CancellationToken ct)
{
// EN: Validate products exist and get prices
// VI: Xác thực products tồn tại và lấy giá
var productIds = request.Items.Select(i => i.ProductId).ToList();
var products = await _productService.GetByIdsAsync(productIds, ct);
if (products.Count != productIds.Count)
{
var missingIds = productIds.Except(products.Select(p => p.Id));
throw new NotFoundException("Products", string.Join(", ", missingIds));
}
// EN: Create order aggregate
// VI: Tạo order aggregate
var order = new Order(request.UserId, request.ShippingAddress);
foreach (var item in request.Items)
{
var product = products.First(p => p.Id == item.ProductId);
order.AddItem(product.Id, item.Quantity, product.Price);
}
// EN: Persist via repository
// VI: Lưu qua repository
await _orderRepository.AddAsync(order, ct);
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
_logger.LogInformation(
"EN: Order {OrderId} created for user {UserId} / " +
"VI: Order {OrderId} đã tạo cho user {UserId}",
order.Id, request.UserId);
return new OrderResult(order.Id, order.Status.ToString(), order.TotalAmount);
}
}
```
### Command Validator
```csharp
/// <summary>
/// EN: FluentValidation validator for CreateOrderCommand.
/// VI: FluentValidation validator cho CreateOrderCommand.
/// </summary>
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty().WithMessage("User ID is required");
RuleFor(x => x.ShippingAddress)
.NotNull().WithMessage("Shipping address is required")
.SetValidator(new AddressValidator()!);
RuleFor(x => x.Items)
.NotEmpty().WithMessage("At least one item is required")
.Must(items => items.Count <= 100)
.WithMessage("Maximum 100 items per order");
RuleForEach(x => x.Items)
.SetValidator(new OrderItemDtoValidator());
}
}
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
RuleFor(x => x.City).NotEmpty().MaximumLength(100);
RuleFor(x => x.PostalCode).NotEmpty().Matches(@"^\d{5}(-\d{4})?$");
RuleFor(x => x.Country).NotEmpty().MaximumLength(100);
}
}
public class OrderItemDtoValidator : AbstractValidator<OrderItemDto>
{
public OrderItemDtoValidator()
{
RuleFor(x => x.ProductId).NotEmpty();
RuleFor(x => x.Quantity).GreaterThan(0).LessThanOrEqualTo(1000);
RuleFor(x => x.UnitPrice).GreaterThanOrEqualTo(0);
}
}
```
---
## Query Patterns
### Optimized Query with Dapper
```csharp
/// <summary>
/// EN: Query for paginated user orders.
/// VI: Query cho orders phân trang của user.
/// </summary>
public record GetUserOrdersQuery(
string UserId,
int Skip = 0,
int Take = 20,
OrderStatus? StatusFilter = null) : IRequest<PagedResult<OrderSummaryDto>>;
/// <summary>
/// EN: Handler using Dapper for optimized reads.
/// VI: Handler dùng Dapper cho đọc tối ưu.
/// </summary>
public class GetUserOrdersQueryHandler
: IRequestHandler<GetUserOrdersQuery, PagedResult<OrderSummaryDto>>
{
private readonly IDbConnection _connection;
public GetUserOrdersQueryHandler(IDbConnection connection)
{
_connection = connection;
}
public async Task<PagedResult<OrderSummaryDto>> Handle(
GetUserOrdersQuery request,
CancellationToken ct)
{
var whereClause = "WHERE o.UserId = @UserId";
var parameters = new DynamicParameters();
parameters.Add("UserId", request.UserId);
parameters.Add("Skip", request.Skip);
parameters.Add("Take", request.Take);
if (request.StatusFilter.HasValue)
{
whereClause += " AND o.Status = @Status";
parameters.Add("Status", request.StatusFilter.Value.ToString());
}
// EN: Count total
// VI: Đếm tổng
var countSql = $"SELECT COUNT(*) FROM Orders o {whereClause}";
var total = await _connection.ExecuteScalarAsync<int>(countSql, parameters);
// EN: Get data with pagination
// VI: Lấy dữ liệu với phân trang
var dataSql = $@"
SELECT
o.Id,
o.Status,
o.TotalAmount,
o.CreatedAt,
(SELECT COUNT(*) FROM OrderItems WHERE OrderId = o.Id) as ItemCount
FROM Orders o
{whereClause}
ORDER BY o.CreatedAt DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY";
var orders = await _connection.QueryAsync<OrderSummaryDto>(dataSql, parameters);
return new PagedResult<OrderSummaryDto>(
orders.ToList(),
total,
request.Skip / request.Take + 1,
request.Take);
}
}
```
### Complex Query with Multi-Mapping
```csharp
/// <summary>
/// EN: Query for order detail with items.
/// VI: Query cho chi tiết order với items.
/// </summary>
public record GetOrderDetailQuery(
Guid OrderId,
string UserId) : IRequest<OrderDetailDto?>;
public class GetOrderDetailQueryHandler
: IRequestHandler<GetOrderDetailQuery, OrderDetailDto?>
{
private readonly IDbConnection _connection;
public async Task<OrderDetailDto?> Handle(
GetOrderDetailQuery request,
CancellationToken ct)
{
const string sql = @"
SELECT
o.Id, o.UserId, o.Status, o.TotalAmount, o.CreatedAt,
o.ShippingAddress_Street as Street,
o.ShippingAddress_City as City,
o.ShippingAddress_PostalCode as PostalCode,
o.ShippingAddress_Country as Country,
oi.Id as ItemId, oi.ProductId, oi.Quantity, oi.UnitPrice,
p.Name as ProductName
FROM Orders o
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
LEFT JOIN Products p ON oi.ProductId = p.Id
WHERE o.Id = @OrderId AND o.UserId = @UserId";
var orderDict = new Dictionary<Guid, OrderDetailDto>();
await _connection.QueryAsync<OrderDetailDto, OrderItemDetailDto?, OrderDetailDto>(
sql,
(order, item) =>
{
if (!orderDict.TryGetValue(order.Id, out var existingOrder))
{
existingOrder = order;
existingOrder.Items = new List<OrderItemDetailDto>();
orderDict.Add(order.Id, existingOrder);
}
if (item != null)
existingOrder.Items.Add(item);
return existingOrder;
},
new { request.OrderId, request.UserId },
splitOn: "ItemId");
return orderDict.Values.FirstOrDefault();
}
}
```
---
## Pipeline Behaviors
### Complete Validation Behavior
```csharp
/// <summary>
/// EN: Validation behavior with detailed error mapping.
/// VI: Validation behavior với ánh xạ lỗi chi tiết.
/// </summary>
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
private readonly ILogger<ValidationBehavior<TRequest, TResponse>> _logger;
public ValidationBehavior(
IEnumerable<IValidator<TRequest>> validators,
ILogger<ValidationBehavior<TRequest, TResponse>> logger)
{
_validators = validators;
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var typeName = typeof(TRequest).Name;
if (!_validators.Any())
{
_logger.LogDebug("No validators for {RequestType}", typeName);
return await next();
}
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, ct)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
{
_logger.LogWarning(
"Validation failed for {RequestType}: {Errors}",
typeName,
string.Join(", ", failures.Select(f => f.ErrorMessage)));
throw new ValidationException(failures);
}
return await next();
}
}
```
### Logging Behavior with Performance Tracking
```csharp
/// <summary>
/// EN: Logging behavior with performance alerts.
/// VI: Logging behavior với cảnh báo hiệu năng.
/// </summary>
public class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
private readonly TimeSpan _slowThreshold = TimeSpan.FromSeconds(3);
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
var requestId = Guid.NewGuid().ToString("N")[..8];
_logger.LogInformation(
"[{RequestId}] Handling {RequestName}",
requestId, requestName);
var stopwatch = Stopwatch.StartNew();
try
{
var response = await next();
stopwatch.Stop();
if (stopwatch.Elapsed > _slowThreshold)
{
_logger.LogWarning(
"[{RequestId}] SLOW: {RequestName} took {ElapsedMs}ms",
requestId, requestName, stopwatch.ElapsedMilliseconds);
}
else
{
_logger.LogInformation(
"[{RequestId}] Completed {RequestName} in {ElapsedMs}ms",
requestId, requestName, stopwatch.ElapsedMilliseconds);
}
return response;
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex,
"[{RequestId}] FAILED: {RequestName} after {ElapsedMs}ms",
requestId, requestName, stopwatch.ElapsedMilliseconds);
throw;
}
}
}
```
### Transaction Behavior
```csharp
/// <summary>
/// EN: Transaction behavior for commands.
/// VI: Transaction behavior cho commands.
/// </summary>
public class TransactionBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ApplicationDbContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
ApplicationDbContext dbContext,
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
// EN: Only wrap commands in transactions
// VI: Chỉ wrap commands trong transactions
if (!IsCommand())
return await next();
var typeName = typeof(TRequest).Name;
await using var transaction = await _dbContext.Database
.BeginTransactionAsync(ct);
try
{
_logger.LogDebug("Begin transaction for {RequestType}", typeName);
var response = await next();
await transaction.CommitAsync(ct);
_logger.LogDebug("Committed transaction for {RequestType}", typeName);
return response;
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
_logger.LogError(ex, "Rolled back transaction for {RequestType}", typeName);
throw;
}
}
private static bool IsCommand()
{
return typeof(TRequest).Name.EndsWith("Command");
}
}
```
---
## Idempotency
### Identified Command Pattern
```csharp
/// <summary>
/// EN: Wrapper for idempotent commands.
/// VI: Wrapper cho commands idempotent.
/// </summary>
public record IdentifiedCommand<TCommand, TResult>(
Guid RequestId,
TCommand Command) : IRequest<TResult>
where TCommand : IRequest<TResult>;
/// <summary>
/// EN: Handler for identified commands.
/// VI: Handler cho identified commands.
/// </summary>
public class IdentifiedCommandHandler<TCommand, TResult>
: IRequestHandler<IdentifiedCommand<TCommand, TResult>, TResult>
where TCommand : IRequest<TResult>
{
private readonly IMediator _mediator;
private readonly IRequestManager _requestManager;
private readonly ILogger<IdentifiedCommandHandler<TCommand, TResult>> _logger;
public IdentifiedCommandHandler(
IMediator mediator,
IRequestManager requestManager,
ILogger<IdentifiedCommandHandler<TCommand, TResult>> logger)
{
_mediator = mediator;
_requestManager = requestManager;
_logger = logger;
}
public async Task<TResult> Handle(
IdentifiedCommand<TCommand, TResult> request,
CancellationToken ct)
{
var commandName = typeof(TCommand).Name;
// EN: Check if already processed
// VI: Kiểm tra đã xử lý chưa
if (await _requestManager.ExistsAsync(request.RequestId, ct))
{
_logger.LogWarning(
"Duplicate request {RequestId} for {CommandName}",
request.RequestId, commandName);
return default!; // EN: Return cached result if needed
}
// EN: Mark as processing
// VI: Đánh dấu đang xử lý
await _requestManager.CreateAsync(request.RequestId, commandName, ct);
try
{
var result = await _mediator.Send(request.Command, ct);
_logger.LogInformation(
"Processed {CommandName} with RequestId {RequestId}",
commandName, request.RequestId);
return result;
}
catch
{
await _requestManager.DeleteAsync(request.RequestId, ct);
throw;
}
}
}
```
### Request Manager
```csharp
/// <summary>
/// EN: Interface for request deduplication.
/// VI: Interface cho request deduplication.
/// </summary>
public interface IRequestManager
{
Task<bool> ExistsAsync(Guid requestId, CancellationToken ct);
Task CreateAsync(Guid requestId, string commandName, CancellationToken ct);
Task DeleteAsync(Guid requestId, CancellationToken ct);
}
/// <summary>
/// EN: Database-backed request manager.
/// VI: Request manager với database.
/// </summary>
public class RequestManager : IRequestManager
{
private readonly ApplicationDbContext _context;
public RequestManager(ApplicationDbContext context)
{
_context = context;
}
public async Task<bool> ExistsAsync(Guid requestId, CancellationToken ct)
{
return await _context.ClientRequests
.AnyAsync(r => r.Id == requestId, ct);
}
public async Task CreateAsync(Guid requestId, string commandName, CancellationToken ct)
{
var request = new ClientRequest
{
Id = requestId,
Name = commandName,
Time = DateTime.UtcNow
};
_context.ClientRequests.Add(request);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid requestId, CancellationToken ct)
{
var request = await _context.ClientRequests.FindAsync(new object[] { requestId }, ct);
if (request != null)
{
_context.ClientRequests.Remove(request);
await _context.SaveChangesAsync(ct);
}
}
}
/// <summary>
/// EN: Entity for tracking processed requests.
/// VI: Entity theo dõi requests đã xử lý.
/// </summary>
public class ClientRequest
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime Time { get; set; }
}
```
---
## Domain Events
### Publishing Domain Events
```csharp
/// <summary>
/// EN: Domain event interface.
/// VI: Interface domain event.
/// </summary>
public interface IDomainEvent : INotification
{
DateTime OccurredOn { get; }
}
/// <summary>
/// EN: Order created domain event.
/// VI: Domain event order được tạo.
/// </summary>
public record OrderCreatedEvent(
Guid OrderId,
string UserId,
decimal TotalAmount) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
/// <summary>
/// EN: Handler for OrderCreatedEvent.
/// VI: Handler cho OrderCreatedEvent.
/// </summary>
public class OrderCreatedEventHandler : INotificationHandler<OrderCreatedEvent>
{
private readonly IEmailService _emailService;
private readonly ILogger<OrderCreatedEventHandler> _logger;
public OrderCreatedEventHandler(
IEmailService emailService,
ILogger<OrderCreatedEventHandler> logger)
{
_emailService = emailService;
_logger = logger;
}
public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
{
_logger.LogInformation(
"Sending order confirmation email for Order {OrderId}",
notification.OrderId);
await _emailService.SendOrderConfirmationAsync(
notification.UserId,
notification.OrderId,
notification.TotalAmount,
ct);
}
}
```
---
## Resources / Tài Nguyên
- [MediatR Documentation](https://github.com/jbogard/MediatR)
- [FluentValidation](https://docs.fluentvalidation.net/)
- [CQRS Pattern - Microsoft](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs)
- [eShopOnContainers - CQRS](https://github.com/dotnet-architecture/eShopOnContainers)

View File

@@ -0,0 +1,401 @@
---
name: docker-traefik
description: Docker containerization và Traefik reverse proxy. Use for Dockerfile, docker-compose, routing rules, SSL termination, và load balancing.
compatibility: "Docker 24+, Docker Compose v2+, Traefik v3+"
metadata:
author: Velik Ho
version: "1.0"
---
# Docker & Traefik Patterns / Mẫu Docker & Traefik
Docker containerization và Traefik reverse proxy cho GoodGo microservices.
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Creating Dockerfiles for .NET services / Tạo Dockerfiles cho services .NET
- Configuring docker-compose for local dev / Cấu hình docker-compose cho dev local
- Setting up Traefik routing / Cài đặt Traefik routing
- Configuring SSL/TLS termination / Cấu hình SSL/TLS termination
- Implementing load balancing / Triển khai load balancing
- Managing service discovery / Quản lý service discovery
## Core Concepts / Khái Niệm Cốt Lõi
### Architecture Overview / Tổng Quan Kiến Trúc
```
Internet
┌─────────────────────────────────────────────────────┐
│ Traefik (Gateway) │
│ - SSL Termination - Rate Limiting │
│ - Load Balancing - Path Routing │
└───────┬────────────────────────────────┬────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ iam-service │ │ storage-svc │
│ :8080 │ │ :8080 │
└───────────────┘ └───────────────┘
```
### Traefik Concepts / Các Khái Niệm Traefik
| Concept | Description | Example |
|---------|-------------|---------|
| **EntryPoints** | Network ports | `web:80`, `websecure:443` |
| **Routers** | Match requests to services | `PathPrefix(\`/api/v1/iam\`)` |
| **Services** | Backend targets | `loadbalancer.server.port=8080` |
| **Middlewares** | Request/Response modifiers | `stripprefix`, `ratelimit` |
### Docker Best Practices / Best Practices Docker
1. **Multi-stage builds** - Separate build and runtime
2. **Non-root user** - Security best practice
3. **Alpine images** - Smaller image size
4. **.dockerignore** - Exclude unnecessary files
5. **Layer caching** - Optimize build time
## Key Patterns / Mẫu Chính
### Dockerfile for .NET Service
```dockerfile
# ===================================
# EN: Build stage / VI: Stage build
# ===================================
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src
# EN: Copy solution and restore dependencies
# VI: Copy solution và restore dependencies
COPY *.slnx ./
COPY src/MyService.API/*.csproj src/MyService.API/
COPY src/MyService.Domain/*.csproj src/MyService.Domain/
COPY src/MyService.Infrastructure/*.csproj src/MyService.Infrastructure/
RUN dotnet restore
# EN: Copy source code and build
# VI: Copy source code và build
COPY src/ src/
RUN dotnet publish src/MyService.API -c Release -o /app --no-restore
# ===================================
# EN: Runtime stage / VI: Stage runtime
# ===================================
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
WORKDIR /app
# EN: Install additional packages if needed
# VI: Cài đặt packages bổ sung nếu cần
RUN apk add --no-cache icu-libs
# EN: Create non-root user for security
# VI: Tạo user không phải root để bảo mật
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# EN: Copy published files
# VI: Copy files đã publish
COPY --from=build --chown=appuser:appgroup /app .
# EN: Configure environment
# VI: Cấu hình môi trường
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DOTNET_RUNNING_IN_CONTAINER=true
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "MyService.API.dll"]
```
### Docker Compose với Traefik
```yaml
# docker-compose.yml
version: "3.8"
services:
# ===================================
# Traefik - API Gateway
# ===================================
traefik:
image: traefik:v3.0
container_name: traefik
command:
# EN: Enable API and Dashboard
# VI: Bật API và Dashboard
- "--api.dashboard=true"
- "--api.insecure=true"
# EN: Docker provider configuration
# VI: Cấu hình Docker provider
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=goodgo-network"
# EN: Entrypoints
# VI: Các điểm vào
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# EN: Access logs
# VI: Logs truy cập
- "--accesslog=true"
- "--accesslog.format=json"
ports:
- "80:80"
- "443:443"
- "8080:8080" # Dashboard
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- goodgo-network
labels:
# EN: Enable Traefik dashboard
# VI: Bật Traefik dashboard
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.localhost`)"
- "traefik.http.routers.dashboard.service=api@internal"
# ===================================
# IAM Service
# ===================================
iam-service-net:
build:
context: ../..
dockerfile: services/iam-service-net/Dockerfile
container_name: iam-service-net
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=${IAM_DATABASE_URL}
labels:
- "traefik.enable=true"
# EN: Router configuration / VI: Cấu hình router
- "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)"
- "traefik.http.routers.iam-service-net.entrypoints=web"
# EN: Service configuration / VI: Cấu hình service
- "traefik.http.services.iam-service-net.loadbalancer.server.port=8080"
# EN: Health check / VI: Kiểm tra sức khỏe
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.interval=10s"
networks:
- goodgo-network
depends_on:
- postgres
# ===================================
# Storage Service
# ===================================
storage-service-net:
build:
context: ../..
dockerfile: services/storage-service-net/Dockerfile
container_name: storage-service-net
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=${STORAGE_DATABASE_URL}
- MinIO__Endpoint=${MINIO_ENDPOINT}
- MinIO__AccessKey=${MINIO_ACCESS_KEY}
- MinIO__SecretKey=${MINIO_SECRET_KEY}
labels:
- "traefik.enable=true"
- "traefik.http.routers.storage-service-net.rule=PathPrefix(`/api/v1/storage`)"
- "traefik.http.routers.storage-service-net.entrypoints=web"
- "traefik.http.services.storage-service-net.loadbalancer.server.port=8080"
networks:
- goodgo-network
depends_on:
- postgres
- minio
# ===================================
# PostgreSQL
# ===================================
postgres:
image: postgres:15-alpine
container_name: postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- goodgo-network
# ===================================
# MinIO (S3-compatible storage)
# ===================================
minio:
image: minio/minio:latest
container_name: minio
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001"
networks:
- goodgo-network
networks:
goodgo-network:
driver: bridge
volumes:
postgres_data:
minio_data:
```
### Traefik Middlewares
```yaml
# EN: Strip path prefix middleware
# VI: Middleware bỏ prefix path
services:
iam-service-net:
labels:
- "traefik.enable=true"
- "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)"
- "traefik.http.routers.iam-service-net.entrypoints=web"
# EN: Apply middleware to strip prefix
# VI: Áp dụng middleware để bỏ prefix
- "traefik.http.routers.iam-service-net.middlewares=iam-stripprefix"
- "traefik.http.middlewares.iam-stripprefix.stripprefix.prefixes=/api/v1/iam"
- "traefik.http.services.iam-service-net.loadbalancer.server.port=8080"
```
### Rate Limiting Middleware
```yaml
services:
api-service:
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=PathPrefix(`/api`)"
# EN: Rate limiting middleware
# VI: Middleware giới hạn rate
- "traefik.http.routers.api.middlewares=api-ratelimit"
- "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
- "traefik.http.middlewares.api-ratelimit.ratelimit.period=1m"
```
## Common Mistakes / Lỗi Thường Gặp
### 1. Running as Root
```dockerfile
# ❌ BAD: Running as root
FROM mcr.microsoft.com/dotnet/aspnet:8.0
COPY --from=build /app .
ENTRYPOINT ["dotnet", "MyService.dll"]
# ✅ GOOD: Non-root user
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
RUN adduser -D appuser
USER appuser
COPY --from=build --chown=appuser /app .
ENTRYPOINT ["dotnet", "MyService.dll"]
```
### 2. No Health Checks
```yaml
# ❌ BAD: No health check
services:
my-service:
build: .
labels:
- "traefik.enable=true"
# ✅ GOOD: With health check
services:
my-service:
build: .
labels:
- "traefik.enable=true"
- "traefik.http.services.my-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.my-service.loadbalancer.healthcheck.interval=10s"
```
### 3. Exposing Internal Ports
```yaml
# ❌ BAD: Exposing all ports
services:
my-service:
ports:
- "8080:8080" # Direct access bypasses Traefik
# ✅ GOOD: Only expose through Traefik
services:
my-service:
# No ports exposed directly
labels:
- "traefik.enable=true"
- "traefik.http.services.my-service.loadbalancer.server.port=8080"
```
## Quick Reference / Tham Chiếu Nhanh
### Docker Commands
```bash
# EN: Build and start / VI: Build và start
docker-compose -f deployments/local/docker-compose.yml up -d --build
# EN: View logs / VI: Xem logs
docker-compose logs -f iam-service-net
# EN: Restart service / VI: Restart service
docker-compose restart iam-service-net
# EN: Stop all / VI: Dừng tất cả
docker-compose down
# EN: Clean rebuild / VI: Rebuild sạch
docker-compose down -v && docker-compose up -d --build
```
### Traefik Router Rules
| Rule | Example | Description |
|------|---------|-------------|
| `Host` | ``Host(`api.example.com`)`` | Match by hostname |
| `PathPrefix` | ``PathPrefix(`/api`)`` | Match by path prefix |
| `Path` | ``Path(`/api/health`)`` | Match exact path |
| `Method` | ``Method(`GET`, `POST`)`` | Match by HTTP method |
| `Headers` | ``Headers(`X-Custom`, `value`)`` | Match by header |
### Common Labels
```yaml
# EN: Basic routing / VI: Routing cơ bản
- "traefik.enable=true"
- "traefik.http.routers.{name}.rule=PathPrefix(`/path`)"
- "traefik.http.routers.{name}.entrypoints=web"
- "traefik.http.services.{name}.loadbalancer.server.port=8080"
# EN: With HTTPS / VI: Với HTTPS
- "traefik.http.routers.{name}-secure.rule=PathPrefix(`/path`)"
- "traefik.http.routers.{name}-secure.entrypoints=websecure"
- "traefik.http.routers.{name}-secure.tls=true"
```
## Resources / Tài Nguyên
- [Detailed Examples](./references/REFERENCE.md) - Full configurations
- [Project Rules](../project-rules/SKILL.md) - Docker naming conventions
- [Observability](../observability/SKILL.md) - Container monitoring
- [Error Handling](../error-handling-patterns/SKILL.md) - Health checks

View File

@@ -0,0 +1,560 @@
# Docker & Traefik - Detailed Reference
Detailed configurations và examples cho Docker và Traefik trong GoodGo.
## Table of Contents
1. [Dockerfile Patterns](#dockerfile-patterns)
2. [Docker Compose Configurations](#docker-compose-configurations)
3. [Traefik Configuration](#traefik-configuration)
4. [SSL/TLS Setup](#ssltls-setup)
5. [Load Balancing](#load-balancing)
6. [Middleware Examples](#middleware-examples)
7. [Development Environment](#development-environment)
---
## Dockerfile Patterns
### Optimized .NET Dockerfile
```dockerfile
# ===================================
# Stage 1: Build
# ===================================
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src
# EN: Copy only project files first for better caching
# VI: Copy chỉ project files trước để cache tốt hơn
COPY services/iam-service-net/*.slnx ./
COPY services/iam-service-net/src/IamService.API/*.csproj src/IamService.API/
COPY services/iam-service-net/src/IamService.Domain/*.csproj src/IamService.Domain/
COPY services/iam-service-net/src/IamService.Infrastructure/*.csproj src/IamService.Infrastructure/
# EN: Restore dependencies
# VI: Restore dependencies
RUN dotnet restore src/IamService.API
# EN: Copy remaining source files
# VI: Copy các source files còn lại
COPY services/iam-service-net/src/ src/
# EN: Build and publish
# VI: Build và publish
RUN dotnet publish src/IamService.API \
-c Release \
-o /app \
--no-restore \
/p:PublishTrimmed=false \
/p:PublishSingleFile=false
# ===================================
# Stage 2: Runtime
# ===================================
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
# EN: Install required packages
# VI: Cài đặt packages cần thiết
RUN apk add --no-cache \
icu-libs \
tzdata \
&& rm -rf /var/cache/apk/*
# EN: Set timezone
# VI: Đặt timezone
ENV TZ=Asia/Ho_Chi_Minh
# EN: Create non-root user
# VI: Tạo user không phải root
RUN addgroup -g 1000 -S appgroup && \
adduser -u 1000 -S appuser -G appgroup
WORKDIR /app
# EN: Copy published files with correct ownership
# VI: Copy files đã publish với ownership đúng
COPY --from=build --chown=appuser:appgroup /app .
# EN: Switch to non-root user
# VI: Chuyển sang user không phải root
USER appuser
# EN: Environment configuration
# VI: Cấu hình môi trường
ENV ASPNETCORE_URLS=http://+:8080 \
ASPNETCORE_ENVIRONMENT=Production \
DOTNET_RUNNING_IN_CONTAINER=true \
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE 8080
# EN: Health check
# VI: Kiểm tra sức khỏe
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "IamService.API.dll"]
```
### .dockerignore
```
# EN: Git / VI: Git
.git
.gitignore
# EN: Build outputs / VI: Build outputs
**/bin/
**/obj/
**/out/
# EN: IDE / VI: IDE
.vs/
.vscode/
.idea/
*.swp
*.user
# EN: Test results / VI: Kết quả test
**/TestResults/
**/coverage/
# EN: Docker / VI: Docker
**/Dockerfile*
**/docker-compose*
**/.dockerignore
# EN: Documentation / VI: Tài liệu
**/README.md
**/docs/
# EN: Secrets / VI: Secrets
**/*.env
**/appsettings.Development.json
**/appsettings.Local.json
```
---
## Docker Compose Configurations
### Complete Development Stack
```yaml
# deployments/local/docker-compose.yml
version: "3.8"
services:
# ===================================
# TRAEFIK - API Gateway
# ===================================
traefik:
image: traefik:v3.0
container_name: traefik
restart: unless-stopped
command:
# EN: API and Dashboard
- "--api.dashboard=true"
- "--api.insecure=true"
# EN: Docker provider
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=goodgo-network"
# EN: Entrypoints
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# EN: Logging
- "--log.level=INFO"
- "--accesslog=true"
- "--accesslog.format=json"
# EN: Metrics
- "--metrics.prometheus=true"
- "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0"
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/certs:/certs:ro
networks:
- goodgo-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.localhost`)"
- "traefik.http.routers.traefik.service=api@internal"
# ===================================
# IAM SERVICE
# ===================================
iam-service-net:
build:
context: ../..
dockerfile: services/iam-service-net/Dockerfile
container_name: iam-service-net
restart: unless-stopped
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=${IAM_DATABASE_URL}
- Jwt__Authority=http://iam-service-net:8080
- Jwt__Audience=goodgo-api
labels:
- "traefik.enable=true"
# EN: HTTP Router
- "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)"
- "traefik.http.routers.iam-service-net.entrypoints=web"
- "traefik.http.routers.iam-service-net.middlewares=iam-headers"
# EN: Service
- "traefik.http.services.iam-service-net.loadbalancer.server.port=8080"
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.interval=10s"
# EN: Response headers middleware
- "traefik.http.middlewares.iam-headers.headers.customresponseheaders.X-Service-Name=iam-service"
networks:
- goodgo-network
depends_on:
postgres:
condition: service_healthy
# ===================================
# STORAGE SERVICE
# ===================================
storage-service-net:
build:
context: ../..
dockerfile: services/storage-service-net/Dockerfile
container_name: storage-service-net
restart: unless-stopped
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=${STORAGE_DATABASE_URL}
- MinIO__Endpoint=minio:9000
- MinIO__AccessKey=${MINIO_ACCESS_KEY}
- MinIO__SecretKey=${MINIO_SECRET_KEY}
- MinIO__UseSSL=false
- IamService__BaseUrl=http://iam-service-net:8080
labels:
- "traefik.enable=true"
- "traefik.http.routers.storage-service-net.rule=PathPrefix(`/api/v1/storage`)"
- "traefik.http.routers.storage-service-net.entrypoints=web"
- "traefik.http.services.storage-service-net.loadbalancer.server.port=8080"
- "traefik.http.services.storage-service-net.loadbalancer.healthcheck.path=/health/live"
networks:
- goodgo-network
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_started
# ===================================
# POSTGRESQL
# ===================================
postgres:
image: postgres:15-alpine
container_name: postgres
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_MULTIPLE_DATABASES=iam_db,storage_db,wallet_db
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/create-multiple-dbs.sh:/docker-entrypoint-initdb.d/create-multiple-dbs.sh
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- goodgo-network
# ===================================
# REDIS
# ===================================
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- goodgo-network
# ===================================
# MINIO (S3-compatible)
# ===================================
minio:
image: minio/minio:latest
container_name: minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 10s
retries: 3
networks:
- goodgo-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.minio-console.rule=Host(`minio.localhost`)"
- "traefik.http.routers.minio-console.service=minio-console"
- "traefik.http.services.minio-console.loadbalancer.server.port=9001"
networks:
goodgo-network:
driver: bridge
name: goodgo-network
volumes:
postgres_data:
redis_data:
minio_data:
```
### Multi-Database Init Script
```bash
#!/bin/bash
# scripts/create-multiple-dbs.sh
set -e
set -u
function create_user_and_database() {
local database=$1
echo " Creating database '$database'"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
CREATE DATABASE $database;
GRANT ALL PRIVILEGES ON DATABASE $database TO $POSTGRES_USER;
EOSQL
}
if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then
echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES"
for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do
create_user_and_database $db
done
echo "Multiple databases created"
fi
```
---
## Traefik Configuration
### Static Configuration (traefik.yml)
```yaml
# infra/traefik/traefik.yml
# EN: API and Dashboard
# VI: API và Dashboard
api:
dashboard: true
insecure: false
# EN: Entrypoints
# VI: Các điểm vào
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
# EN: Certificate resolvers
# VI: Bộ giải quyết chứng chỉ
certificatesResolvers:
letsencrypt:
acme:
email: admin@goodgo.vn
storage: /certs/acme.json
httpChallenge:
entryPoint: web
# EN: Providers
# VI: Providers
providers:
docker:
exposedByDefault: false
network: goodgo-network
file:
directory: /etc/traefik/dynamic
watch: true
# EN: Logging
# VI: Logging
log:
level: INFO
format: json
accessLog:
format: json
filters:
statusCodes:
- "400-599"
# EN: Metrics
# VI: Metrics
metrics:
prometheus:
buckets:
- 0.1
- 0.3
- 1.2
- 5.0
```
### Dynamic Configuration
```yaml
# infra/traefik/dynamic/middlewares.yml
http:
middlewares:
# EN: Security headers
# VI: Headers bảo mật
secure-headers:
headers:
frameDeny: true
sslRedirect: true
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customResponseHeaders:
X-Robots-Tag: "noindex,nofollow"
# EN: Rate limiting
# VI: Giới hạn rate
rate-limit:
rateLimit:
average: 100
burst: 50
period: 1m
# EN: Compression
# VI: Nén
compress:
compress: {}
# EN: CORS
# VI: CORS
cors:
headers:
accessControlAllowMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
accessControlAllowHeaders:
- "*"
accessControlAllowOriginList:
- "https://app.goodgo.vn"
- "http://localhost:3000"
accessControlMaxAge: 100
addVaryHeader: true
```
---
## SSL/TLS Setup
### Local Development with mkcert
```bash
# EN: Install mkcert / VI: Cài đặt mkcert
brew install mkcert
mkcert -install
# EN: Generate certificates / VI: Tạo certificates
cd infra/traefik/certs
mkcert "*.localhost" localhost 127.0.0.1 ::1
```
### Traefik with Local Certs
```yaml
# docker-compose.override.yml for local HTTPS
services:
traefik:
volumes:
- ./infra/traefik/certs:/certs:ro
labels:
- "traefik.http.routers.traefik.tls=true"
# Dynamic config: infra/traefik/dynamic/tls.yml
tls:
certificates:
- certFile: /certs/_wildcard.localhost.pem
keyFile: /certs/_wildcard.localhost-key.pem
stores:
default:
defaultCertificate:
certFile: /certs/_wildcard.localhost.pem
keyFile: /certs/_wildcard.localhost-key.pem
```
---
## Load Balancing
### Multiple Instances
```yaml
services:
iam-service-net:
deploy:
replicas: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.iam.rule=PathPrefix(`/api/v1/iam`)"
- "traefik.http.services.iam.loadbalancer.server.port=8080"
# EN: Sticky sessions (optional)
# VI: Sticky sessions (tùy chọn)
- "traefik.http.services.iam.loadbalancer.sticky.cookie.name=iam_sticky"
- "traefik.http.services.iam.loadbalancer.sticky.cookie.secure=true"
# EN: Health check
- "traefik.http.services.iam.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.iam.loadbalancer.healthcheck.interval=5s"
```
---
## Resources / Tài Nguyên
- [Traefik Documentation](https://doc.traefik.io/traefik/)
- [Docker Best Practices](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/)
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
- [Docker Compose Specification](https://docs.docker.com/compose/compose-file/)

View File

@@ -1,7 +1,7 @@
---
name: error-handling-patterns
description: Global error handling, domain exceptions, và Result pattern. Use for exception middleware, validation errors, Polly resiliency, và health checks.
compatibility: ".NET 8+, Polly, FluentValidation, Microsoft.Extensions.Diagnostics.HealthChecks"
compatibility: ".NET 10+, Polly, FluentValidation, Microsoft.Extensions.Diagnostics.HealthChecks"
metadata:
author: Velik Ho
version: "1.0"

View File

@@ -0,0 +1,451 @@
---
name: observability
description: Monitoring, logging, và tracing patterns. Use for Prometheus metrics, Grafana dashboards, Loki logging, và distributed tracing.
compatibility: ".NET 10+, Serilog, OpenTelemetry, Prometheus, Grafana"
metadata:
author: Velik Ho
version: "1.0"
---
# Observability Patterns / Mẫu Quan Sát
Monitoring, logging, và tracing cho GoodGo microservices.
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Implementing health checks / Triển khai health checks
- Configuring structured logging / Cấu hình structured logging
- Setting up Prometheus metrics / Cài đặt Prometheus metrics
- Implementing distributed tracing / Triển khai distributed tracing
- Creating Grafana dashboards / Tạo Grafana dashboards
## Core Concepts / Khái Niệm Cốt Lõi
### Three Pillars of Observability / Ba Trụ Cột của Observability
```
┌─────────────────────────────────────────────────────────────┐
│ OBSERVABILITY │
├───────────────────┬───────────────────┬───────────────────┤
│ METRICS │ LOGS │ TRACES │
│ (Prometheus) │ (Loki/ELK) │ (OpenTelemetry) │
├───────────────────┼───────────────────┼───────────────────┤
│ What's happening │ What happened │ How it happened │
│ (Số liệu) │ (Nhật ký) │ (Theo dõi) │
└───────────────────┴───────────────────┴───────────────────┘
```
### Health Check Types / Các Loại Health Check
| Type | Purpose | Endpoint |
|------|---------|----------|
| **Liveness** | Is the app running? | `/health/live` |
| **Readiness** | Can the app accept traffic? | `/health/ready` |
| **Startup** | Has the app initialized? | `/health/startup` |
### Logging Levels / Các Mức Log
| Level | Purpose | Example |
|-------|---------|---------|
| **Trace** | Detailed debugging | Variable values |
| **Debug** | Development info | Method entry/exit |
| **Information** | Normal operations | Request processed |
| **Warning** | Potential problems | Slow query |
| **Error** | Errors handled | Exception caught |
| **Critical** | System failures | Database down |
## Key Patterns / Mẫu Chính
### Health Checks Configuration
```csharp
/// <summary>
/// EN: Configure comprehensive health checks.
/// VI: Cấu hình health checks toàn diện.
/// </summary>
// Program.cs
builder.Services.AddHealthChecks()
// EN: Database health / VI: Health database
.AddNpgSql(
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "postgresql",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "db", "ready" })
// EN: Redis cache / VI: Redis cache
.AddRedis(
redisConnectionString: builder.Configuration["Redis:ConnectionString"]!,
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: new[] { "cache", "ready" })
// EN: Custom health check / VI: Health check tùy chỉnh
.AddCheck<ExternalServiceHealthCheck>(
"external-api",
failureStatus: HealthStatus.Degraded,
tags: new[] { "external", "ready" });
// EN: Map health endpoints / VI: Map health endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false, // EN: Just check app is running
ResponseWriter = WriteMinimalResponse
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = WriteDetailedResponse
});
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = WriteDetailedResponse
});
```
### Structured Logging with Serilog
```csharp
/// <summary>
/// EN: Configure Serilog with structured logging.
/// VI: Cấu hình Serilog với structured logging.
/// </summary>
// Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Application", "IamService")
.WriteTo.Console(new JsonFormatter())
.WriteTo.Seq(builder.Configuration["Seq:ServerUrl"]!)
.CreateLogger();
builder.Host.UseSerilog();
// EN: Usage in code / VI: Sử dụng trong code
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public async Task<Order> CreateOrderAsync(CreateOrderCommand cmd)
{
// EN: Structured logging with properties
// VI: Structured logging với properties
_logger.LogInformation(
"Creating order for user {UserId} with {ItemCount} items",
cmd.UserId,
cmd.Items.Count);
try
{
var order = await ProcessOrderAsync(cmd);
_logger.LogInformation(
"Order {OrderId} created successfully. Total: {TotalAmount}",
order.Id,
order.TotalAmount);
return order;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to create order for user {UserId}",
cmd.UserId);
throw;
}
}
}
```
### Prometheus Metrics
```csharp
/// <summary>
/// EN: Configure Prometheus metrics.
/// VI: Cấu hình Prometheus metrics.
/// </summary>
// Program.cs
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter();
});
// EN: Map Prometheus endpoint / VI: Map Prometheus endpoint
app.MapPrometheusScrapingEndpoint();
/// <summary>
/// EN: Custom metrics for business logic.
/// VI: Metrics tùy chỉnh cho business logic.
/// </summary>
public class OrderMetrics
{
private readonly Counter<int> _ordersCreated;
private readonly Histogram<double> _orderProcessingDuration;
private readonly UpDownCounter<int> _activeOrders;
public OrderMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("GoodGo.Orders");
_ordersCreated = meter.CreateCounter<int>(
"orders_created_total",
description: "Total number of orders created");
_orderProcessingDuration = meter.CreateHistogram<double>(
"order_processing_duration_seconds",
description: "Duration of order processing in seconds");
_activeOrders = meter.CreateUpDownCounter<int>(
"active_orders",
description: "Number of orders currently being processed");
}
public void OrderCreated(string status)
{
_ordersCreated.Add(1, new KeyValuePair<string, object?>("status", status));
}
public void RecordProcessingDuration(double seconds)
{
_orderProcessingDuration.Record(seconds);
}
public void IncrementActiveOrders() => _activeOrders.Add(1);
public void DecrementActiveOrders() => _activeOrders.Add(-1);
}
```
### Distributed Tracing with OpenTelemetry
```csharp
/// <summary>
/// EN: Configure OpenTelemetry distributed tracing.
/// VI: Cấu hình OpenTelemetry distributed tracing.
/// </summary>
// Program.cs
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("iam-service")
.AddAttributes(new[]
{
new KeyValuePair<string, object>("deployment.environment",
builder.Environment.EnvironmentName)
}))
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
options.Filter = ctx =>
!ctx.Request.Path.StartsWithSegments("/health");
})
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddSource("GoodGo.Orders")
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(builder.Configuration["Otlp:Endpoint"]!);
});
});
/// <summary>
/// EN: Custom activity for business operations.
/// VI: Activity tùy chỉnh cho business operations.
/// </summary>
public class OrderService
{
private static readonly ActivitySource ActivitySource = new("GoodGo.Orders");
public async Task<Order> ProcessOrderAsync(CreateOrderCommand cmd)
{
using var activity = ActivitySource.StartActivity("ProcessOrder");
activity?.SetTag("user.id", cmd.UserId);
activity?.SetTag("order.items_count", cmd.Items.Count);
try
{
// EN: Create child span for validation
// VI: Tạo child span cho validation
using var validationActivity = ActivitySource.StartActivity("ValidateOrder");
await ValidateOrderAsync(cmd);
validationActivity?.SetStatus(ActivityStatusCode.Ok);
// EN: Create child span for persistence
// VI: Tạo child span cho persistence
using var persistActivity = ActivitySource.StartActivity("PersistOrder");
var order = await SaveOrderAsync(cmd);
persistActivity?.SetTag("order.id", order.Id.ToString());
activity?.SetTag("order.id", order.Id.ToString());
activity?.SetStatus(ActivityStatusCode.Ok);
return order;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}
```
### Request Logging Middleware
```csharp
/// <summary>
/// EN: Middleware for request/response logging.
/// VI: Middleware cho logging request/response.
/// </summary>
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(
RequestDelegate next,
ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
var requestId = context.TraceIdentifier;
// EN: Log request / VI: Log request
_logger.LogInformation(
"Request {RequestId} started: {Method} {Path}",
requestId,
context.Request.Method,
context.Request.Path);
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
// EN: Log response / VI: Log response
_logger.LogInformation(
"Request {RequestId} completed: {StatusCode} in {ElapsedMs}ms",
requestId,
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
}
}
```
## Common Mistakes / Lỗi Thường Gặp
### 1. Logging Sensitive Data
```csharp
// ❌ BAD: Logging password
_logger.LogInformation("Login: {Email} {Password}", email, password);
// ✅ GOOD: Redact sensitive data
_logger.LogInformation("Login attempt for {Email}", email);
```
### 2. Missing Correlation ID
```csharp
// ❌ BAD: No correlation between logs
_logger.LogInformation("Processing order");
_logger.LogInformation("Order completed");
// ✅ GOOD: Include correlation ID
using (_logger.BeginScope(new Dictionary<string, object>
{
["OrderId"] = orderId,
["UserId"] = userId
}))
{
_logger.LogInformation("Processing order");
_logger.LogInformation("Order completed");
}
```
### 3. No Health Check for Dependencies
```csharp
// ❌ BAD: Only check if app runs
builder.Services.AddHealthChecks();
// ✅ GOOD: Check all dependencies
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString, name: "database")
.AddRedis(redisConnection, name: "cache")
.AddUrlGroup(new Uri(externalApi), name: "external-api");
```
## Quick Reference / Tham Chiếu Nhanh
### Serilog Configuration (appsettings.json)
```json
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Json.JsonFormatter" } },
{ "Name": "Seq", "Args": { "serverUrl": "http://seq:5341" } }
],
"Enrich": ["FromLogContext", "WithMachineName", "WithEnvironmentName"]
}
}
```
### Health Check Endpoints
| Endpoint | Purpose | K8s Probe |
|----------|---------|-----------|
| `/health/live` | App is running | livenessProbe |
| `/health/ready` | App can accept traffic | readinessProbe |
| `/health/startup` | App has initialized | startupProbe |
### Common Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `http_requests_total` | Counter | Total HTTP requests |
| `http_request_duration_seconds` | Histogram | Request duration |
| `dotnet_gc_collections_total` | Counter | GC collections |
| `process_cpu_seconds_total` | Counter | CPU usage |
## Resources / Tài Nguyên
- [Detailed Examples](./references/REFERENCE.md) - Full configurations
- [Error Handling](../error-handling-patterns/SKILL.md) - Health checks
- [Docker Traefik](../docker-traefik/SKILL.md) - Container monitoring
- [Project Rules](../project-rules/SKILL.md) - Logging standards

View File

@@ -0,0 +1,570 @@
# Observability - Detailed Reference
Detailed configurations và examples cho Observability stack trong GoodGo.
## Table of Contents
1. [Serilog Configuration](#serilog-configuration)
2. [OpenTelemetry Setup](#opentelemetry-setup)
3. [Prometheus & Grafana](#prometheus--grafana)
4. [Health Checks](#health-checks)
5. [Loki Logging](#loki-logging)
6. [Alerting](#alerting)
---
## Serilog Configuration
### Complete Program.cs Setup
```csharp
/// <summary>
/// EN: Complete Serilog configuration for microservice.
/// VI: Serilog configuration đầy đủ cho microservice.
/// </summary>
using Serilog;
using Serilog.Events;
using Serilog.Formatting.Json;
using Serilog.Sinks.Grafana.Loki;
// EN: Configure Serilog bootstrap logger for startup errors
// VI: Cấu hình Serilog bootstrap logger cho lỗi startup
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// EN: Configure Serilog from configuration
// VI: Cấu hình Serilog từ configuration
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "IamService")
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.WriteTo.Console(new JsonFormatter())
.WriteTo.GrafanaLoki(
context.Configuration["Loki:Endpoint"]!,
labels: new[]
{
new LokiLabel { Key = "app", Value = "iam-service" },
new LokiLabel { Key = "env", Value = context.HostingEnvironment.EnvironmentName }
}));
// ... rest of configuration
var app = builder.Build();
// EN: Add Serilog request logging middleware
// VI: Thêm Serilog request logging middleware
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
if (httpContext.User.Identity?.IsAuthenticated == true)
{
diagnosticContext.Set("UserId", httpContext.User.FindFirst("sub")?.Value);
}
};
});
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
```
### appsettings.json for Serilog
```json
{
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.Seq"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning",
"Grpc": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog"
}
},
{
"Name": "Seq",
"Args": {
"serverUrl": "http://seq:5341",
"apiKey": ""
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId",
"WithEnvironmentName"
],
"Properties": {
"Application": "IamService"
}
}
}
```
---
## OpenTelemetry Setup
### Complete OpenTelemetry Configuration
```csharp
/// <summary>
/// EN: Configure OpenTelemetry for tracing and metrics.
/// VI: Cấu hình OpenTelemetry cho tracing và metrics.
/// </summary>
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(
serviceName: "iam-service",
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
serviceInstanceId: Environment.MachineName)
.AddAttributes(new[]
{
new KeyValuePair<string, object>("deployment.environment",
builder.Environment.EnvironmentName),
new KeyValuePair<string, object>("host.name", Environment.MachineName)
}))
.WithTracing(tracing =>
{
tracing
// EN: ASP.NET Core instrumentation
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
options.Filter = ctx =>
!ctx.Request.Path.StartsWithSegments("/health") &&
!ctx.Request.Path.StartsWithSegments("/metrics");
})
// EN: HTTP client instrumentation
.AddHttpClientInstrumentation(options =>
{
options.RecordException = true;
options.FilterHttpRequestMessage = req =>
!req.RequestUri?.Host.Contains("health") ?? true;
})
// EN: Entity Framework instrumentation
.AddEntityFrameworkCoreInstrumentation(options =>
{
options.SetDbStatementForText = true;
options.SetDbStatementForStoredProcedure = true;
})
// EN: Custom activity sources
.AddSource("GoodGo.Iam")
.AddSource("GoodGo.Orders")
// EN: Export to OTLP (Jaeger/Tempo)
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(builder.Configuration["Otlp:Endpoint"]!);
options.Protocol = OtlpExportProtocol.Grpc;
});
})
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
// EN: Custom meters
.AddMeter("GoodGo.Iam")
.AddMeter("GoodGo.Orders")
// EN: Prometheus exporter
.AddPrometheusExporter();
});
// EN: Map Prometheus scraping endpoint
app.MapPrometheusScrapingEndpoint();
```
### Custom Activity Source
```csharp
/// <summary>
/// EN: Service with custom tracing.
/// VI: Service với tracing tùy chỉnh.
/// </summary>
public class OrderService
{
private static readonly ActivitySource ActivitySource = new("GoodGo.Orders");
private readonly ILogger<OrderService> _logger;
public async Task<Order> ProcessOrderAsync(CreateOrderCommand cmd, CancellationToken ct)
{
// EN: Create root span for order processing
// VI: Tạo root span cho xử lý order
using var activity = ActivitySource.StartActivity(
"ProcessOrder",
ActivityKind.Internal);
activity?.SetTag("user.id", cmd.UserId);
activity?.SetTag("order.items_count", cmd.Items.Count);
try
{
// EN: Child span: Validate
// VI: Child span: Xác thực
using (var validateActivity = ActivitySource.StartActivity("ValidateOrder"))
{
await ValidateOrderAsync(cmd, ct);
validateActivity?.SetTag("validation.result", "success");
}
// EN: Child span: Check inventory
// VI: Child span: Kiểm tra tồn kho
using (var inventoryActivity = ActivitySource.StartActivity("CheckInventory"))
{
await CheckInventoryAsync(cmd.Items, ct);
}
// EN: Child span: Persist
// VI: Child span: Lưu trữ
Order order;
using (var persistActivity = ActivitySource.StartActivity("PersistOrder"))
{
order = await SaveOrderAsync(cmd, ct);
persistActivity?.SetTag("order.id", order.Id.ToString());
}
activity?.SetTag("order.id", order.Id.ToString());
activity?.SetTag("order.total", order.TotalAmount);
activity?.SetStatus(ActivityStatusCode.Ok);
return order;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
_logger.LogError(ex, "Failed to process order for user {UserId}", cmd.UserId);
throw;
}
}
}
```
---
## Prometheus & Grafana
### Docker Compose for Observability Stack
```yaml
# infra/observability/docker-compose.yml
version: "3.8"
services:
# ===================================
# PROMETHEUS
# ===================================
prometheus:
image: prom/prometheus:v2.47.0
container_name: prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--web.enable-lifecycle"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- ./prometheus/alerts:/etc/prometheus/alerts
- prometheus_data:/prometheus
ports:
- "9090:9090"
networks:
- goodgo-network
# ===================================
# GRAFANA
# ===================================
grafana:
image: grafana/grafana:10.1.0
container_name: grafana
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- ./grafana/dashboards:/var/lib/grafana/dashboards
- grafana_data:/var/lib/grafana
ports:
- "3000:3000"
networks:
- goodgo-network
depends_on:
- prometheus
- loki
# ===================================
# LOKI (Log aggregation)
# ===================================
loki:
image: grafana/loki:2.9.0
container_name: loki
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./loki/loki-config.yml:/etc/loki/loki-config.yml
- loki_data:/loki
ports:
- "3100:3100"
networks:
- goodgo-network
# ===================================
# TEMPO (Distributed tracing)
# ===================================
tempo:
image: grafana/tempo:2.2.0
container_name: tempo
command: -config.file=/etc/tempo/tempo-config.yml
volumes:
- ./tempo/tempo-config.yml:/etc/tempo/tempo-config.yml
- tempo_data:/var/tempo
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
networks:
- goodgo-network
volumes:
prometheus_data:
grafana_data:
loki_data:
tempo_data:
networks:
goodgo-network:
external: true
```
### Prometheus Configuration
```yaml
# infra/observability/prometheus/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: []
rule_files:
- /etc/prometheus/alerts/*.yml
scrape_configs:
# EN: Prometheus self-monitoring
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
# EN: GoodGo Services via Traefik
- job_name: "goodgo-services"
docker_sd_configs:
- host: unix:///var/run/docker.sock
filters:
- name: network
values: ["goodgo-network"]
relabel_configs:
- source_labels: [__meta_docker_container_name]
regex: /(.*)
target_label: container
- source_labels: [__meta_docker_container_label_com_docker_compose_service]
target_label: service
- source_labels: [__address__]
regex: (.+):.*
replacement: ${1}:8080
target_label: __address__
- source_labels: [__meta_docker_container_label_traefik_enable]
regex: "true"
action: keep
# EN: Traefik metrics
- job_name: "traefik"
static_configs:
- targets: ["traefik:8080"]
```
### Grafana Dashboard (JSON)
```json
{
"dashboard": {
"title": "GoodGo Services Overview",
"panels": [
{
"title": "Request Rate",
"type": "graph",
"targets": [
{
"expr": "rate(http_server_request_duration_seconds_count[5m])",
"legendFormat": "{{service}}"
}
]
},
{
"title": "Error Rate",
"type": "graph",
"targets": [
{
"expr": "rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[5m])",
"legendFormat": "{{service}} - 5xx"
}
]
},
{
"title": "Request Duration P99",
"type": "graph",
"targets": [
{
"expr": "histogram_quantile(0.99, rate(http_server_request_duration_seconds_bucket[5m]))",
"legendFormat": "{{service}}"
}
]
}
]
}
}
```
---
## Health Checks
### Comprehensive Health Check Configuration
```csharp
/// <summary>
/// EN: Configure all health checks.
/// VI: Cấu hình tất cả health checks.
/// </summary>
builder.Services.AddHealthChecks()
// EN: Database
.AddNpgSql(
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "postgresql",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "db", "ready", "critical" })
// EN: Redis
.AddRedis(
redisConnectionString: builder.Configuration["Redis:ConnectionString"]!,
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: new[] { "cache", "ready" })
// EN: External HTTP dependency
.AddUrlGroup(
new Uri(builder.Configuration["Services:Payment:HealthUrl"]!),
name: "payment-service",
failureStatus: HealthStatus.Degraded,
tags: new[] { "external", "ready" })
// EN: Disk space
.AddDiskStorageHealthCheck(
setup: options => options.AddDrive("/", 1024),
name: "disk-space",
failureStatus: HealthStatus.Degraded,
tags: new[] { "infrastructure" })
// EN: Memory
.AddProcessAllocatedMemoryHealthCheck(
maximumMegabytesAllocated: 500,
name: "memory",
tags: new[] { "infrastructure" });
// EN: Map endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false,
ResponseWriter = WriteMinimalResponse
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = hc => hc.Tags.Contains("ready"),
ResponseWriter = WriteDetailedResponse
});
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = WriteDetailedResponse
});
// EN: Health check response writers
static Task WriteMinimalResponse(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
return context.Response.WriteAsync(
JsonSerializer.Serialize(new { status = report.Status.ToString() }));
}
static Task WriteDetailedResponse(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString(),
totalDuration = report.TotalDuration.TotalMilliseconds,
entries = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
duration = e.Value.Duration.TotalMilliseconds,
description = e.Value.Description,
tags = e.Value.Tags,
data = e.Value.Data
})
};
return context.Response.WriteAsJsonAsync(result);
}
```
---
## Resources / Tài Nguyên
- [OpenTelemetry .NET](https://opentelemetry.io/docs/instrumentation/net/)
- [Serilog Documentation](https://serilog.net/)
- [Prometheus Documentation](https://prometheus.io/docs/)
- [Grafana Dashboards](https://grafana.com/grafana/dashboards/)
- [Loki Documentation](https://grafana.com/docs/loki/)

View File

@@ -1,7 +1,7 @@
---
name: project-rules
description: GoodGo Platform coding standards and architecture. Use when working with services, apps, packages, or infrastructure.
compatibility: ".NET 8+, ASP.NET Core, MediatR, Entity Framework Core"
compatibility: ".NET 10+, ASP.NET Core, MediatR, Entity Framework Core"
metadata:
author: Velik Ho
version: "2.0"

View File

@@ -1,7 +1,7 @@
---
name: repository-pattern
description: Entity Framework Core repository và data access patterns. Use for DbContext, repositories, migrations, aggregate roots, Unit of Work, và CQRS queries.
compatibility: ".NET 8+, Entity Framework Core 8+, Dapper"
compatibility: ".NET 10+, Entity Framework Core 8+, Dapper"
metadata:
author: Velik Ho
version: "1.0"

View File

@@ -1,7 +1,7 @@
---
name: security
description: Security patterns for GoodGo platform. Use for authentication, authorization, data protection, input validation, rate limiting, or secrets management.
compatibility: ".NET 8+, ASP.NET Core Identity, Duende IdentityServer"
compatibility: ".NET 10+, ASP.NET Core Identity, Duende IdentityServer"
metadata:
author: Velik Ho
version: "2.0"

View File

@@ -1,7 +1,7 @@
---
name: testing-patterns
description: Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers.
compatibility: ".NET 8+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost"
compatibility: ".NET 10+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost"
metadata:
author: Velik Ho
version: "1.0"