diff --git a/services/_template_dot_net/.env.example b/services/_template_dot_net/.env.example index d01da75f..f9053bc3 100644 --- a/services/_template_dot_net/.env.example +++ b/services/_template_dot_net/.env.example @@ -1,108 +1,40 @@ -# Environment Variables / Biến Môi Trường - -# EN: Copy this file to .env and fill in the values -# VI: Sao chép file này sang .env và điền các giá trị - -# ============================================ -# Application Settings / Cài Đặt Ứng Dụng -# ============================================ - -# EN: Environment name (Development, Staging, Production) -# VI: Tên môi trường (Development, Staging, Production) +# Environment / Môi Trường ASPNETCORE_ENVIRONMENT=Development -# EN: Service port (default: 8080) -# VI: Cổng service (mặc định: 8080) -PORT=8080 - -# EN: Service name -# VI: Tên service -SERVICE_NAME=your-service-name - -# ============================================ # Database / Cơ Sở Dữ Liệu -# ============================================ +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres -# EN: PostgreSQL connection string -# VI: Chuỗi kết nối PostgreSQL -DATABASE_URL=Host=localhost;Port=5432;Database=your_db;Username=postgres;Password=postgres - -# ============================================ -# Redis Cache / Redis Cache -# ============================================ - -# EN: Redis connection string (optional) -# VI: Chuỗi kết nối Redis (tùy chọn) +# Redis Cache REDIS_URL=localhost:6379 +REDIS_PASSWORD= -# ============================================ -# Authentication / Xác Thực -# ============================================ - -# EN: JWT secret key (minimum 32 characters) -# VI: Khóa bí mật JWT (tối thiểu 32 ký tự) -JWT_SECRET=your-secret-key-must-be-at-least-32-characters-long - -# EN: JWT issuer -# VI: Nhà phát hành JWT +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here JWT_ISSUER=goodgo-platform - -# EN: JWT audience -# VI: Đối tượng JWT JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 -# EN: Access token expiration (in minutes) -# VI: Thời gian hết hạn access token (phút) -JWT_ACCESS_TOKEN_EXPIRATION=15 +# API Configuration / Cấu Hình API +API_PORT=5000 +API_BASE_PATH=/api/v1/myservice -# EN: Refresh token expiration (in days) -# VI: Thời gian hết hạn refresh token (ngày) -JWT_REFRESH_TOKEN_EXPIRATION=7 +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=myservice -# ============================================ -# External Services / Dịch Vụ Bên Ngoài -# ============================================ - -# EN: API Gateway URL -# VI: URL API Gateway -API_GATEWAY_URL=http://localhost - -# EN: Internal service authentication key -# VI: Khóa xác thực service nội bộ -INTERNAL_API_KEY=your-internal-api-key - -# ============================================ -# Observability / Giám Sát -# ============================================ - -# EN: Log level (Trace, Debug, Information, Warning, Error, Critical) -# VI: Mức log (Trace, Debug, Information, Warning, Error, Critical) +# Logging LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 -# EN: Enable detailed errors (true in development only) -# VI: Bật lỗi chi tiết (chỉ true trong development) -DETAILED_ERRORS=true +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true -# EN: OpenTelemetry endpoint (optional) -# VI: Endpoint OpenTelemetry (tùy chọn) -OTEL_EXPORTER_ENDPOINT=http://localhost:4317 +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 -# ============================================ -# CORS Settings / Cài Đặt CORS -# ============================================ - -# EN: Allowed origins (comma-separated) -# VI: Origins được phép (phân cách bởi dấu phẩy) -CORS_ORIGINS=http://localhost:3000,http://localhost:5173 - -# ============================================ -# Rate Limiting / Giới Hạn Tốc Độ -# ============================================ - -# EN: Enable rate limiting -# VI: Bật giới hạn tốc độ -RATE_LIMIT_ENABLED=true - -# EN: Requests per minute -# VI: Số requests mỗi phút -RATE_LIMIT_REQUESTS_PER_MINUTE=100 +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 diff --git a/services/_template_dot_net/.gitignore b/services/_template_dot_net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/_template_dot_net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/_template_dot_net/ARCHITECTURE.md b/services/_template_dot_net/ARCHITECTURE.md index 3fbb8378..c07bae76 100644 --- a/services/_template_dot_net/ARCHITECTURE.md +++ b/services/_template_dot_net/ARCHITECTURE.md @@ -1,274 +1,288 @@ -# .NET Service Architecture / Kiến Trúc Dịch Vụ .NET +# Architecture Documentation / Tài Liệu Kiến Trúc -> **EN**: Comprehensive architecture documentation for .NET microservices -> **VI**: Tài liệu kiến trúc toàn diện cho microservices .NET +> **EN**: Detailed architecture documentation for the .NET 10 Microservice Template. +> **VI**: Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10. -## Table of Contents / Mục Lục +## Architecture Overview / Tổng Quan Kiến Trúc -- [Clean Architecture Overview](#clean-architecture-overview) -- [Layer Responsibilities](#layer-responsibilities) -- [Project Structure](#project-structure) -- [Dependency Flow](#dependency-flow) -- [Design Patterns](#design-patterns) -- [Best Practices](#best-practices) - -## Clean Architecture Overview - -**EN**: This template implements Clean Architecture (also known as Onion Architecture or Hexagonal Architecture) to achieve: -- **Independence of Frameworks**: Business logic doesn't depend on frameworks -- **Testability**: Business rules can be tested without UI, database, or external services -- **Independence of UI**: UI can change without changing business rules -- **Independence of Database**: Business rules are not bound to database -- **Independence of External Services**: Business rules don't know about external services - -**VI**: Template này triển khai Clean Architecture (còn gọi là Onion Architecture hoặc Hexagonal Architecture) để đạt được: -- **Độc lập với Frameworks**: Business logic không phụ thuộc vào frameworks -- **Khả năng kiểm thử**: Business rules có thể được test mà không cần UI, database hoặc external services -- **Độc lập với UI**: UI có thể thay đổi mà không ảnh hưởng business rules -- **Độc lập với Database**: Business rules không bị ràng buộc với database -- **Độc lập với External Services**: Business rules không biết về external services +```mermaid +graph TB + subgraph "API Layer / Lớp API" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Domain Layer / Lớp Domain" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Infrastructure Layer / Lớp Infrastructure" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` ## Layer Responsibilities / Trách Nhiệm Các Lớp -### 1. Domain Layer (Core) / Lớp Domain (Lõi) +### 1. Domain Layer (MyService.Domain) -**EN**: The innermost layer containing enterprise business rules. +**EN**: The heart of the application containing pure business logic. This layer: +- Has **ZERO** external dependencies (except MediatR.Contracts for events) +- Contains only POCO classes +- Implements DDD tactical patterns -**VI**: Lớp trong cùng chứa các business rules của doanh nghiệp. +**VI**: Trái tim của ứng dụng chứa business logic thuần túy. Lớp này: +- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events) +- Chỉ chứa các class POCO +- Triển khai các tactical patterns của DDD -**Contains / Chứa:** -- Entities / Thực thể -- Value Objects -- Domain Events -- Repository Interfaces -- Domain Services Interfaces +#### Components / Thành Phần -**No dependencies / Không phụ thuộc**: This layer has no dependencies on other layers. +| Component | Purpose / Mục Đích | +|-----------|-------------------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots with their entities and value objects | +| **Events** | Domain events for cross-aggregate communication | +| **Exceptions** | Domain-specific exceptions for business rule violations | -### 2. Application Layer / Lớp Application +### 2. Infrastructure Layer (MyService.Infrastructure) -**EN**: Contains application-specific business rules and orchestrates workflows. +**EN**: Technical implementations and external concerns: +- Database access (EF Core) +- Repository implementations +- External service integrations -**VI**: Chứa các business rules cụ thể của ứng dụng và điều phối workflows. +**VI**: Triển khai kỹ thuật và các mối quan tâm bên ngoài: +- Truy cập database (EF Core) +- Triển khai repositories +- Tích hợp external services -**Contains / Chứa:** -- DTOs (Data Transfer Objects) -- Service Interfaces -- Service Implementations -- Validators (FluentValidation) -- Mappers (AutoMapper) +### 3. API Layer (MyService.API) -**Dependencies / Phụ thuộc**: Domain layer only +**EN**: Application entry point and CQRS implementation: +- Controllers for HTTP handling +- Commands for write operations +- Queries for read operations +- MediatR behaviors for cross-cutting concerns -### 3. Infrastructure Layer / Lớp Infrastructure +**VI**: Điểm vào ứng dụng và triển khai CQRS: +- Controllers để xử lý HTTP +- Commands cho các thao tác ghi +- Queries cho các thao tác đọc +- MediatR behaviors cho cross-cutting concerns -**EN**: Contains implementation details for external concerns. +## CQRS Flow / Luồng CQRS -**VI**: Chứa implementation details cho các concerns bên ngoài. - -**Contains / Chứa:** -- DbContext (Entity Framework Core) -- Repository Implementations -- External Service Clients -- Cache Implementations -- File Storage Implementations - -**Dependencies / Phụ thuộc**: Domain layer - -### 4. API Layer (Presentation) / Lớp API (Presentation) - -**EN**: Contains all the entry points to the application. - -**VI**: Chứa tất cả các điểm vào của ứng dụng. - -**Contains / Chứa:** -- Controllers -- Middleware -- Filters -- Configuration -- Startup/Program.cs - -**Dependencies / Phụ thuộc**: Application and Infrastructure layers - -## Dependency Flow / Luồng Phụ Thuộc - -``` -┌─────────────────────────────────────────┐ -│ API Layer (Presentation) │ -│ Controllers, Middleware, Config │ -└───────────────┬─────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ Application Layer │ -│ Services, DTOs, Validators │ -└──────────┬──────────────────────┬────────┘ - │ │ - ▼ ▼ -┌──────────────────┐ ┌─────────────────────┐ -│ Domain Layer │ │ Infrastructure │ -│ Entities, │◄──│ DbContext, Repos, │ -│ Interfaces │ │ External Services │ -└──────────────────┘ └─────────────────────┘ -``` - -**EN**: All dependencies point inward. The Domain layer has no dependencies on any other layer. - -**VI**: Tất cả dependencies đều hướng vào trong. Lớp Domain không phụ thuộc vào bất kỳ lớp nào khác. - -## Design Patterns / Mẫu Thiết Kế - -### Repository Pattern - -**EN**: Abstracts data access logic and provides a collection-like interface for accessing domain objects. - -**VI**: Trừu tượng hóa logic truy cập dữ liệu và cung cấp interface giống collection để truy cập domain objects. - -```csharp -// Domain Layer - Interface -public interface IUserRepository -{ - Task GetByIdAsync(Guid id); - Task> GetAllAsync(); - Task AddAsync(User user); - Task UpdateAsync(User user); - Task DeleteAsync(Guid id); -} - -// Infrastructure Layer - Implementation -public class UserRepository : IUserRepository -{ - private readonly ApplicationDbContext _context; +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext - public UserRepository(ApplicationDbContext context) - { - _context = context; + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events / Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Raises| DE[Domain Event] + DE -->|Dispatched by| CTX[DbContext] + CTX -->|Publishes to| M[MediatR] + M -->|Handled by| H1[Handler 1] + M -->|Handled by| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Database Schema / Schema Database + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at } - public async Task GetByIdAsync(Guid id) - { - return await _context.Users.FindAsync(id); - } - // ... other implementations -} -``` - -### Service Pattern - -**EN**: Encapsulates business logic and orchestrates operations across repositories. - -**VI**: Đóng gói business logic và điều phối các operations giữa các repositories. - -```csharp -// Application Layer -public interface IUserService -{ - Task GetUserAsync(Guid id); - Task CreateUserAsync(CreateUserDto dto); -} - -public class UserService : IUserService -{ - private readonly IUserRepository _userRepository; - private readonly IMapper _mapper; - - public UserService(IUserRepository userRepository, IMapper mapper) - { - _userRepository = userRepository; - _mapper = mapper; + sample_statuses { + int id PK + varchar(50) name } - public async Task GetUserAsync(Guid id) - { - var user = await _userRepository.GetByIdAsync(id); - if (user == null) throw new NotFoundException("User not found"); - - return _mapper.Map(user); - } -} + samples ||--o{ sample_statuses : has ``` -### Unit of Work Pattern +## MediatR Pipeline / Pipeline MediatR -**EN**: Maintains a list of objects affected by a business transaction and coordinates writing changes. - -**VI**: Duy trì danh sách các objects bị ảnh hưởng bởi một business transaction và điều phối việc ghi thay đổi. - -### CQRS (Command Query Responsibility Segregation) - -**EN**: Optional pattern for separating read and write operations. - -**VI**: Pattern tùy chọn để tách biệt các operations đọc và ghi. - -## Best Practices / Thực Hành Tốt - -### 1. Dependency Injection - -```csharp -// EN: Register services in Program.cs -// VI: Đăng ký services trong Program.cs -builder.Services.AddScoped(); -builder.Services.AddScoped(); +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing with Transaction + FluentValidation ``` -### 2. Async/Await +### Behavior Order / Thứ Tự Behaviors -```csharp -// EN: Always use async for I/O operations -// VI: Luôn dùng async cho I/O operations -public async Task GetUserAsync(Guid id) +1. **LoggingBehavior** - Logs request handling with timing +2. **ValidatorBehavior** - Validates request using FluentValidation +3. **TransactionBehavior** - Wraps command handlers in database transactions + +## Error Handling / Xử Lý Lỗi + +### Exception Hierarchy / Phân Cấp Exceptions + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +All errors are returned in Problem Details format: + +```json { - return await _context.Users.FindAsync(id); + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Validation Error", + "status": 400, + "detail": "One or more validation errors occurred.", + "errors": { + "Name": ["Name is required"] + } } ``` -### 3. Error Handling +## Health Checks / Health Checks -```csharp -// EN: Create custom exceptions -// VI: Tạo custom exceptions -public class NotFoundException : Exception -{ - public NotFoundException(string message) : base(message) { } -} - -// EN: Global exception handler middleware -// VI: Middleware xử lý exception toàn cục -public class ExceptionMiddleware -{ - // ... implementation -} +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff ``` -### 4. Validation +## Deployment Architecture / Kiến Trúc Deployment -```csharp -// EN: Use FluentValidation for input validation -// VI: Dùng FluentValidation để validate input -public class CreateUserDtoValidator : AbstractValidator -{ - public CreateUserDtoValidator() - { - RuleFor(x => x.Email).NotEmpty().EmailAddress(); - RuleFor(x => x.Password).MinimumLength(8); - } -} +### Docker Compose (Local) + +```yaml +services: + myservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine ``` -## Testing Strategy / Chiến Lược Testing +### Kubernetes (Production) -**EN**: -- **Unit Tests**: Test business logic in isolation -- **Integration Tests**: Test layer interactions -- **E2E Tests**: Test complete workflows +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` -**VI**: -- **Unit Tests**: Test business logic độc lập -- **Integration Tests**: Test tương tác giữa các lớp -- **E2E Tests**: Test toàn bộ workflows +## Security Considerations / Cân Nhắc Bảo Mật -## Resources / Tài Nguyên +1. **Authentication**: JWT Bearer token (configure in production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation on all requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Environment variables, never in code -- [Clean Architecture by Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) -- [.NET Microservices Architecture](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) -- [Domain-Driven Design](https://martinfowler.com/tags/domain%20driven%20design.html) +## Performance Optimization / Tối Ưu Hiệu Năng + +1. **Connection Pooling**: EF Core with Npgsql connection resilience +2. **Async/Await**: All I/O operations are async +3. **Response Caching**: Add caching headers for queries +4. **Database Indexes**: Configure in EntityConfigurations + +## References / Tài Liệu Tham Khảo + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/_template_dot_net/Directory.Build.props b/services/_template_dot_net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/_template_dot_net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/_template_dot_net/Dockerfile b/services/_template_dot_net/Dockerfile index 11a13056..192106ab 100644 --- a/services/_template_dot_net/Dockerfile +++ b/services/_template_dot_net/Dockerfile @@ -2,28 +2,29 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -# EN: Copy project files -# VI: Sao chép các file project -COPY ["src/YourServiceName.Api/YourServiceName.Api.csproj", "src/YourServiceName.Api/"] -COPY ["src/YourServiceName.Domain/YourServiceName.Domain.csproj", "src/YourServiceName.Domain/"] -COPY ["src/YourServiceName.Infrastructure/YourServiceName.Infrastructure.csproj", "src/YourServiceName.Infrastructure/"] +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/MyService.API/MyService.API.csproj", "src/MyService.API/"] +COPY ["src/MyService.Domain/MyService.Domain.csproj", "src/MyService.Domain/"] +COPY ["src/MyService.Infrastructure/MyService.Infrastructure.csproj", "src/MyService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] # EN: Restore dependencies # VI: Khôi phục dependencies -RUN dotnet restore "src/YourServiceName.Api/YourServiceName.Api.csproj" +RUN dotnet restore "src/MyService.API/MyService.API.csproj" # EN: Copy all source code # VI: Sao chép toàn bộ source code -COPY . . +COPY src/ ./src/ # EN: Build the application # VI: Build ứng dụng -WORKDIR "/src/src/YourServiceName.Api" -RUN dotnet build "YourServiceName.Api.csproj" -c Release -o /app/build +WORKDIR "/src/src/MyService.API" +RUN dotnet build "MyService.API.csproj" -c Release -o /app/build --no-restore # Publish stage / Giai đoạn publish FROM build AS publish -RUN dotnet publish "YourServiceName.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false +RUN dotnet publish "MyService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore # Runtime stage / Giai đoạn runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final @@ -31,8 +32,8 @@ WORKDIR /app # EN: Create non-root user for security # VI: Tạo user non-root cho bảo mật -RUN addgroup --gid 1001 --system dotnetuser && \ - adduser --uid 1001 --system --ingroup dotnetuser dotnetuser +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser # EN: Copy published application # VI: Sao chép ứng dụng đã publish @@ -53,12 +54,13 @@ EXPOSE 8080 # EN: Set environment variables # VI: Thiết lập biến môi trường ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production # EN: Health check # VI: Kiểm tra health -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8080/health/live || exit 1 # EN: Start the application # VI: Khởi động ứng dụng -ENTRYPOINT ["dotnet", "YourServiceName.Api.dll"] +ENTRYPOINT ["dotnet", "MyService.API.dll"] diff --git a/services/_template_dot_net/MyService.slnx b/services/_template_dot_net/MyService.slnx new file mode 100644 index 00000000..1222dbb8 --- /dev/null +++ b/services/_template_dot_net/MyService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/_template_dot_net/README.md b/services/_template_dot_net/README.md index f58697b4..820c9d9c 100644 --- a/services/_template_dot_net/README.md +++ b/services/_template_dot_net/README.md @@ -1,40 +1,45 @@ -# .NET Service Template / Template Dịch Vụ .NET +# .NET 10 Microservice Template / Template Microservice .NET 10 -> **EN**: Template for creating new .NET microservices in the GoodGo platform -> **VI**: Template để tạo microservices .NET mới trong nền tảng GoodGo +> **EN**: Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns. +> **VI**: Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. ## Overview / Tổng Quan -**EN**: This template provides a standardized structure for .NET microservices with: -- ASP.NET Core Web API -- Entity Framework Core -- Clean Architecture principles -- Health checks and observability -- Docker support -- Authentication and authorization +**EN**: This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with: -**VI**: Template này cung cấp cấu trúc chuẩn hóa cho các microservices .NET với: -- ASP.NET Core 10 Web API -- Entity Framework Core 10 (PostgreSQL / Neon) -- **Neon Database Integration** (Connection Resilience) -- **Redis Cache** (StackExchange.Redis) -- Nguyên tắc Clean Architecture -- **CQRS với MediatR** -- **Resilience với Polly** -- **Global Exception Handling (RFC 7807)** -- **API Versioning** -- **Audit Logging** -- Health checks và observability -- Hỗ trợ Docker -- Xác thực và phân quyền -- Performance improvements của .NET 10 +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR +- **Clean Architecture** - Domain, Infrastructure, API layered separation +- **EF Core 10** - PostgreSQL with connection resilience +- **FluentValidation** - Request validation +- **API Versioning** - URL segment versioning +- **Health Checks** - Kubernetes-ready probes +- **Structured Logging** - Serilog with console and Seq + +**VI**: Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR +- **Clean Architecture** - Phân tầng Domain, Infrastructure, API +- **EF Core 10** - PostgreSQL với connection resilience +- **FluentValidation** - Validation request +- **API Versioning** - Versioning theo URL segment +- **Health Checks** - Probes sẵn sàng cho Kubernetes +- **Structured Logging** - Serilog với console và Seq ## Prerequisites / Yêu Cầu -- .NET 10.0 SDK or later / .NET 10.0 SDK trở lên -- Docker & Docker Compose -- PostgreSQL (via Neon or local) -- Redis (optional, for caching) +| Requirement | Version | +|-------------|---------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (or use Docker) | + +```bash +# Check .NET version / Kiểm tra phiên bản .NET +dotnet --version +# Should output: 10.0.xxx +``` ## Quick Start / Bắt Đầu Nhanh @@ -48,99 +53,163 @@ cp -r services/_template_dot_net services/your-service-name # EN: Navigate to service directory # VI: Di chuyển đến thư mục service cd services/your-service-name + +# EN: Rename all occurrences of "MyService" to "YourService" +# VI: Đổi tên tất cả "MyService" thành "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} + ``` -### 2. Configure Service / Cấu Hình Service - -**EN**: Update the following files: -- `YourServiceName.csproj` - Project name and namespace -- `appsettings.json` - Configuration settings -- `Dockerfile` - Service-specific settings - -**VI**: Cập nhật các file sau: -- `YourServiceName.csproj` - Tên project và namespace -- `appsettings.json` - Cài đặt cấu hình -- `Dockerfile` - Cài đặt cụ thể cho service - -### 3. Environment Variables / Biến Môi Trường - -Create `.env` file from `.env.example`: +### 2. Configure Environment / Cấu Hình Môi Trường ```bash +# EN: Copy environment template +# VI: Sao chép template môi trường cp .env.example .env + +# EN: Edit with your configuration +# VI: Chỉnh sửa với cấu hình của bạn +nano .env ``` -**Required variables / Biến bắt buộc:** +### 3. Run with Docker / Chạy với Docker -| Variable | Description / Mô Tả | Example | -|----------|---------------------|---------| -| `ASPNETCORE_ENVIRONMENT` | Environment (Development/Production) / Môi trường | `Development` | -| `DATABASE_URL` | PostgreSQL connection string / Chuỗi kết nối PostgreSQL | `Host=localhost;Database=mydb;Username=user;Password=pass` | -| `REDIS_URL` | Redis connection string / Chuỗi kết nối Redis | `localhost:6379` | -| `JWT_SECRET` | JWT signing secret / Secret ký JWT | `your-secret-key-min-32-chars` | +```bash +# EN: Start all services (API + PostgreSQL + Redis) +# VI: Khởi động tất cả services (API + PostgreSQL + Redis) +docker-compose up -d -## Project Structure / Cấu Trúc Dự Án - -``` -services/your-service-name/ -├── src/ -│ ├── YourServiceName.Api/ # Web API layer / Lớp Web API -│ │ ├── Controllers/ # API controllers / Controllers API -│ │ ├── Middleware/ # Custom middleware -│ │ ├── Program.cs # Entry point / Điểm khởi đầu -│ │ └── appsettings.json # Configuration / Cấu hình -│ │ -│ ├── YourServiceName.Application/ # Application layer / Lớp ứng dụng -│ │ ├── DTOs/ # Data Transfer Objects -│ │ ├── Services/ # Business logic services -│ │ └── Interfaces/ # Service interfaces -│ │ -│ ├── YourServiceName.Domain/ # Domain layer / Lớp domain -│ │ ├── Entities/ # Domain entities / Thực thể domain -│ │ └── Interfaces/ # Repository interfaces -│ │ -│ └── YourServiceName.Infrastructure/ # Infrastructure layer / Lớp hạ tầng -│ ├── Data/ # DbContext and migrations -│ ├── Repositories/ # Repository implementations -│ └── Services/ # External service clients -│ -├── tests/ -│ ├── YourServiceName.UnitTests/ # Unit tests -│ └── YourServiceName.IntegrationTests/ # Integration tests -│ -├── Dockerfile # Docker configuration -├── .dockerignore -└── README.md +# EN: View logs +# VI: Xem logs +docker-compose logs -f myservice-api ``` -## Development / Phát Triển - -### Run Locally / Chạy Local +### 4. Run Locally / Chạy Local ```bash # EN: Restore dependencies # VI: Khôi phục dependencies dotnet restore -# EN: Run migrations -# VI: Chạy migrations -dotnet ef database update --project src/YourServiceName.Infrastructure +# EN: Build all projects +# VI: Build tất cả projects +dotnet build -# EN: Start service -# VI: Khởi động service -dotnet run --project src/YourServiceName.Api +# EN: Run the API +# VI: Chạy API +dotnet run --project src/MyService.API ``` -### Run with Docker / Chạy với Docker +## Project Structure / Cấu Trúc Dự Án -```bash -# EN: Build Docker image -# VI: Build Docker image -docker build -t your-service-name . +``` +_template_dot_net/ +├── src/ +│ ├── MyService.API/ # Presentation Layer (Controllers, CQRS) +│ │ ├── Controllers/ # API endpoints +│ │ ├── Application/ # CQRS Implementation +│ │ │ ├── Commands/ # Write operations (MediatR) +│ │ │ ├── Queries/ # Read operations +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Application entry point +│ │ +│ ├── MyService.Domain/ # Domain Layer (Pure business logic) +│ │ ├── AggregatesModel/ # Aggregate roots and entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── MyService.Infrastructure/ # Infrastructure Layer (Data access) +│ ├── EntityConfigurations/ # EF Core Fluent API configurations +│ ├── Repositories/ # Repository implementations +│ ├── Idempotency/ # Request idempotency handling +│ └── MyServiceContext.cs # DbContext with Unit of Work +│ +├── tests/ +│ ├── MyService.UnitTests/ # Unit tests (Domain, Application) +│ └── MyService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Local development setup +├── global.json # .NET SDK version pinning +└── Directory.Build.props # Common MSBuild properties +``` -# EN: Run container -# VI: Chạy container -docker run -p 5000:8080 --env-file .env your-service-name +## API Endpoints / Các Endpoint API + +| Method | Endpoint | Description / Mô Tả | +|--------|----------|---------------------| +| `GET` | `/api/v1/samples` | Get all samples / Lấy tất cả samples | +| `GET` | `/api/v1/samples/{id}` | Get sample by ID / Lấy sample theo ID | +| `POST` | `/api/v1/samples` | Create new sample / Tạo sample mới | +| `PUT` | `/api/v1/samples/{id}` | Update sample / Cập nhật sample | +| `DELETE` | `/api/v1/samples/{id}` | Delete sample / Xóa sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Change status / Thay đổi trạng thái | + +### Health Endpoints + +| Endpoint | Purpose / Mục Đích | +|----------|-------------------| +| `/health` | Full health status / Trạng thái health đầy đủ | +| `/health/live` | Liveness probe / Kiểm tra sống | +| `/health/ready` | Readiness probe / Kiểm tra sẵn sàng | + +## CQRS Pattern / Pattern CQRS + +### Commands (Write Operations) + +```csharp +// Define command / Định nghĩa command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Handle command / Xử lý command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Read Operations) + +```csharp +// Define query / Định nghĩa query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model / Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Business logic validation + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + // State transition + } +} ``` ## Testing / Kiểm Thử @@ -152,75 +221,71 @@ dotnet test # EN: Run with coverage # VI: Chạy với coverage -dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=opencover +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura # EN: Run specific test project # VI: Chạy project test cụ thể -dotnet test tests/YourServiceName.UnitTests +dotnet test tests/MyService.UnitTests ``` -## API Documentation / Tài Liệu API +## Configuration / Cấu Hình -**EN**: The service automatically generates Swagger/OpenAPI documentation available at: -- Development: `http://localhost:5000/swagger` -- Production: `https://api.goodgo.com/your-service/swagger` +### Environment Variables -**VI**: Service tự động tạo tài liệu Swagger/OpenAPI tại: -- Development: `http://localhost:5000/swagger` -- Production: `https://api.goodgo.com/your-service/swagger` +| Variable | Description / Mô Tả | Default | +|----------|---------------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` | +| `DATABASE_URL` | PostgreSQL connection string | - | +| `REDIS_URL` | Redis connection string | - | +| `JWT_SECRET` | JWT signing secret (min 32 chars) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Deployment / Triển Khai + +### Docker Build + +```bash +# EN: Build Docker image +# VI: Build Docker image +docker build -t myservice:latest . + +# EN: Run container +# VI: Chạy container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. ## What's New in .NET 10 / Có Gì Mới Trong .NET 10 -**EN**: This template leverages new .NET 10 features: -- Improved performance and reduced memory allocation -- Enhanced Native AOT support -- Better async/await performance -- Updated C# 13 language features -- Improved JSON serialization performance - -**VI**: Template này tận dụng các tính năng mới của .NET 10: -- Cải thiện hiệu năng và giảm memory allocation -- Hỗ trợ Native AOT tốt hơn -- Hiệu năng async/await được cải thiện -- Tính năng ngôn ngữ C# 13 mới -- Hiệu năng JSON serialization được cải thiện - -## Health Checks - -| Endpoint | Description / Mô Tả | -|----------|---------------------| -| `/health` | Overall health status / Trạng thái tổng thể | -| `/health/live` | Liveness probe / Kiểm tra sống | -| `/health/ready` | Readiness probe / Kiểm tra sẵn sàng | - -## Architecture Patterns / Mẫu Kiến Trúc - -**EN**: This template follows Clean Architecture principles: -1. **API Layer**: Controllers, middleware, configuration -2. **Application Layer**: Business logic, DTOs, services -3. **Domain Layer**: Entities, interfaces, domain logic -4. **Infrastructure Layer**: Data access, external services - -**VI**: Template này tuân theo nguyên tắc Clean Architecture: -1. **Lớp API**: Controllers, middleware, cấu hình -2. **Lớp Application**: Business logic, DTOs, services -3. **Lớp Domain**: Entities, interfaces, domain logic -4. **Lớp Infrastructure**: Truy cập dữ liệu, external services - -## Best Practices / Thực Hành Tốt - -1. **Dependency Injection**: Use built-in DI container / Sử dụng DI container có sẵn -2. **Async/Await**: Use async methods for I/O operations / Dùng async cho I/O -3. **Logging**: Use ILogger for structured logging / Dùng ILogger -4. **Validation**: Use FluentValidation for input validation / Dùng FluentValidation -5. **Error Handling**: Implement global exception middleware / Middleware xử lý lỗi toàn cục +- **C# 14** language features +- Improved **Native AOT** support +- Better **async/await** performance +- Enhanced **JSON serialization** +- Performance improvements across the board +- 3-year **LTS** support (until November 2028) ## Resources / Tài Nguyên -- [ASP.NET Core Documentation](https://docs.microsoft.com/en-us/aspnet/core/) -- [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) -- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) -- [GoodGo Platform Documentation](../../docs/README.md) +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture +- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - CQRS library +- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library ## License / Giấy Phép diff --git a/services/_template_dot_net/appsettings.Development.json b/services/_template_dot_net/appsettings.Development.json deleted file mode 100644 index af1ddc27..00000000 --- a/services/_template_dot_net/appsettings.Development.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "Microsoft.AspNetCore": "Debug", - "Microsoft.EntityFrameworkCore": "Information" - } - }, - "DetailedErrors": true -} \ No newline at end of file diff --git a/services/_template_dot_net/appsettings.json b/services/_template_dot_net/appsettings.json deleted file mode 100644 index 4537a956..00000000 --- a/services/_template_dot_net/appsettings.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "DefaultConnection": "${DATABASE_URL}" - }, - "Redis": { - "ConnectionString": "${REDIS_URL}", - "InstanceName": "YourServiceName:" - }, - "JWT": { - "Secret": "${JWT_SECRET}", - "Issuer": "${JWT_ISSUER}", - "Audience": "${JWT_AUDIENCE}", - "AccessTokenExpirationMinutes": 15, - "RefreshTokenExpirationDays": 7 - }, - "Cors": { - "AllowedOrigins": [ - "http://localhost:3000", - "http://localhost:5173" - ] - }, - "RateLimit": { - "Enabled": true, - "RequestsPerMinute": 100, - "StrictRequestsPerHour": 10 - }, - "OpenTelemetry": { - "ServiceName": "YourServiceName", - "Endpoint": "${OTEL_EXPORTER_ENDPOINT}" - }, - "HealthChecks": { - "UI": { - "Enabled": true - } - } -} \ No newline at end of file diff --git a/services/_template_dot_net/docker-compose.yml b/services/_template_dot_net/docker-compose.yml new file mode 100644 index 00000000..254ceb12 --- /dev/null +++ b/services/_template_dot_net/docker-compose.yml @@ -0,0 +1,72 @@ +version: '3.8' + +# EN: Docker Compose for local development +# VI: Docker Compose cho phát triển local + +services: + myservice-api: + build: + context: . + dockerfile: Dockerfile + container_name: myservice-api + ports: + - "5000:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + - REDIS_URL=redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - myservice-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: myservice-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: myservice_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - myservice-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: myservice-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - myservice-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + +networks: + myservice-network: + driver: bridge diff --git a/services/_template_dot_net/global.json b/services/_template_dot_net/global.json index 228e1c40..f78eeaf4 100644 --- a/services/_template_dot_net/global.json +++ b/services/_template_dot_net/global.json @@ -1,5 +1,7 @@ -Microsoft.NET.Test.Sdk -xunit -xunit.runner.visualstudio -Moq -FluentAssertions +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/_template_dot_net/src/MyService.API/Application/Behaviors/LoggingBehavior.cs b/services/_template_dot_net/src/MyService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..a724424d --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace MyService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Behaviors/TransactionBehavior.cs b/services/_template_dot_net/src/MyService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..8675b649 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MyService.Infrastructure; + +namespace MyService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly MyServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + MyServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + _dbContext.RollbackTransaction(); + throw; + } + }); + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Behaviors/ValidatorBehavior.cs b/services/_template_dot_net/src/MyService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..0062cd60 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace MyService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Commands/ChangeSampleStatusCommand.cs b/services/_template_dot_net/src/MyService.API/Application/Commands/ChangeSampleStatusCommand.cs new file mode 100644 index 00000000..49825490 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Commands/ChangeSampleStatusCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace MyService.API.Application.Commands; + +/// +/// EN: Command to change status of a Sample. +/// VI: Command để thay đổi trạng thái của Sample. +/// +/// EN: Sample ID / VI: ID sample +/// EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel) +public record ChangeSampleStatusCommand( + Guid SampleId, + string NewStatus +) : IRequest; diff --git a/services/_template_dot_net/src/MyService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs b/services/_template_dot_net/src/MyService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs new file mode 100644 index 00000000..76e31030 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs @@ -0,0 +1,70 @@ +using MediatR; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.API.Application.Commands; + +/// +/// EN: Handler for ChangeSampleStatusCommand. +/// VI: Handler cho ChangeSampleStatusCommand. +/// +public class ChangeSampleStatusCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public ChangeSampleStatusCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + ChangeSampleStatusCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}", + request.SampleId, request.NewStatus); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action + switch (request.NewStatus.ToLowerInvariant()) + { + case "activate": + sample.Activate(); + break; + case "complete": + sample.Complete(); + break; + case "cancel": + sample.Cancel(); + break; + default: + _logger.LogWarning( + "Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}", + request.NewStatus); + return false; + } + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}", + request.SampleId, request.NewStatus); + + return true; + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Commands/CreateSampleCommand.cs b/services/_template_dot_net/src/MyService.API/Application/Commands/CreateSampleCommand.cs new file mode 100644 index 00000000..138cc794 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Commands/CreateSampleCommand.cs @@ -0,0 +1,21 @@ +using MediatR; + +namespace MyService.API.Application.Commands; + +/// +/// EN: Command to create a new Sample. +/// VI: Command để tạo một Sample mới. +/// +/// EN: Sample name / VI: Tên sample +/// EN: Optional description / VI: Mô tả tùy chọn +public record CreateSampleCommand( + string Name, + string? Description +) : IRequest; + +/// +/// EN: Result of CreateSampleCommand. +/// VI: Kết quả của CreateSampleCommand. +/// +/// EN: Created sample ID / VI: ID sample đã tạo +public record CreateSampleCommandResult(Guid Id); diff --git a/services/_template_dot_net/src/MyService.API/Application/Commands/CreateSampleCommandHandler.cs b/services/_template_dot_net/src/MyService.API/Application/Commands/CreateSampleCommandHandler.cs new file mode 100644 index 00000000..d7d0fd7c --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Commands/CreateSampleCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.API.Application.Commands; + +/// +/// EN: Handler for CreateSampleCommand. +/// VI: Handler cho CreateSampleCommand. +/// +public class CreateSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public CreateSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + CreateSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}", + request.Name); + + // EN: Create domain entity / VI: Tạo domain entity + var sample = new Sample(request.Name, request.Description); + + // EN: Add to repository / VI: Thêm vào repository + _sampleRepository.Add(sample); + + // EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events) + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}", + sample.Id); + + return new CreateSampleCommandResult(sample.Id); + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Commands/DeleteSampleCommand.cs b/services/_template_dot_net/src/MyService.API/Application/Commands/DeleteSampleCommand.cs new file mode 100644 index 00000000..0de392db --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Commands/DeleteSampleCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace MyService.API.Application.Commands; + +/// +/// EN: Command to delete a Sample. +/// VI: Command để xóa một Sample. +/// +/// EN: Sample ID to delete / VI: ID sample cần xóa +public record DeleteSampleCommand(Guid SampleId) : IRequest; diff --git a/services/_template_dot_net/src/MyService.API/Application/Commands/DeleteSampleCommandHandler.cs b/services/_template_dot_net/src/MyService.API/Application/Commands/DeleteSampleCommandHandler.cs new file mode 100644 index 00000000..c7632189 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Commands/DeleteSampleCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.API.Application.Commands; + +/// +/// EN: Handler for DeleteSampleCommand. +/// VI: Handler cho DeleteSampleCommand. +/// +public class DeleteSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public DeleteSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + DeleteSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Deleting sample {SampleId} / Xóa sample {SampleId}", + request.SampleId); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Delete sample / VI: Xóa sample + _sampleRepository.Delete(sample); + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công", + request.SampleId); + + return true; + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Commands/UpdateSampleCommand.cs b/services/_template_dot_net/src/MyService.API/Application/Commands/UpdateSampleCommand.cs new file mode 100644 index 00000000..6fad8514 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Commands/UpdateSampleCommand.cs @@ -0,0 +1,16 @@ +using MediatR; + +namespace MyService.API.Application.Commands; + +/// +/// EN: Command to update an existing Sample. +/// VI: Command để cập nhật một Sample đã tồn tại. +/// +/// EN: Sample ID to update / VI: ID sample cần cập nhật +/// EN: New name / VI: Tên mới +/// EN: New description / VI: Mô tả mới +public record UpdateSampleCommand( + Guid SampleId, + string Name, + string? Description +) : IRequest; diff --git a/services/_template_dot_net/src/MyService.API/Application/Commands/UpdateSampleCommandHandler.cs b/services/_template_dot_net/src/MyService.API/Application/Commands/UpdateSampleCommandHandler.cs new file mode 100644 index 00000000..e904cf0a --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Commands/UpdateSampleCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.API.Application.Commands; + +/// +/// EN: Handler for UpdateSampleCommand. +/// VI: Handler cho UpdateSampleCommand. +/// +public class UpdateSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public UpdateSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + UpdateSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Updating sample {SampleId} / Cập nhật sample {SampleId}", + request.SampleId); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method + sample.Update(request.Name, request.Description); + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công", + request.SampleId); + + return true; + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Queries/GetSampleQuery.cs b/services/_template_dot_net/src/MyService.API/Application/Queries/GetSampleQuery.cs new file mode 100644 index 00000000..8b90789c --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Queries/GetSampleQuery.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace MyService.API.Application.Queries; + +/// +/// EN: Query to get a Sample by ID. +/// VI: Query để lấy một Sample theo ID. +/// +/// EN: Sample ID / VI: ID sample +public record GetSampleQuery(Guid SampleId) : IRequest; + +/// +/// EN: Sample view model for API responses. +/// VI: Sample view model cho API responses. +/// +public record SampleViewModel( + Guid Id, + string Name, + string? Description, + string Status, + DateTime CreatedAt, + DateTime? UpdatedAt +); diff --git a/services/_template_dot_net/src/MyService.API/Application/Queries/GetSampleQueryHandler.cs b/services/_template_dot_net/src/MyService.API/Application/Queries/GetSampleQueryHandler.cs new file mode 100644 index 00000000..2da10b6d --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Queries/GetSampleQueryHandler.cs @@ -0,0 +1,39 @@ +using MediatR; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.API.Application.Queries; + +/// +/// EN: Handler for GetSampleQuery. +/// VI: Handler cho GetSampleQuery. +/// +public class GetSampleQueryHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + + public GetSampleQueryHandler(ISampleRepository sampleRepository) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + } + + public async Task Handle( + GetSampleQuery request, + CancellationToken cancellationToken) + { + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + return null; + } + + return new SampleViewModel( + sample.Id, + sample.Name, + sample.Description, + sample.Status.Name, + sample.CreatedAt, + sample.UpdatedAt + ); + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Queries/GetSamplesQuery.cs b/services/_template_dot_net/src/MyService.API/Application/Queries/GetSamplesQuery.cs new file mode 100644 index 00000000..d6a98e34 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Queries/GetSamplesQuery.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace MyService.API.Application.Queries; + +/// +/// EN: Query to get all Samples. +/// VI: Query để lấy tất cả Samples. +/// +public record GetSamplesQuery : IRequest>; diff --git a/services/_template_dot_net/src/MyService.API/Application/Queries/GetSamplesQueryHandler.cs b/services/_template_dot_net/src/MyService.API/Application/Queries/GetSamplesQueryHandler.cs new file mode 100644 index 00000000..2185302d --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Queries/GetSamplesQueryHandler.cs @@ -0,0 +1,34 @@ +using MediatR; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.API.Application.Queries; + +/// +/// EN: Handler for GetSamplesQuery. +/// VI: Handler cho GetSamplesQuery. +/// +public class GetSamplesQueryHandler : IRequestHandler> +{ + private readonly ISampleRepository _sampleRepository; + + public GetSamplesQueryHandler(ISampleRepository sampleRepository) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + } + + public async Task> Handle( + GetSamplesQuery request, + CancellationToken cancellationToken) + { + var samples = await _sampleRepository.GetAllAsync(); + + return samples.Select(sample => new SampleViewModel( + sample.Id, + sample.Name, + sample.Description, + sample.Status.Name, + sample.CreatedAt, + sample.UpdatedAt + )); + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Validations/CreateSampleCommandValidator.cs b/services/_template_dot_net/src/MyService.API/Application/Validations/CreateSampleCommandValidator.cs new file mode 100644 index 00000000..2f339fb3 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Validations/CreateSampleCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using MyService.API.Application.Commands; + +namespace MyService.API.Application.Validations; + +/// +/// EN: Validator for CreateSampleCommand. +/// VI: Validator cho CreateSampleCommand. +/// +public class CreateSampleCommandValidator : AbstractValidator +{ + public CreateSampleCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required / Tên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự"); + + RuleFor(x => x.Description) + .MaximumLength(1000) + .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự") + .When(x => x.Description != null); + } +} diff --git a/services/_template_dot_net/src/MyService.API/Application/Validations/UpdateSampleCommandValidator.cs b/services/_template_dot_net/src/MyService.API/Application/Validations/UpdateSampleCommandValidator.cs new file mode 100644 index 00000000..7030d5c8 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Application/Validations/UpdateSampleCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using MyService.API.Application.Commands; + +namespace MyService.API.Application.Validations; + +/// +/// EN: Validator for UpdateSampleCommand. +/// VI: Validator cho UpdateSampleCommand. +/// +public class UpdateSampleCommandValidator : AbstractValidator +{ + public UpdateSampleCommandValidator() + { + RuleFor(x => x.SampleId) + .NotEmpty() + .WithMessage("Sample ID is required / ID sample là bắt buộc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required / Tên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự"); + + RuleFor(x => x.Description) + .MaximumLength(1000) + .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự") + .When(x => x.Description != null); + } +} diff --git a/services/_template_dot_net/src/MyService.API/Controllers/SamplesController.cs b/services/_template_dot_net/src/MyService.API/Controllers/SamplesController.cs new file mode 100644 index 00000000..c87e0ffa --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Controllers/SamplesController.cs @@ -0,0 +1,200 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using MyService.API.Application.Commands; +using MyService.API.Application.Queries; + +namespace MyService.API.Controllers; + +/// +/// EN: Controller for Sample CRUD operations using CQRS pattern. +/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/[controller]")] +[Produces("application/json")] +public class SamplesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public SamplesController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get all samples. + /// VI: Lấy tất cả samples. + /// + /// EN: List of samples / VI: Danh sách samples + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetSamples() + { + var samples = await _mediator.Send(new GetSamplesQuery()); + return Ok(new { success = true, data = samples }); + } + + /// + /// EN: Get a sample by ID. + /// VI: Lấy một sample theo ID. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Sample details / VI: Chi tiết sample + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSample(Guid id) + { + var sample = await _mediator.Send(new GetSampleQuery(id)); + + if (sample is null) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return Ok(new { success = true, data = sample }); + } + + /// + /// EN: Create a new sample. + /// VI: Tạo một sample mới. + /// + /// EN: Create request / VI: Request tạo + /// EN: Created sample ID / VI: ID sample đã tạo + [HttpPost] + [ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateSample([FromBody] CreateSampleRequest request) + { + var command = new CreateSampleCommand(request.Name, request.Description); + var result = await _mediator.Send(command); + + return CreatedAtAction( + nameof(GetSample), + new { id = result.Id }, + new { success = true, data = result }); + } + + /// + /// EN: Update an existing sample. + /// VI: Cập nhật một sample đã tồn tại. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Update request / VI: Request cập nhật + /// EN: Success status / VI: Trạng thái thành công + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateSample(Guid id, [FromBody] UpdateSampleRequest request) + { + var command = new UpdateSampleCommand(id, request.Name, request.Description); + var result = await _mediator.Send(command); + + if (!result) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" }); + } + + /// + /// EN: Delete a sample. + /// VI: Xóa một sample. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Success status / VI: Trạng thái thành công + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteSample(Guid id) + { + var command = new DeleteSampleCommand(id); + var result = await _mediator.Send(command); + + if (!result) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return NoContent(); + } + + /// + /// EN: Change sample status. + /// VI: Thay đổi trạng thái sample. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Status change request / VI: Request thay đổi trạng thái + /// EN: Success status / VI: Trạng thái thành công + [HttpPatch("{id:guid}/status")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request) + { + var command = new ChangeSampleStatusCommand(id, request.Status); + var result = await _mediator.Send(command); + + if (!result) + { + return BadRequest(new + { + success = false, + error = new + { + code = "STATUS_CHANGE_FAILED", + message = "Failed to change sample status / Thay đổi trạng thái sample thất bại" + } + }); + } + + return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" }); + } +} + +/// +/// EN: Request model for creating a sample. +/// VI: Model request để tạo sample. +/// +public record CreateSampleRequest(string Name, string? Description); + +/// +/// EN: Request model for updating a sample. +/// VI: Model request để cập nhật sample. +/// +public record UpdateSampleRequest(string Name, string? Description); + +/// +/// EN: Request model for changing sample status. +/// VI: Model request để thay đổi trạng thái sample. +/// +public record ChangeStatusRequest(string Status); diff --git a/services/_template_dot_net/src/MyService.API/MyService.API.csproj b/services/_template_dot_net/src/MyService.API/MyService.API.csproj new file mode 100644 index 00000000..1b5bb222 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/MyService.API.csproj @@ -0,0 +1,43 @@ + + + + MyService.API + MyService.API + Web API layer with CQRS pattern + myservice-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/_template_dot_net/src/MyService.API/Program.cs b/services/_template_dot_net/src/MyService.API/Program.cs new file mode 100644 index 00000000..bd9b3df4 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Program.cs @@ -0,0 +1,144 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using MyService.API.Application.Behaviors; +using MyService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting MyService API / Khởi động MyService API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services / VI: Thêm Infrastructure services + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "MyService API", + Version = "v1", + Description = "MyService microservice API / API microservice MyService" + }); + }); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "MyService API v1"); + c.RoutePrefix = "swagger"; + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// EN: Make Program class accessible for integration tests +// VI: Làm cho class Program có thể truy cập cho integration tests +public partial class Program { } diff --git a/services/_template_dot_net/src/MyService.API/Properties/launchSettings.json b/services/_template_dot_net/src/MyService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/_template_dot_net/src/MyService.API/appsettings.Development.json b/services/_template_dot_net/src/MyService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System": "Information" + } + } + } +} \ No newline at end of file diff --git a/services/_template_dot_net/src/MyService.API/appsettings.json b/services/_template_dot_net/src/MyService.API/appsettings.json new file mode 100644 index 00000000..523dc0fc --- /dev/null +++ b/services/_template_dot_net/src/MyService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs b/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs new file mode 100644 index 00000000..40bc8c3a --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs @@ -0,0 +1,61 @@ +using MyService.Domain.SeedWork; + +namespace MyService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Repository interface for Sample aggregate. +/// VI: Interface repository cho Sample aggregate. +/// +/// +/// EN: Following repository pattern, this interface defines the contract +/// for data access operations on Sample aggregate. +/// VI: Theo pattern repository, interface này định nghĩa contract +/// cho các thao tác truy cập dữ liệu trên Sample aggregate. +/// +public interface ISampleRepository : IRepository +{ + /// + /// EN: Get a sample by its ID. + /// VI: Lấy một sample theo ID. + /// + /// EN: The sample ID / VI: ID của sample + /// EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy + Task GetAsync(Guid sampleId); + + /// + /// EN: Get all samples. + /// VI: Lấy tất cả samples. + /// + /// EN: List of samples / VI: Danh sách samples + Task> GetAllAsync(); + + /// + /// EN: Add a new sample. + /// VI: Thêm một sample mới. + /// + /// EN: The sample to add / VI: Sample cần thêm + /// EN: The added sample / VI: Sample đã thêm + Sample Add(Sample sample); + + /// + /// EN: Update an existing sample. + /// VI: Cập nhật một sample đã tồn tại. + /// + /// EN: The sample to update / VI: Sample cần cập nhật + void Update(Sample sample); + + /// + /// EN: Delete a sample. + /// VI: Xóa một sample. + /// + /// EN: The sample to delete / VI: Sample cần xóa + void Delete(Sample sample); + + /// + /// EN: Get samples by status. + /// VI: Lấy samples theo trạng thái. + /// + /// EN: The status ID / VI: ID trạng thái + /// EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước + Task> GetByStatusAsync(int statusId); +} diff --git a/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/Sample.cs b/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/Sample.cs new file mode 100644 index 00000000..641bb385 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/Sample.cs @@ -0,0 +1,158 @@ +using MyService.Domain.Events; +using MyService.Domain.Exceptions; +using MyService.Domain.SeedWork; + +namespace MyService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Sample aggregate root demonstrating DDD patterns. +/// VI: Sample aggregate root minh họa các pattern DDD. +/// +public class Sample : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private string _name = null!; + private string? _description; + private SampleStatus _status = null!; + private DateTime _createdAt; + private DateTime? _updatedAt; + + /// + /// EN: Sample name (required). + /// VI: Tên sample (bắt buộc). + /// + public string Name => _name; + + /// + /// EN: Optional description. + /// VI: Mô tả tùy chọn. + /// + public string? Description => _description; + + /// + /// EN: Current status. + /// VI: Trạng thái hiện tại. + /// + public SampleStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: ID trạng thái cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected Sample() + { + } + + /// + /// EN: Create a new Sample with required information. + /// VI: Tạo một Sample mới với thông tin bắt buộc. + /// + /// EN: Sample name / VI: Tên sample + /// EN: Optional description / VI: Mô tả tùy chọn + public Sample(string name, string? description = null) : this() + { + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + Id = Guid.NewGuid(); + _name = name; + _description = description; + _status = SampleStatus.Draft; + StatusId = SampleStatus.Draft.Id; + _createdAt = DateTime.UtcNow; + + // EN: Add domain event for creation + // VI: Thêm domain event cho việc tạo + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + /// + /// EN: Update sample information. + /// VI: Cập nhật thông tin sample. + /// + public void Update(string name, string? description) + { + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + if (_status == SampleStatus.Cancelled) + throw new SampleDomainException("Cannot update a cancelled sample"); + + _name = name; + _description = description; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Activate the sample. + /// VI: Kích hoạt sample. + /// + public void Activate() + { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + + var previousStatus = _status; + _status = SampleStatus.Active; + StatusId = SampleStatus.Active.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Complete the sample. + /// VI: Hoàn thành sample. + /// + public void Complete() + { + if (_status != SampleStatus.Active) + throw new SampleDomainException("Only active samples can be completed"); + + var previousStatus = _status; + _status = SampleStatus.Completed; + StatusId = SampleStatus.Completed.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Cancel the sample. + /// VI: Hủy sample. + /// + public void Cancel() + { + if (_status == SampleStatus.Completed) + throw new SampleDomainException("Cannot cancel a completed sample"); + + if (_status == SampleStatus.Cancelled) + throw new SampleDomainException("Sample is already cancelled"); + + var previousStatus = _status; + _status = SampleStatus.Cancelled; + StatusId = SampleStatus.Cancelled.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } +} diff --git a/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs b/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs new file mode 100644 index 00000000..54ce63ba --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs @@ -0,0 +1,77 @@ +using MyService.Domain.SeedWork; + +namespace MyService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Sample status enumeration following type-safe enum pattern. +/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu. +/// +public class SampleStatus : Enumeration +{ + /// + /// EN: Draft status - initial state + /// VI: Trạng thái nháp - trạng thái ban đầu + /// + public static SampleStatus Draft = new(1, nameof(Draft)); + + /// + /// EN: Active status - ready for use + /// VI: Trạng thái hoạt động - sẵn sàng sử dụng + /// + public static SampleStatus Active = new(2, nameof(Active)); + + /// + /// EN: Completed status - finished processing + /// VI: Trạng thái hoàn thành - đã xử lý xong + /// + public static SampleStatus Completed = new(3, nameof(Completed)); + + /// + /// EN: Cancelled status - cancelled by user + /// VI: Trạng thái đã hủy - bị hủy bởi người dùng + /// + public static SampleStatus Cancelled = new(4, nameof(Cancelled)); + + public SampleStatus(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all available statuses. + /// VI: Lấy tất cả các trạng thái có sẵn. + /// + public static IEnumerable List() => GetAll(); + + /// + /// EN: Parse status from name. + /// VI: Parse trạng thái từ tên. + /// + public static SampleStatus FromName(string name) + { + var status = List().SingleOrDefault(s => + string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase)); + + if (status is null) + { + throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return status; + } + + /// + /// EN: Parse status from ID. + /// VI: Parse trạng thái từ ID. + /// + public static SampleStatus From(int id) + { + var status = List().SingleOrDefault(s => s.Id == id); + + if (status is null) + { + throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return status; + } +} diff --git a/services/_template_dot_net/src/MyService.Domain/Events/SampleCreatedDomainEvent.cs b/services/_template_dot_net/src/MyService.Domain/Events/SampleCreatedDomainEvent.cs new file mode 100644 index 00000000..7e838214 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/Events/SampleCreatedDomainEvent.cs @@ -0,0 +1,22 @@ +using MediatR; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.Domain.Events; + +/// +/// EN: Domain event raised when a new Sample is created. +/// VI: Domain event được phát ra khi một Sample mới được tạo. +/// +public class SampleCreatedDomainEvent : INotification +{ + /// + /// EN: The newly created sample. + /// VI: Sample mới được tạo. + /// + public Sample Sample { get; } + + public SampleCreatedDomainEvent(Sample sample) + { + Sample = sample; + } +} diff --git a/services/_template_dot_net/src/MyService.Domain/Events/SampleStatusChangedDomainEvent.cs b/services/_template_dot_net/src/MyService.Domain/Events/SampleStatusChangedDomainEvent.cs new file mode 100644 index 00000000..f6d9b422 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/Events/SampleStatusChangedDomainEvent.cs @@ -0,0 +1,39 @@ +using MediatR; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.Domain.Events; + +/// +/// EN: Domain event raised when Sample status changes. +/// VI: Domain event được phát ra khi trạng thái Sample thay đổi. +/// +public class SampleStatusChangedDomainEvent : INotification +{ + /// + /// EN: The sample ID. + /// VI: ID của sample. + /// + public Guid SampleId { get; } + + /// + /// EN: Previous status before the change. + /// VI: Trạng thái trước khi thay đổi. + /// + public SampleStatus PreviousStatus { get; } + + /// + /// EN: New status after the change. + /// VI: Trạng thái mới sau khi thay đổi. + /// + public SampleStatus NewStatus { get; } + + public SampleStatusChangedDomainEvent( + Guid sampleId, + SampleStatus previousStatus, + SampleStatus newStatus) + { + SampleId = sampleId; + PreviousStatus = previousStatus; + NewStatus = newStatus; + } +} diff --git a/services/_template_dot_net/src/MyService.Domain/Exceptions/DomainException.cs b/services/_template_dot_net/src/MyService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..7e737f64 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace MyService.Domain.Exceptions; + +/// +/// EN: Base exception for domain errors. +/// VI: Exception cơ sở cho các lỗi domain. +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/_template_dot_net/src/MyService.Domain/Exceptions/SampleDomainException.cs b/services/_template_dot_net/src/MyService.Domain/Exceptions/SampleDomainException.cs new file mode 100644 index 00000000..c850944c --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/Exceptions/SampleDomainException.cs @@ -0,0 +1,21 @@ +namespace MyService.Domain.Exceptions; + +/// +/// EN: Exception for Sample aggregate domain errors. +/// VI: Exception cho các lỗi domain của Sample aggregate. +/// +public class SampleDomainException : DomainException +{ + public SampleDomainException() + { + } + + public SampleDomainException(string message) : base(message) + { + } + + public SampleDomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/_template_dot_net/src/MyService.Domain/MyService.Domain.csproj b/services/_template_dot_net/src/MyService.Domain/MyService.Domain.csproj new file mode 100644 index 00000000..3208317a --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/MyService.Domain.csproj @@ -0,0 +1,14 @@ + + + + MyService.Domain + MyService.Domain + Domain layer containing core business logic and entities + + + + + + + + diff --git a/services/_template_dot_net/src/MyService.Domain/SeedWork/Entity.cs b/services/_template_dot_net/src/MyService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..b07fdd3b --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace MyService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/_template_dot_net/src/MyService.Domain/SeedWork/Enumeration.cs b/services/_template_dot_net/src/MyService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..6641979c --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace MyService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/_template_dot_net/src/MyService.Domain/SeedWork/IAggregateRoot.cs b/services/_template_dot_net/src/MyService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..d361394f --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace MyService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/_template_dot_net/src/MyService.Domain/SeedWork/IRepository.cs b/services/_template_dot_net/src/MyService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..2d539e44 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace MyService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/_template_dot_net/src/MyService.Domain/SeedWork/IUnitOfWork.cs b/services/_template_dot_net/src/MyService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..d37d8fa4 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace MyService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/_template_dot_net/src/MyService.Domain/SeedWork/ValueObject.cs b/services/_template_dot_net/src/MyService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..5cf4188f --- /dev/null +++ b/services/_template_dot_net/src/MyService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace MyService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is 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) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/_template_dot_net/src/MyService.Infrastructure/DependencyInjection.cs b/services/_template_dot_net/src/MyService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..a8dfba0d --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MyService.Domain.AggregatesModel.SampleAggregate; +using MyService.Infrastructure.Idempotency; +using MyService.Infrastructure.Repositories; + +namespace MyService.Infrastructure; + +/// +/// EN: Dependency injection extensions for Infrastructure layer. +/// VI: Extensions dependency injection cho lớp Infrastructure. +/// +public static class DependencyInjection +{ + /// + /// EN: Add infrastructure services to the DI container. + /// VI: Thêm các services infrastructure vào DI container. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL + services.AddDbContext(options => + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Connection string not configured"); + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(MyServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + + // EN: Enable sensitive data logging in development only + // VI: Chỉ bật sensitive data logging trong development + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } + }); + + // EN: Register repositories / VI: Đăng ký repositories + services.AddScoped(); + + // EN: Register idempotency services / VI: Đăng ký idempotency services + services.AddScoped(); + + return services; + } +} diff --git a/services/_template_dot_net/src/MyService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs b/services/_template_dot_net/src/MyService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs new file mode 100644 index 00000000..5c2fbd42 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Sample entity. +/// VI: Cấu hình EF Core cho entity Sample. +/// +public class SampleEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("samples"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(s => s.Id); + + // EN: Ignore domain events (not persisted) + // VI: Bỏ qua domain events (không lưu) + builder.Ignore(s => s.DomainEvents); + + // EN: Properties / VI: Các thuộc tính + builder.Property(s => s.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property("_description") + .HasColumnName("description") + .HasMaxLength(1000); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Status relationship / VI: Quan hệ với Status + builder.Property(s => s.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(s => s.Status) + .WithMany() + .HasForeignKey(s => s.StatusId) + .OnDelete(DeleteBehavior.Restrict); + + // EN: Indexes / VI: Các index + builder.HasIndex("_name"); + builder.HasIndex(s => s.StatusId); + builder.HasIndex("_createdAt"); + } +} diff --git a/services/_template_dot_net/src/MyService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs b/services/_template_dot_net/src/MyService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs new file mode 100644 index 00000000..8b683f56 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyService.Domain.AggregatesModel.SampleAggregate; + +namespace MyService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for SampleStatus enumeration. +/// VI: Cấu hình EF Core cho enumeration SampleStatus. +/// +public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("sample_statuses"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed initial data / VI: Seed dữ liệu ban đầu + builder.HasData( + SampleStatus.Draft, + SampleStatus.Active, + SampleStatus.Completed, + SampleStatus.Cancelled + ); + } +} diff --git a/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/ClientRequest.cs b/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..f65e4a56 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace MyService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/IRequestManager.cs b/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..92097399 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace MyService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/RequestManager.cs b/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..41a5f318 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly MyServiceContext _context; + + public RequestManager(MyServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/_template_dot_net/src/MyService.Infrastructure/MyService.Infrastructure.csproj b/services/_template_dot_net/src/MyService.Infrastructure/MyService.Infrastructure.csproj new file mode 100644 index 00000000..7c81b5e9 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/MyService.Infrastructure.csproj @@ -0,0 +1,36 @@ + + + + MyService.Infrastructure + MyService.Infrastructure + Infrastructure layer for data access and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/services/_template_dot_net/src/MyService.Infrastructure/MyServiceContext.cs b/services/_template_dot_net/src/MyService.Infrastructure/MyServiceContext.cs new file mode 100644 index 00000000..4486367d --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/MyServiceContext.cs @@ -0,0 +1,160 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using MyService.Domain.AggregatesModel.SampleAggregate; +using MyService.Domain.SeedWork; +using MyService.Infrastructure.EntityConfigurations; + +namespace MyService.Infrastructure; + +/// +/// EN: EF Core DbContext for MyService. +/// VI: EF Core DbContext cho MyService. +/// +public class MyServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + /// + /// EN: Samples table. + /// VI: Bảng Samples. + /// + public DbSet Samples => Set(); + + /// + /// EN: Read-only access to current transaction. + /// VI: Truy cập chỉ đọc đến transaction hiện tại. + /// + public IDbContextTransaction? CurrentTransaction => _currentTransaction; + + /// + /// EN: Check if there is an active transaction. + /// VI: Kiểm tra xem có transaction đang hoạt động không. + /// + public bool HasActiveTransaction => _currentTransaction != null; + + public MyServiceContext(DbContextOptions options) : base(options) + { + _mediator = null!; + } + + public MyServiceContext(DbContextOptions options, IMediator mediator) : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + + System.Diagnostics.Debug.WriteLine("MyServiceContext::ctor - " + GetHashCode()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // EN: Apply entity configurations + // VI: Áp dụng các cấu hình entity + modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration()); + } + + /// + /// EN: Save entities and dispatch domain events. + /// VI: Lưu entities và dispatch domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events before saving (side effects) + // VI: Dispatch domain events trước khi lưu (side effects) + await DispatchDomainEventsAsync(); + + // EN: Save changes to database + // VI: Lưu thay đổi vào database + await base.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// EN: Begin a new transaction if none is active. + /// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động. + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction. + /// VI: Commit transaction hiện tại. + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (transaction != _currentTransaction) + throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + RollbackTransaction(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction. + /// VI: Rollback transaction hiện tại. + /// + public void RollbackTransaction() + { + try + { + _currentTransaction?.Rollback(); + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Dispatch all domain events from tracked entities. + /// VI: Dispatch tất cả domain events từ các entities đang được track. + /// + private async Task DispatchDomainEventsAsync() + { + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent); + } + } +} diff --git a/services/_template_dot_net/src/MyService.Infrastructure/Repositories/SampleRepository.cs b/services/_template_dot_net/src/MyService.Infrastructure/Repositories/SampleRepository.cs new file mode 100644 index 00000000..f0a4b109 --- /dev/null +++ b/services/_template_dot_net/src/MyService.Infrastructure/Repositories/SampleRepository.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using MyService.Domain.AggregatesModel.SampleAggregate; +using MyService.Domain.SeedWork; + +namespace MyService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Sample aggregate. +/// VI: Triển khai repository cho Sample aggregate. +/// +public class SampleRepository : ISampleRepository +{ + private readonly MyServiceContext _context; + + /// + /// EN: Unit of work for transaction management. + /// VI: Unit of work cho quản lý transaction. + /// + public IUnitOfWork UnitOfWork => _context; + + public SampleRepository(MyServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetAsync(Guid sampleId) + { + var sample = await _context.Samples + .Include(s => s.Status) + .FirstOrDefaultAsync(s => s.Id == sampleId); + + return sample; + } + + /// + public async Task> GetAllAsync() + { + return await _context.Samples + .Include(s => s.Status) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + } + + /// + public Sample Add(Sample sample) + { + return _context.Samples.Add(sample).Entity; + } + + /// + public void Update(Sample sample) + { + _context.Entry(sample).State = EntityState.Modified; + } + + /// + public void Delete(Sample sample) + { + _context.Samples.Remove(sample); + } + + /// + public async Task> GetByStatusAsync(int statusId) + { + return await _context.Samples + .Include(s => s.Status) + .Where(s => s.StatusId == statusId) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + } +} diff --git a/services/_template_dot_net/src/YourServiceName.Api/Application/Behaviors/LoggingBehavior.cs b/services/_template_dot_net/src/YourServiceName.Api/Application/Behaviors/LoggingBehavior.cs deleted file mode 100644 index dc727b46..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/Application/Behaviors/LoggingBehavior.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Diagnostics; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace YourServiceName.Api.Application.Behaviors; - -public class LoggingBehavior : IPipelineBehavior - where TRequest : IRequest -{ - private readonly ILogger> _logger; - - public LoggingBehavior(ILogger> logger) - { - _logger = logger; - } - - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - var requestName = typeof(TRequest).Name; - _logger.LogInformation("Processing Request: {Name} {@Request}", requestName, request); - - var timer = Stopwatch.StartNew(); - var response = await next(); - timer.Stop(); - - if (timer.ElapsedMilliseconds > 500) - { - _logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@Request}", - requestName, timer.ElapsedMilliseconds, request); - } - - _logger.LogInformation("Completed Request: {Name}", requestName); - return response; - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Api/Application/Behaviors/ValidationBehavior.cs b/services/_template_dot_net/src/YourServiceName.Api/Application/Behaviors/ValidationBehavior.cs deleted file mode 100644 index 61c13f9e..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/Application/Behaviors/ValidationBehavior.cs +++ /dev/null @@ -1,39 +0,0 @@ -using FluentValidation; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace YourServiceName.Api.Application.Behaviors; - -public class ValidationBehavior : IPipelineBehavior - where TRequest : IRequest -{ - private readonly IEnumerable> _validators; - private readonly ILogger> _logger; - - public ValidationBehavior(IEnumerable> validators, ILogger> logger) - { - _validators = validators; - _logger = logger; - } - - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - if (!_validators.Any()) - { - return await next(); - } - - var context = new ValidationContext(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.Count != 0) - { - _logger.LogWarning("Validation failed for request {RequestType}", typeof(TRequest).Name); - throw new ValidationException(failures); - } - - return await next(); - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Api/Application/Common/Models/Result.cs b/services/_template_dot_net/src/YourServiceName.Api/Application/Common/Models/Result.cs deleted file mode 100644 index f7d6e740..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/Application/Common/Models/Result.cs +++ /dev/null @@ -1,24 +0,0 @@ -using MediatR; - -namespace YourServiceName.Api.Application.Common.Models; - -public class Result -{ - public bool Succeeded { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } - public string? ErrorCode { get; init; } - - public static Result Success(T data) => new() { Succeeded = true, Data = data }; - public static Result Failure(string error, string errorCode = "Error") => new() { Succeeded = false, Error = error, ErrorCode = errorCode }; -} - -public class Result -{ - public bool Succeeded { get; init; } - public string? Error { get; init; } - public string? ErrorCode { get; init; } - - public static Result Success() => new() { Succeeded = true }; - public static Result Failure(string error, string errorCode = "Error") => new() { Succeeded = false, Error = error, ErrorCode = errorCode }; -} diff --git a/services/_template_dot_net/src/YourServiceName.Api/Controllers/HealthController.cs b/services/_template_dot_net/src/YourServiceName.Api/Controllers/HealthController.cs deleted file mode 100644 index 5d5b5c07..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/Controllers/HealthController.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace YourServiceName.Api.Controllers; - -/// -/// EN: Health check controller for service monitoring -/// VI: Controller kiểm tra sức khỏe để giám sát service -/// -[ApiController] -[Route("[controller]")] -public class HealthController : ControllerBase -{ - private readonly ILogger _logger; - - public HealthController(ILogger logger) - { - _logger = logger; - } - - /// - /// EN: Liveness probe - checks if the service is running - /// VI: Liveness probe - kiểm tra xem service có đang chạy không - /// - [HttpGet("live")] - public IActionResult Live() - { - return Ok(new { status = "ok", timestamp = DateTime.UtcNow }); - } - - /// - /// EN: Readiness probe - checks if the service is ready to accept traffic - /// VI: Readiness probe - kiểm tra xem service có sẵn sàng nhận traffic không - /// - [HttpGet("ready")] - public IActionResult Ready() - { - // TODO: Add database and Redis connectivity checks - // TODO: Thêm kiểm tra kết nối database và Redis - - return Ok(new - { - status = "ready", - timestamp = DateTime.UtcNow, - dependencies = new - { - database = "ok", - redis = "ok" - } - }); - } - - /// - /// EN: Overall health check - /// VI: Kiểm tra sức khỏe tổng thể - /// - [HttpGet] - public IActionResult Health() - { - return Ok(new - { - service = "YourServiceName", - status = "healthy", - version = "1.0.0", - timestamp = DateTime.UtcNow - }); - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Api/Extensions/ApiVersioningExtensions.cs b/services/_template_dot_net/src/YourServiceName.Api/Extensions/ApiVersioningExtensions.cs deleted file mode 100644 index 39e14566..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/Extensions/ApiVersioningExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Asp.Versioning; -using Microsoft.Extensions.DependencyInjection; - -namespace YourServiceName.Api.Extensions; - -public static class ApiVersioningExtensions -{ - public static IServiceCollection AddStandardApiVersioning(this IServiceCollection services) - { - services.AddApiVersioning(options => - { - options.DefaultApiVersion = new ApiVersion(1, 0); - options.AssumeDefaultVersionWhenUnspecified = true; - options.ReportApiVersions = true; - options.ApiVersionReader = new UrlSegmentApiVersionReader(); - }) - .AddMvc() - .AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; - }); - - return services; - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Api/Extensions/ApplicationServiceExtensions.cs b/services/_template_dot_net/src/YourServiceName.Api/Extensions/ApplicationServiceExtensions.cs deleted file mode 100644 index d2f7f162..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/Extensions/ApplicationServiceExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Reflection; -using FluentValidation; -using MediatR; -using YourServiceName.Api.Application.Behaviors; - -namespace YourServiceName.Api.Extensions; - -public static class ApplicationServiceExtensions -{ - public static IServiceCollection AddApplicationServices(this IServiceCollection services) - { - services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); - - services.AddMediatR(cfg => { - cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - - cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); - cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); - }); - - return services; - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Api/Middleware/GlobalExceptionHandler.cs b/services/_template_dot_net/src/YourServiceName.Api/Middleware/GlobalExceptionHandler.cs deleted file mode 100644 index ef16275a..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/Middleware/GlobalExceptionHandler.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Mvc; -using FluentValidation; - -namespace YourServiceName.Api.Middleware; - -public class GlobalExceptionHandler : IExceptionHandler -{ - private readonly ILogger _logger; - - public GlobalExceptionHandler(ILogger logger) - { - _logger = logger; - } - - public async ValueTask TryHandleAsync( - HttpContext httpContext, - Exception exception, - CancellationToken cancellationToken) - { - _logger.LogError(exception, "Unhandled exception occurred: {Message}", exception.Message); - - var problemDetails = new ProblemDetails - { - Status = StatusCodes.Status500InternalServerError, - Title = "An unexpected error occurred", - Detail = exception.Message, - Instance = httpContext.Request.Path - }; - - if (exception is ValidationException validationException) - { - problemDetails.Status = StatusCodes.Status400BadRequest; - problemDetails.Title = "Validation Error"; - problemDetails.Extensions["errors"] = validationException.Errors - .GroupBy(e => e.PropertyName) - .ToDictionary( - g => g.Key, - g => g.Select(e => e.ErrorMessage).ToArray() - ); - } - else if (exception is KeyNotFoundException) // Or custom NotFoundException - { - problemDetails.Status = StatusCodes.Status404NotFound; - problemDetails.Title = "Resource Not Found"; - } - - httpContext.Response.StatusCode = problemDetails.Status.Value; - await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); - - return true; - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Api/Program.cs b/services/_template_dot_net/src/YourServiceName.Api/Program.cs deleted file mode 100644 index b4618488..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/Program.cs +++ /dev/null @@ -1,86 +0,0 @@ -using YourServiceName.Api.Middleware; -using YourServiceName.Infrastructure; -using YourServiceName.Api.Extensions; - -var builder = WebApplication.CreateBuilder(args); - -// EN: Configure Serilog for structured logging -// VI: Cấu hình Serilog cho structured logging -builder.Host.UseSerilog((context, configuration) => - configuration.ReadFrom.Configuration(context.Configuration)); - -// EN: Add services to the container -// VI: Thêm services vào container -builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -// EN: Configure CORS -// VI: Cấu hình CORS -builder.Services.AddCors(options => -{ - options.AddDefaultPolicy(policy => - { - var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() - ?? new[] { "http://localhost:3000" }; - - policy.WithOrigins(allowedOrigins) - .AllowAnyMethod() - .AllowAnyHeader() - .AllowCredentials(); - }); -}); - -// EN: Add health checks -// VI: Thêm health checks -builder.Services.AddHealthChecks(); - -// EN: Add Infrastructure Services -// VI: Thêm các dịch vụ Infrastructure -builder.Services.AddInfrastructure(builder.Configuration); - -// EN: Add Application Services (MediatR, Behaviors) -// VI: Thêm các dịch vụ Application (MediatR, Behaviors) -builder.Services.AddApplicationServices(); - -// EN: Add API Versioning -// VI: Thêm quản lý phiên bản API -builder.Services.AddStandardApiVersioning(); - -// EN: Add Global Exception Handler -// VI: Thêm xử lý ngoại lệ toàn cục -builder.Services.AddExceptionHandler(); -builder.Services.AddProblemDetails(); - -var app = builder.Build(); - -// EN: Configure the HTTP request pipeline -// VI: Cấu hình HTTP request pipeline -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -// EN: Use Global Exception Handler -// VI: Sử dụng xử lý ngoại lệ toàn cục -app.UseExceptionHandler(); - -// EN: Add Serilog request logging -// VI: Thêm Serilog request logging -app.UseSerilogRequestLogging(); - -app.UseCors(); - -app.UseAuthentication(); -app.UseAuthorization(); - -app.MapControllers(); - -// EN: Map health check endpoints -// VI: Map các endpoints health check -app.MapHealthChecks("/health/live"); -app.MapHealthChecks("/health/ready"); -app.MapHealthChecks("/health"); - -app.Run(); diff --git a/services/_template_dot_net/src/YourServiceName.Api/YourServiceName.Api.csproj b/services/_template_dot_net/src/YourServiceName.Api/YourServiceName.Api.csproj deleted file mode 100644 index 3ae0c1b1..00000000 --- a/services/_template_dot_net/src/YourServiceName.Api/YourServiceName.Api.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net10.0 - enable - enable - YourServiceName.Api - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - diff --git a/services/_template_dot_net/src/YourServiceName.Domain/Common/Interfaces/IAuditableEntity.cs b/services/_template_dot_net/src/YourServiceName.Domain/Common/Interfaces/IAuditableEntity.cs deleted file mode 100644 index 997ee191..00000000 --- a/services/_template_dot_net/src/YourServiceName.Domain/Common/Interfaces/IAuditableEntity.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace YourServiceName.Domain.Common.Interfaces; - -public interface IAuditableEntity -{ - DateTime CreatedAt { get; set; } - string? CreatedBy { get; set; } - DateTime? LastModifiedAt { get; set; } - string? LastModifiedBy { get; set; } -} diff --git a/services/_template_dot_net/src/YourServiceName.Domain/Common/Interfaces/ICacheService.cs b/services/_template_dot_net/src/YourServiceName.Domain/Common/Interfaces/ICacheService.cs deleted file mode 100644 index 38724217..00000000 --- a/services/_template_dot_net/src/YourServiceName.Domain/Common/Interfaces/ICacheService.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace YourServiceName.Domain.Common.Interfaces; - -public interface ICacheService -{ - Task GetAsync(string key, CancellationToken cancellationToken = default); - Task SetAsync(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default); - Task RemoveAsync(string key, CancellationToken cancellationToken = default); -} diff --git a/services/_template_dot_net/src/YourServiceName.Domain/Entities/.gitkeep b/services/_template_dot_net/src/YourServiceName.Domain/Entities/.gitkeep deleted file mode 100644 index d63f1fea..00000000 --- a/services/_template_dot_net/src/YourServiceName.Domain/Entities/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# EN: .gitkeep file to maintain empty directory structure -# VI: File .gitkeep để duy trì cấu trúc thư mục rỗng diff --git a/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/Entity.cs b/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/Entity.cs deleted file mode 100644 index bf714a7e..00000000 --- a/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/Entity.cs +++ /dev/null @@ -1,89 +0,0 @@ -using MediatR; - -namespace YourServiceName.Domain.SeedWork; - -public abstract class Entity -{ - int _id; - public virtual int Id - { - get - { - return _id; - } - protected set - { - _id = value; - } - } - - private List _domainEvents; - public IReadOnlyCollection DomainEvents => _domainEvents?.AsReadOnly(); - - public void AddDomainEvent(INotification eventItem) - { - _domainEvents = _domainEvents ?? new List(); - _domainEvents.Add(eventItem); - } - - public void RemoveDomainEvent(INotification eventItem) - { - _domainEvents?.Remove(eventItem); - } - - public void ClearDomainEvents() - { - _domainEvents?.Clear(); - } - - public bool IsTransient() - { - return this.Id == default(int); - } - - public override bool Equals(object obj) - { - if (obj == null || !(obj is Entity)) - return false; - - if (Object.ReferenceEquals(this, obj)) - return true; - - if (this.GetType() != obj.GetType()) - return false; - - Entity item = (Entity)obj; - - if (item.IsTransient() || this.IsTransient()) - return false; - else - return item.Id == this.Id; - } - - public override int GetHashCode() - { - if (!IsTransient()) - { - if (!_requestedHashCode.HasValue) - _requestedHashCode = this.Id.GetHashCode() ^ 31; // XOR for random distribution (http://blogs.msdn.com/b/ericlippert/archive/2011/02/28/guidelines-and-rules-for-gethashcode.aspx) - - return _requestedHashCode.Value; - } - else - return base.GetHashCode(); - - } - private int? _requestedHashCode; - public static bool operator ==(Entity left, Entity right) - { - if (Object.Equals(left, null)) - return (Object.Equals(right, null)) ? true : false; - else - return left.Equals(right); - } - - public static bool operator !=(Entity left, Entity right) - { - return !(left == right); - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/IAggregateRoot.cs b/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/IAggregateRoot.cs deleted file mode 100644 index 2bb63d5a..00000000 --- a/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/IAggregateRoot.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace YourServiceName.Domain.SeedWork; - -public interface IAggregateRoot { } diff --git a/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/IRepository.cs b/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/IRepository.cs deleted file mode 100644 index 23eb4297..00000000 --- a/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/IRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace YourServiceName.Domain.SeedWork; - -public interface IRepository where T : IAggregateRoot -{ - IUnitOfWork UnitOfWork { get; } -} - -public interface IUnitOfWork : IDisposable -{ - Task SaveChangesAsync(CancellationToken cancellationToken = default); - Task SaveEntitiesAsync(CancellationToken cancellationToken = default); -} diff --git a/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/ValueObject.cs b/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/ValueObject.cs deleted file mode 100644 index d28ec246..00000000 --- a/services/_template_dot_net/src/YourServiceName.Domain/SeedWork/ValueObject.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace YourServiceName.Domain.SeedWork; - -public abstract class ValueObject -{ - protected static bool EqualOperator(ValueObject left, ValueObject right) - { - if (ReferenceEquals(left, null) ^ ReferenceEquals(right, null)) - { - return false; - } - return ReferenceEquals(left, null) || left.Equals(right); - } - - protected static bool NotEqualOperator(ValueObject left, ValueObject right) - { - return !(EqualOperator(left, right)); - } - - protected abstract IEnumerable GetEqualityComponents(); - - public override bool Equals(object obj) - { - if (obj == null || obj.GetType() != GetType()) - { - return false; - } - - var other = (ValueObject)obj; - - return this.GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); - } - - public override int GetHashCode() - { - return GetEqualityComponents() - .Select(x => x != null ? x.GetHashCode() : 0) - .Aggregate((x, y) => x ^ y); - } - // Other utility methods -} diff --git a/services/_template_dot_net/src/YourServiceName.Domain/YourServiceName.Domain.csproj b/services/_template_dot_net/src/YourServiceName.Domain/YourServiceName.Domain.csproj deleted file mode 100644 index 16bfa85d..00000000 --- a/services/_template_dot_net/src/YourServiceName.Domain/YourServiceName.Domain.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - net10.0 - enable - enable - YourServiceName.Domain - - - diff --git a/services/_template_dot_net/src/YourServiceName.Infrastructure/Data/.gitkeep b/services/_template_dot_net/src/YourServiceName.Infrastructure/Data/.gitkeep deleted file mode 100644 index d63f1fea..00000000 --- a/services/_template_dot_net/src/YourServiceName.Infrastructure/Data/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# EN: .gitkeep file to maintain empty directory structure -# VI: File .gitkeep để duy trì cấu trúc thư mục rỗng diff --git a/services/_template_dot_net/src/YourServiceName.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/services/_template_dot_net/src/YourServiceName.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs deleted file mode 100644 index d5344d1b..00000000 --- a/services/_template_dot_net/src/YourServiceName.Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using YourServiceName.Domain.Common.Interfaces; - -namespace YourServiceName.Infrastructure.Data.Interceptors; - -public class AuditableEntityInterceptor : SaveChangesInterceptor -{ - public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) - { - UpdateEntities(eventData.Context); - return base.SavingChanges(eventData, result); - } - - public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) - { - UpdateEntities(eventData.Context); - return base.SavingChangesAsync(eventData, result, cancellationToken); - } - - private void UpdateEntities(DbContext? context) - { - if (context == null) return; - - foreach (var entry in context.ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added) - { - entry.Entity.CreatedAt = DateTime.UtcNow; - entry.Entity.CreatedBy = "system"; // TODO: Get from ICurrentUserService - } - - if (entry.State == EntityState.Added || entry.State == EntityState.Modified) - { - entry.Entity.LastModifiedAt = DateTime.UtcNow; - entry.Entity.LastModifiedBy = "system"; // TODO: Get from ICurrentUserService - } - } - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Infrastructure/DependencyInjection.cs b/services/_template_dot_net/src/YourServiceName.Infrastructure/DependencyInjection.cs deleted file mode 100644 index f96c401c..00000000 --- a/services/_template_dot_net/src/YourServiceName.Infrastructure/DependencyInjection.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.EntityFrameworkCore; -using YourServiceName.Infrastructure.Data.Interceptors; -using YourServiceName.Domain.SeedWork; -using StackExchange.Redis; -using YourServiceName.Domain.Common.Interfaces; - -namespace YourServiceName.Infrastructure; - -public static class DependencyInjection -{ - public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) - { - services.AddScoped(); - - services.AddDbContext((sp, options) => - { - var interceptor = sp.GetService(); - - options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"), builder => - { - // EN: Enable retry on failure for Neon Database/Cloud resilience - // VI: Bật tính năng tự động thử lại khi lỗi kết nối (tốt cho Neon/Cloud DB) - builder.EnableRetryOnFailure( - maxRetryCount: 5, - maxRetryDelay: TimeSpan.FromSeconds(30), - errorCodesToAdd: null); - }) - .AddInterceptors(interceptor); - }); - - services.AddScoped(provider => provider.GetRequiredService()); - - // EN: Register Redis Cache - // VI: Đăng ký Redis Cache - services.AddSingleton(sp => - { - var configuration = sp.GetRequiredService(); - var connectionString = configuration.GetSection("Redis:ConnectionString").Value; - var instanceName = configuration.GetSection("Redis:InstanceName").Value; - - var options = ConfigurationOptions.Parse(connectionString); - // options.ChannelPrefix = instanceName; // Optional prefix - - return ConnectionMultiplexer.Connect(options); - }); - - services.AddScoped(); - - return services; - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Infrastructure/Repositories/Repository.cs b/services/_template_dot_net/src/YourServiceName.Infrastructure/Repositories/Repository.cs deleted file mode 100644 index ae0218d6..00000000 --- a/services/_template_dot_net/src/YourServiceName.Infrastructure/Repositories/Repository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using YourServiceName.Domain.SeedWork; - -namespace YourServiceName.Infrastructure.Repositories; - -public abstract class Repository : IRepository where T : Entity, IAggregateRoot -{ - protected readonly YourServiceNameContext _context; - - public Repository(YourServiceNameContext context) - { - _context = context ?? throw new ArgumentNullException(nameof(context)); - } - - public IUnitOfWork UnitOfWork - { - get - { - return _context; - } - } - - // Common repository methods can be added here -} diff --git a/services/_template_dot_net/src/YourServiceName.Infrastructure/Resilience/ResilienceExtensions.cs b/services/_template_dot_net/src/YourServiceName.Infrastructure/Resilience/ResilienceExtensions.cs deleted file mode 100644 index 71a56201..00000000 --- a/services/_template_dot_net/src/YourServiceName.Infrastructure/Resilience/ResilienceExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Resilience; -using Polly; - -namespace YourServiceName.Infrastructure.Resilience; - -public static class ResilienceExtensions -{ - public static IHttpClientBuilder AddStandardResilienceHandler(this IHttpClientBuilder builder) - { - return builder.AddResilienceHandler("standard-pipeline", builder => - { - // Refer: https://devblogs.microsoft.com/dotnet/building-resilient-web-applications-with-dotnet-circuit-breaker/ - builder.AddRetry(new HttpRetryStrategyOptions - { - MaxRetryAttempts = 3, - Delay = TimeSpan.FromSeconds(2), - BackoffType = DelayBackoffType.Exponential - }); - - builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions - { - SamplingDuration = TimeSpan.FromSeconds(10), - FailureRatio = 0.2, - MinimumThroughput = 3, - BreakDuration = TimeSpan.FromSeconds(30) - }); - - builder.AddTimeout(TimeSpan.FromSeconds(30)); - }); - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Infrastructure/Services/RedisCacheService.cs b/services/_template_dot_net/src/YourServiceName.Infrastructure/Services/RedisCacheService.cs deleted file mode 100644 index edbc9a2d..00000000 --- a/services/_template_dot_net/src/YourServiceName.Infrastructure/Services/RedisCacheService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Configuration; -using StackExchange.Redis; -using YourServiceName.Domain.Common.Interfaces; - -namespace YourServiceName.Infrastructure.Services; - -public class RedisCacheService : ICacheService -{ - private readonly IConnectionMultiplexer _connectionMultiplexer; - private readonly IDatabase _database; - - public RedisCacheService(IConnectionMultiplexer connectionMultiplexer) - { - _connectionMultiplexer = connectionMultiplexer; - _database = _connectionMultiplexer.GetDatabase(); - } - - public async Task GetAsync(string key, CancellationToken cancellationToken = default) - { - var value = await _database.StringGetAsync(key); - if (value.IsNullOrEmpty) - { - return default; - } - - return JsonSerializer.Deserialize(value); - } - - public async Task SetAsync(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) - { - var json = JsonSerializer.Serialize(value); - await _database.StringSetAsync(key, json, expiration); - } - - public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) - { - await _database.KeyDeleteAsync(key); - } -} diff --git a/services/_template_dot_net/src/YourServiceName.Infrastructure/YourServiceName.Infrastructure.csproj b/services/_template_dot_net/src/YourServiceName.Infrastructure/YourServiceName.Infrastructure.csproj deleted file mode 100644 index 69b43732..00000000 --- a/services/_template_dot_net/src/YourServiceName.Infrastructure/YourServiceName.Infrastructure.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net10.0 - enable - enable - YourServiceName.Infrastructure - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - diff --git a/services/_template_dot_net/src/YourServiceName.Infrastructure/YourServiceNameContext.cs b/services/_template_dot_net/src/YourServiceName.Infrastructure/YourServiceNameContext.cs deleted file mode 100644 index da3121e5..00000000 --- a/services/_template_dot_net/src/YourServiceName.Infrastructure/YourServiceNameContext.cs +++ /dev/null @@ -1,62 +0,0 @@ -using MediatR; -using Microsoft.EntityFrameworkCore; -using YourServiceName.Domain.SeedWork; - -namespace YourServiceName.Infrastructure; - -public class YourServiceNameContext : DbContext, IUnitOfWork -{ - private readonly IMediator _mediator; - - public YourServiceNameContext(DbContextOptions options) : base(options) { } - - public YourServiceNameContext(DbContextOptions options, IMediator mediator) : base(options) - { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); - } - - // DbSet Orders { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfigurationsFromAssembly(typeof(YourServiceNameContext).Assembly); - } - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - // Dispatch Domain Events collection. - // Choices: - // A) Right BEFORE committing data (EF SaveChanges) into the DB will make a single transaction including - // side effects from the domain event handlers which are using the same DbContext with "InstancePerLifetimeScope" or "scoped" lifetime - // B) Right AFTER committing data (EF SaveChanges) into the DB will make multiple transactions. - // You will need to handle eventual consistency and compensatory actions in case of failures in any of the Handlers. - - await _mediator.DispatchDomainEventsAsync(this); - - // After executing this line all the changes (from the Command Handler and Domain Event Handlers) - // performed through the DbContext will be committed - var result = await base.SaveChangesAsync(cancellationToken); - - return true; - } -} - -static class MediatorExtension -{ - public static async Task DispatchDomainEventsAsync(this IMediator mediator, YourServiceNameContext ctx) - { - var domainEntities = ctx.ChangeTracker - .Entries() - .Where(x => x.Entity.DomainEvents != null && x.Entity.DomainEvents.Any()); - - var domainEvents = domainEntities - .SelectMany(x => x.Entity.DomainEvents) - .ToList(); - - domainEntities.ToList() - .ForEach(entity => entity.Entity.ClearDomainEvents()); - - foreach (var domainEvent in domainEvents) - await mediator.Publish(domainEvent); - } -} diff --git a/services/_template_dot_net/tests/MyService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/_template_dot_net/tests/MyService.FunctionalTests/Controllers/SamplesControllerTests.cs new file mode 100644 index 00000000..e6e99ac5 --- /dev/null +++ b/services/_template_dot_net/tests/MyService.FunctionalTests/Controllers/SamplesControllerTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace MyService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Samples API endpoints. +/// VI: Functional tests cho các endpoints API Samples. +/// +public class SamplesControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public SamplesControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task GetSamples_ShouldReturnOkWithEmptyList() + { + // Act + var response = await _client.GetAsync("/api/v1/samples"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadFromJsonAsync>>(); + content?.Success.Should().BeTrue(); + } + + [Fact] + public async Task CreateSample_WithValidData_ShouldReturnCreated() + { + // Arrange + var request = new { Name = "Test Sample", Description = "Test Description" }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/samples", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var content = await response.Content.ReadFromJsonAsync>(); + content?.Success.Should().BeTrue(); + content?.Data?.Id.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetSample_WithInvalidId_ShouldReturnNotFound() + { + // Arrange + var invalidId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/samples/{invalidId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task HealthCheck_ShouldReturnHealthy() + { + // Act + var response = await _client.GetAsync("/health/live"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // EN: Helper DTOs for deserialization + // VI: Helper DTOs để deserialize + private record ApiResponse(bool Success, T? Data); + private record CreateSampleResult(Guid Id); +} diff --git a/services/_template_dot_net/tests/MyService.FunctionalTests/CustomWebApplicationFactory.cs b/services/_template_dot_net/tests/MyService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..d74d8338 --- /dev/null +++ b/services/_template_dot_net/tests/MyService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MyService.Infrastructure; + +namespace MyService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // EN: Remove the existing DbContext registration + // VI: Xóa đăng ký DbContext hiện tại + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + { + services.Remove(descriptor); + } + + // EN: Remove DbContext service + // VI: Xóa DbContext service + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(MyServiceContext)); + + if (dbContextDescriptor != null) + { + services.Remove(dbContextDescriptor); + } + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + }); + + // EN: Ensure database is created with seed data + // VI: Đảm bảo database được tạo với seed data + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + }); + } +} diff --git a/services/_template_dot_net/tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj b/services/_template_dot_net/tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj new file mode 100644 index 00000000..4cc894f8 --- /dev/null +++ b/services/_template_dot_net/tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + MyService.FunctionalTests + MyService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/_template_dot_net/tests/MyService.UnitTests/Application/CreateSampleCommandHandlerTests.cs b/services/_template_dot_net/tests/MyService.UnitTests/Application/CreateSampleCommandHandlerTests.cs new file mode 100644 index 00000000..75cdd0e8 --- /dev/null +++ b/services/_template_dot_net/tests/MyService.UnitTests/Application/CreateSampleCommandHandlerTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using MyService.API.Application.Commands; +using MyService.Domain.AggregatesModel.SampleAggregate; +using MyService.Domain.SeedWork; +using Xunit; + +namespace MyService.UnitTests.Application; + +/// +/// EN: Unit tests for CreateSampleCommandHandler. +/// VI: Unit tests cho CreateSampleCommandHandler. +/// +public class CreateSampleCommandHandlerTests +{ + private readonly Mock _mockRepository; + private readonly Mock> _mockLogger; + private readonly CreateSampleCommandHandler _handler; + + public CreateSampleCommandHandlerTests() + { + _mockRepository = new Mock(); + _mockLogger = new Mock>(); + + var mockUnitOfWork = new Mock(); + mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object); + + _handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId() + { + // Arrange + var command = new CreateSampleCommand("Test Sample", "Test Description"); + + _mockRepository.Setup(r => r.Add(It.IsAny())) + .Returns((Sample s) => s); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().NotBeEmpty(); + _mockRepository.Verify(r => r.Add(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldCallSaveEntities() + { + // Arrange + var command = new CreateSampleCommand("Test Sample", null); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + _mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny()), Times.Once); + } +} diff --git a/services/_template_dot_net/tests/MyService.UnitTests/Domain/SampleAggregateTests.cs b/services/_template_dot_net/tests/MyService.UnitTests/Domain/SampleAggregateTests.cs new file mode 100644 index 00000000..dcf48767 --- /dev/null +++ b/services/_template_dot_net/tests/MyService.UnitTests/Domain/SampleAggregateTests.cs @@ -0,0 +1,151 @@ +using FluentAssertions; +using MyService.Domain.AggregatesModel.SampleAggregate; +using MyService.Domain.Exceptions; +using Xunit; + +namespace MyService.UnitTests.Domain; + +/// +/// EN: Unit tests for Sample aggregate. +/// VI: Unit tests cho Sample aggregate. +/// +public class SampleAggregateTests +{ + [Fact] + public void CreateSample_WithValidName_ShouldCreateWithDraftStatus() + { + // Arrange + var name = "Test Sample"; + var description = "Test Description"; + + // Act + var sample = new Sample(name, description); + + // Assert + sample.Name.Should().Be(name); + sample.Description.Should().Be(description); + sample.Status.Should().Be(SampleStatus.Draft); + sample.Id.Should().NotBeEmpty(); + sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent + } + + [Fact] + public void CreateSample_WithEmptyName_ShouldThrowException() + { + // Arrange + var name = ""; + + // Act + var act = () => new Sample(name); + + // Assert + act.Should().Throw() + .WithMessage("Sample name cannot be empty"); + } + + [Fact] + public void Activate_WhenDraft_ShouldChangeToActive() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.ClearDomainEvents(); + + // Act + sample.Activate(); + + // Assert + sample.Status.Should().Be(SampleStatus.Active); + sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent + } + + [Fact] + public void Activate_WhenNotDraft_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + + // Act + var act = () => sample.Activate(); + + // Assert + act.Should().Throw() + .WithMessage("Only draft samples can be activated"); + } + + [Fact] + public void Complete_WhenActive_ShouldChangeToCompleted() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + sample.ClearDomainEvents(); + + // Act + sample.Complete(); + + // Assert + sample.Status.Should().Be(SampleStatus.Completed); + } + + [Fact] + public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled() + { + // Arrange + var sample = new Sample("Test Sample"); + + // Act + sample.Cancel(); + + // Assert + sample.Status.Should().Be(SampleStatus.Cancelled); + } + + [Fact] + public void Cancel_WhenCompleted_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + sample.Complete(); + + // Act + var act = () => sample.Cancel(); + + // Assert + act.Should().Throw() + .WithMessage("Cannot cancel a completed sample"); + } + + [Fact] + public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription() + { + // Arrange + var sample = new Sample("Original Name", "Original Description"); + var newName = "Updated Name"; + var newDescription = "Updated Description"; + + // Act + sample.Update(newName, newDescription); + + // Assert + sample.Name.Should().Be(newName); + sample.Description.Should().Be(newDescription); + sample.UpdatedAt.Should().NotBeNull(); + } + + [Fact] + public void Update_WhenCancelled_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Cancel(); + + // Act + var act = () => sample.Update("New Name", null); + + // Assert + act.Should().Throw() + .WithMessage("Cannot update a cancelled sample"); + } +} diff --git a/services/_template_dot_net/tests/MyService.UnitTests/MyService.UnitTests.csproj b/services/_template_dot_net/tests/MyService.UnitTests/MyService.UnitTests.csproj new file mode 100644 index 00000000..b40272dc --- /dev/null +++ b/services/_template_dot_net/tests/MyService.UnitTests/MyService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + MyService.UnitTests + MyService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +