CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/go-project-structure

Go project structure -- cmd/internal layout, handler/service/repository layers, Makefile, config from environment, domain error types, test placement, dependency injection

90

1.02x
Quality

84%

Does it follow best practices?

Impact

100%

1.02x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
go-project-structure
description:
Go project structure for web APIs and microservices -- cmd/internal/pkg layout, handler/service/repository layers, Makefile with standard targets, go.mod management, configuration from environment, proper test file placement, dependency injection, and domain error types. Use when starting a new Go web service, restructuring a Go project, or when code is in one main.go. Triggers on: new Go microservice, Go API project setup, "set up a Go project", monolithic main.go, missing internal/ package, Go project scaffolding, handler separation, Go configuration management.
keywords:
go project structure, golang project layout, go web api structure, go packages, cmd internal pkg, handler service repository, go configuration, go modules, go makefile, dependency injection, go test placement, go microservice, go domain errors, internal package, go project scaffolding
license:
MIT

Go Project Structure

Practical project layout for Go web APIs and microservices. Covers package layout, layered architecture, configuration, testing, build tooling, and dependency injection.


Critical Pattern: cmd/internal Layout

Every Go project with more than one binary or any shared internal code must use the cmd/ and internal/ directory convention.

myservice/
  cmd/
    server/
      main.go              # HTTP server entry point
    worker/
      main.go              # Background worker entry point (if needed)
  internal/
    config/
      config.go            # Configuration from environment
    domain/
      errors.go            # Domain error types (NotFound, Validation, etc.)
      models.go            # Domain types (Order, User, Product, etc.)
    handler/
      handler.go           # HTTP handler struct and constructor
      routes.go            # Route registration
      orders.go            # Order HTTP handlers
      users.go             # User HTTP handlers
      middleware.go         # CORS, logging, recovery, auth middleware
    service/
      orders.go            # Order business logic
      users.go             # User business logic
    repository/
      orders.go            # Order database queries
      users.go             # User database queries
      db.go                # Database connection, migrations
  go.mod
  go.sum
  Makefile
  .gitignore

Rules:

  • cmd/ contains only main.go files -- entry points that wire dependencies and start the server. No business logic.
  • internal/ contains all private application code. The Go compiler enforces that code inside internal/ cannot be imported by other modules. This is not optional -- it prevents accidental coupling.
  • Each subdirectory under internal/ is a separate Go package with a clear responsibility.
  • Never put application code directly in the project root alongside go.mod. The root package should only contain main.go for the simplest possible projects.

Why internal/ matters:

// This import is BLOCKED by the Go compiler for any external module:
import "github.com/yourorg/myservice/internal/handler"
// Error: use of internal package not allowed

// This is the entire point -- internal/ enforces encapsulation at the module level.

Critical Pattern: Handler / Service / Repository Layers

Separate HTTP concerns, business logic, and data access into distinct packages. Each layer depends only on the layer below it.

handler  -->  service  -->  repository
(HTTP)        (logic)       (database)

Handler Layer (internal/handler/)

Handles HTTP request parsing, response writing, and routing. No business logic, no SQL.

// internal/handler/handler.go
package handler

import "myservice/internal/service"

// Handler holds all HTTP handler dependencies.
type Handler struct {
    orderService *service.OrderService
    userService  *service.UserService
}

// New creates a Handler with all required services.
func New(os *service.OrderService, us *service.UserService) *Handler {
    return &Handler{orderService: os, userService: us}
}
// internal/handler/routes.go
package handler

import (
    "net/http"
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

// RegisterRoutes sets up all routes on the given router.
func (h *Handler) RegisterRoutes(r chi.Router) {
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.RequestID)

    r.Route("/api/v1", func(r chi.Router) {
        r.Route("/orders", func(r chi.Router) {
            r.Post("/", h.CreateOrder)
            r.Get("/", h.ListOrders)
            r.Get("/{id}", h.GetOrder)
        })
        r.Route("/users", func(r chi.Router) {
            r.Post("/", h.CreateUser)
            r.Get("/{id}", h.GetUser)
        })
    })

    r.Get("/health", h.HealthCheck)
}
// internal/handler/orders.go
package handler

