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:
- Locality: related things live together; unrelated things live apart
- Single responsibility: each file and package does one thing; changes don't ripple
- Explicit dependencies: the dependency graph is visible from the code structure, not buried in global variables
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:
- Early stage: flat — fewer files, easier to navigate
- Mid-stage: split by feature module (
internal/handler,internal/service) - Large scale: multiple independent services in a monorepo or separate repositories
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?
- Testability: pass a mock database in tests; no real DB required
- Replaceability: swap the DB driver by changing one line in
main.go - 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:
- Compile-time validation: missing dependencies are build errors, not runtime panics
- No reflection: the generated code is plain Go with zero runtime overhead
- Readable output:
wire_gen.gois a clear, auditable record of the initialization order
Wire disadvantages:
- Requires an extra build step (
wire gen) - Steeper learning curve (Provider, Set, Injector vocabulary)
- Circular dependencies are discovered at compile time (which is good — but the error messages can be cryptic)
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:
- No code generation — better for dynamic or plugin-based architectures
- Optional dependencies via
dig.Instruct tags - Integrates naturally with Uber FX (application framework)
Dig disadvantages:
- Reflection overhead (small; occurs only at startup)
- Missing dependencies are runtime errors, not compile errors
- Error messages in complex graphs can be hard to interpret
Decision guide:
- Small project: hand-written constructor injection (no framework needed)
- Medium project with a team fluent in Go tooling: Wire (compile-time safety)
- Large project requiring dynamic extension: Dig + FX
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:
- Needs no database —
fakeUserStoreoperates entirely in memory - Needs no network — pure functions, no I/O
- Runs in milliseconds — safe to execute on every commit in CI
- Covers the three important cases: happy path, not-found, store error
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:
- Where do I make the change? (locatability: every class of change has an obvious home)
- What else will break? (predictability: dependency direction is visible, blast radius is bounded)
- How do I prove the change is correct? (testability: dependencies are injectable; implementations are swappable)
Go's toolset provides concrete mechanisms for each:
internal/package enforcement → compiler-guaranteed boundaries- Constructor injection → dependency graph is explicit in function signatures
- Implicit interfaces → implementations are swappable without modifying them
context.Context→ cancellation and deadlines propagate across layersfmt.Errorf("%w")→ error chains traverse layer boundaries while preserving root cause
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.