In the previous tutorial, you learned about pointers. Now it is time to learn how to organize a Go project properly.

Good project structure makes your code easy to read, test, and maintain. Go does not force a specific layout, but the community has developed clear conventions. This tutorial teaches you those conventions.

Starting Simple — One Package

When your project is small, keep it simple. A single main package is fine:

my-app/
  go.mod
  main.go
// main.go
package main

import "fmt"

func main() {
    fmt.Println("Hello, Go!")
}

Many beginners think they need a complex folder structure from day one. They don’t. Start simple and add structure when the code gets bigger.

The Standard Go Project Layout

As your project grows, this is the recommended structure:

my-api/
  cmd/
    api/
      main.go          # Entry point for the API server
    worker/
      main.go          # Entry point for a background worker
  internal/
    user/
      handler.go       # HTTP handlers
      service.go       # Business logic
      repository.go    # Database access
      model.go         # Data types
    order/
      handler.go
      service.go
      repository.go
      model.go
    config/
      config.go        # Configuration loading
  pkg/
    validator/
      validator.go     # Reusable validation utilities
  go.mod
  go.sum

Let’s look at each directory.

cmd/ — Entry Points

The cmd/ directory contains the entry points for your application. Each subdirectory is a separate binary:

// cmd/api/main.go
package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    fmt.Println("Starting API server on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
// cmd/worker/main.go
package main

import "fmt"

func main() {
    fmt.Println("Starting background worker")
    // Process jobs from a queue...
}

Build them separately:

go build ./cmd/api
go build ./cmd/worker

If your project has only one binary, you can put main.go in the project root and skip cmd/ entirely.

internal/ — Private Packages

The internal/ directory contains packages that only your project can use. Go enforces this. Other projects cannot import from your internal/ directory:

// internal/user/model.go
package user

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}
// internal/user/repository.go
package user

import "errors"

var ErrNotFound = errors.New("user not found")

type Repository interface {
    FindByID(id int) (*User, error)
    FindAll() ([]User, error)
    Create(user *User) error
    Update(user *User) error
    Delete(id int) error
}

The internal/ directory is your main working area. Most of your code goes here.

pkg/ — Reusable Packages

The pkg/ directory contains packages that other projects can import. If you don’t plan to share code, skip pkg/ entirely:

// pkg/validator/validator.go
package validator

import "regexp"

var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

func IsValidEmail(email string) bool {
    return emailRegex.MatchString(email)
}

func IsNotEmpty(s string) bool {
    return len(s) > 0
}

Note: Many Go developers skip pkg/ and put reusable code in internal/ or in top-level packages. The pkg/ directory is optional.

Clean Architecture Layers

Clean architecture separates your code into layers. Each layer has a specific job:

  1. Model — Data types (structs)
  2. Repository — Database access
  3. Service — Business logic
  4. Handler — HTTP request handling

Here is a complete example:

Model Layer

// internal/user/model.go
package user

import "time"

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
}

type CreateRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

Repository Layer

The repository handles all database operations. It uses an interface so you can swap the implementation (real database, in-memory, mock):

// internal/user/repository.go
package user

import (
    "errors"
    "sync"
)

var ErrNotFound = errors.New("user not found")

type Repository interface {
    FindByID(id int) (*User, error)
    FindAll() ([]User, error)
    Create(user *User) error
    Delete(id int) error
}

// In-memory implementation (for learning — replace with database later)
type MemoryRepository struct {
    mu     sync.RWMutex
    users  map[int]*User
    nextID int
}

func NewMemoryRepository() *MemoryRepository {
    return &MemoryRepository{
        users:  make(map[int]*User),
        nextID: 1,
    }
}

func (r *MemoryRepository) FindByID(id int) (*User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    user, ok := r.users[id]
    if !ok {
        return nil, ErrNotFound
    }
    return user, nil
}

func (r *MemoryRepository) FindAll() ([]User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    users := make([]User, 0, len(r.users))
    for _, u := range r.users {
        users = append(users, *u)
    }
    return users, nil
}

func (r *MemoryRepository) Create(user *User) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    user.ID = r.nextID
    r.nextID++
    r.users[user.ID] = user
    return nil
}

