Go project structure -- cmd/internal layout, handler/service/repository layers, Makefile, config from environment, domain error types, test placement, dependency injection
90
84%
Does it follow best practices?
Impact
100%
1.02xAverage score across 5 eval scenarios
Passed
No known issues
Practical project layout for Go web APIs and microservices. Covers package layout, layered architecture, configuration, testing, build tooling, and dependency injection.
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
.gitignoreRules:
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.internal/ is a separate Go package with a clear responsibility.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.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)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)
}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
}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
}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",
},
})
}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
}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:
github.com/yourorg/myservice)myservice)go mod tidy before committing to remove unused dependencies and add missing onesgo.mod and go.sum -- go.sum is a lockfile for dependency integritygo.sumEvery 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.htmlWhy a Makefile:
make build, make test, make lint work the same on every Go projectGo 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.sqlRules:
*_test.go and live in the same directory as the code they testpackage handler_test for black-box tests (only test exported API)testdata/ directories for test fixtures -- the Go toolchain ignores testdata/ during buildstesting.T for unit tests, testing.B for benchmarks// 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)
}
})
}
}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
}# 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/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"`
}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
MakefileSplit to the full cmd/internal/ layout when:
cmd/<name>/main.go, wires dependencies onlyinternal/ -- not importable by other modulesinternal/domain/ or internal/model/ with no external depsNotFound, Validation, Conflict) in internal/domain/errors.gointernal/config/go.mod with proper module path, go.sum committedbuild, run, test, lint, fmt, tidy targets*_test.go) alongside source files, not in a separate directory.gitignore includes bin/, coverage.out, .env, IDE fileswriteJSON, writeError) centralized in handler packagemain.go