ddd-teaching-skill
GitHub基于 Goca CLI 的 DDD 与 Clean Architecture 教学工具。通过引导式六步流程,结合 TDD 实践,教授分层架构、依赖规则及核心概念(实体、用例等),帮助用户掌握高质量代码生成与架构设计。
触发场景
安装
npx skills add sazardev/goca --skill ddd-teaching-skill -g -y
SKILL.md
Frontmatter
{
"name": "ddd-teaching-skill",
"description": "Enseña Domain-Driven Design y Clean Architecture de forma guiada usando Goca CLI. Actívar cuando el usuario pregunte \"aprender DDD\", \"como usar Goca\", \"enseñame Clean Architecture\", \"que es domain\", \"como generar entity\/usecase\/repository\/handler\", o pida practicar DDD con Goca. Inactívar al completar el flujo o si el usuario cambia a tareas no relacionadas con aprendizaje."
}
DDD Teaching — Aprendizaje Guiado con Goca
Filosofía
DDD + Clean Architecture resuelven el problema de código que muere lentamente: lógica de negocio mezclada con frameworks, tests imposibles, cambios que cascada. La solución es la Dependency Rule — las dependencias apuntan hacia adentro.
Goca materializa esta arquitectura generando código en 4 capas:
Handler (adapter) → UseCase (app logic) → Repository (persistence) → Domain (pure)
↓ ↓ ↓ ↓
HTTP/gRPC business logic SQL/Redis entities
Cada capa solo conoce la que está inmediatamente dentro de ella, y siempre a través de interfaces.
Activación
Invocar este skill automáticamente cuando el usuario:
- Pregunte conceptos de DDD o Clean Architecture
- Quiera aprender a usar Goca paso a paso
- Pida "generar un feature completo" sin entender qué genera
- Muestre código con violaciones arquitectónicas (handler con GORM, usecase con http, etc.)
Mapa Conceptual ↔ Comandos Goca
| Concepto DDD | Comando Goca | Archivo generado | Propósito |
|---|---|---|---|
| Entidad | goca entity |
internal/domain/product.go |
Pureza del dominio, invariantes |
| Value Object | goca entity --validation |
domain/product.go — Validate() |
Validación encapsulada |
| Repository interface | goca repository --interface-only |
repository/interfaces.go |
Contrato de persistencia |
| Repository impl | goca repository -d postgres |
repository/postgres_product.go |
GORM encapsulado |
| DTO / Application Service | goca usecase |
usecase/dto.go, service.go |
Separación capas |
| UseCase interface | goca usecase |
usecase/product_usecase.go |
Contrato para handlers |
| Handler / Adapter | goca handler -t http |
handler/http/product_handler.go |
Delivery |
| DI Container | goca di |
di/container.go |
Wiring |
Flujo Guiado — 6 Pasos (con TDD)
Cada paso: Concepto DDD → Comando Goca → Código generado → Test
Paso 1: Init — Scaffold del Proyecto
Concepto: Separación en capas limpias. El directorio internal/ es la frontera — nada fuera de internal/ puede importar lo de adentro, pero lo de adentro sí puede importar pkg/.
Comando:
goca init ecommerce --module github.com/myapp/ecommerce --database postgres
Estructura generada:
internal/
domain/ ← Puro: sin imports externos, solo lógica de negocio
usecase/ ← Solo importa domain + repository interfaces
repository/ ← Implementa interfaces; conoce GORM, no el usecase
handler/ ← Conoce interfaces de usecase, nunca repository directo
Análisis: Cada carpeta corresponde exactamente a un círculo de Clean Architecture. La regla de dependencia se aplica a nivel de import: handler → usecase → repository → domain. Prohibido saltar capas.
Test:
cd ecommerce && go build ./... && go vet ./...
# Debe pasar sin errores: es el esqueleto vacío pero correcto
Paso 2: Entity — Domain Puro
Concepto: Una entidad DDD tiene identidad (ID) e invariantes — reglas que siempre deben cumplirse. Los Value Objects son inmutables y se comparan por valor. Ambos viven en domain/ sin dependencias externas.
Comando:
goca entity Product --fields "name:string,price:float64,category:string" --validation --business-rules
Código generado (extraído de cmd/templates.go y cmd/template_components.go):
// internal/domain/product.go
package domain
type Product struct {
ID int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"type:varchar(255);not null"`
Price float64 `json:"price" gorm:"type:decimal(10,2);not null"`
Category string `json:"category" gorm:"type:varchar(100)"`
}
func (p *Product) Validate() error {
if p.Name == "" {
return errors.New("product name is required")
}
if p.Price <= 0 {
return errors.New("product price must be positive")
}
return nil
}
func (p *Product) IsExpensive() bool {
return p.Price > 1000
}
// internal/domain/errors.go
package domain
import "errors"
var (
ErrInvalidProductName = errors.New("product name is required")
ErrInvalidProductPrice = errors.New("product price must be positive")
)
Análisis:
- Struct
Product: sin lógica externa. Los tags de campo son metadata de serialización, no comportamiento. Validate(): invariante expresado como método del dominio. Sin dependencias externas. Sin ORM. Sin HTTP.IsExpensive(): regla de negocio co-localizada con los datos. Cambia la regla? Cambias este método, no un service remoto.- Errores como sentinel values (
var Err...), no strings mágicos. El consumidor puede hacererrors.Is(err, domain.ErrInvalidProductName).
Test (TDD primero):
// internal/domain/product_test.go
func TestProduct_Validate(t *testing.T) {
tests := []struct {
name string
product Product
wantErr bool
}{
{"valid product", Product{Name: "Laptop", Price: 999.99, Category: "Electronics"}, false},
{"empty name", Product{Price: 999.99}, true},
{"zero price", Product{Name: "Laptop", Price: 0}, true},
{"negative price", Product{Name: "Laptop", Price: -1}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.product.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
func TestProduct_IsExpensive(t *testing.T) {
cheap := Product{Name: "Notebook", Price: 10}
expensive := Product{Name: "MacBook", Price: 2500}
if cheap.IsExpensive() {
t.Error("expected cheap product to not be expensive")
}
if !expensive.IsExpensive() {
t.Error("expected expensive product to be expensive")
}
}
Paso 3: UseCase — Lógica de Aplicación
Concepto: El Application Service orquesta la lógica de negocio. No contiene reglas de dominio (esas van en la entidad). Usa DTOs para desacoplar el mundo exterior del dominio. Depende de interfaces de repositorio, no de implementaciones concretas.
Comando:
goca usecase ProductService --entity Product --operations "create,read,update,delete,list"
Código generado (extraído de cmd/template_components.go + cmd/templates.go):
// internal/usecase/product_usecase.go
package usecase
import (
"github.com/myapp/ecommerce/internal/domain"
"github.com/myapp/ecommerce/internal/repository"
)
type ProductUseCase interface {
CreateProduct(input CreateProductInput) (*CreateProductOutput, error)
GetProductByID(id int) (*ProductOutput, error)
UpdateProduct(id int, input UpdateProductInput) error
DeleteProduct(id int) error
ListProducts() (*ListProductsOutput, error)
}
type productService struct {
repo repository.ProductRepository
}
func NewProductService(repo repository.ProductRepository) ProductUseCase {
return &productService{repo: repo}
}
func (s *productService) CreateProduct(input CreateProductInput) (*CreateProductOutput, error) {
product := domain.Product{
Name: input.Name,
Price: input.Price,
Category: input.Category,
}
if err := product.Validate(); err != nil {
return nil, err
}
if err := s.repo.Save(&product); err != nil {
return nil, err
}
return &CreateProductOutput{
Product: &product,
Message: "Product created successfully",
}, nil
}
// internal/usecase/dto.go
type CreateProductInput struct {
Name string `json:"name"`
Price float64 `json:"price"`
Category string `json:"category"`
}
type CreateProductOutput struct {
Product *domain.Product `json:"product"`
Message string `json:"message"`
}
type UpdateProductInput struct {
Name *string `json:"name,omitempty"`
Price *float64 `json:"price,omitempty"`
Category *string `json:"category,omitempty"`
}
type ProductOutput struct {
Product *domain.Product `json:"product"`
}
type ListProductsOutput struct {
Products []domain.Product `json:"products"`
Total int `json:"total"`
}
// internal/usecase/interfaces.go
package usecase
import "github.com/myapp/ecommerce/internal/domain"
type ProductRepository interface {
Save(product *domain.Product) error
FindByID(id int) (*domain.Product, error)
Update(product *domain.Product) error
Delete(id int) error
FindAll() ([]domain.Product, error)
}
Análisis:
ProductUseCasees interfaz pública. El handler programa contra esta interfaz, no contra el struct concreto.productServicees privado (minúscula). Solo se exporta el constructorNewProductService(repo). Nadie puede acoplar al tipo concreto.- Constructor injection: el repo se inyecta. El service no crea su propio repo, no llama a
sql.Open(), no sabe si es Postgres o Mock. CreateProductInputvsdomain.Product: el input puede tener validaciones distintas al domain. El Update usa punteros (*string) para distinguir "no enviado" de "enviado vacío".- La interfaz
ProductRepositoryenusecase/interfaces.goes la misma que enrepository/interfaces.go. El usecase la necesita para su firma.
Test con Mock (TDD — RED antes de implementar):
// internal/usecase/product_service_test.go
func TestCreateProduct_Success(t *testing.T) {
mockRepo := new(MockProductRepository)
mockRepo.On("Save", mock.AnythingOfType("*domain.Product")).Return(nil)
svc := NewProductService(mockRepo)
input := CreateProductInput{Name: "Laptop", Price: 999.99, Category: "Electronics"}
output, err := svc.CreateProduct(input)
assert.NoError(t, err)
assert.NotNil(t, output.Product)
assert.Equal(t, "Laptop", output.Product.Name)
assert.Equal(t, "Product created successfully", output.Message)
mockRepo.AssertExpectations(t)
}
func TestCreateProduct_InvalidData(t *testing.T) {
mockRepo := new(MockProductRepository)
svc := NewProductService(mockRepo)
input := CreateProductInput{Name: "", Price: 0}
_, err := svc.CreateProduct(input)
assert.Error(t, err)
mockRepo.AssertNotCalled(t, "Save")
}
Paso 4: Repository — Persistencia
Concepto: El Repository Pattern abstrae el almacenamiento detrás de una interfaz. El dominio y el usecase conocen la interfaz, no la implementación. GORM, SQL, Redis, MongoDB — todo queda encapsulado detrás de esta interfaz.
Comando:
goca repository Product --database postgres --transactions
Código generado (extraído de cmd/templates.go + cmd/repository.go):
// internal/repository/interfaces.go
package repository
import "github.com/myapp/ecommerce/internal/domain"
type ProductRepository interface {
Save(product *domain.Product) error
FindByID(id int) (*domain.Product, error)
Update(product *domain.Product) error
Delete(id int) error
FindAll() ([]domain.Product, error)
}
// internal/repository/postgres_product_repository.go
package repository
import (
"gorm.io/gorm"
"github.com/myapp/ecommerce/internal/domain"
)
type postgresProductRepository struct {
db *gorm.DB
}
func NewPostgresProductRepository(db *gorm.DB) ProductRepository {
return &postgresProductRepository{db: db}
}
func (r *postgresProductRepository) Save(product *domain.Product) error {
return r.db.Create(product).Error
}
func (r *postgresProductRepository) FindByID(id int) (*domain.Product, error) {
var product domain.Product
if err := r.db.First(&product, id).Error; err != nil {
return nil, err
}
return &product, nil
}
func (r *postgresProductRepository) Update(product *domain.Product) error {
return r.db.Save(product).Error
}
func (r *postgresProductRepository) Delete(id int) error {
return r.db.Delete(&domain.Product{}, id).Error
}
func (r *postgresProductRepository) FindAll() ([]domain.Product, error) {
var products []domain.Product
if err := r.db.Find(&products).Error; err != nil {
return nil, err
}
return products, nil
}
Análisis:
- Interfaz en
repository/interfaces.godefine el contrato. El usecase la consume. El handler ni la conoce. postgresProductRepositoryes privado — nadie fuera del package la instancia excepto el DI container.- El constructor
NewPostgresProductRepository(db *gorm.DB) ProductRepositorydevuelve la interfaz, no el struct. - GORM está completamente encapsulado en este package. Si migras a MongoDB, cambias este archivo, no tocas domain ni usecase.
--transactionsagregaSaveWithTx(tx *gorm.DB, ...)para Unit of Work.
Paso 5: Handler — Delivery Adapter
Concepto: El Adapter convierte requests externos (HTTP, gRPC, CLI) en llamadas a usecase. No contiene lógica de negocio. Solo serialización, routing, delegación.
Comando:
goca handler Product --type http --validation
Código generado (extraído de cmd/templates.go):
// internal/handler/http/product_handler.go
package http
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/myapp/ecommerce/internal/usecase"
)
type ProductHandler struct {
usecase usecase.ProductUseCase
}
func NewProductHandler(uc usecase.ProductUseCase) *ProductHandler {
return &ProductHandler{usecase: uc}
}
func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
var input usecase.CreateProductInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
output, err := h.usecase.CreateProduct(input)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(output)
}
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid product ID", http.StatusBadRequest)
return
}
output, err := h.usecase.GetProductByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(output)
}
// internal/handler/http/routes.go
package http
import (
"github.com/gorilla/mux"
"github.com/myapp/ecommerce/internal/usecase"
)
func SetupProductRoutes(router *mux.Router, uc usecase.ProductUseCase) {
handler := NewProductHandler(uc)
router.HandleFunc("/products", handler.CreateProduct).Methods("POST")
router.HandleFunc("/products/{id}", handler.GetProduct).Methods("GET")
router.HandleFunc("/products/{id}", handler.UpdateProduct).Methods("PUT")
router.HandleFunc("/products/{id}", handler.DeleteProduct).Methods("DELETE")
router.HandleFunc("/products", handler.ListProducts).Methods("GET")
}
Análisis:
- Handler importa
usecasesolo. No importarepository, no importagorm. ✅ ProductHandlerrecibeusecase.ProductUseCase(interfaz), no el service concreto.- No hay lógica de negocio aquí. Solo: parse request → call usecase → serialize response.
- Si cambias HTTP → gRPC, el handler cambia pero
usecaseydomainno se tocan. Routesse inyecta el usecase y construye el handler — el router no necesita saber cómo construirlo.
Test con httptest:
func TestCreateProductHandler(t *testing.T) {
mockUC := new(MockProductUseCase)
mockUC.On("CreateProduct", mock.Anything).Return(&usecase.CreateProductOutput{
Product: &domain.Product{Name: "Laptop", Price: 999.99},
Message: "Product created successfully",
}, nil)
handler := NewProductHandler(mockUC)
body := `{"name":"Laptop","price":999.99,"category":"Electronics"}`
req := httptest.NewRequest(http.MethodPost, "/products", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.CreateProduct(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), "Laptop")
mockUC.AssertExpectations(t)
}
Paso 6: DI — Wiring Completo
Concepto: El Composition Root es el único lugar donde se instancian implementaciones concretas. Construye el grafo de objetos completo. Todas las demás capas reciben sus dependencias ya construidas.
Comando:
goca di --features "Product" --database postgres
Código generado (extraído de cmd/di.go):
// internal/di/container.go
package di
import (
"gorm.io/gorm"
"github.com/myapp/ecommerce/internal/repository"
"github.com/myapp/ecommerce/internal/usecase"
"github.com/myapp/ecommerce/internal/handler/http"
)
type Container struct {
db *gorm.DB
productRepo repository.ProductRepository
productUC usecase.ProductUseCase
productHandler *http.ProductHandler
}
func NewContainer(db *gorm.DB) *Container {
c := &Container{db: db}
c.setupRepositories()
c.setupUseCases()
c.setupHandlers()
return c
}
func (c *Container) setupRepositories() {
c.productRepo = repository.NewPostgresProductRepository(c.db)
}
func (c *Container) setupUseCases() {
c.productUC = usecase.NewProductService(c.productRepo)
}
func (c *Container) setupHandlers() {
c.productHandler = http.NewProductHandler(c.productUC)
}
func (c *Container) ProductHandler() *http.ProductHandler {
return c.productHandler
}
Análisis:
NewContainer(db)recibe la conexión a BD. El container construye TODO el grafo.- Único lugar donde se llama a
NewPostgresProductRepository,NewProductService,NewProductHandler. - Si quieres cambiar Postgres → MySQL, cambias UNA línea en
setupRepositories. - Los getters (
ProductHandler()) exponen solo lo quemain.gonecesita.
Feature Completo (atajo)
goca feature Product --fields "name:string,price:float64,category:string" --database postgres --validation --handlers http
Este comando ejecuta Pasos 2-6 en una sola operación. Equivale a:
1. goca entity Product --fields "name:string,price:float64,category:string" --validation --business-rules
2. goca usecase ProductService --entity Product --operations "create,read,update,delete,list"
3. goca repository Product --database postgres
4. goca handler Product --type http --validation
5. goca messages Product --all
6. goca di --features "Product" --database postgres
Además integra el handler en main.go y registra migraciones automáticas.
Anti-Patterns (qué NO hacer)
❌ Handler importando repository directamente
type ProductHandler struct {
repo *gorm.DB // MAL: handler conoce la BD y GORM!
}
✅ Handler solo conoce usecase.ProductUseCase
❌ UseCase con GORM o SQL
type productService struct {
db *gorm.DB // MAL: lógica de negocio conoce GORM
}
✅ UseCase recibe repository.ProductRepository (interfaz abstracta)
❌ Entidad anémica (solo getters/setters, sin comportamiento)
type Product struct {
Name string // MAL: sin Validate(), sin reglas de negocio
Price float64 // solo es una struct de datos, no una entidad DDD
}
✅ Domain tiene métodos: Validate(), IsExpensive(), etc.
❌ DTOs expuestos desde handler — usa usecase.CreateProductInput, no crees DTOs en handler.
❌ init() y variables globales — usa constructor injection siempre.
Límites del Skill
Este skill enseña DDD + Clean Architecture a través de Goca. Para otros aspectos, delegar:
| Necesitas | Recurso |
|---|---|
| Benchmarks, fuzzing, test coverage avanzado | golang-testing skill |
| Table-driven tests, helpers, golden files | golang-testing skill |
| sync.Pool, zero allocation, performance | golang-performance skill |
| Interface design, functional options, patterns | golang-patterns skill |
| Validar dependencias entre capas archivo por archivo | ArchitectGuard agent mode en .github/AGENTS.md |
| Verificar que templates generan código compilable | CodegenAuditor agent mode en .github/AGENTS.md |
| Guía completa de comandos Goca | GUIDE.md |
Cuando el usuario cambie a una tarea no relacionada con aprendizaje DDD (ej: "arregla este bug", "agrega esta feature"), desactivar este skill y continuar como agente normal.
版本历史
- 8aaeb51 当前 2026-07-11 16:58


