Chapter 25

Project Architecture and Dependency Injection

Project Architecture and Dependency Injection

How many Go projects have you seen where main runs to three hundred lines?

Perhaps something like this:

func main() {
    db, _ := sql.Open("mysql", os.Getenv("DSN"))
    redisClient := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    cfg := Config{
        Port:   os.Getenv("PORT"),
        Secret: os.Getenv("SECRET"),
        Debug:  os.Getenv("DEBUG") == "true",
    }
    userHandler := &UserHandler{db: db, redis: redisClient, cfg: cfg}
    orderHandler := &OrderHandler{db: db, cfg: cfg}
    r := chi.NewRouter()
    r.Get("/users/{id}", userHandler.GetUser)
    r.Post("/orders", orderHandler.CreateOrder)
    // ... 50 more lines of route registration
    http.ListenAndServe(":"+cfg.Port, r)
}

This code works, but it carries a hidden structural problem: everything is coupled to everything else.

Change a config key and you must search the entire main function to find every usage. Test a single handler and you must spin up a real database and Redis. Switch HTTP routers and you must touch every handler file. This coupling is not a deliberate bad decision โ€” it is the natural outcome of never thinking about structure.

Project architecture is not decoration. It is the load-bearing structure of the code: it determines where you apply the force of a change, how far that change propagates, and how much evolution the system can absorb before it collapses under its own weight.

This chapter builds a Go project structure from first principles โ€” layout, dependency management, and a startup chain where each component is independently testable and independently evolvable.


Level 1: Why Project Structure Matters

Software Doesn't Stop When It Ships

Most abandoned codebases are not abandoned because the technology is outdated. They are abandoned because the cost of change became intolerable.

Cost of change = time to find the right place + time to understand the blast radius + time to verify the fix + time to repair unintended breakage

Good project structure reduces each of these terms:

The Go Project Layout Debate

The Go community has argued for years about the "right" layout.

Option A: golang-standards/project-layout (widely used template)

myproject/
โ”œโ”€โ”€ cmd/           โ† program entry points
โ”‚   โ””โ”€โ”€ server/
โ”‚       โ””โ”€โ”€ main.go
โ”œโ”€โ”€ internal/      โ† private packages (not importable from outside)
โ”‚   โ”œโ”€โ”€ handler/
โ”‚   โ”œโ”€โ”€ service/
โ”‚   โ””โ”€โ”€ repository/
โ”œโ”€โ”€ pkg/           โ† public reusable packages
โ”‚   โ””โ”€โ”€ utils/
โ”œโ”€โ”€ configs/
โ””โ”€โ”€ docs/

Option B: flat layout (favored by Go core team members)

myproject/
โ”œโ”€โ”€ main.go
โ”œโ”€โ”€ server.go
โ”œโ”€โ”€ handler.go
โ”œโ”€โ”€ config.go
โ””โ”€โ”€ db.go

The real answer: there is no universally correct layout. Structure should grow with the project:

The one concrete rule: use internal/. Since Go 1.4, packages inside internal/ can only be imported by code in the same module. This is a compiler-enforced boundary between "internal implementation" and "public interface" โ€” the only layout constraint that actually has teeth.

The yiteai-dev-server Pattern: Config โ†’ Server โ†’ Handler

Our server follows a linear initialization chain where each step is explicit:

main.go
  โ””โ”€โ”€ load config (YAML + env overrides)
        โ””โ”€โ”€ open DB connection pool
              โ””โ”€โ”€ open Redis client
                    โ””โ”€โ”€ create Repositories (injected with DB)
                          โ””โ”€โ”€ create Services (injected with Repos + Redis)
                                โ””โ”€โ”€ create Handlers (injected with Services + Config)
                                      โ””โ”€โ”€ create Router (register Handler routes)
                                            โ””โ”€โ”€ create HTTP Server
                                                  โ””โ”€โ”€ start listening

Every step is visible, every dependency is explicit. No global variables, no init() magic, no "where did this dependency come from?" confusion.


Level 2: Layered Architecture and Dependency Injection

Three Layers: cmd / internal / pkg

cmd/                   โ† thin entry point (main package)
  server/              โ† assembles and starts the server
    main.go

internal/              โ† core business logic (not exported)
  config/              โ† config loading and validation
  server/              โ† HTTP server lifecycle
  handler/             โ† HTTP request handlers
  service/             โ† business logic
  repository/          โ† data access (DB queries)
  model/               โ† data structure definitions