import (
    "encoding/json"
    "net/http"

    "github.com/go-chi/chi/v5"
    "myservice/internal/domain"
)

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    var req domain.CreateOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, domain.NewValidationError("invalid request body"))
        return
    }

    order, err := h.orderService.Create(r.Context(), req)
    if err != nil {
        writeError(w, err)
        return
    }

    writeJSON(w, http.StatusCreated, order)
}

func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    order, err := h.orderService.GetByID(r.Context(), id)
    if err != nil {
        writeError(w, err)
        return
    }
    writeJSON(w, http.StatusOK, order)
}

Service Layer (internal/service/)

Contains business logic. Depends on repository interfaces, not concrete implementations.

// internal/service/orders.go
package service

import (
    "context"
    "fmt"

    "myservice/internal/domain"
)

// OrderRepository defines the data access interface for orders.
type OrderRepository interface {
    Create(ctx context.Context, order domain.Order) error
    GetByID(ctx context.Context, id string) (domain.Order, error)
    List(ctx context.Context) ([]domain.Order, error)
}

// OrderService implements order business logic.
type OrderService struct {
    repo OrderRepository
}

// NewOrderService creates an OrderService with the given repository.
func NewOrderService(repo OrderRepository) *OrderService {
    return &OrderService{repo: repo}
}

func (s *OrderService) Create(ctx context.Context, req domain.CreateOrderRequest) (domain.Order, error) {
    if len(req.Items) == 0 {
        return domain.Order{}, domain.NewValidationError("order must have at least one item")
    }

    order := domain.Order{
        ID:         generateID(),
        CustomerID: req.CustomerID,
        Items:      req.Items,
        Status:     domain.OrderStatusPending,
    }

    if err := s.repo.Create(ctx, order); err != nil {
        return domain.Order{}, fmt.Errorf("creating order: %w", err)
    }

    return order, nil
}

Repository Layer (internal/repository/)

Handles all database operations. Returns domain types.

// internal/repository/orders.go
package repository

import (
    "context"
    "database/sql"
    "fmt"

    "myservice/internal/domain"
)

// OrderRepo implements service.OrderRepository using SQL.
type OrderRepo struct {
    db *sql.DB
}

// NewOrderRepo creates a new OrderRepo.
func NewOrderRepo(db *sql.DB) *OrderRepo {
    return &OrderRepo{db: db}
}

func (r *OrderRepo) GetByID(ctx context.Context, id string) (domain.Order, error) {
    var order domain.Order
    err := r.db.QueryRowContext(ctx,
        "SELECT id, customer_id, status, created_at FROM orders WHERE id = ?", id,
    ).Scan(&order.ID, &order.CustomerID, &order.Status, &order.CreatedAt)
    if err == sql.ErrNoRows {
        return domain.Order{}, domain.NewNotFoundError("order", id)
    }
    if err != nil {
        return domain.Order{}, fmt.Errorf("querying order %s: %w", id, err)
    }
    return order, nil
}

Critical Pattern: Domain Error Types

Define domain-specific error types in internal/domain/errors.go. Handlers use these to write appropriate HTTP responses.

// internal/domain/errors.go
package domain

import "fmt"

// AppError is the base error type for all domain errors.
type AppError struct {
    Code    string `json:"code"`
    Message string `json:"message"`
    Status  int    `json:"-"`
}

func (e *AppError) Error() string {
    return e.Message
}

// NewNotFoundError creates a not-found error for a resource.
func NewNotFoundError(resource, id string) *AppError {
    return &AppError{
        Code:    "NOT_FOUND",
        Message: fmt.Sprintf("%s %q not found", resource, id),
        Status:  404,
    }
}