func (r *MemoryRepository) Delete(id int) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, ok := r.users[id]; !ok {
        return ErrNotFound
    }
    delete(r.users, id)
    return nil
}

Service Layer

The service contains business logic. It uses the repository interface, not the concrete implementation:

// internal/user/service.go
package user

import (
    "errors"
    "time"
)

var (
    ErrEmptyName  = errors.New("name cannot be empty")
    ErrEmptyEmail = errors.New("email cannot be empty")
)

type Service struct {
    repo Repository
}

func NewService(repo Repository) *Service {
    return &Service{repo: repo}
}

func (s *Service) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

func (s *Service) ListUsers() ([]User, error) {
    return s.repo.FindAll()
}

func (s *Service) CreateUser(req CreateRequest) (*User, error) {
    // Validate
    if req.Name == "" {
        return nil, ErrEmptyName
    }
    if req.Email == "" {
        return nil, ErrEmptyEmail
    }

    user := &User{
        Name:      req.Name,
        Email:     req.Email,
        CreatedAt: time.Now(),
    }

    if err := s.repo.Create(user); err != nil {
        return nil, err
    }

    return user, nil
}

func (s *Service) DeleteUser(id int) error {
    return s.repo.Delete(id)
}

Handler Layer

The handler translates HTTP requests to service calls:

// internal/user/handler.go
package user

import (
    "encoding/json"
    "errors"
    "fmt"
    "net/http"
    "strconv"
)

type Handler struct {
    service *Service
}

func NewHandler(service *Service) *Handler {
    return &Handler{service: service}
}

func (h *Handler) HandleGetUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.PathValue("id")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "invalid user ID", http.StatusBadRequest)
        return
    }

    user, err := h.service.GetUser(id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "user not found", http.StatusNotFound)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func (h *Handler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
    users, err := h.service.ListUsers()
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

func (h *Handler) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    user, err := h.service.CreateUser(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

func (h *Handler) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.PathValue("id")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "invalid user ID", http.StatusBadRequest)
        return
    }

    if err := h.service.DeleteUser(id); err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "user not found", http.StatusNotFound)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("GET /users", h.HandleListUsers)
    mux.HandleFunc("GET /users/{id}", h.HandleGetUser)
    mux.HandleFunc("POST /users", h.HandleCreateUser)
    mux.HandleFunc("DELETE /users/{id}", h.HandleDeleteUser)
    fmt.Println("Registered user routes")
}

Dependency Injection — Wiring It All Together

Dependency injection means passing dependencies from the outside instead of creating them inside the component. In Go, you do this without a framework:

// cmd/api/main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/kemalcodes/go-tutorial/internal/user"
)