pkg/                   โ† reusable utilities (exportable)
  errcode/             โ† shared error codes
  middleware/          โ† HTTP middleware
  pagination/          โ† pagination helpers

The dependency direction rule (never break this):

cmd       โ†’ internal           (allowed)
handler   โ†’ service            (allowed)
service   โ†’ repository         (allowed)
repository โ†’ model             (allowed)

repository โ†’ handler           (FORBIDDEN โ€” lower layers must not know upper layers)
service    โ†’ handler           (FORBIDDEN)

This rule is the Dependency Inversion Principle in practice. Lower layers (repository) are unaware of upper layers (handler). Upper layers depend on interfaces, not on concrete implementations. The result: you can swap a database driver by changing one line in main.go without touching any handler.

Constructor Injection: Dependency Injection Without a Framework

"Dependency injection" sounds like an enterprise pattern requiring a container. In Go, the most common form is straightforward: pass dependencies as constructor arguments instead of creating them inside functions.

// Wrong: dependency created inside the constructor (implicit coupling)
func NewUserHandler() *UserHandler {
    db, _ := sql.Open("mysql", os.Getenv("DSN")) // dependency appears from nowhere
    return &UserHandler{db: db}
}

// Right: dependency passed in (explicit coupling)
func NewUserHandler(db *sql.DB, cfg *config.Config) *UserHandler {
    return &UserHandler{db: db, cfg: cfg}
}

Why does this matter?

  1. Testability: pass a mock database in tests; no real DB required
  2. Replaceability: swap the DB driver by changing one line in main.go
  3. Explicitness: the function signature documents what the handler requires

Go further by depending on interfaces rather than concrete types:

// Define the interface in the consumer package (not the implementation package)
type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*model.User, error)
    Create(ctx context.Context, user *model.User) error
}

type UserHandler struct {
    repo   UserRepository  // interface, not *sql.DB
    cfg    *config.Config
    logger *slog.Logger
}

func NewUserHandler(repo UserRepository, cfg *config.Config, logger *slog.Logger) *UserHandler {
    return &UserHandler{repo: repo, cfg: cfg, logger: logger}
}

Config Loading Patterns

Pattern 1: pure environment variables (12-Factor style)

type Config struct {
    Port  string `env:"PORT,default=8080"`
    DSN   string `env:"DATABASE_DSN,required"`
    Debug bool   `env:"DEBUG,default=false"`
}

cfg := &Config{}
if err := env.Parse(cfg); err != nil {
    log.Fatal(err)
}

Pattern 2: YAML file with env overrides (more flexible)

// internal/config/config.go
package config

import (
    "fmt"
    "strings"
    "github.com/spf13/viper"
)

type Config struct {
    Server   ServerConfig   `mapstructure:"server"`
    Database DatabaseConfig `mapstructure:"database"`
    Redis    RedisConfig    `mapstructure:"redis"`
}

type ServerConfig struct {
    Port         int  `mapstructure:"port"`
    ReadTimeout  int  `mapstructure:"read_timeout"`
    WriteTimeout int  `mapstructure:"write_timeout"`
    Debug        bool `mapstructure:"debug"`
}

type DatabaseConfig struct {
    DSN         string `mapstructure:"dsn"`
    MaxOpenConn int    `mapstructure:"max_open_conn"`
    MaxIdleConn int    `mapstructure:"max_idle_conn"`
}

func Load(path string) (*Config, error) {
    v := viper.New()
    v.SetConfigFile(path)
    v.SetConfigType("yaml")

    // DATABASE_DSN env var overrides database.dsn from the file
    v.AutomaticEnv()
    v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

    if err := v.ReadInConfig(); err != nil {
        if _, notFound := err.(viper.ConfigFileNotFoundError); !notFound {
            return nil, fmt.Errorf("read config %s: %w", path, err)
        }
        // File not found โ€” rely on env vars and defaults
    }

    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshal config: %w", err)
    }
    return &cfg, nil
}

Corresponding config/app.yaml:

server:
  port: 8080
  read_timeout: 30
  write_timeout: 30
  debug: false

database:
  dsn: "user:pass@tcp(localhost:3306)/mydb?parseTime=true"
  max_open_conn: 25
  max_idle_conn: 10

redis:
  addr: "localhost:6379"
  password: ""
  db: 0