// NewValidationError creates a validation error.
func NewValidationError(msg string) *AppError {
    return &AppError{
        Code:    "VALIDATION_ERROR",
        Message: msg,
        Status:  400,
    }
}

// NewConflictError creates a conflict error.
func NewConflictError(msg string) *AppError {
    return &AppError{
        Code:    "CONFLICT",
        Message: msg,
        Status:  409,
    }
}

In the handler layer, map domain errors to HTTP responses:

// internal/handler/response.go
package handler

import (
    "encoding/json"
    "errors"
    "net/http"

    "myservice/internal/domain"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, err error) {
    var appErr *domain.AppError
    if errors.As(err, &appErr) {
        writeJSON(w, appErr.Status, map[string]any{"error": appErr})
        return
    }
    writeJSON(w, http.StatusInternalServerError, map[string]any{
        "error": map[string]string{
            "code":    "INTERNAL_ERROR",
            "message": "internal server error",
        },
    })
}

Critical Pattern: Configuration from Environment

Load all configuration from environment variables with sensible defaults. Never hardcode connection strings, ports, or secrets.

// internal/config/config.go
package config

import (
    "os"
    "strconv"
    "strings"
    "time"
)

// Config holds all application configuration.
type Config struct {
    Port            int
    DatabaseURL     string
    AllowedOrigins  []string
    ReadTimeout     time.Duration
    WriteTimeout    time.Duration
    ShutdownTimeout time.Duration
    LogLevel        string
}

// Load reads configuration from environment variables with defaults.
func Load() Config {
    return Config{
        Port:            getEnvInt("PORT", 8080),
        DatabaseURL:     getEnv("DATABASE_URL", "sqlite://data.db"),
        AllowedOrigins:  strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
        ReadTimeout:     time.Duration(getEnvInt("READ_TIMEOUT_SECONDS", 10)) * time.Second,
        WriteTimeout:    time.Duration(getEnvInt("WRITE_TIMEOUT_SECONDS", 30)) * time.Second,
        ShutdownTimeout: time.Duration(getEnvInt("SHUTDOWN_TIMEOUT_SECONDS", 15)) * time.Second,
        LogLevel:        getEnv("LOG_LEVEL", "info"),
    }
}

func getEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}

func getEnvInt(key string, fallback int) int {
    if v := os.Getenv(key); v != "" {
        if i, err := strconv.Atoi(v); err == nil {
            return i
        }
    }
    return fallback
}

For larger projects, use github.com/kelseyhightower/envconfig or github.com/spf13/viper:

// Using envconfig (struct tags map to env vars)
import "github.com/kelseyhightower/envconfig"

type Config struct {
    Port        int      `envconfig:"PORT" default:"8080"`
    DatabaseURL string   `envconfig:"DATABASE_URL" required:"true"`
    LogLevel    string   `envconfig:"LOG_LEVEL" default:"info"`
}

func Load() (Config, error) {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        return Config{}, fmt.Errorf("loading config: %w", err)
    }
    return cfg, nil
}

Critical Pattern: go.mod Management

Every Go project must have a properly configured go.mod at the project root.

module github.com/yourorg/myservice

go 1.22

require (
    github.com/go-chi/chi/v5 v5.0.12
    github.com/mattn/go-sqlite3 v1.14.22
)

Rules:

  • Module path must match your repository URL (e.g., github.com/yourorg/myservice)
  • For private/internal projects, any valid module path works (e.g., myservice)
  • Run go mod tidy before committing to remove unused dependencies and add missing ones
  • Commit both go.mod and go.sum -- go.sum is a lockfile for dependency integrity
  • Never manually edit go.sum

Critical Pattern: Makefile with Standard Targets

Every Go project should have a Makefile with standard targets for building, testing, linting, and running.

.PHONY: build run test lint clean fmt vet tidy

# Build the application binary
build:
	go build -o bin/server ./cmd/server

# Run the application
run:
	go run ./cmd/server

