
adding-features
Guide for adding new feature modules following Clean Architecture pattern. Use when creating new fea
Adding Features
This skill guides you through adding new feature modules following this project's Clean Architecture and feature-based modular design with correct patterns from the actual codebase.
Feature Module Structure
Each feature follows this structure:
internal/features/myfeature/
├── module.go # DI container + route registration
├── delivery/http/
│ ├── handler/
│ │ └── myfeature_handler.go # HTTP handlers
│ └── dto/
│ └── myfeature_dto.go # Request/response DTOs + validation
└── usecase/
└── myfeature_usecase.go # Business logic + interface
Critical Patterns from Actual Code
⚠️ IMPORTANT: Context Pattern
- ALL use case methods MUST accept
ctx context.Contextas first parameter - ALL repository methods MUST accept
ctx context.Contextas first parameter - Handlers pass
c.Request.Context()to use cases
⚠️ IMPORTANT: Use Case Return Pattern
Use cases return (data, int, error) where int is HTTP status code:
func (u *myUsecase) Create(ctx context.Context, req dto.Request) (*dto.Response, int, error)
⚠️ IMPORTANT: Validation Pattern
- DTOs do NOT use binding tags (only json tags)
- Validation is done via custom
Validate(lang constants.Lang) map[string][]stringmethod - Handler calls
ShouldBindJSON()first, thenreq.Validate(lang)
⚠️ IMPORTANT: ID Type
- User.ID is
string(notuuid.UUID) - UUID stored as string in database
Step-by-Step Process
1. Create Feature Directory Structure
mkdir -p internal/features/{feature-name}/delivery/http/{handler,dto}
mkdir -p internal/features/{feature-name}/usecase
2. Create Domain Entities (if needed)
If your feature needs new entities, add them to internal/shared/domain/entity/:
// internal/shared/domain/entity/product.go
package entity
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Product struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
Name string `json:"name" gorm:"type:varchar(255);not null"`
Description string `json:"description" gorm:"type:text"`
Price float64 `json:"price" gorm:"type:decimal(10,2);not null"`
UserID string `json:"user_id" gorm:"type:varchar(36);not null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (Product) TableName() string {
return "products"
}
// Constructor with UUID generation
func NewProduct(name, description string, price float64, userID string) *Product {
return &Product{
ID: uuid.New().String(),
Name: name,
Description: description,
Price: price,
UserID: userID,
}
}
// BeforeCreate hook
func (p *Product) BeforeCreate(tx *gorm.DB) error {
if p.ID == "" {
p.ID = uuid.New().String()
}
return nil
}
3. Create Repository Interface (if needed)
Add repository interfaces to internal/shared/domain/repository/:
// internal/shared/domain/repository/product_repository.go
package repository
import (
"app/internal/shared/domain/entity"
"context"
)
type ProductRepository interface {
Create(ctx context.Context, product *entity.Product) error
GetByID(ctx context.Context, id string) (*entity.Product, error)
Update(ctx context.Context, product *entity.Product) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, userID string, limit, offset int) ([]*entity.Product, int, error)
}
IMPORTANT: All methods accept ctx context.Context as first parameter!
4. Implement Repository
Create implementation in internal/shared/infrastructure/repository/:
// internal/shared/infrastructure/repository/product_repository_impl.go
package repository
import (
"app/internal/shared/domain/entity"
"app/internal/shared/domain/repository"
"context"
"gorm.io/gorm"
)
type productRepository struct {
db *gorm.DB
}
func NewProductRepository(db *gorm.DB) repository.ProductRepository {
return &productRepository{db: db}
}
func (r *productRepository) Create(ctx context.Context, product *entity.Product) error {
return r.db.WithContext(ctx).Create(product).Error
}
func (r *productRepository) GetByID(ctx context.Context, id string) (*entity.Product, error) {
var product entity.Product
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&product).Error; err != nil {
return nil, err
}
return &product, nil
}
func (r *productRepository) Update(ctx context.Context, product *entity.Product) error {
return r.db.WithContext(ctx).Updates(product).Error
}
func (r *productRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&entity.Product{}).Error
}
func (r *productRepository) List(ctx context.Context, userID string, limit, offset int) ([]*entity.Product, int, error) {
var products []*entity.Product
query := r.db.WithContext(ctx).Where("user_id = ?", userID)
var total int64
if err := query.Model(&entity.Product{}).Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Limit(limit).Offset(offset).Find(&products).Error; err != nil {
return nil, 0, err
}
return products, int(total), nil
}
5. Create DTOs with Validation
Define request/response structures in internal/features/{feature-name}/delivery/http/dto/:
// internal/features/product/delivery/http/dto/product_dto.go
package dto
import (
"app/internal/shared/constants"
"app/internal/shared/domain/entity"
"fmt"
)
// CreateProductRequest - NO binding tags!
type CreateProductRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
}
// Validate method for custom validation
func (r *CreateProductRequest) Validate(lang constants.Lang) map[string][]string {
errors := make(map[string][]string)
if r.Name == "" {
errors["name"] = append(errors["name"],
fmt.Sprintf(constants.GetValidationMessage(constants.Required, lang), "name"))
}
if r.Price <= 0 {
errors["price"] = append(errors["price"], "price must be greater than 0")
}
return errors
}
// UpdateProductRequest
type UpdateProductRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
}
func (r *UpdateProductRequest) Validate(lang constants.Lang) map[string][]string {
errors := make(map[string][]string)
if r.Name == "" {
errors["name"] = append(errors["name"],
fmt.Sprintf(constants.GetValidationMessage(constants.Required, lang), "name"))
}
if r.Price <= 0 {
errors["price"] = append(errors["price"], "price must be greater than 0")
}
return errors
}
// ProductResponse
type ProductResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"` // time.Time, not string!
}
// Mapper function
func ToProductResponse(product *entity.Product) *ProductResponse {
return &ProductResponse{
ID: product.ID,
Name: product.Name,
Description: product.Description,
Price: product.Price,
UserID: product.UserID,
CreatedAt: product.CreatedAt, // Direct assignment, no .Format()
}
}
IMPORTANT:
- DTOs use ONLY
jsontags, NObindingtags - Validation via custom
Validate(lang)method - Returns
map[string][]stringfor field-specific errors - Include mapper functions like
ToProductResponse
6. Create Use Case
Define interface and implementation in internal/features/{feature-name}/usecase/:
// internal/features/product/usecase/product_usecase.go
package usecase
import (
"app/internal/features/product/delivery/http/dto"
"app/internal/shared/constants"
"app/internal/shared/delivery/http/middleware"
"app/internal/shared/domain/entity"
"app/internal/shared/domain/repository"
"context"
"net/http"
"github.com/sirupsen/logrus"
)
// Interface
type ProductUsecase interface {
Create(ctx context.Context, userID string, req dto.CreateProductRequest) (*dto.ProductResponse, int, error)
GetByID(ctx context.Context, id string) (*dto.ProductResponse, int, error)
Update(ctx context.Context, id string, req dto.UpdateProductRequest) (*dto.ProductResponse, int, error)
Delete(ctx context.Context, id string) (int, error)
}
// Implementation
type productUsecase struct {
productRepo repository.ProductRepository
logger *logrus.Logger
}
func NewProductUsecase(productRepo repository.ProductRepository, logger *logrus.Logger) ProductUsecase {
return &productUsecase{
productRepo: productRepo,
logger: logger,
}
}
// Create product
func (u *productUsecase) Create(ctx context.Context, userID string, req dto.CreateProductRequest) (*dto.ProductResponse, int, error) {
// Get language from context
lang := middleware.GetLangFromContext(ctx)
// Create entity using constructor
product := entity.NewProduct(req.Name, req.Description, req.Price, userID)
// Save to repository
if err := u.productRepo.Create(ctx, product); err != nil {
u.logger.Error("u.productRepo.Create ", err)
return nil, http.StatusInternalServerError, constants.GetError(constants.SomethingWentWrong, lang)
}
// Return with mapper
return dto.ToProductResponse(product), http.StatusCreated, nil
}
// GetByID retrieves product
func (u *productUsecase) GetByID(ctx context.Context, id string) (*dto.ProductResponse, int, error) {
lang := middleware.GetLangFromContext(ctx)
product, err := u.productRepo.GetByID(ctx, id)
if err != nil {
u.logger.Error("u.productRepo.GetByID ", err)
return nil, http.StatusNotFound, constants.GetError(constants.UserNotFound, lang) // Use appropriate error constant
}
return dto.ToProductResponse(product), http.StatusOK, nil
}
// Update product
func (u *productUsecase) Update(ctx context.Context, id string, req dto.UpdateProductRequest) (*dto.ProductResponse, int, error) {
lang := middleware.GetLangFromContext(ctx)
// Get existing product
product, err := u.productRepo.GetByID(ctx, id)
if err != nil {
u.logger.Error("u.productRepo.GetByID ", err)
return nil, http.StatusNotFound, constants.GetError(constants.UserNotFound, lang)
}
// Update fields
product.Name = req.Name
product.Description = req.Description
product.Price = req.Price
// Save
if err := u.productRepo.Update(ctx, product); err != nil {
u.logger.Error("u.productRepo.Update ", err)
return nil, http.StatusInternalServerError, constants.GetError(constants.SomethingWentWrong, lang)
}
return dto.ToProductResponse(product), http.StatusOK, nil
}
// Delete product
func (u *productUsecase) Delete(ctx context.Context, id string) (int, error) {
lang := middleware.GetLangFromContext(ctx)
// Check if exists
_, err := u.productRepo.GetByID(ctx, id)
if err != nil {
u.logger.Error("u.productRepo.GetByID ", err)
return http.StatusNotFound, constants.GetError(constants.UserNotFound, lang)
}
// Delete
if err := u.productRepo.Delete(ctx, id); err != nil {
u.logger.Error("u.productRepo.Delete ", err)
return http.StatusInternalServerError, constants.GetError(constants.SomethingWentWrong, lang)
}
return http.StatusOK, nil
}
CRITICAL PATTERNS:
- ✅ Methods accept
ctx context.Contextas first parameter - ✅ Return
(data, int, error)with HTTP status code - ✅ Get language via
middleware.GetLangFromContext(ctx) - ✅ Use entity constructors:
entity.NewProduct(...) - ✅ Use mapper functions:
dto.ToProductResponse(product) - ✅ Pass context to repository:
u.productRepo.Create(ctx, product)
7. Create Handler
Implement HTTP handlers in internal/features/{feature-name}/delivery/http/handler/:
// internal/features/product/delivery/http/handler/product_handler.go
package handler
import (
"app/internal/features/product/delivery/http/dto"
"app/internal/features/product/usecase"
"app/internal/shared/constants"
"app/internal/shared/delivery/http/middleware"
"app/internal/shared/delivery/http/response"
"app/pkg/jwt"
"net/http"
"github.com/gin-gonic/gin"
)
type ProductHandler struct {
productUsecase usecase.ProductUsecase
}
func NewProductHandler(uc usecase.ProductUsecase) *ProductHandler {
return &ProductHandler{productUsecase: uc}
}
// CreateProduct godoc
// @Summary Create product
// @Description Create a new product
// @Tags products
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateProductRequest true "Product data"
// @Success 201 {object} response.Response{data=dto.ProductResponse}
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /api/v1/products [post]
func (h *ProductHandler) CreateProduct(c *gin.Context) {
lang := middleware.GetLangFromGin(c)
// Get user from JWT claims
claimsVal, exists := c.Get("sess")
if !exists {
response.NewResponse(c, http.StatusUnauthorized, nil,
constants.GetErrorMessage(constants.Unauthorized, lang), nil)
return
}
claims, ok := claimsVal.(*jwt.Claims) // Type: *jwt.Claims
if !ok {
response.NewResponse(c, http.StatusUnauthorized, nil,
constants.GetErrorMessage(constants.Unauthorized, lang), nil)
return
}
// Bind JSON
var req dto.CreateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang),
map[string][]string{"body": {err.Error()}})
return
}
// Custom validation
if errors := req.Validate(lang); len(errors) > 0 {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang), errors)
return
}
// Call use case with context
product, status, err := h.productUsecase.Create(c.Request.Context(), claims.UserID, req)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
response.NewResponse(c, status, product, "Product created successfully", nil)
}
// GetProduct godoc
// @Summary Get product
// @Description Get product by ID
// @Tags products
// @Produce json
// @Security BearerAuth
// @Param id path string true "Product ID"
// @Success 200 {object} response.Response{data=dto.ProductResponse}
// @Failure 404 {object} response.Response
// @Router /api/v1/products/{id} [get]
func (h *ProductHandler) GetProduct(c *gin.Context) {
id := c.Param("id")
product, status, err := h.productUsecase.GetByID(c.Request.Context(), id)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
response.NewResponse(c, status, product, "Product retrieved successfully", nil)
}
CRITICAL HANDLER PATTERNS:
- ✅ Get lang:
middleware.GetLangFromGin(c) - ✅ Claims type:
*jwt.Claimswith fieldclaims.UserID - ✅ Bind first:
c.ShouldBindJSON(&req) - ✅ Then validate:
req.Validate(lang) - ✅ Structured errors:
map[string][]string{"body": {err.Error()}} - ✅ Pass context:
h.usecase.Create(c.Request.Context(), ...) - ✅ Use status from usecase:
product, status, err := h.usecase.Create(...)
8. Create Module
Wire dependencies in internal/features/{feature-name}/module.go:
// internal/features/product/module.go
package product
import (
"app/internal/features/product/delivery/http/handler"
"app/internal/features/product/usecase"
"app/internal/shared/delivery/http/middleware"
"app/internal/shared/domain/repository"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
type Module struct {
handler *handler.ProductHandler
}
func NewModule(productRepo repository.ProductRepository, logger *logrus.Logger) *Module {
// Wire dependencies
uc := usecase.NewProductUsecase(productRepo, logger)
h := handler.NewProductHandler(uc)
return &Module{handler: h}
}
func (m *Module) Name() string {
return "product"
}
func (m *Module) RegisterRoutes(rg *gin.RouterGroup) {
// Protected routes
productGroup := rg.Group("/products")
productGroup.Use(middleware.AuthMiddleware())
{
productGroup.POST("", m.handler.CreateProduct)
productGroup.GET("/:id", m.handler.GetProduct)
productGroup.PUT("/:id", m.handler.UpdateProduct)
productGroup.DELETE("/:id", m.handler.DeleteProduct)
}
}
9. Register Module in App
Add the module to internal/app/app.go:
// Initialize repository
productRepo := sharedRepo.NewProductRepository(a.DB.GetDB())
// Register all features
features := []Feature{
auth.NewModule(userRepo, a.Logger),
user.NewModule(userRepo, a.Logger),
product.NewModule(productRepo, a.Logger), // Add your new feature
}
10. Update Swagger Documentation
make swag
Quick Reference Checklist
When adding a new feature:
- Context parameter in ALL use case and repository methods
- Use case returns
(data, int, error)with HTTP status code - DTOs without binding tags, only json tags
- Custom
Validate(lang)method in DTOs returningmap[string][]string - Entity ID is
stringtype, notuuid.UUID - Entity has constructor function
NewEntity(...) - Mapper functions in DTO package:
ToEntityResponse(entity) - Handler binds JSON first, then calls custom validate
- Handler passes
c.Request.Context()to use case - Handler uses
*jwt.Claimstype withclaims.UserIDfield - Use case gets lang via
middleware.GetLangFromContext(ctx) - Error response supports both string and
map[string][]string - Generate Swagger docs after changes