Level 3: Building the Startup Chain

The Full Startup Chain

// cmd/server/main.go
package main

import (
    "fmt"
    "log/slog"
    "os"

    "github.com/myorg/myapp/internal/config"
    "github.com/myorg/myapp/internal/database"
    "github.com/myorg/myapp/internal/handler"
    "github.com/myorg/myapp/internal/repository"
    "github.com/myorg/myapp/internal/server"
    "github.com/myorg/myapp/internal/service"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))

    if err := run(logger); err != nil {
        logger.Error("fatal error", "err", err)
        os.Exit(1)
    }
}

func run(logger *slog.Logger) error {
    // Step 1: load configuration
    cfg, err := config.Load("config/app.yaml")
    if err != nil {
        return fmt.Errorf("load config: %w", err)
    }
    logger.Info("config loaded", "port", cfg.Server.Port)

    // Step 2: open database
    db, err := database.NewPostgres(cfg.Database)
    if err != nil {
        return fmt.Errorf("connect database: %w", err)
    }
    defer db.Close()

    // Step 3: open Redis
    redisClient, err := database.NewRedis(cfg.Redis)
    if err != nil {
        return fmt.Errorf("connect redis: %w", err)
    }
    defer redisClient.Close()

    // Step 4: repository layer
    userRepo := repository.NewUserRepository(db)
    orderRepo := repository.NewOrderRepository(db)

    // Step 5: service layer (business logic)
    userService := service.NewUserService(userRepo, redisClient, logger)
    orderService := service.NewOrderService(orderRepo, userService, logger)

    // Step 6: handler layer
    userHandler := handler.NewUserHandler(userService, cfg, logger)
    orderHandler := handler.NewOrderHandler(orderService, cfg, logger)
    healthHandler := handler.NewHealthHandler(db, redisClient)

    // Step 7: build HTTP server with routes
    srv := server.New(cfg.Server, logger,
        server.WithHandlers(userHandler, orderHandler, healthHandler),
    )

    // Step 8: run with graceful shutdown
    return srv.Run()
}

Graceful Shutdown with context

// internal/server/server.go
package server

import (
    "context"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

type Server struct {
    httpServer *http.Server
    logger     *slog.Logger
}

func New(cfg config.ServerConfig, logger *slog.Logger, opts ...Option) *Server {
    mux := http.NewServeMux()
    s := &Server{
        httpServer: &http.Server{
            Addr:         fmt.Sprintf(":%d", cfg.Port),
            Handler:      mux,
            ReadTimeout:  time.Duration(cfg.ReadTimeout) * time.Second,
            WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
            IdleTimeout:  120 * time.Second,
        },
        logger: logger,
    }
    for _, opt := range opts {
        opt(s, mux)
    }
    return s
}

func (s *Server) Run() error {
    errCh := make(chan error, 1)
    go func() {
        s.logger.Info("listening", "addr", s.httpServer.Addr)
        if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            errCh <- fmt.Errorf("http listen: %w", err)
        }
        close(errCh)
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    select {
    case err := <-errCh:
        return err
    case sig := <-quit:
        s.logger.Info("shutdown signal received", "signal", sig)
    }

    // Allow in-flight requests up to 30 seconds to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := s.httpServer.Shutdown(ctx); err != nil {
        return fmt.Errorf("graceful shutdown: %w", err)
    }

    s.logger.Info("shutdown complete")
    return nil
}

Structured Error Wrapping Across Layers

In a multi-layer architecture, errors must be wrapped correctly at each layer โ€” preserving the root cause while adding context:

// repository layer: raw error + contextual message
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*model.User, error) {
    var user model.User
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = ?", id,
    ).Scan(&user.ID, &user.Name, &user.Email)

    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("query user %d: %w", id, err)
    }
    return &user, nil
}

// service layer: adds business context
func (s *UserService) GetUser(ctx context.Context, id int64) (*model.User, error) {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get user: %w", err)
    }
    return user, nil
}

// handler layer: translates to HTTP response
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)

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

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

The %w verb in fmt.Errorf wraps the error into a chain. errors.Is() traverses the entire chain to check for a root cause โ€” even through multiple layers of wrapping:

Full error message seen in logs:
  "get user: user 42: sql: no rows in result set"

errors.Is(err, repository.ErrNotFound) returns true โ† penetrates the full chain

