Project ⏱ 8 min read

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
Layer violation example: A handler that directly calls 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

WhatWhere
required, min, max, uuid, oneof, emailStruct tags
Parse body + fire validatorHandler
Uniqueness, FK exists, quota, business limitsService
Auth, rate-limitMiddleware

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