09 · Project Standards
Layer rules, Fiber patterns, repository interfaces, DI, logging
The Three-Layer Rule
HTTP Request
↓
Handler — parse request, validate, call service, format response
↓ NO business logic. NO default values for identity fields.
Service — all business logic, defaults (createdBy fallback to "system")
↓ NO direct DB access.
Repository — data access only. NO business logic. NO HTTP concepts.
↓
Database
db.Find(), or a repository that decides what default value to use for createdBy. Both are wrong.
Handler Pattern
// internal/handlers/system.go
type SystemHandler struct {
config *config.Config
}
func NewSystemHandler(config *config.Config) *SystemHandler {
return &SystemHandler{config: config}
}
func (h *SystemHandler) Info(c *fiber.Ctx) error {
return c.JSON(APIInfo{
Message: "Welcome to LP Coupon API",
Environment: h.config.App.Environment,
Status: "healthy",
})
}
Handlers receive dependencies via constructor injection. The handler method receives *fiber.Ctx and returns error. For errors, return them — the ErrorHandlerMiddleware formats the response.
Validation — Rule of Thumb
| What | Where |
|---|---|
required, min, max, uuid, oneof, email | Struct tags |
| Parse body + fire validator | Handler |
| Uniqueness, FK exists, quota, business limits | Service |
| Auth, rate-limit | Middleware |
Service Pattern
// internal/services/coupon_service.go
type CouponService struct {
logger zlog.Logger
couponRepo repository.CouponRepository // interface, not concrete type
userCouponRepo repository.UserCouponRepository
userCouponsCountRepo repository.UserCouponsCountRepository
}
func NewCouponService(
logger zlog.Logger,
couponRepo repository.CouponRepository,
userCouponRepo repository.UserCouponRepository,
userCouponsCountRepo repository.UserCouponsCountRepository,
) *CouponService {
return &CouponService{
logger: logger,
couponRepo: couponRepo,
userCouponRepo: userCouponRepo,
userCouponsCountRepo: userCouponsCountRepo,
}
}
Services accept interfaces (for testability), return concrete structs. Business defaults live here — e.g. if createdBy == "" { createdBy = "system" }.
Repository Interface Pattern
// internal/repository/interfaces.go — contracts defined here
type CouponRepository interface {
GetByID(ctx context.Context, id string) (*models.Coupon, error)
Create(ctx context.Context, coupon *models.Coupon) error
Update(ctx context.Context, id string, updates map[string]interface{}) error
// ... more methods
}
// internal/repository/coupon_repository.go — implementation
type GORMCouponRepository struct {
db *gorm.DB
roDB *gorm.DB
}
func NewCouponRepository(db, roDB *gorm.DB) *GORMCouponRepository {
return &GORMCouponRepository{db: db, roDB: roDB}
}
Always add new methods to the interface in interfaces.go first, then implement them. Tests mock the interface — they never touch real DB.
Dependency Injection — routes.go
// internal/routes/routes.go — all wiring happens here
func SetupRoutes(app *fiber.App, db, roDB *database.ConnectionManager, ...) {
// 1. Create repositories (concrete types, accept DB)
couponRepo := repository.NewCouponRepository(db.GetDatabase().DB, roDB.GetDatabase().DB)
userCouponRepo := repository.NewUserCouponRepository(db.GetDatabase().DB, roDB.GetDatabase().DB)
// 2. Create services (accept repository interfaces)
couponService := services.NewCouponService(log.Zlogger, couponRepo, userCouponRepo, ...)
// 3. Create handlers (accept service)
couponHandler := handlers.NewCouponHandler(cfg, log.Zlogger, couponService)
// 4. Register routes
v1 := app.Group(BasePath + "/v1")
coupon := v1.Group("/coupon")
coupon.Get("/:couponId", couponHandler.GetCouponByID)
}
No DI framework. No @Injectable(). Just constructor calls in the right order. Adding a new endpoint: repeat this pattern.
Logging with zlog
// Services and repositories receive a zlog.Logger
s.logger.Infof(ctx, "Processing coupon %s", couponID)
s.logger.Errorf(ctx, "Failed to get coupon: %v", err)
// Always pass context — it carries the trace ID
// The logger automatically includes it in the log output
Never use fmt.Println or log.Println in service/repo code. Use the injected zlog.Logger with context so logs include the request trace ID.
Key Takeaways
- Handlers: parse + validate + call service + format response. No logic, no defaults.
- Services: all business logic. Default values live here (
createdBy = "system"). - Repositories: data access only. Defined by interfaces in
interfaces.go. - All wiring in
routes.go: repos → services → handlers → routes. - Log with the injected
zlog.Logger+ context — neverfmt.Printlnin services/repos.