Skip to content

Architecture Patterns

Merq backend mengikuti Clean Architecture principles dengan clear separation of concerns.

┌──────────────────────────────────────┐
│ HTTP Layer │
│ (Handlers) │
│ - Parse requests │
│ - Validate input │
│ - Call services │
│ - Format responses │
└────────────┬─────────────────────────┘
┌────────────▼─────────────────────────┐
│ Business Logic Layer │
│ (Services) │
│ - Business rules │
│ - Transaction management │
│ - Orchestration │
│ - Domain validation │
└────────────┬─────────────────────────┘
┌────────────▼─────────────────────────┐
│ Data Access Layer │
│ (Repositories) │
│ - Database queries │
│ - GORM operations │
│ - Filter application │
│ - Pagination │
└────────────┬─────────────────────────┘
┌────────────▼─────────────────────────┐
│ Database │
│ (PostgreSQL) │
└──────────────────────────────────────┘

Dependencies MUST flow inward (downward):

Handler → Service → Repository → Database

Rules:

  • ✅ Handlers depend on Services
  • ✅ Services depend on Repositories
  • ✅ Repositories depend on Database
  • ❌ Services NEVER import Handlers
  • ❌ Repositories NEVER import Services

Purpose: HTTP concerns only

Responsibilities:

  • Parse query parameters, path parameters, request body
  • Validate request format (via binding tags)
  • Extract auth context (workspace_id, user_id, permissions)
  • Call appropriate service method
  • Format HTTP response
  • Set appropriate HTTP status codes

Should NOT:

  • Contain business logic
  • Direct database access
  • Complex calculations
  • Transaction management

Example:

func (h *OutletHandler) Create(c *gin.Context) {
workspaceID := c.GetUint("workspace_id")
var req dto.CreateOutletRequest
if err := c.ShouldBindJSON(&req); err != nil {
_ = c.Error(err)
return
}
outlet, err := h.service.Create(c, workspaceID, &req)
if err != nil {
_ = c.Error(err)
return
}
utils.HandleResponse(c, http.StatusCreated, "Outlet created", "OUTLET_CREATE", outlet)
}

Purpose: Business logic and orchestration

Responsibilities:

  • Implement business rules
  • Validate domain constraints
  • Coordinate multiple repositories
  • Manage transactions
  • Apply workspace scoping
  • Transform between DTOs and domain models

Should NOT:

  • Parse HTTP requests
  • Return HTTP responses
  • Direct database queries (use repositories)

Example:

func (s *OutletService) Create(ctx context.Context, workspaceID uint, req *dto.CreateOutletRequest) (*dto.OutletResponse, error) {
// Business validation
if req.Latitude < -90 || req.Latitude > 90 {
return nil, customerror.New(http.StatusBadRequest, "INVALID_LATITUDE", "Latitude must be between -90 and 90")
}
// Check if outlet name already exists in workspace
existing, _ := s.repo.FindByName(ctx, workspaceID, req.Name)
if existing != nil {
return nil, customerror.New(http.StatusConflict, "OUTLET_EXISTS", "Outlet name already exists")
}
// Create domain model
outlet := &domain.Outlet{
WorkspaceID: workspaceID,
Name: req.Name,
Address: req.Address,
Latitude: req.Latitude,
Longitude: req.Longitude,
Status: "active",
}
// Persist via repository
if err := s.repo.Create(ctx, outlet); err != nil {
return nil, err
}
// Transform to response DTO
response := s.toResponse(outlet)
return &response, nil
}

Purpose: Data access abstraction

Responsibilities:

  • Execute database queries
  • Apply filters
  • Handle pagination
  • Manage GORM operations
  • Map database rows to domain models

Should NOT:

  • Business logic
  • HTTP concerns
  • DTO transformations (use domain models)

Example:

func (r *OutletRepository) Find(ctx context.Context, filter *domain.OutletListFilter) ([]domain.Outlet, *domain.PaginationMeta, error) {
query := r.db.WithContext(ctx).Model(&domain.Outlet{})
// Apply workspace filter (CRITICAL)
if filter.Workspace != nil {
query = query.Where("workspace_id = ?", *filter.Workspace)
}
// Apply additional filters
if filter.Status != nil {
query = query.Where("status = ?", *filter.Status)
}
if filter.Search != nil && *filter.Search != "" {
query = query.Where("name ILIKE ?", "%"+*filter.Search+"%")
}
// Pagination
var total int64
query.Count(&total)
meta := utils.Paginate(query, filter.Page, filter.Limit)
// Execute
var outlets []domain.Outlet
err := query.Find(&outlets).Error
return outlets, meta, err
}

Interface-based repositories for testability and flexibility:

// Define interface
type OutletRepository interface {
Find(ctx context.Context, filter *domain.OutletListFilter) ([]domain.Outlet, *domain.PaginationMeta, error)
FindByID(ctx context.Context, id uint, workspaceID uint) (*domain.Outlet, error)
Create(ctx context.Context, outlet *domain.Outlet) error
Update(ctx context.Context, outlet *domain.Outlet) error
Delete(ctx context.Context, id uint, workspaceID uint) error
}
// Implement interface
type outletRepository struct {
db *gorm.DB
}
func NewOutletRepository(db *gorm.DB) OutletRepository {
return &outletRepository{db: db}
}

Benefits:

  • Easy to mock for testing
  • Can swap implementations
  • Clear contract definition

Services orchestrate business operations:

type OutletService interface {
Find(ctx context.Context, workspaceID uint, filter *domain.OutletListFilter) ([]dto.OutletResponse, *domain.PaginationMeta, error)
Create(ctx context.Context, workspaceID uint, req *dto.CreateOutletRequest) (*dto.OutletResponse, error)
}
type outletService struct {
repo OutletRepository
}
func NewOutletService(repo OutletRepository) OutletService {
return &outletService{repo: repo}
}

Data Transfer Objects untuk API boundaries:

// Request DTO - input validation
type CreateOutletRequest struct {
Name string `json:"name" binding:"required,min=1,max=255"`
Address string `json:"address" binding:"required"`
Latitude float64 `json:"latitude" binding:"required,min=-90,max=90"`
Longitude float64 `json:"longitude" binding:"required,min=-180,max=180"`
}
// Response DTO - output formatting
type OutletResponse struct {
ID uint `json:"id"`
WorkspaceID uint `json:"workspace_id"`
Name string `json:"name"`
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

Centralized object creation:

// Wire up dependencies
func InitializeOutletModule(db *gorm.DB) *OutletHandler {
repo := repository.NewOutletRepository(db)
service := service.NewOutletService(repo)
handler := handler.NewOutletHandler(service)
return handler
}

Services manage transactions for multi-step operations:

func (s *ProjectService) Create(ctx context.Context, workspaceID uint, req *dto.CreateProjectRequest) (*dto.ProjectResponse, error) {
var project *domain.Project
err := utils.WithTransaction(s.db, func(tx *gorm.DB) error {
// Create project
project = &domain.Project{
WorkspaceID: workspaceID,
PrincipalID: req.PrincipalID,
Name: req.Name,
Status: "active",
}
if err := s.repo.CreateWithTx(ctx, tx, project); err != nil {
return err
}
// Create default team
team := &domain.Team{
WorkspaceID: workspaceID,
ProjectID: project.ID,
Name: "Default Team",
Status: "active",
}
if err := s.teamRepo.CreateWithTx(ctx, tx, team); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
response := s.toResponse(project)
return &response, nil
}
internal/utils/transaction.go
func WithTransaction(db *gorm.DB, fn func(*gorm.DB) error) error {
tx := db.Begin()
if tx.Error != nil {
return tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r)
}
}()
if err := fn(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
type OutletHandler struct {
service service.OutletService
}
func NewOutletHandler(service service.OutletService) *OutletHandler {
return &OutletHandler{service: service}
}
cmd/server/main.go
func main() {
db := config.InitDB()
// Initialize repositories
outletRepo := repository.NewOutletRepository(db)
projectRepo := repository.NewProjectRepository(db)
// Initialize services
outletService := service.NewOutletService(outletRepo)
projectService := service.NewProjectService(projectRepo)
// Initialize handlers
outletHandler := handler.NewOutletHandler(outletService)
projectHandler := handler.NewProjectHandler(projectService)
// Setup routes
router := gin.Default()
api := router.Group("/api/office/v1")
outletHandler.RegisterRoutes(api)
projectHandler.RegisterRoutes(api)
router.Run(":8080")
}
internal/customerror/error.go
type AppError struct {
HTTPStatus int
Code string
Message string
}
func (e *AppError) Error() string {
return e.Message
}
func New(status int, code string, message string) *AppError {
return &AppError{
HTTPStatus: status,
Code: code,
Message: message,
}
}
// Repository returns error
func (r *OutletRepository) FindByID(ctx context.Context, id uint, workspaceID uint) (*domain.Outlet, error) {
var outlet domain.Outlet
err := r.db.WithContext(ctx).
Where("id = ? AND workspace_id = ?", id, workspaceID).
First(&outlet).Error
if err != nil {
return nil, err // Propagate as-is
}
return &outlet, nil
}
// Service wraps with domain error
func (s *OutletService) GetByID(ctx context.Context, id uint, workspaceID uint) (*dto.OutletResponse, error) {
outlet, err := s.repo.FindByID(ctx, id, workspaceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, customerror.New(http.StatusNotFound, "OUTLET_NOT_FOUND", "Outlet not found")
}
return nil, err
}
response := s.toResponse(outlet)
return &response, nil
}
// Handler delegates to middleware
func (h *OutletHandler) GetByID(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
workspaceID := c.GetUint("workspace_id")
outlet, err := h.service.GetByID(c, uint(id), workspaceID)
if err != nil {
_ = c.Error(err) // Error middleware handles it
return
}
utils.HandleResponse(c, http.StatusOK, "Success", "OUTLET_DETAIL", outlet)
}

Each layer has ONE job:

// ✅ CORRECT - Handler only handles HTTP
func (h *Handler) Create(c *gin.Context) {
var req dto.Request
c.ShouldBindJSON(&req)
result, err := h.service.Create(c, req)
// ... return response
}
// ❌ WRONG - Handler doing business logic
func (h *Handler) Create(c *gin.Context) {
var req dto.Request
c.ShouldBindJSON(&req)
// Business logic in handler - BAD!
if req.Amount < 0 {
c.JSON(400, gin.H{"error": "Invalid amount"})
return
}
db.Create(&model) // Direct DB access - BAD!
}

Depend on interfaces, not concrete implementations:

// ✅ CORRECT - Service depends on interface
type OutletService struct {
repo OutletRepository // Interface
}
// ❌ WRONG - Service depends on concrete type
type OutletService struct {
repo *outletRepository // Concrete struct
}

Always pass context through layers:

// ✅ CORRECT
func (s *Service) Create(ctx context.Context, data *Data) error {
return s.repo.Create(ctx, data)
}
// ❌ WRONG - Lost context
func (s *Service) Create(data *Data) error {
return s.repo.Create(context.Background(), data)
}

ALWAYS apply workspace filter:

// ✅ CORRECT
func (s *Service) Find(ctx context.Context, workspaceID uint, filter *Filter) {
filter.Workspace = &workspaceID
return s.repo.Find(ctx, filter)
}
// ❌ WRONG - Missing workspace
func (s *Service) Find(ctx context.Context, filter *Filter) {
return s.repo.Find(ctx, filter)
}