Level 4: Advanced Topics

Wire: Compile-Time Dependency Injection

As a service grows, writing the wiring by hand (NewA(NewB(NewC(...)))) becomes tedious and error-prone. Google's Wire solves this with code generation.

Wire's core concepts are Providers (constructor functions) and Injectors (wiring declarations):

// wire_provider.go
//go:build wireinject

package main

import (
    "github.com/google/wire"
    "github.com/myorg/myapp/internal/config"
    "github.com/myorg/myapp/internal/handler"
    "github.com/myorg/myapp/internal/repository"
    "github.com/myorg/myapp/internal/service"
)

var RepositorySet = wire.NewSet(
    repository.NewUserRepository,
    repository.NewOrderRepository,
)

var ServiceSet = wire.NewSet(
    service.NewUserService,
    service.NewOrderService,
)

// Wire reads this declaration and generates the implementation
func InitializeServer(cfg *config.Config) (*server.Server, error) {
    wire.Build(
        database.NewPostgres,
        database.NewRedis,
        RepositorySet,
        ServiceSet,
        handler.NewUserHandler,
        handler.NewOrderHandler,
        server.New,
    )
    return nil, nil // Wire replaces this body
}

After wire gen ./..., Wire analyzes the dependency graph and emits wire_gen.go:

// wire_gen.go โ€” auto-generated, do not edit
func InitializeServer(cfg *config.Config) (*server.Server, error) {
    db, err := database.NewPostgres(cfg.Database)
    if err != nil {
        return nil, err
    }
    redisClient, err := database.NewRedis(cfg.Redis)
    if err != nil {
        return nil, err
    }
    userRepository := repository.NewUserRepository(db)
    orderRepository := repository.NewOrderRepository(db)
    userService := service.NewUserService(userRepository, redisClient)
    orderService := service.NewOrderService(orderRepository, userService)
    userHandler := handler.NewUserHandler(userService, cfg)
    orderHandler := handler.NewOrderHandler(orderService, cfg)
    srv := server.New(cfg.Server, userHandler, orderHandler)
    return srv, nil
}

Wire advantages:

Wire disadvantages:

Uber Dig: Runtime Dependency Injection

Uber Dig is a reflection-based runtime DI container:

import "go.uber.org/dig"

func buildContainer() *dig.Container {
    c := dig.New()
    c.Provide(config.Load)
    c.Provide(database.NewPostgres)
    c.Provide(database.NewRedis)
    c.Provide(repository.NewUserRepository)
    c.Provide(service.NewUserService)
    c.Provide(handler.NewUserHandler)
    c.Provide(server.New)
    return c
}

func main() {
    c := buildContainer()
    if err := c.Invoke(func(srv *server.Server) error {
        return srv.Run()
    }); err != nil {
        log.Fatal(err)
    }
}

Dig advantages:

Dig disadvantages:

Decision guide:

Plugin Architecture with Interfaces

Good project structure allows swapping an implementation without changing the layers above it. Go's structural typing (implicit interface satisfaction) makes this natural:

// UserStore interface โ€” defined in the service package (the consumer)
type UserStore interface {
    GetByID(ctx context.Context, id int64) (*model.User, error)
    Save(ctx context.Context, user *model.User) error
    Delete(ctx context.Context, id int64) error
}

// MySQL implementation
type mysqlUserStore struct{ db *sql.DB }
func NewMySQLUserStore(db *sql.DB) UserStore { return &mysqlUserStore{db: db} }

// PostgreSQL implementation โ€” switch by changing one line in main.go
type postgresUserStore struct{ db *sql.DB }
func NewPostgresUserStore(db *sql.DB) UserStore { return &postgresUserStore{db: db} }

// In-memory implementation for tests
type inMemoryUserStore struct {
    mu    sync.RWMutex
    users map[int64]*model.User
    next  int64
}
func NewInMemoryUserStore() UserStore {
    return &inMemoryUserStore{users: make(map[int64]*model.User)}
}

The interface is defined where it is used (in the service package), not where it is implemented (in the repository package). This is idiomatic Go: interfaces are satisfied implicitly. Any type whose method set is a superset of the interface is automatically an implementation โ€” no implements declaration required.

This also means you can define an interface for an existing type after the fact, without modifying the type โ€” a flexibility that Java and C# cannot match.

Testing with Fake Dependencies

Constructor injection plus interfaces makes unit tests trivial:

