chore: Refactor skill documentation for clarity and consistency
- Revised multiple skill documentation files to improve clarity and ensure consistency across descriptions and formatting. - Enhanced the organization of content to facilitate easier navigation and understanding for developers. - This update aims to streamline the documentation process and support better skill development practices.
This commit is contained in:
441
.agent/skills/deployment-kubernetes/SKILL.md
Normal file
441
.agent/skills/deployment-kubernetes/SKILL.md
Normal file
@@ -0,0 +1,441 @@
|
||||
---
|
||||
name: deployment-kubernetes
|
||||
description: Kubernetes deployment patterns. Use for Pods, Services, Ingress, Helm Charts, ConfigMaps, Secrets, và health probes.
|
||||
compatibility: "Kubernetes 1.28+, Helm 3+"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Kubernetes Deployment Patterns / Mẫu Triển Khai Kubernetes
|
||||
|
||||
Kubernetes deployment patterns cho GoodGo microservices production.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Deploying services to Kubernetes / Triển khai services lên Kubernetes
|
||||
- Creating Helm charts / Tạo Helm charts
|
||||
- Configuring Ingress routing / Cấu hình Ingress routing
|
||||
- Managing secrets and configs / Quản lý secrets và configs
|
||||
- Setting up health probes / Cài đặt health probes
|
||||
- Scaling applications / Scale ứng dụng
|
||||
|
||||
## Core Concepts / Khái Niệm Cốt Lõi
|
||||
|
||||
### Kubernetes Architecture / Kiến Trúc Kubernetes
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ KUBERNETES CLUSTER │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ INGRESS │ │
|
||||
│ │ (NGINX / Traefik Controller) │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
||||
│ │ SERVICES │ │
|
||||
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
|
||||
│ │ │ iam-svc │ │ order-svc │ │storage-svc│ │ │
|
||||
│ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │
|
||||
│ └──────────┼──────────────┼──────────────┼────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────────▼──────────────▼──────────────▼────────────┐ │
|
||||
│ │ PODS │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
|
||||
│ │ │ Pod 1 │ │ Pod 2 │ │ Pod 3 │ │ │
|
||||
│ │ │ replica │ │ replica │ │ replica │ │ │
|
||||
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Resources / Các Tài Nguyên Chính
|
||||
|
||||
| Resource | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| **Pod** | Smallest deployable unit | Container(s) + volumes |
|
||||
| **Deployment** | Manages ReplicaSets | Rolling updates |
|
||||
| **Service** | Stable network endpoint | Load balancing |
|
||||
| **Ingress** | HTTP routing | Host/path rules |
|
||||
| **ConfigMap** | Non-sensitive config | App settings |
|
||||
| **Secret** | Sensitive data | Passwords, keys |
|
||||
|
||||
### Health Probes / Các Loại Probe
|
||||
|
||||
| Probe | Purpose | Failure Action |
|
||||
|-------|---------|----------------|
|
||||
| **Liveness** | Is container alive? | Restart container |
|
||||
| **Readiness** | Can accept traffic? | Remove from LB |
|
||||
| **Startup** | Has started? | Block other probes |
|
||||
|
||||
## Key Patterns / Mẫu Chính
|
||||
|
||||
### Deployment Manifest
|
||||
|
||||
```yaml
|
||||
# k8s/base/iam-service/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: iam-service
|
||||
labels:
|
||||
app: iam-service
|
||||
tier: backend
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: iam-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: iam-service
|
||||
spec:
|
||||
containers:
|
||||
- name: iam-service
|
||||
image: goodgo/iam-service:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Production"
|
||||
- name: ConnectionStrings__DefaultConnection
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: iam-secrets
|
||||
key: database-url
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/startup
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
failureThreshold: 30
|
||||
```
|
||||
|
||||
### Service Manifest
|
||||
|
||||
```yaml
|
||||
# k8s/base/iam-service/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: iam-service
|
||||
labels:
|
||||
app: iam-service
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: iam-service
|
||||
```
|
||||
|
||||
### Ingress Configuration
|
||||
|
||||
```yaml
|
||||
# k8s/base/ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: goodgo-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- api.goodgo.vn
|
||||
secretName: goodgo-tls
|
||||
rules:
|
||||
- host: api.goodgo.vn
|
||||
http:
|
||||
paths:
|
||||
- path: /api/v1/iam
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: iam-service
|
||||
port:
|
||||
number: 80
|
||||
- path: /api/v1/orders
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: order-service
|
||||
port:
|
||||
number: 80
|
||||
- path: /api/v1/storage
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: storage-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
### ConfigMap & Secret
|
||||
|
||||
```yaml
|
||||
# k8s/base/configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
ASPNETCORE_ENVIRONMENT: "Production"
|
||||
Logging__LogLevel__Default: "Information"
|
||||
Redis__InstanceName: "GoodGo:"
|
||||
|
||||
---
|
||||
# k8s/base/secret.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: iam-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
database-url: "Host=postgres;Database=iam_db;Username=postgres;Password=secret"
|
||||
jwt-secret: "your-super-secret-key-here"
|
||||
```
|
||||
|
||||
### Helm Chart Structure
|
||||
|
||||
```
|
||||
charts/
|
||||
└── goodgo-service/
|
||||
├── Chart.yaml
|
||||
├── values.yaml
|
||||
├── templates/
|
||||
│ ├── deployment.yaml
|
||||
│ ├── service.yaml
|
||||
│ ├── ingress.yaml
|
||||
│ ├── configmap.yaml
|
||||
│ ├── secret.yaml
|
||||
│ ├── hpa.yaml
|
||||
│ └── _helpers.tpl
|
||||
└── values/
|
||||
├── development.yaml
|
||||
├── staging.yaml
|
||||
└── production.yaml
|
||||
```
|
||||
|
||||
### Helm Values File
|
||||
|
||||
```yaml
|
||||
# charts/goodgo-service/values.yaml
|
||||
replicaCount: 3
|
||||
|
||||
image:
|
||||
repository: goodgo/iam-service
|
||||
tag: "latest"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: api.goodgo.vn
|
||||
paths:
|
||||
- path: /api/v1/iam
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: goodgo-tls
|
||||
hosts:
|
||||
- api.goodgo.vn
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
|
||||
env:
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Production"
|
||||
|
||||
envFromSecret:
|
||||
- name: ConnectionStrings__DefaultConnection
|
||||
secretName: iam-secrets
|
||||
secretKey: database-url
|
||||
```
|
||||
|
||||
### HorizontalPodAutoscaler
|
||||
|
||||
```yaml
|
||||
# k8s/base/hpa.yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: iam-service-hpa
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: iam-service
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
```
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
### 1. No Resource Limits
|
||||
|
||||
```yaml
|
||||
# ❌ BAD: No limits
|
||||
containers:
|
||||
- name: app
|
||||
image: myapp
|
||||
|
||||
# ✅ GOOD: With limits
|
||||
containers:
|
||||
- name: app
|
||||
image: myapp
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
```
|
||||
|
||||
### 2. Missing Health Probes
|
||||
|
||||
```yaml
|
||||
# ❌ BAD: No probes
|
||||
containers:
|
||||
- name: app
|
||||
|
||||
# ✅ GOOD: All probes configured
|
||||
containers:
|
||||
- name: app
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8080
|
||||
```
|
||||
|
||||
### 3. Hardcoded Secrets
|
||||
|
||||
```yaml
|
||||
# ❌ BAD: Secret in env
|
||||
env:
|
||||
- name: DB_PASSWORD
|
||||
value: "mysecretpassword"
|
||||
|
||||
# ✅ GOOD: From Secret
|
||||
env:
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: db-secrets
|
||||
key: password
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
### kubectl Commands
|
||||
|
||||
```bash
|
||||
# EN: Apply manifests / VI: Áp dụng manifests
|
||||
kubectl apply -f k8s/base/
|
||||
|
||||
# EN: Check pod status / VI: Kiểm tra trạng thái pods
|
||||
kubectl get pods -l app=iam-service
|
||||
|
||||
# EN: View logs / VI: Xem logs
|
||||
kubectl logs -f deployment/iam-service
|
||||
|
||||
# EN: Scale deployment / VI: Scale deployment
|
||||
kubectl scale deployment iam-service --replicas=5
|
||||
|
||||
# EN: Rollout status / VI: Trạng thái rollout
|
||||
kubectl rollout status deployment/iam-service
|
||||
|
||||
# EN: Rollback / VI: Rollback
|
||||
kubectl rollout undo deployment/iam-service
|
||||
```
|
||||
|
||||
### Helm Commands
|
||||
|
||||
```bash
|
||||
# EN: Install chart / VI: Cài đặt chart
|
||||
helm install iam-service ./charts/goodgo-service -f values/production.yaml
|
||||
|
||||
# EN: Upgrade / VI: Nâng cấp
|
||||
helm upgrade iam-service ./charts/goodgo-service -f values/production.yaml
|
||||
|
||||
# EN: Rollback / VI: Rollback
|
||||
helm rollback iam-service 1
|
||||
|
||||
# EN: List releases / VI: Liệt kê releases
|
||||
helm list -A
|
||||
```
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Detailed Examples](./references/REFERENCE.md) - Full configurations
|
||||
- [Docker Traefik](../docker-traefik/SKILL.md) - Container basics
|
||||
- [Observability](../observability/SKILL.md) - Health checks
|
||||
- [Error Handling](../error-handling-patterns/SKILL.md) - Probes setup
|
||||
446
.agent/skills/deployment-kubernetes/references/REFERENCE.md
Normal file
446
.agent/skills/deployment-kubernetes/references/REFERENCE.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# Kubernetes Deployment - Detailed Reference
|
||||
|
||||
Detailed configurations và examples cho Kubernetes deployment trong GoodGo.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Complete Deployment Example](#complete-deployment-example)
|
||||
2. [Helm Chart Templates](#helm-chart-templates)
|
||||
3. [Kustomize Configuration](#kustomize-configuration)
|
||||
4. [CI/CD Integration](#cicd-integration)
|
||||
5. [Production Configurations](#production-configurations)
|
||||
|
||||
---
|
||||
|
||||
## Complete Deployment Example
|
||||
|
||||
### Namespace
|
||||
|
||||
```yaml
|
||||
# k8s/base/namespace.yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: goodgo
|
||||
labels:
|
||||
name: goodgo
|
||||
environment: production
|
||||
```
|
||||
|
||||
### Complete Service Deployment
|
||||
|
||||
```yaml
|
||||
# k8s/base/iam-service/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: iam-service
|
||||
namespace: goodgo
|
||||
labels:
|
||||
app.kubernetes.io/name: iam-service
|
||||
app.kubernetes.io/part-of: goodgo
|
||||
app.kubernetes.io/version: "1.0.0"
|
||||
spec:
|
||||
replicas: 3
|
||||
revisionHistoryLimit: 5
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: iam-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: iam-service
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
serviceAccountName: iam-service
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: iam-service
|
||||
image: goodgo/iam-service:1.0.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: iam-config
|
||||
env:
|
||||
- name: ConnectionStrings__DefaultConnection
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: iam-secrets
|
||||
key: database-url
|
||||
- name: Jwt__SecretKey
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: iam-secrets
|
||||
key: jwt-secret
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/startup
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
failureThreshold: 30
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: iam-service
|
||||
topologyKey: kubernetes.io/hostname
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: topology.kubernetes.io/zone
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: iam-service
|
||||
```
|
||||
|
||||
### Service Account
|
||||
|
||||
```yaml
|
||||
# k8s/base/iam-service/serviceaccount.yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: iam-service
|
||||
namespace: goodgo
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: iam-service-role
|
||||
namespace: goodgo
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["configmaps", "secrets"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: iam-service-rolebinding
|
||||
namespace: goodgo
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: iam-service
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: iam-service-role
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
### PodDisruptionBudget
|
||||
|
||||
```yaml
|
||||
# k8s/base/iam-service/pdb.yaml
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: iam-service-pdb
|
||||
namespace: goodgo
|
||||
spec:
|
||||
minAvailable: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: iam-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Helm Chart Templates
|
||||
|
||||
### Chart.yaml
|
||||
|
||||
```yaml
|
||||
# charts/goodgo-service/Chart.yaml
|
||||
apiVersion: v2
|
||||
name: goodgo-service
|
||||
description: A Helm chart for GoodGo microservices
|
||||
type: application
|
||||
version: 1.0.0
|
||||
appVersion: "1.0.0"
|
||||
maintainers:
|
||||
- name: GoodGo Team
|
||||
email: team@goodgo.vn
|
||||
```
|
||||
|
||||
### Deployment Template
|
||||
|
||||
```yaml
|
||||
# charts/goodgo-service/templates/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "goodgo-service.fullname" . }}
|
||||
labels:
|
||||
{{- include "goodgo-service.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "goodgo-service.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "goodgo-service.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "goodgo-service.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.targetPort }}
|
||||
protocol: TCP
|
||||
{{- if .Values.env }}
|
||||
env:
|
||||
{{- toYaml .Values.env | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.envFromSecret }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "goodgo-service.fullname" . }}-secrets
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.probes.liveness.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.probes.readiness.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
```
|
||||
|
||||
### Helpers Template
|
||||
|
||||
```yaml
|
||||
# charts/goodgo-service/templates/_helpers.tpl
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "goodgo-service.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "goodgo-service.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "goodgo-service.labels" -}}
|
||||
helm.sh/chart: {{ include "goodgo-service.chart" . }}
|
||||
{{ include "goodgo-service.selectorLabels" . }}
|
||||
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "goodgo-service.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "goodgo-service.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### Production Values
|
||||
|
||||
```yaml
|
||||
# charts/goodgo-service/values/production.yaml
|
||||
replicaCount: 3
|
||||
|
||||
image:
|
||||
repository: gcr.io/goodgo/iam-service
|
||||
tag: "1.0.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 3
|
||||
maxReplicas: 20
|
||||
targetCPUUtilizationPercentage: 70
|
||||
targetMemoryUtilizationPercentage: 80
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rate-limit: "100"
|
||||
nginx.ingress.kubernetes.io/rate-limit-window: "1m"
|
||||
hosts:
|
||||
- host: api.goodgo.vn
|
||||
paths:
|
||||
- path: /api/v1/iam
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: goodgo-tls
|
||||
hosts:
|
||||
- api.goodgo.vn
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
path: /health/live
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
readiness:
|
||||
path: /health/ready
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Deploy
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy to Kubernetes
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gcr.io
|
||||
username: _json_key
|
||||
password: ${{ secrets.GCP_SA_KEY }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./services/iam-service-net
|
||||
push: true
|
||||
tags: gcr.io/goodgo/iam-service:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Set up kubectl
|
||||
uses: azure/setup-kubectl@v3
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v3
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
run: |
|
||||
helm upgrade --install iam-service ./charts/goodgo-service \
|
||||
--namespace goodgo \
|
||||
--create-namespace \
|
||||
--values ./charts/goodgo-service/values/production.yaml \
|
||||
--set image.tag=${{ github.sha }} \
|
||||
--wait
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Kubernetes Documentation](https://kubernetes.io/docs/)
|
||||
- [Helm Documentation](https://helm.sh/docs/)
|
||||
- [Kustomize Documentation](https://kustomize.io/)
|
||||
- [NGINX Ingress Controller](https://kubernetes.github.io/ingress-nginx/)
|
||||
480
.agent/skills/domain-driven-design/SKILL.md
Normal file
480
.agent/skills/domain-driven-design/SKILL.md
Normal file
@@ -0,0 +1,480 @@
|
||||
---
|
||||
name: domain-driven-design
|
||||
description: DDD patterns cho complex business logic. Use for Aggregates, Value Objects, Entities, Domain Events, và Rich Domain Model.
|
||||
compatibility: ".NET 8+, EF Core 8+"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Domain-Driven Design Patterns / Mẫu DDD
|
||||
|
||||
DDD patterns cho GoodGo microservices với complex business logic.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Modeling complex business domains / Mô hình hóa domain phức tạp
|
||||
- Designing aggregates and entities / Thiết kế aggregates và entities
|
||||
- Implementing business rules in domain / Triển khai business rules trong domain
|
||||
- Creating value objects / Tạo value objects
|
||||
- Raising domain events / Raise domain events
|
||||
|
||||
## Core Concepts / Khái Niệm Cốt Lõi
|
||||
|
||||
### DDD Building Blocks / Các Khối Xây Dựng DDD
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DOMAIN MODEL LAYER │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ AGGREGATE ROOT │ │
|
||||
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
|
||||
│ │ │ Entity │ │ Value Object │ │ │
|
||||
│ │ │ (Identity) │ │ (No Identity) │ │ │
|
||||
│ │ └─────────────────┘ └─────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ • Business Rules / Quy tắc nghiệp vụ │ │
|
||||
│ │ • Domain Events / Domain Events │ │
|
||||
│ │ • Invariants / Bất biến │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Domain Service │ │ Domain Events │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Entity vs Value Object / Entity vs Value Object
|
||||
|
||||
| Aspect | Entity | Value Object |
|
||||
|--------|--------|--------------|
|
||||
| **Identity** | Has unique ID | No identity |
|
||||
| **Equality** | By ID | By all properties |
|
||||
| **Mutability** | Mutable (via methods) | Immutable |
|
||||
| **Lifecycle** | Independent | Belongs to Entity |
|
||||
| **Example** | Order, User | Address, Money |
|
||||
|
||||
### Aggregate Rules / Quy Tắc Aggregate
|
||||
|
||||
1. **One repository per aggregate root** / Một repository cho mỗi aggregate root
|
||||
2. **Reference only by ID** / Chỉ tham chiếu qua ID
|
||||
3. **Atomic transaction boundary** / Ranh giới transaction atomic
|
||||
4. **Consistency within aggregate** / Nhất quán trong aggregate
|
||||
|
||||
## Key Patterns / Mẫu Chính
|
||||
|
||||
### Entity Base Class
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Base class for all entities.
|
||||
/// VI: Base class cho tất cả entities.
|
||||
/// </summary>
|
||||
public abstract class Entity
|
||||
{
|
||||
private int? _requestedHashCode;
|
||||
private List<IDomainEvent>? _domainEvents;
|
||||
|
||||
public virtual Guid Id { get; protected set; }
|
||||
|
||||
public IReadOnlyCollection<IDomainEvent> DomainEvents
|
||||
=> _domainEvents?.AsReadOnly() ?? Array.Empty<IDomainEvent>().AsReadOnly();
|
||||
|
||||
public void AddDomainEvent(IDomainEvent eventItem)
|
||||
{
|
||||
_domainEvents ??= new List<IDomainEvent>();
|
||||
_domainEvents.Add(eventItem);
|
||||
}
|
||||
|
||||
public void RemoveDomainEvent(IDomainEvent eventItem)
|
||||
{
|
||||
_domainEvents?.Remove(eventItem);
|
||||
}
|
||||
|
||||
public void ClearDomainEvents()
|
||||
{
|
||||
_domainEvents?.Clear();
|
||||
}
|
||||
|
||||
public bool IsTransient()
|
||||
{
|
||||
return Id == default;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not Entity other)
|
||||
return false;
|
||||
|
||||
if (ReferenceEquals(this, other))
|
||||
return true;
|
||||
|
||||
if (GetType() != other.GetType())
|
||||
return false;
|
||||
|
||||
if (IsTransient() || other.IsTransient())
|
||||
return false;
|
||||
|
||||
return Id.Equals(other.Id);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
if (!IsTransient())
|
||||
{
|
||||
_requestedHashCode ??= Id.GetHashCode() ^ 31;
|
||||
return _requestedHashCode.Value;
|
||||
}
|
||||
return base.GetHashCode();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Aggregate Root
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Marker interface for aggregate roots.
|
||||
/// VI: Interface đánh dấu aggregate roots.
|
||||
/// </summary>
|
||||
public interface IAggregateRoot { }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Order aggregate root with business rules.
|
||||
/// VI: Order aggregate root với business rules.
|
||||
/// </summary>
|
||||
public class Order : Entity, IAggregateRoot
|
||||
{
|
||||
private readonly List<OrderItem> _orderItems = new();
|
||||
|
||||
public string UserId { get; private set; }
|
||||
public Address ShippingAddress { get; private set; }
|
||||
public OrderStatus Status { get; private set; }
|
||||
public decimal TotalAmount { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public DateTime? SubmittedAt { get; private set; }
|
||||
|
||||
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
|
||||
|
||||
// EN: Required by EF Core
|
||||
private Order() { }
|
||||
|
||||
public Order(string userId, Address shippingAddress)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
|
||||
ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress));
|
||||
Status = OrderStatus.Draft;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
TotalAmount = 0;
|
||||
|
||||
// EN: Raise domain event
|
||||
// VI: Raise domain event
|
||||
AddDomainEvent(new OrderCreatedDomainEvent(Id, userId));
|
||||
}
|
||||
|
||||
public void AddItem(Guid productId, int quantity, decimal unitPrice)
|
||||
{
|
||||
// EN: Business rule: Can only add items to draft orders
|
||||
// VI: Quy tắc: Chỉ thêm items vào orders draft
|
||||
if (Status != OrderStatus.Draft)
|
||||
throw new DomainException("Cannot add items to non-draft order");
|
||||
|
||||
if (quantity <= 0)
|
||||
throw new ArgumentException("Quantity must be positive", nameof(quantity));
|
||||
|
||||
if (unitPrice < 0)
|
||||
throw new ArgumentException("Price cannot be negative", nameof(unitPrice));
|
||||
|
||||
var existingItem = _orderItems.FirstOrDefault(i => i.ProductId == productId);
|
||||
if (existingItem != null)
|
||||
{
|
||||
existingItem.IncreaseQuantity(quantity);
|
||||
}
|
||||
else
|
||||
{
|
||||
_orderItems.Add(new OrderItem(productId, quantity, unitPrice));
|
||||
}
|
||||
|
||||
RecalculateTotal();
|
||||
}
|
||||
|
||||
public void RemoveItem(Guid productId)
|
||||
{
|
||||
if (Status != OrderStatus.Draft)
|
||||
throw new DomainException("Cannot remove items from non-draft order");
|
||||
|
||||
var item = _orderItems.FirstOrDefault(i => i.ProductId == productId);
|
||||
if (item != null)
|
||||
{
|
||||
_orderItems.Remove(item);
|
||||
RecalculateTotal();
|
||||
}
|
||||
}
|
||||
|
||||
public void Submit()
|
||||
{
|
||||
// EN: Business rule: Cannot submit empty order
|
||||
// VI: Quy tắc: Không thể submit order trống
|
||||
if (!_orderItems.Any())
|
||||
throw new DomainException("Cannot submit empty order");
|
||||
|
||||
if (Status != OrderStatus.Draft)
|
||||
throw new DomainException($"Cannot submit order in {Status} status");
|
||||
|
||||
Status = OrderStatus.Submitted;
|
||||
SubmittedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new OrderSubmittedDomainEvent(Id, UserId, TotalAmount));
|
||||
}
|
||||
|
||||
public void Cancel(string reason)
|
||||
{
|
||||
if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
|
||||
throw new DomainException("Cannot cancel shipped or delivered order");
|
||||
|
||||
Status = OrderStatus.Cancelled;
|
||||
AddDomainEvent(new OrderCancelledDomainEvent(Id, reason));
|
||||
}
|
||||
|
||||
private void RecalculateTotal()
|
||||
{
|
||||
TotalAmount = _orderItems.Sum(i => i.Quantity * i.UnitPrice);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Value Object
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Base class for value objects.
|
||||
/// VI: Base class cho value objects.
|
||||
/// </summary>
|
||||
public abstract class ValueObject
|
||||
{
|
||||
protected abstract IEnumerable<object?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Address value object.
|
||||
/// VI: Value object địa chỉ.
|
||||
/// </summary>
|
||||
public class Address : ValueObject
|
||||
{
|
||||
public string Street { get; }
|
||||
public string City { get; }
|
||||
public string State { get; }
|
||||
public string PostalCode { get; }
|
||||
public string Country { get; }
|
||||
|
||||
public Address(string street, string city, string state, string postalCode, string country)
|
||||
{
|
||||
Street = street ?? throw new ArgumentNullException(nameof(street));
|
||||
City = city ?? throw new ArgumentNullException(nameof(city));
|
||||
State = state ?? throw new ArgumentNullException(nameof(state));
|
||||
PostalCode = postalCode ?? throw new ArgumentNullException(nameof(postalCode));
|
||||
Country = country ?? throw new ArgumentNullException(nameof(country));
|
||||
}
|
||||
|
||||
protected override IEnumerable<object?> GetEqualityComponents()
|
||||
{
|
||||
yield return Street;
|
||||
yield return City;
|
||||
yield return State;
|
||||
yield return PostalCode;
|
||||
yield return Country;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Money value object with currency.
|
||||
/// VI: Value object tiền tệ.
|
||||
/// </summary>
|
||||
public class Money : ValueObject
|
||||
{
|
||||
public decimal Amount { get; }
|
||||
public string Currency { get; }
|
||||
|
||||
public Money(decimal amount, string currency)
|
||||
{
|
||||
if (amount < 0)
|
||||
throw new ArgumentException("Amount cannot be negative");
|
||||
|
||||
Amount = amount;
|
||||
Currency = currency?.ToUpperInvariant()
|
||||
?? throw new ArgumentNullException(nameof(currency));
|
||||
}
|
||||
|
||||
public Money Add(Money other)
|
||||
{
|
||||
if (Currency != other.Currency)
|
||||
throw new InvalidOperationException("Cannot add different currencies");
|
||||
|
||||
return new Money(Amount + other.Amount, Currency);
|
||||
}
|
||||
|
||||
protected override IEnumerable<object?> GetEqualityComponents()
|
||||
{
|
||||
yield return Amount;
|
||||
yield return Currency;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Events
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Domain event interface.
|
||||
/// VI: Interface domain event.
|
||||
/// </summary>
|
||||
public interface IDomainEvent : INotification
|
||||
{
|
||||
Guid Id { get; }
|
||||
DateTime OccurredOn { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event when order is submitted.
|
||||
/// VI: Domain event khi order được submit.
|
||||
/// </summary>
|
||||
public record OrderSubmittedDomainEvent : IDomainEvent
|
||||
{
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
public Guid OrderId { get; init; }
|
||||
public string UserId { get; init; }
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
public OrderSubmittedDomainEvent(Guid orderId, string userId, decimal totalAmount)
|
||||
{
|
||||
OrderId = orderId;
|
||||
UserId = userId;
|
||||
TotalAmount = totalAmount;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
### 1. Anemic Domain Model
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Anemic model with no behavior
|
||||
public class Order
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Status { get; set; }
|
||||
public List<OrderItem> Items { get; set; }
|
||||
}
|
||||
|
||||
// ✅ GOOD: Rich domain model with behavior
|
||||
public class Order
|
||||
{
|
||||
private readonly List<OrderItem> _items = new();
|
||||
public OrderStatus Status { get; private set; }
|
||||
|
||||
public void AddItem(Guid productId, int quantity, decimal price)
|
||||
{
|
||||
if (Status != OrderStatus.Draft)
|
||||
throw new DomainException("Cannot modify non-draft order");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Direct Property Modification
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Direct modification bypasses rules
|
||||
order.Status = OrderStatus.Submitted;
|
||||
|
||||
// ✅ GOOD: Use domain methods
|
||||
order.Submit();
|
||||
```
|
||||
|
||||
### 3. Logic in Application Layer
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Business logic in handler
|
||||
public async Task Handle(CreateOrderCommand cmd)
|
||||
{
|
||||
if (cmd.Items.Count == 0)
|
||||
throw new Exception("Empty order");
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ GOOD: Business logic in domain
|
||||
public void Submit()
|
||||
{
|
||||
if (!_orderItems.Any())
|
||||
throw new DomainException("Cannot submit empty order");
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
### Aggregate Design Guidelines
|
||||
|
||||
| Guideline | Description |
|
||||
|-----------|-------------|
|
||||
| Small aggregates | Keep aggregates focused |
|
||||
| Reference by ID | 외부 aggregates chỉ tham chiếu qua ID |
|
||||
| Eventual consistency | Between aggregates |
|
||||
| Immediate consistency | Within aggregate |
|
||||
|
||||
### When to Use Each Pattern
|
||||
|
||||
| Pattern | Use When |
|
||||
|---------|----------|
|
||||
| Entity | Has identity, lifecycle |
|
||||
| Value Object | No identity, immutable |
|
||||
| Aggregate | Group of related entities |
|
||||
| Domain Event | Side effects needed |
|
||||
| Domain Service | Logic doesn't fit entity |
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
|
||||
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access
|
||||
- [CQRS MediatR](../cqrs-mediatr/SKILL.md) - Command handlers
|
||||
- [Testing Patterns](../testing-patterns/SKILL.md) - Domain testing
|
||||
530
.agent/skills/domain-driven-design/references/REFERENCE.md
Normal file
530
.agent/skills/domain-driven-design/references/REFERENCE.md
Normal file
@@ -0,0 +1,530 @@
|
||||
# Domain-Driven Design - Detailed Reference
|
||||
|
||||
Detailed code examples cho DDD patterns trong GoodGo.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Entity Patterns](#entity-patterns)
|
||||
2. [Value Objects](#value-objects)
|
||||
3. [Aggregates](#aggregates)
|
||||
4. [Domain Events](#domain-events)
|
||||
5. [Domain Services](#domain-services)
|
||||
6. [Specifications](#specifications)
|
||||
|
||||
---
|
||||
|
||||
## Entity Patterns
|
||||
|
||||
### Complete Entity Base Class
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Base Entity class with domain events support.
|
||||
/// VI: Base Entity class với hỗ trợ domain events.
|
||||
/// </summary>
|
||||
public abstract class Entity : IEquatable<Entity>
|
||||
{
|
||||
private readonly List<IDomainEvent> _domainEvents = new();
|
||||
private int? _requestedHashCode;
|
||||
|
||||
public virtual Guid Id { get; protected set; }
|
||||
|
||||
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
|
||||
|
||||
protected void AddDomainEvent(IDomainEvent domainEvent)
|
||||
{
|
||||
_domainEvents.Add(domainEvent);
|
||||
}
|
||||
|
||||
public void ClearDomainEvents() => _domainEvents.Clear();
|
||||
|
||||
public bool IsTransient() => Id == default;
|
||||
|
||||
public bool Equals(Entity? other)
|
||||
{
|
||||
if (other is null) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
if (GetType() != other.GetType()) return false;
|
||||
if (IsTransient() || other.IsTransient()) return false;
|
||||
return Id.Equals(other.Id);
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => Equals(obj as Entity);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
if (IsTransient()) return base.GetHashCode();
|
||||
_requestedHashCode ??= Id.GetHashCode() ^ 31;
|
||||
return _requestedHashCode.Value;
|
||||
}
|
||||
|
||||
public static bool operator ==(Entity? left, Entity? right) => Equals(left, right);
|
||||
public static bool operator !=(Entity? left, Entity? right) => !Equals(left, right);
|
||||
}
|
||||
```
|
||||
|
||||
### Child Entity (OrderItem)
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: OrderItem entity belonging to Order aggregate.
|
||||
/// VI: Entity OrderItem thuộc Order aggregate.
|
||||
/// </summary>
|
||||
public class OrderItem : Entity
|
||||
{
|
||||
public Guid ProductId { get; private set; }
|
||||
public string ProductName { get; private set; }
|
||||
public int Quantity { get; private set; }
|
||||
public decimal UnitPrice { get; private set; }
|
||||
public decimal TotalPrice => Quantity * UnitPrice;
|
||||
|
||||
// EN: Required by EF Core
|
||||
private OrderItem() { }
|
||||
|
||||
internal OrderItem(Guid productId, string productName, int quantity, decimal unitPrice)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
ProductId = productId;
|
||||
ProductName = productName ?? throw new ArgumentNullException(nameof(productName));
|
||||
SetQuantity(quantity);
|
||||
UnitPrice = unitPrice >= 0 ? unitPrice
|
||||
: throw new ArgumentException("Price cannot be negative");
|
||||
}
|
||||
|
||||
internal void SetQuantity(int quantity)
|
||||
{
|
||||
if (quantity <= 0)
|
||||
throw new DomainException("Quantity must be positive");
|
||||
|
||||
Quantity = quantity;
|
||||
}
|
||||
|
||||
internal void IncreaseQuantity(int amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new ArgumentException("Amount must be positive");
|
||||
|
||||
Quantity += amount;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Value Objects
|
||||
|
||||
### Value Object Base
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Base class for value objects with equality support.
|
||||
/// VI: Base class cho value objects với hỗ trợ equality.
|
||||
/// </summary>
|
||||
public abstract class ValueObject : IEquatable<ValueObject>
|
||||
{
|
||||
protected abstract IEnumerable<object?> GetEqualityComponents();
|
||||
|
||||
public bool Equals(ValueObject? other)
|
||||
{
|
||||
if (other is null) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
if (GetType() != other.GetType()) return false;
|
||||
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => Equals(obj as ValueObject);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return GetEqualityComponents()
|
||||
.Aggregate(0, (hash, component) =>
|
||||
HashCode.Combine(hash, component?.GetHashCode() ?? 0));
|
||||
}
|
||||
|
||||
public static bool operator ==(ValueObject? left, ValueObject? right)
|
||||
=> Equals(left, right);
|
||||
|
||||
public static bool operator !=(ValueObject? left, ValueObject? right)
|
||||
=> !Equals(left, right);
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Value Objects
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Email value object with validation.
|
||||
/// VI: Value object email với validation.
|
||||
/// </summary>
|
||||
public sealed class Email : ValueObject
|
||||
{
|
||||
public string Value { get; }
|
||||
|
||||
private Email(string value) => Value = value;
|
||||
|
||||
public static Email Create(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
throw new DomainException("Email cannot be empty");
|
||||
|
||||
if (!IsValidEmail(email))
|
||||
throw new DomainException("Invalid email format");
|
||||
|
||||
return new Email(email.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addr = new System.Net.Mail.MailAddress(email);
|
||||
return addr.Address == email;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override IEnumerable<object?> GetEqualityComponents()
|
||||
{
|
||||
yield return Value;
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static implicit operator string(Email email) => email.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: DateRange value object.
|
||||
/// VI: Value object khoảng thời gian.
|
||||
/// </summary>
|
||||
public sealed class DateRange : ValueObject
|
||||
{
|
||||
public DateTime Start { get; }
|
||||
public DateTime End { get; }
|
||||
public TimeSpan Duration => End - Start;
|
||||
|
||||
private DateRange(DateTime start, DateTime end)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
}
|
||||
|
||||
public static DateRange Create(DateTime start, DateTime end)
|
||||
{
|
||||
if (end < start)
|
||||
throw new DomainException("End date must be after start date");
|
||||
|
||||
return new DateRange(start, end);
|
||||
}
|
||||
|
||||
public bool Contains(DateTime date) => date >= Start && date <= End;
|
||||
|
||||
public bool Overlaps(DateRange other)
|
||||
=> Start < other.End && End > other.Start;
|
||||
|
||||
protected override IEnumerable<object?> GetEqualityComponents()
|
||||
{
|
||||
yield return Start;
|
||||
yield return End;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Money value object with currency operations.
|
||||
/// VI: Value object tiền tệ với các phép toán.
|
||||
/// </summary>
|
||||
public sealed class Money : ValueObject
|
||||
{
|
||||
public decimal Amount { get; }
|
||||
public Currency Currency { get; }
|
||||
|
||||
private Money(decimal amount, Currency currency)
|
||||
{
|
||||
Amount = amount;
|
||||
Currency = currency;
|
||||
}
|
||||
|
||||
public static Money Create(decimal amount, Currency currency)
|
||||
{
|
||||
if (amount < 0)
|
||||
throw new DomainException("Amount cannot be negative");
|
||||
|
||||
return new Money(amount, currency);
|
||||
}
|
||||
|
||||
public static Money Zero(Currency currency) => new(0, currency);
|
||||
|
||||
public Money Add(Money other)
|
||||
{
|
||||
EnsureSameCurrency(other);
|
||||
return new Money(Amount + other.Amount, Currency);
|
||||
}
|
||||
|
||||
public Money Subtract(Money other)
|
||||
{
|
||||
EnsureSameCurrency(other);
|
||||
var result = Amount - other.Amount;
|
||||
if (result < 0)
|
||||
throw new DomainException("Insufficient funds");
|
||||
return new Money(result, Currency);
|
||||
}
|
||||
|
||||
public Money Multiply(decimal factor)
|
||||
{
|
||||
if (factor < 0)
|
||||
throw new ArgumentException("Factor cannot be negative");
|
||||
return new Money(Amount * factor, Currency);
|
||||
}
|
||||
|
||||
private void EnsureSameCurrency(Money other)
|
||||
{
|
||||
if (Currency != other.Currency)
|
||||
throw new DomainException("Cannot perform operation on different currencies");
|
||||
}
|
||||
|
||||
protected override IEnumerable<object?> GetEqualityComponents()
|
||||
{
|
||||
yield return Amount;
|
||||
yield return Currency;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Amount:N2} {Currency}";
|
||||
}
|
||||
|
||||
public enum Currency { VND, USD, EUR }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Aggregates
|
||||
|
||||
### Complete Aggregate Example
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Order aggregate with full business logic.
|
||||
/// VI: Order aggregate với business logic đầy đủ.
|
||||
/// </summary>
|
||||
public class Order : Entity, IAggregateRoot
|
||||
{
|
||||
private readonly List<OrderItem> _items = new();
|
||||
|
||||
public string UserId { get; private set; } = default!;
|
||||
public Address ShippingAddress { get; private set; } = default!;
|
||||
public Address? BillingAddress { get; private set; }
|
||||
public OrderStatus Status { get; private set; }
|
||||
public Money TotalAmount { get; private set; } = Money.Zero(Currency.VND);
|
||||
public string? CancellationReason { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public DateTime? SubmittedAt { get; private set; }
|
||||
public DateTime? ShippedAt { get; private set; }
|
||||
public DateTime? DeliveredAt { get; private set; }
|
||||
|
||||
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
|
||||
|
||||
private Order() { } // EF Core
|
||||
|
||||
public Order(string userId, Address shippingAddress, Address? billingAddress = null)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
|
||||
ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress));
|
||||
BillingAddress = billingAddress;
|
||||
Status = OrderStatus.Draft;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new OrderCreatedDomainEvent(Id, userId));
|
||||
}
|
||||
|
||||
// EN: Command methods / VI: Các method command
|
||||
public void AddItem(Guid productId, string productName, int quantity, decimal unitPrice)
|
||||
{
|
||||
EnsureOrderIsDraft();
|
||||
|
||||
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
|
||||
if (existingItem != null)
|
||||
{
|
||||
existingItem.IncreaseQuantity(quantity);
|
||||
}
|
||||
else
|
||||
{
|
||||
var item = new OrderItem(productId, productName, quantity, unitPrice);
|
||||
_items.Add(item);
|
||||
}
|
||||
|
||||
RecalculateTotal();
|
||||
AddDomainEvent(new OrderItemAddedDomainEvent(Id, productId, quantity));
|
||||
}
|
||||
|
||||
public void UpdateItemQuantity(Guid productId, int newQuantity)
|
||||
{
|
||||
EnsureOrderIsDraft();
|
||||
|
||||
var item = _items.FirstOrDefault(i => i.ProductId == productId)
|
||||
?? throw new DomainException($"Item {productId} not found in order");
|
||||
|
||||
if (newQuantity <= 0)
|
||||
{
|
||||
_items.Remove(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
item.SetQuantity(newQuantity);
|
||||
}
|
||||
|
||||
RecalculateTotal();
|
||||
}
|
||||
|
||||
public void UpdateShippingAddress(Address newAddress)
|
||||
{
|
||||
if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
|
||||
throw new DomainException("Cannot change address for shipped order");
|
||||
|
||||
ShippingAddress = newAddress ?? throw new ArgumentNullException(nameof(newAddress));
|
||||
}
|
||||
|
||||
public void Submit()
|
||||
{
|
||||
EnsureOrderIsDraft();
|
||||
|
||||
if (!_items.Any())
|
||||
throw new DomainException("Cannot submit empty order");
|
||||
|
||||
Status = OrderStatus.Submitted;
|
||||
SubmittedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new OrderSubmittedDomainEvent(Id, UserId, TotalAmount.Amount));
|
||||
}
|
||||
|
||||
public void ConfirmPayment()
|
||||
{
|
||||
if (Status != OrderStatus.Submitted)
|
||||
throw new DomainException($"Cannot confirm payment for order in {Status} status");
|
||||
|
||||
Status = OrderStatus.Paid;
|
||||
AddDomainEvent(new OrderPaidDomainEvent(Id, TotalAmount.Amount));
|
||||
}
|
||||
|
||||
public void Ship(string trackingNumber)
|
||||
{
|
||||
if (Status != OrderStatus.Paid)
|
||||
throw new DomainException("Order must be paid before shipping");
|
||||
|
||||
Status = OrderStatus.Shipped;
|
||||
ShippedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new OrderShippedDomainEvent(Id, trackingNumber));
|
||||
}
|
||||
|
||||
public void Cancel(string reason)
|
||||
{
|
||||
if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
|
||||
throw new DomainException("Cannot cancel shipped order");
|
||||
|
||||
Status = OrderStatus.Cancelled;
|
||||
CancellationReason = reason;
|
||||
|
||||
AddDomainEvent(new OrderCancelledDomainEvent(Id, reason));
|
||||
}
|
||||
|
||||
// EN: Private helpers / VI: Helpers private
|
||||
private void EnsureOrderIsDraft()
|
||||
{
|
||||
if (Status != OrderStatus.Draft)
|
||||
throw new DomainException("Order is not in draft status");
|
||||
}
|
||||
|
||||
private void RecalculateTotal()
|
||||
{
|
||||
var total = _items.Sum(i => i.TotalPrice);
|
||||
TotalAmount = Money.Create(total, Currency.VND);
|
||||
}
|
||||
}
|
||||
|
||||
public enum OrderStatus
|
||||
{
|
||||
Draft,
|
||||
Submitted,
|
||||
Paid,
|
||||
Shipped,
|
||||
Delivered,
|
||||
Cancelled
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Events
|
||||
|
||||
### Domain Event Definitions
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Domain events for Order aggregate.
|
||||
/// VI: Domain events cho Order aggregate.
|
||||
/// </summary>
|
||||
public record OrderCreatedDomainEvent(Guid OrderId, string UserId) : IDomainEvent
|
||||
{
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public record OrderItemAddedDomainEvent(Guid OrderId, Guid ProductId, int Quantity) : IDomainEvent
|
||||
{
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public record OrderSubmittedDomainEvent(Guid OrderId, string UserId, decimal TotalAmount) : IDomainEvent
|
||||
{
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Event Dispatcher
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Dispatch domain events via MediatR.
|
||||
/// VI: Dispatch domain events qua MediatR.
|
||||
/// </summary>
|
||||
public class DomainEventDispatcher : IDomainEventDispatcher
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<DomainEventDispatcher> _logger;
|
||||
|
||||
public async Task DispatchEventsAsync(IEnumerable<Entity> entities, CancellationToken ct = default)
|
||||
{
|
||||
var domainEvents = entities
|
||||
.SelectMany(e => e.DomainEvents)
|
||||
.ToList();
|
||||
|
||||
foreach (var domainEvent in domainEvents)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Dispatching domain event {EventType}: {EventId}",
|
||||
domainEvent.GetType().Name,
|
||||
domainEvent.Id);
|
||||
|
||||
await _mediator.Publish(domainEvent, ct);
|
||||
}
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
entity.ClearDomainEvents();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Domain-Driven Design - Eric Evans](https://www.domainlanguage.com/)
|
||||
- [Implementing DDD - Vaughn Vernon](https://www.informit.com/store/implementing-domain-driven-design-9780321834577)
|
||||
- [Microsoft DDD Guidance](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/)
|
||||
373
.agent/skills/inter-service-communication/SKILL.md
Normal file
373
.agent/skills/inter-service-communication/SKILL.md
Normal file
@@ -0,0 +1,373 @@
|
||||
---
|
||||
name: inter-service-communication
|
||||
description: Giao tiếp liên dịch vụ. Use for Event Bus (RabbitMQ), Integration Events, HTTP Client với Polly, và gRPC patterns.
|
||||
compatibility: ".NET 8+, MassTransit, RabbitMQ, Polly, gRPC"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Inter-Service Communication / Giao Tiếp Liên Dịch Vụ
|
||||
|
||||
Patterns giao tiếp giữa các microservices trong GoodGo.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Publishing domain events to other services / Publish domain events đến services khác
|
||||
- Consuming integration events / Consume integration events
|
||||
- Making HTTP calls to other services / Gọi HTTP đến services khác
|
||||
- Implementing resilient communication / Triển khai giao tiếp có khả năng phục hồi
|
||||
|
||||
## Core Concepts / Khái Niệm Cốt Lõi
|
||||
|
||||
### Communication Patterns / Các Mẫu Giao Tiếp
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ COMMUNICATION PATTERNS │
|
||||
├───────────────────────────────┬─────────────────────────────┤
|
||||
│ SYNCHRONOUS │ ASYNCHRONOUS │
|
||||
│ (Request/Response) │ (Fire & Forget) │
|
||||
├───────────────────────────────┼─────────────────────────────┤
|
||||
│ • HTTP/REST APIs │ • Message Queue (RabbitMQ) │
|
||||
│ • gRPC │ • Event Bus │
|
||||
│ • Direct service calls │ • Pub/Sub │
|
||||
├───────────────────────────────┼─────────────────────────────┤
|
||||
│ Use for: Queries, UI │ Use for: Commands, Events │
|
||||
│ Pro: Immediate response │ Pro: Decoupled, resilient │
|
||||
│ Con: Tight coupling │ Con: Eventual consistency │
|
||||
└───────────────────────────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Event Types / Các Loại Event
|
||||
|
||||
| Type | Scope | Transport | Example |
|
||||
|------|-------|-----------|---------|
|
||||
| **Domain Event** | Within aggregate | In-memory | OrderItemAdded |
|
||||
| **Integration Event** | Between services | Message broker | OrderCreatedIntegrationEvent |
|
||||
|
||||
### Anti-patterns to Avoid / Anti-patterns Cần Tránh
|
||||
|
||||
```
|
||||
❌ Synchronous HTTP Chains (Anti-pattern)
|
||||
|
||||
Service A ──HTTP──▶ Service B ──HTTP──▶ Service C ──HTTP──▶ DB
|
||||
│ │
|
||||
└── Single point of failure ──┘
|
||||
|
||||
✅ Async Event-Driven (Recommended)
|
||||
|
||||
Service A ──Publish──▶ Event Bus ──Subscribe──▶ Service B
|
||||
│
|
||||
└────Subscribe──▶ Service C
|
||||
```
|
||||
|
||||
## Key Patterns / Mẫu Chính
|
||||
|
||||
### Integration Event Definition
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Base interface for integration events.
|
||||
/// VI: Interface cơ sở cho integration events.
|
||||
/// </summary>
|
||||
public interface IIntegrationEvent
|
||||
{
|
||||
Guid Id { get; }
|
||||
DateTime OccurredOn { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Event when order is created.
|
||||
/// VI: Event khi order được tạo.
|
||||
/// </summary>
|
||||
public record OrderCreatedIntegrationEvent : IIntegrationEvent
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
|
||||
|
||||
public Guid OrderId { get; init; }
|
||||
public string UserId { get; init; } = default!;
|
||||
public decimal TotalAmount { get; init; }
|
||||
public IReadOnlyList<OrderItemInfo> Items { get; init; } = Array.Empty<OrderItemInfo>();
|
||||
}
|
||||
|
||||
public record OrderItemInfo(Guid ProductId, int Quantity, decimal UnitPrice);
|
||||
```
|
||||
|
||||
### Event Publisher with MassTransit
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Publish integration events via MassTransit.
|
||||
/// VI: Publish integration events qua MassTransit.
|
||||
/// </summary>
|
||||
public class IntegrationEventPublisher : IIntegrationEventPublisher
|
||||
{
|
||||
private readonly IPublishEndpoint _publishEndpoint;
|
||||
private readonly ILogger<IntegrationEventPublisher> _logger;
|
||||
|
||||
public IntegrationEventPublisher(
|
||||
IPublishEndpoint publishEndpoint,
|
||||
ILogger<IntegrationEventPublisher> logger)
|
||||
{
|
||||
_publishEndpoint = publishEndpoint;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task PublishAsync<T>(T @event, CancellationToken ct = default)
|
||||
where T : class, IIntegrationEvent
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Publishing event {EventType} with Id {EventId}",
|
||||
typeof(T).Name,
|
||||
@event.Id);
|
||||
|
||||
await _publishEndpoint.Publish(@event, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Usage in command handler / VI: Sử dụng trong command handler
|
||||
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IIntegrationEventPublisher _eventPublisher;
|
||||
|
||||
public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
|
||||
{
|
||||
var order = new Order(request.UserId, request.ShippingAddress);
|
||||
// ... add items
|
||||
|
||||
await _orderRepository.AddAsync(order, ct);
|
||||
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
// EN: Publish integration event
|
||||
// VI: Publish integration event
|
||||
await _eventPublisher.PublishAsync(new OrderCreatedIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
UserId = order.UserId,
|
||||
TotalAmount = order.TotalAmount,
|
||||
Items = order.OrderItems.Select(i => new OrderItemInfo(
|
||||
i.ProductId, i.Quantity, i.UnitPrice)).ToList()
|
||||
}, ct);
|
||||
|
||||
return new OrderResult(order.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Consumer
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Consumer for OrderCreatedIntegrationEvent in Inventory Service.
|
||||
/// VI: Consumer cho OrderCreatedIntegrationEvent trong Inventory Service.
|
||||
/// </summary>
|
||||
public class OrderCreatedConsumer : IConsumer<OrderCreatedIntegrationEvent>
|
||||
{
|
||||
private readonly IInventoryService _inventoryService;
|
||||
private readonly ILogger<OrderCreatedConsumer> _logger;
|
||||
|
||||
public OrderCreatedConsumer(
|
||||
IInventoryService inventoryService,
|
||||
ILogger<OrderCreatedConsumer> logger)
|
||||
{
|
||||
_inventoryService = inventoryService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Consume(ConsumeContext<OrderCreatedIntegrationEvent> context)
|
||||
{
|
||||
var @event = context.Message;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Received OrderCreatedEvent: {OrderId}",
|
||||
@event.OrderId);
|
||||
|
||||
try
|
||||
{
|
||||
// EN: Reserve inventory for order items
|
||||
// VI: Đặt trước inventory cho order items
|
||||
foreach (var item in @event.Items)
|
||||
{
|
||||
await _inventoryService.ReserveAsync(
|
||||
item.ProductId,
|
||||
item.Quantity,
|
||||
@event.OrderId,
|
||||
context.CancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Inventory reserved for Order: {OrderId}",
|
||||
@event.OrderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to reserve inventory for Order: {OrderId}",
|
||||
@event.OrderId);
|
||||
throw; // EN: Will retry based on retry policy
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MassTransit Configuration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure MassTransit with RabbitMQ.
|
||||
/// VI: Cấu hình MassTransit với RabbitMQ.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddMassTransit(x =>
|
||||
{
|
||||
// EN: Register consumers
|
||||
// VI: Đăng ký consumers
|
||||
x.AddConsumer<OrderCreatedConsumer>();
|
||||
x.AddConsumer<ProductPriceChangedConsumer>();
|
||||
|
||||
x.UsingRabbitMq((context, cfg) =>
|
||||
{
|
||||
cfg.Host(builder.Configuration["RabbitMQ:Host"], "/", h =>
|
||||
{
|
||||
h.Username(builder.Configuration["RabbitMQ:Username"]!);
|
||||
h.Password(builder.Configuration["RabbitMQ:Password"]!);
|
||||
});
|
||||
|
||||
// EN: Configure retry policy
|
||||
// VI: Cấu hình retry policy
|
||||
cfg.UseMessageRetry(r => r.Intervals(
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(15)));
|
||||
|
||||
// EN: Configure endpoints
|
||||
// VI: Cấu hình endpoints
|
||||
cfg.ConfigureEndpoints(context);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Resilient HTTP Client with Polly
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure resilient HTTP client for inter-service calls.
|
||||
/// VI: Cấu hình HTTP client có khả năng phục hồi cho gọi liên dịch vụ.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddHttpClient<IIamServiceClient, IamServiceClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(builder.Configuration["Services:Iam:BaseUrl"]!);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
})
|
||||
.AddResilienceHandler("iam-service", builder =>
|
||||
{
|
||||
// EN: Retry with exponential backoff
|
||||
builder.AddRetry(new HttpRetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = 3,
|
||||
Delay = TimeSpan.FromMilliseconds(500),
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
|
||||
.HandleResult(r => (int)r.StatusCode >= 500)
|
||||
.Handle<HttpRequestException>()
|
||||
});
|
||||
|
||||
// EN: Circuit breaker
|
||||
builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
|
||||
{
|
||||
FailureRatio = 0.5,
|
||||
SamplingDuration = TimeSpan.FromSeconds(30),
|
||||
BreakDuration = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
// EN: Timeout
|
||||
builder.AddTimeout(TimeSpan.FromSeconds(10));
|
||||
});
|
||||
```
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
### 1. Synchronous HTTP Chains
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Chain of HTTP calls
|
||||
public async Task ProcessOrder(Order order)
|
||||
{
|
||||
await _inventoryService.ReserveAsync(order); // HTTP
|
||||
await _paymentService.ChargeAsync(order); // HTTP
|
||||
await _shippingService.CreateShipmentAsync(order); // HTTP
|
||||
}
|
||||
|
||||
// ✅ GOOD: Event-driven, async
|
||||
public async Task ProcessOrder(Order order)
|
||||
{
|
||||
await _eventPublisher.PublishAsync(new OrderCreatedEvent(order));
|
||||
// Each service subscribes and processes independently
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Missing Idempotency
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: No idempotency check
|
||||
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
|
||||
{
|
||||
await _service.CreateShipment(context.Message.OrderId);
|
||||
}
|
||||
|
||||
// ✅ GOOD: Idempotent processing
|
||||
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
|
||||
{
|
||||
var eventId = context.Message.Id;
|
||||
if (await _processedEvents.ExistsAsync(eventId))
|
||||
return; // Already processed
|
||||
|
||||
await _service.CreateShipment(context.Message.OrderId);
|
||||
await _processedEvents.MarkProcessedAsync(eventId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. No Retry Policy
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Raw HTTP call without resilience
|
||||
var response = await _httpClient.GetAsync("/api/users/123");
|
||||
|
||||
// ✅ GOOD: Use resilient HTTP client (configured at DI level)
|
||||
// Retries, circuit breaker, and timeout are handled automatically
|
||||
var response = await _resilientClient.GetAsync("/api/users/123");
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Pattern | Transport |
|
||||
|----------|---------|-----------|
|
||||
| Get data for UI | Sync HTTP | REST/gRPC |
|
||||
| Notify state change | Async Event | RabbitMQ |
|
||||
| Long-running process | Async Event | RabbitMQ |
|
||||
| Real-time updates | Pub/Sub | RabbitMQ/SignalR |
|
||||
|
||||
### Message Broker vs Direct Call
|
||||
|
||||
| Aspect | Message Broker | Direct HTTP |
|
||||
|--------|----------------|-------------|
|
||||
| Coupling | Loose | Tight |
|
||||
| Failure handling | Built-in retry | Manual |
|
||||
| Scalability | High | Medium |
|
||||
| Debugging | Harder | Easier |
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
|
||||
- [Error Handling](../error-handling-patterns/SKILL.md) - Polly patterns
|
||||
- [CQRS MediatR](../cqrs-mediatr/SKILL.md) - Domain events
|
||||
- [Docker Traefik](../docker-traefik/SKILL.md) - RabbitMQ container
|
||||
@@ -0,0 +1,520 @@
|
||||
# Inter-Service Communication - Detailed Reference
|
||||
|
||||
Detailed code examples cho giao tiếp liên dịch vụ trong GoodGo.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [MassTransit Setup](#masstransit-setup)
|
||||
2. [Integration Events](#integration-events)
|
||||
3. [Event Consumers](#event-consumers)
|
||||
4. [HTTP Client Patterns](#http-client-patterns)
|
||||
5. [Outbox Pattern](#outbox-pattern)
|
||||
6. [gRPC Setup](#grpc-setup)
|
||||
|
||||
---
|
||||
|
||||
## MassTransit Setup
|
||||
|
||||
### Complete MassTransit Configuration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure MassTransit with RabbitMQ for production.
|
||||
/// VI: Cấu hình MassTransit với RabbitMQ cho production.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddMassTransit(x =>
|
||||
{
|
||||
// EN: Register all consumers from assembly
|
||||
// VI: Đăng ký tất cả consumers từ assembly
|
||||
x.AddConsumers(typeof(Program).Assembly);
|
||||
|
||||
// EN: Configure saga if needed
|
||||
// x.AddSagaStateMachine<OrderStateMachine, OrderState>()
|
||||
// .EntityFrameworkRepository(...);
|
||||
|
||||
x.UsingRabbitMq((context, cfg) =>
|
||||
{
|
||||
var rabbitConfig = builder.Configuration.GetSection("RabbitMQ");
|
||||
|
||||
cfg.Host(rabbitConfig["Host"], rabbitConfig["VirtualHost"] ?? "/", h =>
|
||||
{
|
||||
h.Username(rabbitConfig["Username"]!);
|
||||
h.Password(rabbitConfig["Password"]!);
|
||||
});
|
||||
|
||||
// EN: Global retry policy
|
||||
// VI: Retry policy toàn cục
|
||||
cfg.UseMessageRetry(r =>
|
||||
{
|
||||
r.Incremental(3, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2));
|
||||
r.Ignore<ValidationException>();
|
||||
});
|
||||
|
||||
// EN: Dead letter queue for failed messages
|
||||
// VI: Dead letter queue cho messages thất bại
|
||||
cfg.UseDelayedRedelivery(r => r.Intervals(
|
||||
TimeSpan.FromMinutes(5),
|
||||
TimeSpan.FromMinutes(15),
|
||||
TimeSpan.FromMinutes(30)));
|
||||
|
||||
// EN: Configure endpoints
|
||||
// VI: Cấu hình endpoints
|
||||
cfg.ConfigureEndpoints(context, new KebabCaseEndpointNameFormatter("goodgo", false));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"RabbitMQ": {
|
||||
"Host": "localhost",
|
||||
"VirtualHost": "/",
|
||||
"Username": "guest",
|
||||
"Password": "guest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Events
|
||||
|
||||
### Event Definitions
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Integration event base record.
|
||||
/// VI: Integration event record cơ sở.
|
||||
/// </summary>
|
||||
public abstract record IntegrationEvent
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
|
||||
public string CorrelationId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Event when order status changes.
|
||||
/// VI: Event khi trạng thái order thay đổi.
|
||||
/// </summary>
|
||||
public record OrderStatusChangedIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public Guid OrderId { get; init; }
|
||||
public string PreviousStatus { get; init; } = default!;
|
||||
public string NewStatus { get; init; } = default!;
|
||||
public string UserId { get; init; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Event when product price changes.
|
||||
/// VI: Event khi giá sản phẩm thay đổi.
|
||||
/// </summary>
|
||||
public record ProductPriceChangedIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public Guid ProductId { get; init; }
|
||||
public decimal OldPrice { get; init; }
|
||||
public decimal NewPrice { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Event when payment is completed.
|
||||
/// VI: Event khi thanh toán hoàn thành.
|
||||
/// </summary>
|
||||
public record PaymentCompletedIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public Guid PaymentId { get; init; }
|
||||
public Guid OrderId { get; init; }
|
||||
public decimal Amount { get; init; }
|
||||
public string PaymentMethod { get; init; } = default!;
|
||||
}
|
||||
```
|
||||
|
||||
### Event Publisher Service
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Service to publish integration events.
|
||||
/// VI: Service publish integration events.
|
||||
/// </summary>
|
||||
public interface IEventPublisher
|
||||
{
|
||||
Task PublishAsync<T>(T @event, CancellationToken ct = default) where T : class;
|
||||
}
|
||||
|
||||
public class MassTransitEventPublisher : IEventPublisher
|
||||
{
|
||||
private readonly IPublishEndpoint _publishEndpoint;
|
||||
private readonly ILogger<MassTransitEventPublisher> _logger;
|
||||
|
||||
public MassTransitEventPublisher(
|
||||
IPublishEndpoint publishEndpoint,
|
||||
ILogger<MassTransitEventPublisher> logger)
|
||||
{
|
||||
_publishEndpoint = publishEndpoint;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task PublishAsync<T>(T @event, CancellationToken ct = default)
|
||||
where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
await _publishEndpoint.Publish(@event, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published event {EventType}: {@Event}",
|
||||
typeof(T).Name, @event);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to publish event {EventType}: {@Event}",
|
||||
typeof(T).Name, @event);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Consumers
|
||||
|
||||
### Consumer with Idempotency
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Idempotent consumer for payment completed events.
|
||||
/// VI: Consumer idempotent cho payment completed events.
|
||||
/// </summary>
|
||||
public class PaymentCompletedConsumer : IConsumer<PaymentCompletedIntegrationEvent>
|
||||
{
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IIdempotencyService _idempotency;
|
||||
private readonly ILogger<PaymentCompletedConsumer> _logger;
|
||||
|
||||
public PaymentCompletedConsumer(
|
||||
IOrderService orderService,
|
||||
IIdempotencyService idempotency,
|
||||
ILogger<PaymentCompletedConsumer> logger)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_idempotency = idempotency;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Consume(ConsumeContext<PaymentCompletedIntegrationEvent> context)
|
||||
{
|
||||
var @event = context.Message;
|
||||
|
||||
// EN: Check if already processed
|
||||
// VI: Kiểm tra đã xử lý chưa
|
||||
if (await _idempotency.HasBeenProcessedAsync(@event.Id))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Event {EventId} already processed, skipping",
|
||||
@event.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing PaymentCompleted for Order {OrderId}",
|
||||
@event.OrderId);
|
||||
|
||||
await _orderService.ConfirmPaymentAsync(
|
||||
@event.OrderId,
|
||||
@event.PaymentId,
|
||||
@event.Amount,
|
||||
context.CancellationToken);
|
||||
|
||||
// EN: Mark as processed
|
||||
// VI: Đánh dấu đã xử lý
|
||||
await _idempotency.MarkAsProcessedAsync(@event.Id);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully processed PaymentCompleted for Order {OrderId}",
|
||||
@event.OrderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to process PaymentCompleted for Order {OrderId}",
|
||||
@event.OrderId);
|
||||
throw; // EN: Will trigger retry
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Consumer Definition (Advanced Config)
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Consumer definition with custom retry and concurrency.
|
||||
/// VI: Consumer definition với retry và concurrency tùy chỉnh.
|
||||
/// </summary>
|
||||
public class PaymentCompletedConsumerDefinition
|
||||
: ConsumerDefinition<PaymentCompletedConsumer>
|
||||
{
|
||||
public PaymentCompletedConsumerDefinition()
|
||||
{
|
||||
// EN: Endpoint name
|
||||
// VI: Tên endpoint
|
||||
EndpointName = "order-service-payment-completed";
|
||||
|
||||
// EN: Prefetch count for concurrency
|
||||
// VI: Prefetch count cho concurrency
|
||||
ConcurrentMessageLimit = 10;
|
||||
}
|
||||
|
||||
protected override void ConfigureConsumer(
|
||||
IReceiveEndpointConfigurator endpointConfigurator,
|
||||
IConsumerConfigurator<PaymentCompletedConsumer> consumerConfigurator,
|
||||
IRegistrationContext context)
|
||||
{
|
||||
// EN: Custom retry for this consumer
|
||||
// VI: Retry tùy chỉnh cho consumer này
|
||||
endpointConfigurator.UseMessageRetry(r =>
|
||||
{
|
||||
r.Intervals(
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromMinutes(5));
|
||||
r.Ignore<OrderNotFoundException>();
|
||||
});
|
||||
|
||||
// EN: Circuit breaker
|
||||
// VI: Circuit breaker
|
||||
endpointConfigurator.UseCircuitBreaker(cb =>
|
||||
{
|
||||
cb.TrackingPeriod = TimeSpan.FromMinutes(1);
|
||||
cb.TripThreshold = 15;
|
||||
cb.ActiveThreshold = 10;
|
||||
cb.ResetInterval = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP Client Patterns
|
||||
|
||||
### Typed HTTP Client
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Typed HTTP client for IAM service.
|
||||
/// VI: Typed HTTP client cho IAM service.
|
||||
/// </summary>
|
||||
public interface IIamServiceClient
|
||||
{
|
||||
Task<UserInfoDto?> GetUserAsync(string userId, CancellationToken ct = default);
|
||||
Task<bool> ValidateTokenAsync(string token, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class IamServiceClient : IIamServiceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<IamServiceClient> _logger;
|
||||
|
||||
public IamServiceClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<IamServiceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<UserInfoDto?> GetUserAsync(string userId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/v1/users/{userId}", ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserInfoDto>>(ct);
|
||||
return result?.Data;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get user {UserId} from IAM service", userId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateTokenAsync(string token, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/validate");
|
||||
request.Content = JsonContent.Create(new { Token = token });
|
||||
|
||||
var response = await _httpClient.SendAsync(request, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Token validation failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Client Registration with Polly
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Register resilient HTTP clients.
|
||||
/// VI: Đăng ký HTTP clients có khả năng phục hồi.
|
||||
/// </summary>
|
||||
|
||||
builder.Services.AddHttpClient<IIamServiceClient, IamServiceClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(builder.Configuration["Services:Iam:BaseUrl"]!);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
})
|
||||
.AddResilienceHandler("iam-client", builder =>
|
||||
{
|
||||
builder.AddRetry(new HttpRetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = 3,
|
||||
Delay = TimeSpan.FromMilliseconds(300),
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
|
||||
.HandleResult(r => r.StatusCode >= HttpStatusCode.InternalServerError)
|
||||
.Handle<HttpRequestException>()
|
||||
.Handle<TimeoutRejectedException>()
|
||||
});
|
||||
|
||||
builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
|
||||
{
|
||||
FailureRatio = 0.3,
|
||||
SamplingDuration = TimeSpan.FromSeconds(60),
|
||||
MinimumThroughput = 10,
|
||||
BreakDuration = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
builder.AddTimeout(TimeSpan.FromSeconds(10));
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Outbox Pattern
|
||||
|
||||
### Outbox Entity
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Outbox message entity for reliable event publishing.
|
||||
/// VI: Outbox message entity cho event publishing đáng tin cậy.
|
||||
/// </summary>
|
||||
public class OutboxMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = default!;
|
||||
public string Payload { get; set; } = default!;
|
||||
public DateTime OccurredOn { get; set; }
|
||||
public DateTime? ProcessedOn { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public int RetryCount { get; set; }
|
||||
}
|
||||
|
||||
// EN: DbContext configuration
|
||||
// VI: Cấu hình DbContext
|
||||
modelBuilder.Entity<OutboxMessage>(entity =>
|
||||
{
|
||||
entity.ToTable("OutboxMessages");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.ProcessedOn);
|
||||
});
|
||||
```
|
||||
|
||||
### Outbox Processor
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Background service to process outbox messages.
|
||||
/// VI: Background service xử lý outbox messages.
|
||||
/// </summary>
|
||||
public class OutboxProcessor : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<OutboxProcessor> _logger;
|
||||
private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessPendingMessagesAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing outbox messages");
|
||||
}
|
||||
|
||||
await Task.Delay(_pollingInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessPendingMessagesAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var publisher = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
|
||||
|
||||
var messages = await dbContext.OutboxMessages
|
||||
.Where(m => m.ProcessedOn == null && m.RetryCount < 3)
|
||||
.OrderBy(m => m.OccurredOn)
|
||||
.Take(100)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
try
|
||||
{
|
||||
var eventType = Type.GetType(message.Type)!;
|
||||
var @event = JsonSerializer.Deserialize(message.Payload, eventType)!;
|
||||
|
||||
await publisher.Publish(@event, eventType, ct);
|
||||
|
||||
message.ProcessedOn = DateTime.UtcNow;
|
||||
_logger.LogDebug("Processed outbox message {Id}", message.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message.RetryCount++;
|
||||
message.Error = ex.Message;
|
||||
_logger.LogWarning(ex, "Failed to process outbox message {Id}", message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [MassTransit Documentation](https://masstransit.io/)
|
||||
- [RabbitMQ Documentation](https://www.rabbitmq.com/documentation.html)
|
||||
- [Polly Documentation](https://github.com/App-vNext/Polly)
|
||||
- [gRPC for .NET](https://docs.microsoft.com/en-us/aspnet/core/grpc/)
|
||||
356
.agent/skills/redis-caching/SKILL.md
Normal file
356
.agent/skills/redis-caching/SKILL.md
Normal file
@@ -0,0 +1,356 @@
|
||||
---
|
||||
name: redis-caching
|
||||
description: Redis caching patterns cho distributed systems. Use for cache-aside, session storage, rate limiting, và distributed locks.
|
||||
compatibility: ".NET 8+, StackExchange.Redis, Microsoft.Extensions.Caching.StackExchangeRedis"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Redis Caching Patterns / Mẫu Caching Redis
|
||||
|
||||
Redis caching và distributed data patterns cho GoodGo microservices.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Implementing distributed cache / Triển khai distributed cache
|
||||
- Caching API responses / Caching responses API
|
||||
- Managing user sessions / Quản lý user sessions
|
||||
- Implementing rate limiting / Triển khai rate limiting
|
||||
- Using Redis as primary store (e.g., shopping cart) / Dùng Redis làm store chính
|
||||
|
||||
## Core Concepts / Khái Niệm Cốt Lõi
|
||||
|
||||
### Caching Strategies / Các Chiến Lược Caching
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CACHING PATTERNS │
|
||||
├─────────────────────┬─────────────────────┬─────────────────┤
|
||||
│ CACHE-ASIDE │ WRITE-THROUGH │ WRITE-BEHIND │
|
||||
│ (Read pattern) │ (Write pattern) │ (Async write) │
|
||||
├─────────────────────┼─────────────────────┼─────────────────┤
|
||||
│ 1. Check cache │ 1. Write to cache │ 1. Write cache │
|
||||
│ 2. If miss, get DB │ 2. Write to DB sync │ 2. Queue DB │
|
||||
│ 3. Populate cache │ │ 3. Async flush │
|
||||
└─────────────────────┴─────────────────────┴─────────────────┘
|
||||
```
|
||||
|
||||
### Redis Data Types / Các Kiểu Dữ Liệu Redis
|
||||
|
||||
| Type | Use Case | Example |
|
||||
|------|----------|---------|
|
||||
| **String** | Simple cache | User profile, JWT token |
|
||||
| **Hash** | Object cache | Shopping cart |
|
||||
| **List** | Queues | Task queue |
|
||||
| **Set** | Unique items | Active users |
|
||||
| **Sorted Set** | Rankings | Leaderboard |
|
||||
|
||||
### Cache Invalidation / Xóa Cache
|
||||
|
||||
| Strategy | Description | Use When |
|
||||
|----------|-------------|----------|
|
||||
| **TTL** | Auto-expire after time | Data changes rarely |
|
||||
| **Event-based** | Invalidate on update | Data changes often |
|
||||
| **Version key** | Cache with version | Complex objects |
|
||||
|
||||
## Key Patterns / Mẫu Chính
|
||||
|
||||
### Redis Configuration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure Redis distributed cache.
|
||||
/// VI: Cấu hình Redis distributed cache.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = builder.Configuration["Redis:ConnectionString"];
|
||||
options.InstanceName = "GoodGo:";
|
||||
});
|
||||
|
||||
// EN: Register Redis connection multiplexer for advanced scenarios
|
||||
// VI: Đăng ký Redis connection multiplexer cho các trường hợp nâng cao
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!));
|
||||
```
|
||||
|
||||
### Cache Service Implementation
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Generic cache service with typed operations.
|
||||
/// VI: Cache service generic với typed operations.
|
||||
/// </summary>
|
||||
public interface ICacheService
|
||||
{
|
||||
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
|
||||
Task SetAsync<T>(string key, T value, TimeSpan? expiry = null, CancellationToken ct = default);
|
||||
Task RemoveAsync(string key, CancellationToken ct = default);
|
||||
Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiry = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class RedisCacheService : ICacheService
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly ILogger<RedisCacheService> _logger;
|
||||
private static readonly TimeSpan DefaultExpiry = TimeSpan.FromMinutes(30);
|
||||
|
||||
public RedisCacheService(
|
||||
IDistributedCache cache,
|
||||
ILogger<RedisCacheService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
|
||||
{
|
||||
var data = await _cache.GetStringAsync(key, ct);
|
||||
if (data == null)
|
||||
{
|
||||
_logger.LogDebug("Cache miss for key: {Key}", key);
|
||||
return default;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache hit for key: {Key}", key);
|
||||
return JsonSerializer.Deserialize<T>(data);
|
||||
}
|
||||
|
||||
public async Task SetAsync<T>(
|
||||
string key,
|
||||
T value,
|
||||
TimeSpan? expiry = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = expiry ?? DefaultExpiry
|
||||
};
|
||||
|
||||
var data = JsonSerializer.Serialize(value);
|
||||
await _cache.SetStringAsync(key, data, options, ct);
|
||||
|
||||
_logger.LogDebug("Cached key: {Key} with expiry: {Expiry}", key, expiry ?? DefaultExpiry);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
await _cache.RemoveAsync(key, ct);
|
||||
_logger.LogDebug("Removed cache key: {Key}", key);
|
||||
}
|
||||
|
||||
public async Task<T> GetOrSetAsync<T>(
|
||||
string key,
|
||||
Func<Task<T>> factory,
|
||||
TimeSpan? expiry = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cached = await GetAsync<T>(key, ct);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
|
||||
var value = await factory();
|
||||
await SetAsync(key, value, expiry, ct);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cache-Aside Pattern in Query Handler
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Query handler with cache-aside pattern.
|
||||
/// VI: Query handler với cache-aside pattern.
|
||||
/// </summary>
|
||||
public class GetUserProfileQueryHandler
|
||||
: IRequestHandler<GetUserProfileQuery, UserProfileDto?>
|
||||
{
|
||||
private readonly ICacheService _cache;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(15);
|
||||
|
||||
public async Task<UserProfileDto?> Handle(
|
||||
GetUserProfileQuery request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cacheKey = $"user:profile:{request.UserId}";
|
||||
|
||||
// EN: Try get from cache first
|
||||
// VI: Thử lấy từ cache trước
|
||||
return await _cache.GetOrSetAsync(
|
||||
cacheKey,
|
||||
async () =>
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, ct);
|
||||
return user?.ToProfileDto();
|
||||
},
|
||||
CacheExpiry,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cache Invalidation on Write
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Invalidate cache when data changes.
|
||||
/// VI: Xóa cache khi dữ liệu thay đổi.
|
||||
/// </summary>
|
||||
public class UpdateUserProfileCommandHandler
|
||||
: IRequestHandler<UpdateUserProfileCommand, Unit>
|
||||
{
|
||||
private readonly ICacheService _cache;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public async Task<Unit> Handle(
|
||||
UpdateUserProfileCommand request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, ct)
|
||||
?? throw new NotFoundException("User", request.UserId);
|
||||
|
||||
user.UpdateProfile(request.DisplayName, request.Bio);
|
||||
await _userRepository.UnitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
// EN: Invalidate cache after update
|
||||
// VI: Xóa cache sau khi cập nhật
|
||||
await _cache.RemoveAsync($"user:profile:{request.UserId}", ct);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Shopping Cart with Redis Hash
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Shopping cart repository using Redis Hash.
|
||||
/// VI: Repository giỏ hàng dùng Redis Hash.
|
||||
/// </summary>
|
||||
public class RedisCartRepository : ICartRepository
|
||||
{
|
||||
private readonly IDatabase _redis;
|
||||
private static readonly TimeSpan CartExpiry = TimeSpan.FromDays(7);
|
||||
|
||||
public RedisCartRepository(IConnectionMultiplexer redis)
|
||||
{
|
||||
_redis = redis.GetDatabase();
|
||||
}
|
||||
|
||||
public async Task<Cart?> GetAsync(string cartId)
|
||||
{
|
||||
var data = await _redis.StringGetAsync($"cart:{cartId}");
|
||||
return data.IsNullOrEmpty
|
||||
? null
|
||||
: JsonSerializer.Deserialize<Cart>(data!);
|
||||
}
|
||||
|
||||
public async Task SaveAsync(Cart cart)
|
||||
{
|
||||
var key = $"cart:{cart.Id}";
|
||||
var data = JsonSerializer.Serialize(cart);
|
||||
|
||||
await _redis.StringSetAsync(key, data, CartExpiry);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string cartId)
|
||||
{
|
||||
await _redis.KeyDeleteAsync($"cart:{cartId}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
### 1. No Cache Expiry
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: No expiry leads to stale data
|
||||
await _cache.SetStringAsync(key, data);
|
||||
|
||||
// ✅ GOOD: Always set expiry
|
||||
await _cache.SetStringAsync(key, data, new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Cache Stampede
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Multiple requests hit DB simultaneously on cache miss
|
||||
var data = await _cache.GetAsync<Data>(key);
|
||||
if (data == null)
|
||||
{
|
||||
data = await _db.GetDataAsync();
|
||||
await _cache.SetAsync(key, data);
|
||||
}
|
||||
|
||||
// ✅ GOOD: Use locking to prevent stampede
|
||||
var data = await _cache.GetOrSetWithLockAsync(key, async () =>
|
||||
{
|
||||
return await _db.GetDataAsync();
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Caching Null Values
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Not caching null causes repeated DB calls
|
||||
if (data != null)
|
||||
await _cache.SetAsync(key, data);
|
||||
|
||||
// ✅ GOOD: Cache null with short TTL
|
||||
var cacheValue = new CacheWrapper<Data> { Value = data, IsNull = data == null };
|
||||
await _cache.SetAsync(key, cacheValue, data == null ? TimeSpan.FromMinutes(1) : TimeSpan.FromHours(1));
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
### Cache Key Naming
|
||||
|
||||
```csharp
|
||||
// EN: Pattern: {entity}:{id}:{optional-subkey}
|
||||
// VI: Pattern: {entity}:{id}:{optional-subkey}
|
||||
"user:profile:123"
|
||||
"order:items:456"
|
||||
"product:details:789"
|
||||
"user:orders:123:page:1"
|
||||
```
|
||||
|
||||
### Common TTL Values
|
||||
|
||||
| Data Type | TTL | Reason |
|
||||
|-----------|-----|--------|
|
||||
| User profile | 15-30 min | Changes moderately |
|
||||
| Product catalog | 1-4 hours | Batch updates |
|
||||
| Shopping cart | 7 days | User convenience |
|
||||
| Session | 30 min sliding | Security |
|
||||
| Rate limit | 1 min | Short window |
|
||||
|
||||
### Redis Commands via CLI
|
||||
|
||||
```bash
|
||||
# EN: View all keys with pattern
|
||||
redis-cli KEYS "user:*"
|
||||
|
||||
# EN: Get TTL of key
|
||||
redis-cli TTL "user:profile:123"
|
||||
|
||||
# EN: Delete keys by pattern
|
||||
redis-cli KEYS "cart:*" | xargs redis-cli DEL
|
||||
```
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
|
||||
- [Error Handling](../error-handling-patterns/SKILL.md) - Cache failure handling
|
||||
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access patterns
|
||||
- [Docker Traefik](../docker-traefik/SKILL.md) - Redis container setup
|
||||
479
.agent/skills/redis-caching/references/REFERENCE.md
Normal file
479
.agent/skills/redis-caching/references/REFERENCE.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# Redis Caching - Detailed Reference
|
||||
|
||||
Detailed code examples cho Redis caching patterns trong GoodGo.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Connection Setup](#connection-setup)
|
||||
2. [Cache Service](#cache-service)
|
||||
3. [Shopping Cart](#shopping-cart)
|
||||
4. [Rate Limiting](#rate-limiting)
|
||||
5. [Distributed Locks](#distributed-locks)
|
||||
6. [Session Management](#session-management)
|
||||
|
||||
---
|
||||
|
||||
## Connection Setup
|
||||
|
||||
### Redis Configuration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure Redis for distributed cache and direct access.
|
||||
/// VI: Cấu hình Redis cho distributed cache và truy cập trực tiếp.
|
||||
/// </summary>
|
||||
|
||||
// appsettings.json
|
||||
{
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379,abortConnect=false,connectTimeout=5000",
|
||||
"InstanceName": "GoodGo:",
|
||||
"DefaultDatabase": 0
|
||||
}
|
||||
}
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = builder.Configuration["Redis:ConnectionString"];
|
||||
options.InstanceName = builder.Configuration["Redis:InstanceName"];
|
||||
});
|
||||
|
||||
// EN: For advanced scenarios (Lua scripts, pub/sub, etc.)
|
||||
// VI: Cho các trường hợp nâng cao (Lua scripts, pub/sub, v.v.)
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
{
|
||||
var config = ConfigurationOptions.Parse(
|
||||
builder.Configuration["Redis:ConnectionString"]!);
|
||||
config.AbortOnConnectFail = false;
|
||||
config.ConnectRetry = 3;
|
||||
return ConnectionMultiplexer.Connect(config);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache Service
|
||||
|
||||
### Complete Cache Service Implementation
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Full-featured cache service with serialization and error handling.
|
||||
/// VI: Cache service đầy đủ tính năng với serialization và xử lý lỗi.
|
||||
/// </summary>
|
||||
public class RedisCacheService : ICacheService
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ILogger<RedisCacheService> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public RedisCacheService(
|
||||
IDistributedCache cache,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<RedisCacheService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_redis = redis;
|
||||
_logger = logger;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = await _cache.GetStringAsync(key, ct);
|
||||
if (string.IsNullOrEmpty(data))
|
||||
{
|
||||
_logger.LogDebug("Cache MISS: {Key}", key);
|
||||
return default;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache HIT: {Key}", key);
|
||||
return JsonSerializer.Deserialize<T>(data, _jsonOptions);
|
||||
}
|
||||
catch (RedisConnectionException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis connection failed for key: {Key}", key);
|
||||
return default; // EN: Graceful degradation / VI: Xử lý graceful
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetAsync<T>(
|
||||
string key,
|
||||
T value,
|
||||
TimeSpan? absoluteExpiry = null,
|
||||
TimeSpan? slidingExpiry = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = new DistributedCacheEntryOptions();
|
||||
|
||||
if (absoluteExpiry.HasValue)
|
||||
options.AbsoluteExpirationRelativeToNow = absoluteExpiry;
|
||||
if (slidingExpiry.HasValue)
|
||||
options.SlidingExpiration = slidingExpiry;
|
||||
|
||||
var data = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
await _cache.SetStringAsync(key, data, options, ct);
|
||||
|
||||
_logger.LogDebug("Cache SET: {Key}", key);
|
||||
}
|
||||
catch (RedisConnectionException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cache key: {Key}", key);
|
||||
// EN: Don't throw - cache is not critical / VI: Không throw - cache không critical
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> GetOrSetAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
TimeSpan? expiry = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cached = await GetAsync<T>(key, ct);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
|
||||
// EN: Use lock to prevent cache stampede
|
||||
// VI: Dùng lock để tránh cache stampede
|
||||
var lockKey = $"lock:{key}";
|
||||
var db = _redis.GetDatabase();
|
||||
|
||||
if (await db.LockTakeAsync(lockKey, Environment.MachineName, TimeSpan.FromSeconds(10)))
|
||||
{
|
||||
try
|
||||
{
|
||||
// EN: Double-check after acquiring lock
|
||||
// VI: Kiểm tra lại sau khi lấy lock
|
||||
cached = await GetAsync<T>(key, ct);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
|
||||
var value = await factory(ct);
|
||||
await SetAsync(key, value, expiry, ct: ct);
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await db.LockReleaseAsync(lockKey, Environment.MachineName);
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Lock not acquired, wait and retry
|
||||
// VI: Không lấy được lock, chờ và thử lại
|
||||
await Task.Delay(100, ct);
|
||||
return await GetOrSetAsync(key, factory, expiry, ct);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
await _cache.RemoveAsync(key, ct);
|
||||
_logger.LogDebug("Cache REMOVE: {Key}", key);
|
||||
}
|
||||
|
||||
public async Task RemoveByPatternAsync(string pattern, CancellationToken ct = default)
|
||||
{
|
||||
var server = _redis.GetServer(_redis.GetEndPoints().First());
|
||||
var keys = server.Keys(pattern: pattern).ToArray();
|
||||
|
||||
if (keys.Length > 0)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
await db.KeyDeleteAsync(keys);
|
||||
_logger.LogDebug("Cache REMOVE pattern: {Pattern}, Count: {Count}", pattern, keys.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shopping Cart
|
||||
|
||||
### Cart Entity
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Shopping cart stored in Redis.
|
||||
/// VI: Giỏ hàng lưu trong Redis.
|
||||
/// </summary>
|
||||
public class Cart
|
||||
{
|
||||
public string Id { get; set; } = default!;
|
||||
public string UserId { get; set; } = default!;
|
||||
public List<CartItem> Items { get; set; } = new();
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public decimal TotalAmount => Items.Sum(i => i.Quantity * i.UnitPrice);
|
||||
|
||||
public void AddItem(Guid productId, string productName, int quantity, decimal unitPrice)
|
||||
{
|
||||
var existing = Items.FirstOrDefault(i => i.ProductId == productId);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Quantity += quantity;
|
||||
existing.UnitPrice = unitPrice; // EN: Update price
|
||||
}
|
||||
else
|
||||
{
|
||||
Items.Add(new CartItem
|
||||
{
|
||||
ProductId = productId,
|
||||
ProductName = productName,
|
||||
Quantity = quantity,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
}
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void RemoveItem(Guid productId)
|
||||
{
|
||||
Items.RemoveAll(i => i.ProductId == productId);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
public class CartItem
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
public string ProductName { get; set; } = default!;
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Cart Repository
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Redis-backed cart repository.
|
||||
/// VI: Repository giỏ hàng với Redis.
|
||||
/// </summary>
|
||||
public class RedisCartRepository : ICartRepository
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ILogger<RedisCartRepository> _logger;
|
||||
private static readonly TimeSpan DefaultExpiry = TimeSpan.FromDays(7);
|
||||
|
||||
public RedisCartRepository(
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<RedisCartRepository> logger)
|
||||
{
|
||||
_db = redis.GetDatabase();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Cart?> GetByUserIdAsync(string userId, CancellationToken ct = default)
|
||||
{
|
||||
var key = GetKey(userId);
|
||||
var data = await _db.StringGetAsync(key);
|
||||
|
||||
if (data.IsNullOrEmpty)
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize<Cart>(data!);
|
||||
}
|
||||
|
||||
public async Task SaveAsync(Cart cart, CancellationToken ct = default)
|
||||
{
|
||||
var key = GetKey(cart.UserId);
|
||||
var data = JsonSerializer.Serialize(cart);
|
||||
|
||||
await _db.StringSetAsync(key, data, DefaultExpiry);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cart saved for user {UserId}, Items: {ItemCount}",
|
||||
cart.UserId, cart.Items.Count);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string userId, CancellationToken ct = default)
|
||||
{
|
||||
var key = GetKey(userId);
|
||||
await _db.KeyDeleteAsync(key);
|
||||
|
||||
_logger.LogInformation("Cart deleted for user {UserId}", userId);
|
||||
}
|
||||
|
||||
private static string GetKey(string userId) => $"cart:{userId}";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Redis Rate Limiter
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Redis-based sliding window rate limiter.
|
||||
/// VI: Rate limiter sliding window với Redis.
|
||||
/// </summary>
|
||||
public class RedisRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ILogger<RedisRateLimiter> _logger;
|
||||
|
||||
public RedisRateLimiter(
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<RedisRateLimiter> logger)
|
||||
{
|
||||
_db = redis.GetDatabase();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RateLimitResult> CheckAsync(
|
||||
string key,
|
||||
int maxRequests,
|
||||
TimeSpan window)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var windowStart = now - (long)window.TotalMilliseconds;
|
||||
var redisKey = $"ratelimit:{key}";
|
||||
|
||||
// EN: Lua script for atomic sliding window
|
||||
// VI: Lua script cho sliding window atomic
|
||||
var script = @"
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
|
||||
local count = redis.call('ZCARD', KEYS[1])
|
||||
if count < tonumber(ARGV[2]) then
|
||||
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[4])
|
||||
return {1, count + 1, tonumber(ARGV[2])}
|
||||
else
|
||||
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
||||
local retryAfter = oldest[2] + tonumber(ARGV[5]) - tonumber(ARGV[3])
|
||||
return {0, count, tonumber(ARGV[2]), retryAfter}
|
||||
end
|
||||
";
|
||||
|
||||
var result = await _db.ScriptEvaluateAsync(script,
|
||||
new RedisKey[] { redisKey },
|
||||
new RedisValue[]
|
||||
{
|
||||
windowStart,
|
||||
maxRequests,
|
||||
now,
|
||||
(int)window.TotalSeconds,
|
||||
(long)window.TotalMilliseconds
|
||||
});
|
||||
|
||||
var values = (RedisResult[])result!;
|
||||
var allowed = (int)values[0] == 1;
|
||||
var currentCount = (int)values[1];
|
||||
var limit = (int)values[2];
|
||||
var retryAfter = values.Length > 3 ? TimeSpan.FromMilliseconds((long)values[3]) : TimeSpan.Zero;
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rate limit exceeded for {Key}: {Count}/{Limit}",
|
||||
key, currentCount, limit);
|
||||
}
|
||||
|
||||
return new RateLimitResult(allowed, currentCount, limit, retryAfter);
|
||||
}
|
||||
}
|
||||
|
||||
public record RateLimitResult(
|
||||
bool IsAllowed,
|
||||
int CurrentCount,
|
||||
int Limit,
|
||||
TimeSpan RetryAfter);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Distributed Locks
|
||||
|
||||
### Redis Lock Service
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Distributed lock using Redis.
|
||||
/// VI: Distributed lock dùng Redis.
|
||||
/// </summary>
|
||||
public class RedisLockService : IDistributedLockService
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly ILogger<RedisLockService> _logger;
|
||||
|
||||
public async Task<IAsyncDisposable?> AcquireAsync(
|
||||
string resource,
|
||||
TimeSpan expiry,
|
||||
TimeSpan? waitTime = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var lockKey = $"lock:{resource}";
|
||||
var lockValue = Guid.NewGuid().ToString();
|
||||
var deadline = DateTime.UtcNow + (waitTime ?? TimeSpan.Zero);
|
||||
|
||||
do
|
||||
{
|
||||
if (await _db.LockTakeAsync(lockKey, lockValue, expiry))
|
||||
{
|
||||
_logger.LogDebug("Lock acquired: {Resource}", resource);
|
||||
return new RedisLock(_db, lockKey, lockValue, _logger);
|
||||
}
|
||||
|
||||
if (waitTime.HasValue)
|
||||
await Task.Delay(50, ct);
|
||||
|
||||
} while (waitTime.HasValue && DateTime.UtcNow < deadline && !ct.IsCancellationRequested);
|
||||
|
||||
_logger.LogWarning("Failed to acquire lock: {Resource}", resource);
|
||||
return null;
|
||||
}
|
||||
|
||||
private class RedisLock : IAsyncDisposable
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly string _key;
|
||||
private readonly string _value;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public RedisLock(IDatabase db, string key, string value, ILogger logger)
|
||||
{
|
||||
_db = db;
|
||||
_key = key;
|
||||
_value = value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _db.LockReleaseAsync(_key, _value);
|
||||
_logger.LogDebug("Lock released: {Key}", _key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Usage / VI: Cách dùng
|
||||
await using var lockHandle = await _lockService.AcquireAsync(
|
||||
$"order:{orderId}",
|
||||
TimeSpan.FromMinutes(1),
|
||||
waitTime: TimeSpan.FromSeconds(5));
|
||||
|
||||
if (lockHandle == null)
|
||||
throw new ConflictException("Order is being processed");
|
||||
|
||||
await ProcessOrderAsync(orderId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [StackExchange.Redis](https://stackexchange.github.io/StackExchange.Redis/)
|
||||
- [Microsoft Distributed Caching](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed)
|
||||
- [Redis Documentation](https://redis.io/documentation)
|
||||
40
note.md
40
note.md
@@ -2,46 +2,6 @@ Tài khoản: hongochai10@icloud.com
|
||||
Mật Khẩu: Velik@2026
|
||||
|
||||
|
||||
📋 Đề Xuất Agent Skills Mới cho GoodGo Platform
|
||||
Skills Hiện Có (6 skills):
|
||||
Skill Mô Tả
|
||||
api-design RESTful API design standards
|
||||
comment-code Bilingual comments EN/VI
|
||||
documentation Documentation guidelines
|
||||
project-rules Coding standards & architecture
|
||||
security Security patterns
|
||||
skill-authoring Hướng dẫn viết Agent Skills
|
||||
🆕 Đề Xuất Skills Mới
|
||||
1. testing-patterns
|
||||
Mô tả: Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers.
|
||||
Lý do: Dự án có thư mục tests/ trong các services nhưng chưa có skill hướng dẫn chuẩn testing.
|
||||
2. repository-pattern
|
||||
Mô tả: Entity Framework Core repository và data access patterns. Use for DbContext, repositories, migrations, và query optimization.
|
||||
Lý do: Tất cả services sử dụng EF Core theo Clean Architecture pattern.
|
||||
3. error-handling-patterns
|
||||
Mô tả: Global error handling, domain exceptions, và Result pattern. Use for exception middleware, validation errors, và logging.
|
||||
Lý do: Được reference trong api-design và project-rules nhưng chưa tồn tại.
|
||||
4. docker-traefik
|
||||
Mô tả: Docker containerization và Traefik reverse proxy. Use for Dockerfile, docker-compose, routing rules, và load balancing.
|
||||
Lý do: Dự án sử dụng Traefik làm API Gateway (thư mục infra/traefik/).
|
||||
5. observability
|
||||
Mô tả: Monitoring, logging, và tracing patterns. Use for Prometheus metrics, Grafana dashboards, Loki logging, và distributed tracing.
|
||||
Lý do: Có thư mục infra/observability/ với stack monitoring đầy đủ.
|
||||
6. cqrs-mediatr
|
||||
Mô tả: CQRS pattern với MediatR. Use for Commands, Queries, Handlers, Behaviors, và Pipeline.
|
||||
Lý do: Tất cả controllers sử dụng MediatR pattern (Commands/Queries).
|
||||
7. inter-service-communication
|
||||
Mô tả: Giao tiếp giữa các microservices. Use for HttpClient, gRPC, message queues, và service discovery.
|
||||
Lý do: Các services như storage-service-net gọi iam-service-net.
|
||||
8. redis-caching
|
||||
Mô tả: Redis caching strategies và patterns. Use for cache-aside, cache invalidation, distributed caching.
|
||||
Lý do: Tech stack bao gồm Redis (StackExchange.Redis).
|
||||
9. domain-driven-design
|
||||
Mô tả: DDD patterns cho microservices. Use for Aggregates, Entities, Value Objects, Domain Events, và Bounded Contexts.
|
||||
Lý do: Cấu trúc Domain/AggregatesModel/ cho thấy dự án tuân thủ DDD.
|
||||
10. deployment-kubernetes
|
||||
Mô tả: Kubernetes deployment patterns. Use for manifests, Helm charts, ConfigMaps, Secrets, và rolling updates.
|
||||
Lý do: Thư mục deployments/kubernetes/ cho K8s deployment.
|
||||
📊 Mức Độ Ưu Tiên
|
||||
Ưu Tiên Skills Lý Do
|
||||
Cao testing-patterns, repository-pattern, error-handling-patterns Core development patterns
|
||||
|
||||
Reference in New Issue
Block a user