🛠️ Development Guide¶
A comprehensive guide to understanding the codebase and building new features.
📑 Table of Contents¶
- Architecture Overview
- Configuration System
- Directory Structure
- Understanding the Layers
- How User Management Works
- Adding New Features
- Best Practices
- Common Patterns
🏗️ Architecture Overview¶
This project follows Clean Architecture principles with clear separation of concerns:
┌─────────────────────────────────────────────────────────┐
│ HTTP Layer │
│ (Handlers - Receive requests, return responses) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Business Layer │
│ (Services - Business logic, orchestration) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Data Layer │
│ (Repositories - Database operations) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Database │
│ (PostgreSQL - Data storage) │
└─────────────────────────────────────────────────────────┘
Key Principles¶
- Separation of Concerns - Each layer has one responsibility
- Dependency Injection - Dependencies flow inward
- Interface-Based - Layers communicate through interfaces
- Testable - Each layer can be tested independently
- Maintainable - Easy to understand and modify
⚙️ Configuration System¶
The application uses a Viper-based configuration system with layered precedence for flexibility and security.
Configuration Architecture¶
Environment Variables (.env) <-- Highest Priority
↓ (overrides)
Environment Config (config.{env}.yaml)
↓ (overrides)
Base Config (config.yaml)
↓ (overrides)
Default Values (hardcoded) <-- Lowest Priority
Using Configuration in Code¶
Step 1: Load configuration in main.go
package main
import (
"github.com/vahiiiid/go-rest-api-boilerplate/internal/config"
)
func main() {
// Load configuration using Viper
cfg, err := config.LoadConfig("") // Auto-detects environment
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Pass typed config to services
authService := auth.NewService(&cfg.JWT)
database, err := db.NewPostgresDBFromDatabaseConfig(cfg.Database)
// ...
}
Step 2: Inject configuration into services
// Before (manual env reads)
func NewService() Service {
secret := os.Getenv("JWT_SECRET") // ❌ Direct env access
// ...
}
// After (typed config injection)
func NewService(cfg *config.JWTConfig) Service {
secret := cfg.Secret // ✅ Type-safe access
ttl := time.Duration(cfg.TTLHours) * time.Hour
// ...
}
Configuration Validation¶
The config system includes automatic validation:
// Production validations automatically applied
func (c *Config) Validate() error {
if c.JWT.Secret == "" {
return fmt.Errorf("JWT secret is required")
}
if c.App.Environment == "production" {
if len(c.JWT.Secret) < 32 {
return fmt.Errorf("JWT secret must be 32+ chars in production")
}
if c.Database.SSLMode == "disable" {
return fmt.Errorf("SSL required in production")
}
}
return nil
}
Testing Configuration¶
Use the test helper for consistent test configs:
func TestUserService(t *testing.T) {
// Get pre-configured test config
cfg := config.NewTestConfig()
// Override specific values if needed
cfg.JWT.TTLHours = 1
service := NewService(&cfg.JWT)
// ... test with consistent config
}
📖 Complete configuration reference: Configuration Guide
📁 Directory Structure¶
go-rest-api-boilerplate/
├── .github/ # GitHub workflows, issue templates, PR templates
├── api/ # API documentation (Swagger, Postman)
│ └── docs/ # Generated Swagger docs
├── cmd/ # Application entry points (server, migrate)
├── configs/ # YAML configuration files for all environments
├── internal/ # Main application code (private)
│ ├── auth/ # Authentication logic (JWT, middleware)
│ ├── config/ # Configuration management and validation
│ ├── contextutil/ # Context helpers/utilities
│ ├── db/ # Database connection and setup
│ ├── middleware/ # HTTP middleware (logging, rate limiting)
│ ├── migrate/ # Migration logic and status checks
│ ├── server/ # Router and server setup
│ └── user/ # User domain (handlers, services, repository)
├── migrations/ # Versioned SQL migration files
├── scripts/ # Helper shell scripts (entrypoints, quick-start)
├── tests/ # Integration and utility tests
├── tmp/ # Temp files (e.g., Air hot-reload, gitignored)
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose (development)
├── docker-compose.prod.yml # Docker Compose (production)
├── Makefile # Build and workflow automation
├── README.md # Main project overview
├── CONTRIBUTING.md # Contribution guidelines
├── SECURITY.md # Security policy
├── LICENSE # Project license
└── ... # Other root files (changelog, codecov, etc.)
File Responsibilities¶
| File/Folder | Purpose |
|---|---|
.github/ | GitHub workflows, issue templates, PR templates |
api/ | API documentation (Swagger, Postman) |
cmd/ | Application entry points (server, migrate) |
configs/ | YAML configuration files for all environments |
internal/ | Main application code (private) |
internal/auth/ | Authentication logic (JWT, middleware) |
internal/config/ | Configuration management and validation |
internal/contextutil/ | Context helpers/utilities |
internal/db/ | Database connection and setup |
internal/middleware/ | HTTP middleware (logging, rate limiting) |
internal/migrate/ | Migration logic and status checks |
internal/server/ | Router and server setup |
internal/user/ | User domain (handlers, services, repository) |
migrations/ | Versioned SQL migration files |
scripts/ | Helper shell scripts (entrypoints, quick-start) |
tests/ | Integration and utility tests |
tmp/ | Temp files (e.g., Air hot-reload, gitignored) |
Dockerfile | Multi-stage Docker build |
docker-compose.yml | Docker Compose (development) |
docker-compose.prod.yml | Docker Compose (production) |
Makefile | Build and workflow automation |
README.md | Main project overview |
CONTRIBUTING.md | Contribution guidelines |
SECURITY.md | Security policy |
LICENSE | Project license |
| ... | Other root files (changelog, codecov, etc.) |
🔍 Understanding the Layers¶
1. Model Layer (model.go)¶
Purpose: Define database schema using GORM
Example:
package user
import (
"gorm.io/gorm"
"time"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
Key Points: - Use GORM tags for database constraints - Use JSON tags for API responses - Use json:"-" to hide sensitive fields - gorm.DeletedAt enables soft deletes
2. DTO Layer (dto.go)¶
Purpose: Define request/response structures
Example:
package user
type RegisterRequest struct {
Name string `json:"name" binding:"required,min=2"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
type UserResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
Key Points: - Use binding tags for validation - Never expose PasswordHash in responses - Create separate structs for requests/responses - Use clear, descriptive names
3. Repository Layer (repository.go)¶
Purpose: Handle all database operations
Example:
package user
import (
"gorm.io/gorm"
)
type UserRepository interface {
Create(user *User) error
FindByID(id uint) (*User, error)
FindByEmail(email string) (*User, error)
List() ([]User, error)
Update(user *User) error
Delete(id uint) error
}
type GormUserRepository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) UserRepository {
return &GormUserRepository{db: db}
}
func (r *GormUserRepository) Create(user *User) error {
return r.db.Create(user).Error
}
func (r *GormUserRepository) FindByID(id uint) (*User, error) {
var user User
if err := r.db.First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
// ... more methods
Key Points: - Define interface first - Use GORM methods for queries - Always check for errors - Return appropriate GORM errors
4. Service Layer (service.go)¶
Purpose: Implement business logic and orchestration
Example:
package user
import (
"errors"
"golang.org/x/crypto/bcrypt"
)
type UserService struct {
repo UserRepository
}
func NewService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) RegisterUser(req RegisterRequest) (*User, error) {
// Check if user exists
existing, _ := s.repo.FindByEmail(req.Email)
if existing != nil {
return nil, errors.New("email already exists")
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Create user
user := &User{
Name: req.Name,
Email: req.Email,
PasswordHash: string(hash),
}
if err := s.repo.Create(user); err != nil {
return nil, err
}
return user, nil
}
// ... more methods
Key Points: - Validate business rules - Orchestrate repository calls - Handle errors appropriately - Keep logic testable
5. Handler Layer (handler.go)¶
Purpose: Handle HTTP requests and responses
Example:
package user
import (
"net/http"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
service *UserService
authService *auth.AuthService
}
func NewHandler(service *UserService, authService *auth.AuthService) *UserHandler {
return &UserHandler{
service: service,
authService: authService,
}
}
// @Summary Register a new user
// @Description Create a new user account
// @Tags auth
// @Accept json
// @Produce json
// @Param user body RegisterRequest true "User registration data"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} ErrorResponse
// @Router /api/v1/auth/register [post]
func (h *UserHandler) Register(c *gin.Context) {
var req RegisterRequest
// Bind and validate request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
// Call service
user, err := h.service.RegisterUser(req)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
// Generate token
token, err := h.authService.GenerateToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to generate token"})
return
}
// Return response
c.JSON(http.StatusOK, TokenResponse{
Token: token,
User: toUserResponse(user),
})
}
Key Points: - Bind request data with validation - Call service layer - Return appropriate HTTP status codes - Add Swagger annotations - Keep handlers thin
👤 How User Management Works¶
Let's trace a registration request through all layers:
Request Flow¶
1. HTTP Request
POST /api/v1/auth/register
Body: {"name": "Alice", "email": "alice@example.com", "password": "secret123"}
↓
2. Handler (handler.go)
- Binds JSON to RegisterRequest
- Validates using binding tags
- Calls service.RegisterUser()
↓
3. Service (service.go)
- Checks if email already exists (calls repo.FindByEmail)
- Hashes password with bcrypt
- Creates User model
- Calls repo.Create()
↓
4. Repository (repository.go)
- Executes GORM Create
- Inserts into database
- Returns user with ID
↓
5. Back to Service
- Returns user to handler
↓
6. Back to Handler
- Generates JWT token
- Converts user to UserResponse
- Returns JSON response
↓
7. HTTP Response
{"token": "eyJ...", "user": {"id": 1, "name": "Alice", ...}}
Authentication Flow¶
Protected endpoints use middleware:
1. Request with Header
Authorization: Bearer eyJhbGc...
↓
2. Auth Middleware (auth/middleware.go)
- Extracts token from header
- Validates JWT signature
- Parses claims (user ID)
- Sets user ID in context
↓
3. Handler
- Gets user ID from context
- Proceeds with business logic
🚀 Adding New Features¶
Follow these steps to add a new feature:
Step-by-Step Checklist¶
- 1. Create domain directory in
internal/ - 2. Define model (
model.go) - 3. Create migration files
- 4. Define DTOs (
dto.go) - 5. Create repository interface and implementation (
repository.go) - 6. Implement business logic (
service.go) - 7. Create HTTP handlers with Swagger docs (
handler.go) - 8. Register routes in
router.go - 9. Update
main.gofor migrations - 10. Write tests
- 11. Update API documentation
🎯 Best Practices¶
1. Error Handling¶
DO:
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("resource not found")
}
return nil, fmt.Errorf("database error: %w", err)
}
DON'T:
2. Validation¶
Always validate at multiple layers: - DTO level - Use binding tags - Service level - Business rules - Database level - Constraints
3. Security¶
Always: - ✅ Hash passwords with bcrypt - ✅ Validate JWT tokens - ✅ Check ownership before operations - ✅ Use parameterized queries (GORM does this) - ✅ Never expose sensitive data in responses
4. Testing¶
Test each layer:
// Repository test (use test database)
func TestCreateTodo(t *testing.T) {
db := setupTestDB()
repo := NewRepository(db)
todo := &Todo{Title: "Test", UserID: 1}
err := repo.Create(todo)
assert.NoError(t, err)
assert.NotZero(t, todo.ID)
}
// Service test (mock repository)
func TestCreateTodo_Service(t *testing.T) {
mockRepo := &MockTodoRepository{}
service := NewService(mockRepo)
mockRepo.On("Create", mock.Anything).Return(nil)
todo, err := service.CreateTodo(1, CreateTodoRequest{Title: "Test"})
assert.NoError(t, err)
assert.NotNil(t, todo)
}
// Handler test (use httptest)
func TestCreateTodoHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewHandler(mockService)
router.POST("/todos", handler.CreateTodo)
req := httptest.NewRequest("POST", "/todos", body)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusCreated, rec.Code)
}
5. Logging¶
Add structured logging:
import "log"
func (s *TodoService) CreateTodo(userID uint, req CreateTodoRequest) (*Todo, error) {
log.Printf("Creating todo for user %d: %s", userID, req.Title)
// ... business logic ...
log.Printf("Todo created successfully: %d", todo.ID)
return todo, nil
}
🔄 Common Patterns¶
Pagination¶
type PaginationParams struct {
Page int `form:"page" binding:"min=1"`
Limit int `form:"limit" binding:"min=1,max=100"`
}
func (r *GormTodoRepository) FindByUserIDPaginated(userID uint, params PaginationParams) ([]Todo, int64, error) {
var todos []Todo
var total int64
offset := (params.Page - 1) * params.Limit
// Count total
r.db.Model(&Todo{}).Where("user_id = ?", userID).Count(&total)
// Get paginated results
err := r.db.Where("user_id = ?", userID).
Offset(offset).
Limit(params.Limit).
Order("created_at DESC").
Find(&todos).Error
return todos, total, err
}
Filtering¶
type TodoFilter struct {
Completed *bool `form:"completed"`
Search string `form:"search"`
}
func (r *GormTodoRepository) FindWithFilter(userID uint, filter TodoFilter) ([]Todo, error) {
query := r.db.Where("user_id = ?", userID)
if filter.Completed != nil {
query = query.Where("completed = ?", *filter.Completed)
}
if filter.Search != "" {
query = query.Where("title ILIKE ?", "%"+filter.Search+"%")
}
var todos []Todo
err := query.Find(&todos).Error
return todos, err
}
Batch Operations¶
func (s *TodoService) MarkAllCompleted(userID uint) error {
return s.repo.db.
Model(&Todo{}).
Where("user_id = ? AND completed = ?", userID, false).
Update("completed", true).
Error
}
Transactions¶
func (s *TodoService) BulkCreate(userID uint, todos []CreateTodoRequest) error {
return s.repo.db.Transaction(func(tx *gorm.DB) error {
for _, req := range todos {
todo := &Todo{
Title: req.Title,
UserID: userID,
}
if err := tx.Create(todo).Error; err != nil {
return err // Rollback
}
}
return nil // Commit
})
}
📚 Additional Resources¶
GORM Documentation¶
Gin Documentation¶
Swagger¶
Testing¶
🆘 Getting Help¶
If you're stuck:
- Check the example implementations in
internal/user/ - Review this guide
- Check GORM/Gin documentation
- Look at the tests in
tests/ - Open an issue on GitHub
✅ Checklist for New Features¶
Before considering your feature complete:
- Model defined with proper GORM tags
- Migration files created (up and down)
- DTOs defined with validation tags
- Repository interface and implementation
- Service with business logic
- Handlers with Swagger annotations
- Routes registered in router
- Migration added to main.go
- Tests written for all layers
- Swagger docs regenerated
- API tested with curl/Postman
- Error cases handled
- Logging added
- Documentation updated
Happy Coding! 🚀
Remember: Start simple, test often, and refactor as needed. The architecture supports growth and change!