// user_service_test.go
package service_test

import (
    "context"
    "errors"
    "testing"

    "github.com/myorg/myapp/internal/model"
    "github.com/myorg/myapp/internal/repository"
    "github.com/myorg/myapp/internal/service"
)

// Fake implementation โ€” satisfies UserStore without touching a database
type fakeUserStore struct {
    users map[int64]*model.User
    err   error // injectable error for testing failure paths
}

func (f *fakeUserStore) GetByID(_ context.Context, id int64) (*model.User, error) {
    if f.err != nil {
        return nil, f.err
    }
    u, ok := f.users[id]
    if !ok {
        return nil, repository.ErrNotFound
    }
    return u, nil
}

func (f *fakeUserStore) Save(_ context.Context, user *model.User) error {
    if f.err != nil {
        return f.err
    }
    f.users[user.ID] = user
    return nil
}

func (f *fakeUserStore) Delete(_ context.Context, id int64) error {
    delete(f.users, id)
    return f.err
}

func TestUserService_GetUser(t *testing.T) {
    t.Run("user exists", func(t *testing.T) {
        store := &fakeUserStore{
            users: map[int64]*model.User{
                1: {ID: 1, Name: "Alice", Email: "[email protected]"},
            },
        }
        svc := service.NewUserService(store, nil, slog.Default())

        got, err := svc.GetUser(context.Background(), 1)
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        if got.Name != "Alice" {
            t.Errorf("name: want Alice, got %s", got.Name)
        }
    })

    t.Run("user not found", func(t *testing.T) {
        store := &fakeUserStore{users: map[int64]*model.User{}}
        svc := service.NewUserService(store, nil, slog.Default())

        _, err := svc.GetUser(context.Background(), 999)
        if !errors.Is(err, repository.ErrNotFound) {
            t.Errorf("want ErrNotFound, got %v", err)
        }
    })

    t.Run("store error propagates", func(t *testing.T) {
        store := &fakeUserStore{err: errors.New("connection reset")}
        svc := service.NewUserService(store, nil, slog.Default())

        _, err := svc.GetUser(context.Background(), 1)
        if err == nil {
            t.Fatal("expected an error, got nil")
        }
    })
}

This test suite:

Monorepo Layout

When an organization runs multiple Go services, a monorepo with Go workspace support (Go 1.18+) unifies the development experience:

company/
โ”œโ”€โ”€ go.work                    โ† workspace file
โ”œโ”€โ”€ services/
โ”‚   โ”œโ”€โ”€ user-service/
โ”‚   โ”‚   โ”œโ”€โ”€ go.mod
โ”‚   โ”‚   โ”œโ”€โ”€ cmd/server/main.go
โ”‚   โ”‚   โ””โ”€โ”€ internal/
โ”‚   โ”œโ”€โ”€ order-service/
โ”‚   โ”‚   โ”œโ”€โ”€ go.mod
โ”‚   โ”‚   โ””โ”€โ”€ ...
โ”‚   โ””โ”€โ”€ notification-service/
โ”‚       โ”œโ”€โ”€ go.mod
โ”‚       โ””โ”€โ”€ ...
โ””โ”€โ”€ shared/                    โ† packages used by multiple services
    โ”œโ”€โ”€ go.mod
    โ”œโ”€โ”€ errcode/
    โ”œโ”€โ”€ middleware/
    โ””โ”€โ”€ pagination/

go.work file:

go 1.22

use (
    ./services/user-service
    ./services/order-service
    ./services/notification-service
    ./shared
)

With workspace mode, each service references the local version of shared during development. Changes to shared are immediately visible to all services without publishing a new module version. CI can run all service test suites in a single go test ./... invocation.


Summary

Project architecture is not about picking a directory template. It is about answering three questions deliberately:

  1. Where do I make the change? (locatability: every class of change has an obvious home)
  2. What else will break? (predictability: dependency direction is visible, blast radius is bounded)
  3. How do I prove the change is correct? (testability: dependencies are injectable; implementations are swappable)

Go's toolset provides concrete mechanisms for each:

Assembled deliberately, these mechanisms produce a codebase that handles evolution: new requirements, new team members, new infrastructure choices, and new scale. No framework required. No magic. Just consistent application of a small number of well-understood principles.

Rate this chapter
4.7  / 5  (6 ratings)

๐Ÿ’ฌ Comments