# Run all tests
test:
	go test ./... -v -race -count=1

# Run tests with coverage
test-coverage:
	go test ./... -coverprofile=coverage.out -race
	go tool cover -html=coverage.out -o coverage.html

# Format code
fmt:
	go fmt ./...
	goimports -w .

# Run go vet
vet:
	go vet ./...

# Run linter (install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
lint:
	golangci-lint run ./...

# Tidy dependencies
tidy:
	go mod tidy

# Clean build artifacts
clean:
	rm -rf bin/ coverage.out coverage.html

Why a Makefile:

  • Provides discoverable, standard commands for any contributor
  • make build, make test, make lint work the same on every Go project
  • Documents the exact build and test commands
  • CI pipelines can use the same targets

Critical Pattern: Test File Placement

Go test files live alongside the code they test, in the same directory. This is a Go convention enforced by the toolchain -- do not put tests in a separate tests/ directory.

internal/
  handler/
    orders.go
    orders_test.go        # Tests for orders.go -- same directory
    handler.go
    handler_test.go       # Tests for handler.go -- same directory
  service/
    orders.go
    orders_test.go        # Tests for orders.go
  repository/
    orders.go
    orders_test.go        # Tests for orders.go
    testdata/             # Test fixtures (SQL files, JSON fixtures)
      seed.sql

Rules:

  • Test files are named *_test.go and live in the same directory as the code they test
  • Use the same package name for white-box tests (access unexported identifiers)
  • Use package handler_test for black-box tests (only test exported API)
  • Use testdata/ directories for test fixtures -- the Go toolchain ignores testdata/ during builds
  • Use testing.T for unit tests, testing.B for benchmarks
  • Use table-driven tests as the standard pattern:
// internal/service/orders_test.go
package service

import (
    "context"
    "testing"

    "myservice/internal/domain"
)

