Architecture Patterns
Backend Architecture Patterns
Section titled “Backend Architecture Patterns”Merq backend mengikuti Clean Architecture principles dengan clear separation of concerns.
Layer Architecture
Section titled “Layer Architecture”┌──────────────────────────────────────┐│ 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) │└──────────────────────────────────────┘Dependency Direction
Section titled “Dependency Direction”Dependencies MUST flow inward (downward):
Handler → Service → Repository → DatabaseRules:
- ✅ Handlers depend on Services
- ✅ Services depend on Repositories
- ✅ Repositories depend on Database
- ❌ Services NEVER import Handlers
- ❌ Repositories NEVER import Services
Layer Responsibilities
Section titled “Layer Responsibilities”Handler Layer
Section titled “Handler Layer”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)}Service Layer
Section titled “Service Layer”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}Repository Layer
Section titled “Repository Layer”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}Design Patterns
Section titled “Design Patterns”Repository Pattern
Section titled “Repository Pattern”Interface-based repositories for testability and flexibility:
// Define interfacetype 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 interfacetype 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
Service Pattern
Section titled “Service Pattern”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}}DTO Pattern
Section titled “DTO Pattern”Data Transfer Objects untuk API boundaries:
// Request DTO - input validationtype 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 formattingtype 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"`}Factory Pattern
Section titled “Factory Pattern”Centralized object creation:
// Wire up dependenciesfunc InitializeOutletModule(db *gorm.DB) *OutletHandler { repo := repository.NewOutletRepository(db) service := service.NewOutletService(repo) handler := handler.NewOutletHandler(service) return handler}Transaction Management
Section titled “Transaction Management”Service-Level Transactions
Section titled “Service-Level Transactions”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}Transaction Helper
Section titled “Transaction Helper”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}Dependency Injection
Section titled “Dependency Injection”Constructor Injection
Section titled “Constructor Injection”type OutletHandler struct { service service.OutletService}
func NewOutletHandler(service service.OutletService) *OutletHandler { return &OutletHandler{service: service}}Main Application Wiring
Section titled “Main Application Wiring”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")}Error Handling Strategy
Section titled “Error Handling Strategy”Custom Error Types
Section titled “Custom Error Types”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, }}Error Propagation
Section titled “Error Propagation”// Repository returns errorfunc (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 errorfunc (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 middlewarefunc (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)}Best Practices
Section titled “Best Practices”1. Single Responsibility Principle
Section titled “1. Single Responsibility Principle”Each layer has ONE job:
// ✅ CORRECT - Handler only handles HTTPfunc (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 logicfunc (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!}2. Dependency Inversion
Section titled “2. Dependency Inversion”Depend on interfaces, not concrete implementations:
// ✅ CORRECT - Service depends on interfacetype OutletService struct { repo OutletRepository // Interface}
// ❌ WRONG - Service depends on concrete typetype OutletService struct { repo *outletRepository // Concrete struct}3. Context Propagation
Section titled “3. Context Propagation”Always pass context through layers:
// ✅ CORRECTfunc (s *Service) Create(ctx context.Context, data *Data) error { return s.repo.Create(ctx, data)}
// ❌ WRONG - Lost contextfunc (s *Service) Create(data *Data) error { return s.repo.Create(context.Background(), data)}4. Workspace Scoping
Section titled “4. Workspace Scoping”ALWAYS apply workspace filter:
// ✅ CORRECTfunc (s *Service) Find(ctx context.Context, workspaceID uint, filter *Filter) { filter.Workspace = &workspaceID return s.repo.Find(ctx, filter)}
// ❌ WRONG - Missing workspacefunc (s *Service) Find(ctx context.Context, filter *Filter) { return s.repo.Find(ctx, filter)}