This document defines the coding standards and conventions for the backend codebase.

Table of Contents

  1. Naming Conventions
  2. Database Conventions (PostgreSQL/GORM)
  3. API Conventions
  4. Architecture (Hexagonal)
  5. Go Code Conventions
  6. File Organization
  7. Error Handling
  8. Migration Guide
  9. Why These Conventions?

Naming Conventions

General Rules

ContextConventionExample
Database Fieldssnake_caseuser_id, created_at, is_active
API Request/Responsesnake_caseleave_type_id, start_date, is_half_day
Go Struct FieldsPascalCaseUserID, CreatedAt, IsActive
Go VariablescamelCaseuserId, createdAt, isActive
Go FunctionsPascalCase (public), camelCase (private)FindByID(), validateInput()
Go Package Nameslowercase (single word)repository, services, domain
File Namessnake_caseleave_repository.go, handler_test.go
[!NOTE] All file names MUST use snake_case. Be descriptive but avoid redundant prefixes if the directory already provides context.

Database Conventions

The backend uses PostgreSQL with GORM as the ORM.

PostgreSQL Field Naming

Always use snake_case for all database fields.

✅ Correct GORM Model Definition:

type Product struct {
    ID             string     `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
    OrganizationID string     `gorm:"type:uuid;not null;index" json:"organization_id"`
    Name           string     `gorm:"not null" json:"name"`
    Price          int        `gorm:"not null" json:"price"` // Price in cents
    CreatedAt      time.Time  `gorm:"autoCreateTime" json:"created_at"`
    UpdatedAt      time.Time  `gorm:"autoUpdateTime" json:"updated_at"`
    DeletedAt      *time.Time `gorm:"index" json:"deleted_at"`
}

Standard Field Names

Use these consistent field names across all tables:
FieldPurposeTypeAlways Present
idPrimary keyuuid✅ Yes
created_atCreation timestamptimestamp✅ Yes
updated_atLast update timestamptimestamp✅ Yes
deleted_atSoft delete timestamptimestampOptional
organization_idMulti-tenancy org IDuuid✅ Yes
user_idAssociated user (Better Auth User ID)textOptional

ID Conventions

Primary Keys (id)

  • Format: UUID v4
  • GORM Tag: type:uuid;default:gen_random_uuid()
  • Why: Universally unique, natively supported by PostgreSQL, and allows ID generation in the application layer.

User IDs (user_id)

  • Source: Better Auth users.id
  • Type: text (The Go backend treats this as a foreign reference to the Better Auth schema managed by the Bun monolith).
  • Note: The Go backend is read-only for the users and organizations tables.

Table Naming

  • Format: snake_case and plural (e.g., leave_requests, bulk_jobs).
  • Better Auth Tables: users, organizations, members, invitation.

API Conventions

Request/Response Format

Always use snake_case for JSON fields in API requests and responses.

Pagination Standard

All list endpoints MUST use the standardized pagination envelope.

Request Headers/Params:

  • Header: X-Pagination-Type: cursor (optional, defaults to offset)
  • Params: limit, page (for offset mode), cursor (for cursor mode).

Response Format:

{
  "items": [...],
  "pagination": {
    "mode": "offset",
    "limit": 20,
    "hasNext": true,
    "hasPrev": false,
    "page": 1,
    "totalPages": 5,
    "totalRecords": 100
  }
}
[!NOTE] Use the pagination.Wrap(items, meta) helper from backend/go/pkg/pagination.

Status Enums

Use UPPERCASE for status values:
const (
    StatusPending  = "PENDING"
    StatusApproved = "APPROVED"
    StatusRejected = "REJECTED"
)
Why: Clear distinction from regular string values, widely used convention.

Architecture

We follow a Hexagonal Architecture (Ports and Adapters) pattern to keep business logic decoupled from infrastructure.

Layers

  1. Domain (internal/modules/{module}/domain/entity):
    • Pure Go structs representing business entities.
    • Minimal dependencies (no GORM or JSON tags here if possible).
  2. Application (internal/modules/{module}/application):
    • Ports: Interfaces for repositories and external services.
    • Usecases: Orchestration of domain logic.
  3. Adapters (internal/modules/{module}/adapter):
    • Inbound/HTTP: Echo handlers and request/response DTOs.
    • Outbound/Persistence: GORM repository implementations.
    • Outbound/Messaging: NATS event publishers.

Mapping Pattern

Always use mapping helpers to convert between Database Models (GORM) and Domain Entities.
func toDomain(dbModel *postgresql.LeaveRequest) *entity.LeaveRequest { ... }
func toDB(entity *entity.LeaveRequest) *postgresql.LeaveRequest { ... }

Go Code Conventions

Struct Definitions

Public structs use PascalCase, with JSON and GORM tags in snake_case:
type LeaveRequest struct {
    ID             string    `gorm:"primaryKey;type:uuid" json:"id"`
    UserID         string    `gorm:"not null" json:"user_id"`
    OrganizationID string    `gorm:"type:uuid;not null" json:"organization_id"`
    Status         string    `gorm:"default:'PENDING'" json:"status"`
    CreatedAt      time.Time `json:"created_at"`
}

Repository Queries (GORM)

Always pass context.Context and use method chaining.
func (r *Repository) GetByID(ctx context.Context, id string) (*entity.LeaveRequest, error) {
    var model postgresql.LeaveRequest
    if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil {
        return nil, err
    }
    return toDomain(&model), nil
}

Variable Naming

// Local variables: camelCase
var userID string
var createdAt time.Time
var isActive bool

// Constants: PascalCase or SCREAMING_SNAKE_CASE
const DefaultTimeout = 30 * time.Second
const MAX_RETRY_COUNT = 3

Function Naming

// Public functions: PascalCase (exported)
func FindByID(ctx context.Context, id string) (*User, error)
func CreateLeaveRequest(params CreateParams) (*LeaveRequest, error)

// Private functions: camelCase (unexported)
func validateInput(input string) error
func calculateBusinessDays(start, end time.Time) float64

File Organization

Module Structure

internal/modules/leave/
├── adapter/
│   ├── inbound/
│   │   └── http/                # REST Handlers & DTOs
│   └── outbound/
│       ├── persistence/
│       │   └── postgresql/      # GORM implementation
│       └── messaging/
│           └── nats/            # NATS publishers
├── application/
│   ├── port/                    # Interfaces (Repositories, Services)
│   └── usecase/                 # Business logic orchestration
└── domain/
    └── entity/                  # Business entities

Directory-Specific Naming

To ensure consistency across hexagonal layers, follow these directory-specific patterns:
Layer / DirectoryPatternExample
domain/entity/{entity}.goleave.go, user.go
application/port/{type}.goleave_repository.go
application/usecase/{type}_service.goleave_query_service.go
adapter/inbound/http/[type]_handler.gohandler.go, approval_handler.go
adapter/inbound/event/[type]_consumer.goconsumer.go
adapter/outbound/persistence/[type]_repository.goleave_type_repository.go
Test Files{filename}_test.goleave_service_test.go

Detailed Examples:

  • Ports (application/port/): leave_service.go (Interface), leave_repository.go (Interface).
  • Use Cases (application/usecase/): leave_service.go (Command impl), leave_query_service.go (Query impl).
  • HTTP Adapters (adapter/inbound/http/): handler.go (Primary), approval_handler.go (Specific).
  • Persistence (adapter/outbound/persistence/): leave_request_repository.go (GORM impl).

Rules of Thumb:

  1. Be descriptive - file names should clearly indicate their purpose.
  2. Suffix grouping - group related files by suffix (e.g., _test.go, _repository.go).
  3. Avoid redundant prefixes - if the directory name already provides context (e.g., .../http/handler.go), keep the file name simple. Use specific names (e.g., approval_handler.go) only when multiple handlers exist in the same directory.

Benefits of This Structure

  • Consistency: Easy to navigate the codebase across different modules.
  • Predictability: Developers know exactly where to find handlers or repositories.
  • Scalability: The pattern works consistently as the number of modules grows.

Naming Migration

When renaming files to follow these conventions:
  1. Use git mv to preserve history.
  2. Update all imports and run go build to verify.
  3. Run tests and update related documentation.

Error Handling

API Error Responses

Standardized error format:
{
  "error": "Insufficient leave balance"
}

Go Error Wrapping

Always wrap errors with context using %w.
if err := r.db.Create(&model).Error; err != nil {
    return fmt.Errorf("failed to create leave request: %w", err)
}

Migration Guide

If you find legacy code using MongoDB or Clerk patterns:
  1. Schema: Define a GORM model with UUID primary keys.
  2. Repository: Implement a GORM-based repository in adapter/outbound/persistence/postgresql.
  3. Pagination: Switch to the unified PaginatedResponse envelope.
  4. Auth: Use the shared users and organizations tables (Read-Only).

Why These Conventions?

UUIDs for Primary Keys

Security: Prevents ID enumeration. ✅ Distributed: Safe for multi-region sync without collisions.

Hexagonal Architecture

Testability: Business logic is decoupled from infrastructure and can be tested in isolation. ✅ Consistency: Uniform structure across all modules makes the codebase easier to navigate.

Standardized Pagination

Frontend Integration: The SolidStart frontend uses a single component to handle all paginated data.

References


Enforcement

Pre-commit Hooks

  • golangci-lint for Go code.
  • go fmt and go vet.

Code Review Checklist

  • Database fields use snake_case.
  • API fields use snake_case.
  • Primary keys use UUID v4.
  • Business logic resides in domain or usecase.
  • Errors are wrapped with %w.

Last Updated: 2026-05-01
Version: 2.0
Maintained By: Backend Team