func TestOrderService_Create(t *testing.T) {
    tests := []struct {
        name    string
        req     domain.CreateOrderRequest
        wantErr bool
    }{
        {
            name: "valid order",
            req: domain.CreateOrderRequest{
                CustomerID: "cust-1",
                Items:      []domain.OrderItem{{ProductID: "prod-1", Qty: 2}},
            },
            wantErr: false,
        },
        {
            name: "empty items",
            req: domain.CreateOrderRequest{
                CustomerID: "cust-1",
                Items:      nil,
            },
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            svc := NewOrderService(&mockOrderRepo{})
            _, err := svc.Create(context.Background(), tt.req)
            if (err != nil) != tt.wantErr {
                t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Critical Pattern: Dependency Injection via Constructors

Use constructor functions to inject dependencies. Interfaces are defined by the consumer, not the provider.

// cmd/server/main.go -- wiring everything together
package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "context"
    "syscall"

    "github.com/go-chi/chi/v5"
    _ "github.com/mattn/go-sqlite3"

    "myservice/internal/config"
    "myservice/internal/handler"
    "myservice/internal/repository"
    "myservice/internal/service"
)

func main() {
    cfg := config.Load()

    db, err := sql.Open("sqlite3", cfg.DatabaseURL)
    if err != nil {
        log.Fatalf("opening database: %v", err)
    }
    defer db.Close()

    // Wire dependencies: repository -> service -> handler
    orderRepo := repository.NewOrderRepo(db)
    userRepo := repository.NewUserRepo(db)

    orderSvc := service.NewOrderService(orderRepo)
    userSvc := service.NewUserService(userRepo)

    h := handler.New(orderSvc, userSvc)

    r := chi.NewRouter()
    h.RegisterRoutes(r)

    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", cfg.Port),
        Handler:      r,
        ReadTimeout:  cfg.ReadTimeout,
        WriteTimeout: cfg.WriteTimeout,
    }

    // Graceful shutdown
    go func() {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
        <-sigCh
        ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
        defer cancel()
        srv.Shutdown(ctx)
    }()

    log.Printf("server starting on :%d", cfg.Port)
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatalf("server error: %v", err)
    }
}

Key principle: Interfaces are defined where they are used (in the service package), not where they are implemented (in the repository package). This keeps dependencies flowing inward.

For larger projects, consider google/wire or uber-go/fx for compile-time or runtime dependency injection:

// Using wire (compile-time DI)
//go:build wireinject

package main

import (
    "github.com/google/wire"
    "myservice/internal/handler"
    "myservice/internal/repository"
    "myservice/internal/service"
)

func InitializeHandler(db *sql.DB) *handler.Handler {
    wire.Build(
        repository.NewOrderRepo,
        repository.NewUserRepo,
        service.NewOrderService,
        service.NewUserService,
        handler.New,
    )
    return nil
}

Critical Pattern: .gitignore for Go Projects

# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test artifacts
coverage.out
coverage.html
*.test

# IDE
.idea/
.vscode/settings.json
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Environment
.env
.env.local

# Vendor (if not committed)
# vendor/

# Build cache
tmp/

Critical Pattern: Domain Models in internal/domain/

Define your core business types in internal/domain/ (or internal/model/). These types have no external dependencies -- they are pure Go structs.

// internal/domain/models.go
package domain

import "time"

type OrderStatus string

const (
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusConfirmed OrderStatus = "confirmed"
    OrderStatusShipped   OrderStatus = "shipped"
    OrderStatusCancelled OrderStatus = "cancelled"
)

type Order struct {
    ID         string      `json:"id"`
    CustomerID string      `json:"customer_id"`
    Items      []OrderItem `json:"items"`
    Status     OrderStatus `json:"status"`
    Total      int         `json:"total"`
    CreatedAt  time.Time   `json:"created_at"`
    UpdatedAt  time.Time   `json:"updated_at"`
}

type OrderItem struct {
    ProductID string `json:"product_id"`
    Qty       int    `json:"qty"`
    PriceCents int   `json:"price_cents"`
}

type CreateOrderRequest struct {
    CustomerID string      `json:"customer_id"`
    Items      []OrderItem `json:"items"`
}

When NOT to Split

For a prototype or small utility, a flat layout in one package is fine:

myapp/
  main.go          # Routes, handlers, startup
  db.go            # Database setup + queries
  types.go         # Shared types
  main_test.go     # Tests
  go.mod
  Makefile

Split to the full cmd/internal/ layout when:

  • Any file exceeds ~400 lines
  • You have more than 3 resource types
  • Multiple people are working on the codebase
  • You need to build multiple binaries from the same module

Quick Checklist

  • Entry point in cmd/<name>/main.go, wires dependencies only
  • Application code in internal/ -- not importable by other modules
  • Handler/service/repository layers separated into distinct packages
  • Domain types in internal/domain/ or internal/model/ with no external deps
  • Domain error types (NotFound, Validation, Conflict) in internal/domain/errors.go
  • Configuration loaded from environment variables with defaults in internal/config/
  • go.mod with proper module path, go.sum committed
  • Makefile with build, run, test, lint, fmt, tidy targets
  • Test files (*_test.go) alongside source files, not in a separate directory
  • Table-driven tests as the standard test pattern
  • Constructor-based dependency injection (interfaces defined by consumer)
  • .gitignore includes bin/, coverage.out, .env, IDE files
  • HTTP response helpers (writeJSON, writeError) centralized in handler package
  • Graceful shutdown with signal handling in main.go

Verifiers

  • cmd-internal-layout -- Use cmd/ for entry points and internal/ for application packages
  • handler-service-repository -- Separate HTTP handlers from business logic and data access
  • config-from-environment -- Load configuration from environment variables with defaults
  • go-test-placement -- Place test files alongside source code, use table-driven tests
  • makefile-targets -- Include Makefile with build, test, run, and lint targets
  • domain-error-types -- Define domain-specific error types for structured error responses
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/go-project-structure badge