| name | module-creation |
| description | Complete workflow for creating a new DDD module following ZGO's 8-file standard |
| version | 1.0.0 |
| category | development |
| tags | ["module","ddd","scaffolding","architecture"] |
| author | ZGO Team |
| updated | "2026-01-24T00:00:00.000Z" |
Module Creation Skill
📋 Purpose
This skill guides you through creating a standardized DDD (Domain-Driven Design) module in the ZGO framework, ensuring adherence to the 8-file structure and architectural best practices.
ZGO uses a layered architecture where each business module is self-contained with clear separation of concerns:
- Model Layer (Database entities)
- DTO Layer (Data Transfer Objects + Mappers)
- Repository Layer (Data access)
- Service Layer (Business logic)
- Handler Layer (HTTP controllers)
- Routes (Endpoint registration)
- Provider (Dependency injection)
- Tests (Unit/integration tests)
🎯 When to Use
Use this skill when:
- Creating a new business module (e.g., User, Blog, Product, Order)
- Scaffolding a complete CRUD feature
- Ensuring consistency with ZGO's DDD architecture
- Teaching or onboarding team members to ZGO patterns
⚙️ Prerequisites
🚀 Workflow Steps
Step 1: Define Module Scope
Before writing any code, clearly define:
Business Requirements:
- Module name (PascalCase):
Blog, UserProfile, Product
- Core domain entities and their relationships
- Required operations (CRUD, custom actions)
Technical Specification:
Module: Blog
Domain Entity: BlogPost
Database Table: blog_posts
API Endpoints:
- GET /api/blogs → List all posts (paginated)
- POST /api/blogs → Create new post
- GET /api/blogs/:id → Get post by ID
- PATCH /api/blogs/:id → Update post
- DELETE /api/blogs/:id → Delete post
Fields:
- id (uint, primary key)
- title (string, required, max 255)
- content (text)
- author_id (uint, foreign key)
- status (enum: draft/published)
- created_at, updated_at, deleted_at (GORM standard)
Step 2: Create Module Directory
cd internal/modules
mkdir blog
cd blog
Expected structure:
internal/modules/blog/
├── (8 files will be created below)
Step 3: Create Database Entity (model.go)
Purpose: Define the database table structure using GORM.
File: model.go
package blog
import (
"time"
"gorm.io/gorm"
)
type BlogPostPO struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:255;not null;index"`
Content string `gorm:"type:text"`
AuthorID uint `gorm:"index;not null"`
Status string `gorm:"size:20;default:'draft';index"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (BlogPostPO) TableName() string {
return "blog_posts"
}
Key points:
- Suffix
PO for database models
- Use GORM tags for constraints
- Always include soft delete (
DeletedAt)
- Use
TableName() for explicit table names
Step 4: Create Domain Entity and DTOs (dto.go)
Purpose: Define domain objects, request/response DTOs, and mapper functions.
File: dto.go
package blog
import (
"time"
"github.com/zgiai/zgo/internal/domain"
)
type CreateBlogPostRequest struct {
Title string `json:"title" binding:"required,max=255"`
Content string `json:"content" binding:"required"`
Status string `json:"status" binding:"omitempty,oneof=draft published"`
}
type UpdateBlogPostRequest struct {
Title *string `json:"title" binding:"omitempty,max=255"`
Content *string `json:"content" binding:"omitempty"`
Status *string `json:"status" binding:"omitempty,oneof=draft published"`
}
type BlogPostResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
AuthorID uint `json:"author_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ToBlogPostPO(post *domain.BlogPost) *BlogPostPO {
return &BlogPostPO{
ID: post.ID,
Title: post.Title,
Content: post.Content,
AuthorID: post.AuthorID,
Status: post.Status,
CreatedAt: post.CreatedAt,
UpdatedAt: post.UpdatedAt,
}
}
func FromBlogPostPO(po *BlogPostPO) *domain.BlogPost {
if po == nil {
return nil
}
return &domain.BlogPost{
ID: po.ID,
Title: po.Title,
Content: po.Content,
AuthorID: po.AuthorID,
Status: po.Status,
CreatedAt: po.CreatedAt,
UpdatedAt: po.UpdatedAt,
}
}
func ToResponse(post *domain.BlogPost) *BlogPostResponse {
if post == nil {
return nil
}
return &BlogPostResponse{
ID: post.ID,
Title: post.Title,
Content: post.Content,
AuthorID: post.AuthorID,
Status: post.Status,
CreatedAt: post.CreatedAt,
UpdatedAt: post.UpdatedAt,
}
}
Data flow:
Handler (DTO) → Service (domain.BlogPost) → Repository (BlogPostPO)
← Repository (domain.BlogPost)
← Service (domain.BlogPost)
Handler (DTO) ←
Step 5: Create Repository Layer (repository.go)
Purpose: Handle all database operations, return domain entities.
File: repository.go
package blog
import (
"context"
"github.com/zgiai/zgo/internal/domain"
"gorm.io/gorm"
)
type Repository interface {
Create(ctx context.Context, post *domain.BlogPost) error
GetByID(ctx context.Context, id uint) (*domain.BlogPost, error)
Update(ctx context.Context, post *domain.BlogPost) error
Delete(ctx context.Context, id uint) error
List(ctx context.Context, page, pageSize int) ([]*domain.BlogPost, int64, error)
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) Create(ctx context.Context, post *domain.BlogPost) error {
po := ToBlogPostPO(post)
if err := r.db.WithContext(ctx).Create(po).Error; err != nil {
return err
}
*post = *FromBlogPostPO(po)
return nil
}
func (r *repository) GetByID(ctx context.Context, id uint) (*domain.BlogPost, error) {
var po BlogPostPO
if err := r.db.WithContext(ctx).First(&po, id).Error; err != nil {
return nil, err
}
return FromBlogPostPO(&po), nil
}
func (r *repository) Update(ctx context.Context, post *domain.BlogPost) error {
po := ToBlogPostPO(post)
return r.db.WithContext(ctx).Save(po).Error
}
func (r *repository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&BlogPostPO{}, id).Error
}
func (r *repository) List(ctx context.Context, page, pageSize int) ([]*domain.BlogPost, int64, error) {
var posts []BlogPostPO
var total int64
offset := (page - 1) * pageSize
if err := r.db.WithContext(ctx).Model(&BlogPostPO{}).Count(&total).Error; err != nil {
return nil, 0, err
}
if err := r.db.WithContext(ctx).
Offset(offset).
Limit(pageSize).
Find(&posts).Error; err != nil {
return nil, 0, err
}
result := make([]*domain.BlogPost, len(posts))
for i, po := range posts {
result[i] = FromBlogPostPO(&po)
}
return result, total, nil
}
Key patterns:
- Interface-based design
- Private struct implementation
- Constructor returns interface
- Always use
context.Context
- Convert PO ↔ Domain at repository boundary
Step 6: Create Service Layer (service.go)
Purpose: Implement business logic using domain entities.
File: service.go
package blog
import (
"context"
"errors"
"github.com/zgiai/zgo/internal/domain"
)
var (
ErrBlogPostNotFound = errors.New("blog post not found")
ErrInvalidBlogPostData = errors.New("invalid blog post data")
ErrUnauthorized = errors.New("unauthorized operation")
)
type Service interface {
Create(ctx context.Context, req *CreateBlogPostRequest, authorID uint) (*domain.BlogPost, error)
GetByID(ctx context.Context, id uint) (*domain.BlogPost, error)
Update(ctx context.Context, id uint, req *UpdateBlogPostRequest) (*domain.BlogPost, error)
Delete(ctx context.Context, id uint) error
List(ctx context.Context, page, pageSize int) ([]*domain.BlogPost, int64, error)
}
type service struct {
repo Repository
}
func NewService(repo Repository) Service {
return &service{repo: repo}
}
func (s *service) Create(ctx context.Context, req *CreateBlogPostRequest, authorID uint) (*domain.BlogPost, error) {
if req.Title == "" {
return nil, ErrInvalidBlogPostData
}
status := req.Status
if status == "" {
status = "draft"
}
post := &domain.BlogPost{
Title: req.Title,
Content: req.Content,
AuthorID: authorID,
Status: status,
}
if err := s.repo.Create(ctx, post); err != nil {
return nil, err
}
return post, nil
}
func (s *service) GetByID(ctx context.Context, id uint) (*domain.BlogPost, error) {
post, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, ErrBlogPostNotFound
}
return post, nil
}
func (s *service) Update(ctx context.Context, id uint, req *UpdateBlogPostRequest) (*domain.BlogPost, error) {
post, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, ErrBlogPostNotFound
}
if req.Title != nil {
post.Title = *req.Title
}
if req.Content != nil {
post.Content = *req.Content
}
if req.Status != nil {
post.Status = *req.Status
}
if err := s.repo.Update(ctx, post); err != nil {
return nil, err
}
return post, nil
}
func (s *service) Delete(ctx context.Context, id uint) error {
if _, err := s.repo.GetByID(ctx, id); err != nil {
return ErrBlogPostNotFound
}
return s.repo.Delete(ctx, id)
}
func (s *service) List(ctx context.Context, page, pageSize int) ([]*domain.BlogPost, int64, error) {
return s.repo.List(ctx, page, pageSize)
}
Business logic examples:
- Input validation
- Default values
- Authorization checks
- Business rules enforcement
Step 7: Create HTTP Handlers (handler.go)
Purpose: Handle HTTP requests, use service layer, return responses.
File: handler.go
package blog
import (
"github.com/gin-gonic/gin"
"github.com/zgiai/zgo/pkg/handler"
"github.com/zgiai/zgo/pkg/response"
)
type Handler struct {
service Service
}
func NewHandler(service Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Create(c *gin.Context) {
userID, ok := handler.GetUserID(c)
if !ok {
return
}
var req CreateBlogPostRequest
if !handler.BindJSON(c, &req) {
return
}
post, err := h.service.Create(c.Request.Context(), &req, userID)
if err != nil {
response.HandleError(c, "Failed to create blog post", err)
return
}
response.Created(c, ToResponse(post))
}
func (h *Handler) Get(c *gin.Context) {
id, ok := handler.ParseID(c, "id")
if !ok {
return
}
post, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
response.HandleError(c, "Blog post not found", err)
return
}
response.Success(c, ToResponse(post))
}
func (h *Handler) Update(c *gin.Context) {
id, ok := handler.ParseID(c, "id")
if !ok {
return
}
var req UpdateBlogPostRequest
if !handler.BindJSON(c, &req) {
return
}
post, err := h.service.Update(c.Request.Context(), id, &req)
if err != nil {
response.HandleError(c, "Failed to update blog post", err)
return
}
response.Success(c, ToResponse(post))
}
func (h *Handler) Delete(c *gin.Context) {
id, ok := handler.ParseID(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.HandleError(c, "Failed to delete blog post", err)
return
}
response.NoContent(c)
}
func (h *Handler) List(c *gin.Context) {
page := handler.QueryInt(c, "page", 1)
perPage := handler.QueryInt(c, "per_page", 20)
posts, total, err := h.service.List(c.Request.Context(), page, perPage)
if err != nil {
response.HandleError(c, "Failed to list blog posts", err)
return
}
responses := make([]*BlogPostResponse, len(posts))
for i, post := range posts {
responses[i] = ToResponse(post)
}
response.Success(c, map[string]any{
"data": responses,
"meta": map[string]any{
"total": total,
"page": page,
"per_page": perPage,
"total_pages": (total + int64(perPage) - 1) / int64(perPage),
},
})
}
Handler best practices:
- Use
pkg/handler utilities
- Use
pkg/response for consistent responses
- Add Swagger annotations
- Keep handlers thin (delegate to service)
Step 8: Register Routes (routes.go)
Purpose: Define API endpoints and HTTP methods.
File: routes.go
package blog
import (
"github.com/gin-gonic/gin"
)
func RegisterRoutes(router *gin.RouterGroup, handler *Handler, authMiddleware gin.HandlerFunc) {
blogs := router.Group("/blogs")
{
blogs.GET("", handler.List)
blogs.GET("/:id", handler.Get)
blogs.POST("", authMiddleware, handler.Create)
blogs.PATCH("/:id", authMiddleware, handler.Update)
blogs.DELETE("/:id", authMiddleware, handler.Delete)
}
}
Route patterns:
- Group related endpoints
- Apply middleware selectively
- Follow RESTful conventions
Step 9: Wire Dependency Injection (provider.go)
Purpose: Configure Wire to auto-generate DI code.
File: provider.go
package blog
import "github.com/google/wire"
var ProviderSet = wire.NewSet(
NewRepository,
wire.Bind(new(Repository), new(*repository)),
NewService,
wire.Bind(new(Service), new(*service)),
NewHandler,
)
Wire pattern:
- Export
ProviderSet
- Bind interfaces to implementations
- Constructors must match signatures
Step 10: Create Unit Tests (service_test.go)
Purpose: Test business logic in isolation.
File: service_test.go
package blog
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/zgiai/zgo/internal/domain"
)
type MockRepository struct {
mock.Mock
}
func (m *MockRepository) Create(ctx context.Context, post *domain.BlogPost) error {
args := m.Called(ctx, post)
return args.Error(0)
}
func (m *MockRepository) GetByID(ctx context.Context, id uint) (*domain.BlogPost, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.BlogPost), args.Error(1)
}
func (m *MockRepository) Update(ctx context.Context, post *domain.BlogPost) error {
args := m.Called(ctx, post)
return args.Error(0)
}
func (m *MockRepository) Delete(ctx context.Context, id uint) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockRepository) List(ctx context.Context, page, pageSize int) ([]*domain.BlogPost, int64, error) {
args := m.Called(ctx, page, pageSize)
return args.Get(0).([]*domain.BlogPost), args.Get(1).(int64), args.Error(2)
}
func TestCreate(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo)
ctx := context.Background()
req := &CreateBlogPostRequest{
Title: "Test Post",
Content: "Test content",
Status: "draft",
}
mockRepo.On("Create", ctx, mock.AnythingOfType("*domain.BlogPost")).Return(nil)
post, err := svc.Create(ctx, req, 1)
assert.NoError(t, err)
assert.NotNil(t, post)
assert.Equal(t, "Test Post", post.Title)
assert.Equal(t, uint(1), post.AuthorID)
mockRepo.AssertExpectations(t)
}
Testing best practices:
- Mock repository dependencies
- Test business logic, not infrastructure
- Use table-driven tests for multiple cases
Step 11: Integrate with Wire DI System
Add module to main wire config:
Edit internal/wiring/wire.go:
import (
"github.com/zgiai/zgo/internal/modules/blog"
)
func initializeApplication(cfg *config.Config, db *gorm.DB, ...) (*bootstrap.Application, func(), error) {
wire.Build(
blog.ProviderSet,
)
return nil, nil, nil
}
Generate Wire code:
cd internal/wiring
wire
Expected output:
wire: blog: wrote /path/to/zgo/internal/wiring/wire_gen.go
Step 12: Register Routes in Application
Edit routes/api.go (or wherever routes are registered):
import (
"github.com/zgiai/zgo/internal/modules/blog"
)
func RegisterAPIRoutes(app *bootstrap.Application) {
api := app.Router.Group("/api")
blog.RegisterRoutes(api, app.BlogHandler, app.AuthMiddleware)
}
Step 13: Create Database Migration
Create migration file:
cat > database/migrations/013_create_blog_posts_table.go << 'EOF'
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func init() {
Migrations = append(Migrations, &gormigrate.Migration{
ID: "013_create_blog_posts_table",
Migrate: func(tx *gorm.DB) error {
type BlogPost struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:255;not null;index"`
Content string `gorm:"type:text"`
AuthorID uint `gorm:"index;not null"`
Status string `gorm:"size:20;default:'draft';index"`
CreatedAt int64 `gorm:"autoCreateTime"`
UpdatedAt int64 `gorm:"autoUpdateTime"`
DeletedAt *int64 `gorm:"index"`
}
return tx.AutoMigrate(&BlogPost{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("blog_posts")
},
})
}
EOF
Run migration:
make migrate
Step 14: Create Domain Entity
Create internal/domain/blog_post.go:
package domain
import "time"
type BlogPost struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
AuthorID uint `json:"author_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Step 15: Validation
Automated validation script:
Run the validation script (see scripts/ folder):
./.agent/skills/module-creation/scripts/validate-module.sh blog
Manual checklist:
Test the API:
make air
curl -X POST http://localhost:8080/api/blogs \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Test Post", "content": "Hello world"}'
curl http://localhost:8080/api/blogs
curl http://localhost:8080/api/blogs/1
curl -X PATCH http://localhost:8080/api/blogs/1 \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "published"}'
curl -X DELETE http://localhost:8080/api/blogs/1 \
-H "Authorization: Bearer YOUR_TOKEN"
🔍 Troubleshooting
Common Error 1: Wire Generation Fails
Symptom:
wire: blog: wire_gen.go:XX:YY: no provider found for ...
Cause: Missing or incorrect provider bindings
Solution:
- Check
provider.go includes all constructors:
var ProviderSet = wire.NewSet(
NewRepository,
wire.Bind(new(Repository), new(*repository)),
NewService,
wire.Bind(new(Service), new(*service)),
NewHandler,
)
- Verify interface and implementation match
- Ensure
ProviderSet is added to wiring/wire.go
Common Error 2: Routes Not Working
Symptom: 404 Not Found for /api/blogs
Cause: Routes not registered
Solution:
- Verify
RegisterRoutes is called in routes/api.go
- Check middleware order (auth middleware might block)
- Print registered routes:
for _, route := range app.Router.Routes() {
fmt.Printf("%s %s\n", route.Method, route.Path)
}
Common Error 3: Database Table Not Found
Symptom: Error 1146: Table 'zgo.blog_posts' doesn't exist
Cause: Migration not run
Solution:
make migrate
./zgo migrate
Common Error 4: JSON Binding Fails
Symptom: 400 Bad Request, validation errors
Cause: Incorrect struct tags or request body
Solution:
- Check binding tags in DTO:
type CreateBlogPostRequest struct {
Title string `json:"title" binding:"required,max=255"`
}
- Verify request JSON matches field names (snake_case)
- Test with
curl -v to see actual error message
📚 Examples
See examples/blog-module-complete/ for the full implementation of a Blog module.
🔗 Related Skills
📖 References
🎉 Success!
You've successfully created a complete DDD module following ZGO's 8-file standard!
What's next?
- Add more business logic and validation
- Write integration tests
- Generate Swagger documentation
- Deploy and test in staging environment