func main() {
    // Create dependencies from bottom to top
    userRepo := user.NewMemoryRepository()   // 1. Repository
    userService := user.NewService(userRepo) // 2. Service (depends on repo)
    userHandler := user.NewHandler(userService) // 3. Handler (depends on service)

    // Register routes
    mux := http.NewServeMux()
    userHandler.RegisterRoutes(mux)

    // Start server
    fmt.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

This is the main advantage of the layered approach:

  • To switch from in-memory to PostgreSQL, you only change the repository. The service and handler stay the same.
  • To test the service, you pass a mock repository. No real database needed.
  • Each layer only knows about the layer below it.

Configuration Management

A clean way to handle configuration:

// internal/config/config.go
package config

import (
    "fmt"
    "os"
    "strconv"
)

type Config struct {
    Port     int
    Host     string
    DBHost   string
    DBPort   int
    DBName   string
    LogLevel string
}

func Load() (*Config, error) {
    port, err := strconv.Atoi(getEnv("PORT", "8080"))
    if err != nil {
        return nil, fmt.Errorf("invalid PORT: %w", err)
    }

    dbPort, err := strconv.Atoi(getEnv("DB_PORT", "5432"))
    if err != nil {
        return nil, fmt.Errorf("invalid DB_PORT: %w", err)
    }

    return &Config{
        Port:     port,
        Host:     getEnv("HOST", "0.0.0.0"),
        DBHost:   getEnv("DB_HOST", "localhost"),
        DBPort:   dbPort,
        DBName:   getEnv("DB_NAME", "myapp"),
        LogLevel: getEnv("LOG_LEVEL", "info"),
    }, nil
}

func getEnv(key, defaultValue string) string {
    if value, ok := os.LookupEnv(key); ok {
        return value
    }
    return defaultValue
}

Use it in main.go:

cfg, err := config.Load()
if err != nil {
    log.Fatal("Failed to load config:", err)
}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
log.Fatal(http.ListenAndServe(addr, mux))

When to Split Into Packages

Here is a simple guide:

Keep it in one package when:

  • The code is small (under 500 lines)
  • All the code is related to one concept
  • You are just starting the project

Split into packages when:

  • Different parts of the code serve different purposes
  • You want to control what is exported (public API)
  • The package is getting hard to navigate

Package naming rules:

  • Short, lowercase, one word: user, config, auth
  • No underscores, no mixedCaps: userservice not user_service
  • The package name should describe what the package does, not what it contains
  • Avoid generic names like util, common, helpers
// Good package names
package user       // Handles user domain
package auth       // Handles authentication
package config     // Handles configuration
package middleware // HTTP middleware

// Bad package names
package utils     // Too generic
package helpers   // Too generic
package common    // Too generic
package models    // Describes contents, not purpose

Comparison with Other Languages

ConceptGoJava/SpringRust
Entry pointcmd/ directoryApplication.javasrc/main.rs
Private codeinternal/Package-private classespub(crate) visibility
Shared codepkg/Shared modulesPublic crate API
DI frameworkNone neededSpring IoC containerNone (manual)
Interface locationSame package as consumerSeparate interface packagesTrait definitions in their own module
Buildgo build ./...mvn package or gradle buildcargo build

Go’s approach is simpler. No framework needed for dependency injection. No annotations. Just functions that accept interfaces and return structs.

A Complete Mini-Project

Here is a minimal but complete project structure you can use as a starting point:

notes-api/
  cmd/
    api/
      main.go
  internal/
    note/
      model.go
      repository.go
      service.go
      handler.go
    config/
      config.go
  go.mod

The main.go wires everything together:

// cmd/api/main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/kemalcodes/go-tutorial/internal/config"
    "github.com/kemalcodes/go-tutorial/internal/note"
)

func main() {
    // Load config
    cfg, err := config.Load()
    if err != nil {
        log.Fatal("Failed to load config:", err)
    }

    // Wire dependencies
    noteRepo := note.NewMemoryRepository()
    noteService := note.NewService(noteRepo)
    noteHandler := note.NewHandler(noteService)

    // Register routes
    mux := http.NewServeMux()
    noteHandler.RegisterRoutes(mux)

    // Start server
    addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
    fmt.Printf("Server starting on %s\n", addr)
    log.Fatal(http.ListenAndServe(addr, mux))
}

This structure scales well. As your project grows, you add more packages under internal/. The cmd/ directory stays thin — it only wires things together.

Common Mistakes

1. Over-engineering from the start.

// Don't do this for a small project
my-app/
  cmd/api/main.go
  internal/domain/entity/user.go
  internal/domain/valueobject/email.go
  internal/application/usecase/createuser.go
  internal/infrastructure/persistence/postgres/user.go
  internal/infrastructure/http/handler/user.go
  internal/infrastructure/http/middleware/auth.go
  ...

Start simple. Add structure when you need it, not before.

2. Circular imports.

// package user imports package order
// package order imports package user
// This does NOT compile in Go

Go does not allow circular imports. If two packages need each other, extract the shared types into a third package or use interfaces.

3. Putting everything in package main.

// A 2000-line main.go is hard to read and test
package main

func main() {
    // ... everything here
}

Keep main.go thin. It should only create dependencies and start the application. Put all logic in separate packages under internal/.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-10 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #11: Goroutines, you will learn:

  • What goroutines are — Go’s lightweight concurrency
  • Starting goroutines with the go keyword
  • sync.WaitGroup for waiting on goroutines
  • sync.Mutex for safe concurrent access
  • The race detector

This is where Go gets exciting. Concurrency is Go’s killer feature.


This is part 10 of the Go Tutorial series. Follow along to learn Go from scratch.