Structuring Go Web Applications
Structuring Go Web Applications
Overview
A modular layout keeps web apps maintainable: HTTP stays at the edge, domain rules stay pure, and databases hide behind interfaces. This chapter shows a practical Bookstore layout—not the only valid layout, but one that scales from a tutorial to a small product.
Goals of structure
- Find things fast — routes, handlers, domain, persistence in predictable places
- Test in isolation — swap real DB for mocks without rewriting handlers
- Avoid circular imports — packages depend inward (domain) not sideways forever
- Ship one binary —
cmd/bookstoreis the process entry; libraries live underinternal/
Recommended layout
bookstore/
go.mod
cmd/
bookstore/
main.go # wiring only: config, DI, ListenAndServe
internal/
config/
config.go # env + defaults
domain/
book.go # types + pure validation
user.go
store/
store.go # interfaces (BookRepository, ...)
memory/
book_repo.go # in-memory for demos/tests
postgres/
book_repo.go # real SQL later
httpapi/
server.go # routes + middleware stack
books.go # book handlers
auth.go
respond.go # JSON helpers
web/
templates/ # html/template files
static/ # css/js
migrations/ # optional SQL migrations
internal/ is invisible to other modules—good for app-private code.
Dependency direction
cmd/bookstore
│
▼
httpapi ──uses──► store (interfaces)
│ ▲
│ │ implements
▼ │
domain ◄──────── store/memory, store/postgres
- domain knows nothing about HTTP or SQL.
- store defines interfaces and implementations.
- httpapi depends on interfaces + domain types.
- main constructs concrete types and injects them.
Config from environment
package config
import (
"os"
"time"
)
type Config struct {
Addr string
DatabaseURL string
SessionSecret string
ShutdownTimeout time.Duration
}
func Load() Config {
return Config{
Addr: getenv("ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
SessionSecret: getenv("SESSION_SECRET", "dev-only-change-me"),
ShutdownTimeout: 10 * time.Second,
}
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}Never commit production secrets. Dev defaults are fine if loudly unsafe.
Domain types (Bookstore)
package domain
import "fmt"
type Book struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Price int `json:"price_cents"` // store money as integer cents
}
func (b Book) Validate() error {
if b.Title == "" {
return fmt.Errorf("title is required")
}
if b.Author == "" {
return fmt.Errorf("author is required")
}
if b.Price < 0 {
return fmt.Errorf("price_cents must be >= 0")
}
return nil
}Validation on the domain type keeps handlers short and reuses the same rules for HTML forms and JSON APIs.
Repository interface
package store
import (
"context"
"example.com/bookstore/internal/domain"
)
type BookRepository interface {
List(ctx context.Context) ([]domain.Book, error)
Get(ctx context.Context, id string) (domain.Book, error)
Create(ctx context.Context, b domain.Book) (domain.Book, error)
}Handlers depend on BookRepository, not on Postgres. Chapter 275 implements memory and SQL; chapter 279 mocks this interface.
Wiring in main
package main
import (
"log"
"net/http"
"time"
"example.com/bookstore/internal/config"
"example.com/bookstore/internal/httpapi"
"example.com/bookstore/internal/store/memory"
)
func main() {
cfg := config.Load()
books := memory.NewBookRepo()
api := httpapi.NewServer(books)
srv := &http.Server{
Addr: cfg.Addr,
Handler: api.Handler(),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("bookstore listening on %s", cfg.Addr)
log.Fatal(srv.ListenAndServe())
}main is assembly, not business logic. When you add auth, sessions, and Postgres, only wiring and constructors grow here.
Package naming habits
| Prefer | Avoid |
|---|---|
httpapi, store, domain |
utils, helpers, common dumping grounds |
Short import paths under internal/ |
Giant models package everything imports |
| One concern per package | Circular handler ↔︎ service ↔︎ handler |
If two packages need each other constantly, merge them or extract a smaller shared types package.
Modular design without over-engineering
For a learning Bookstore you do not need:
- Full clean-architecture ceremony with six layers
- A DI framework
- Microservices split by “books” vs “users”
You do need:
- Interfaces at I/O boundaries
context.Contexton store methods- Config outside code
- A single place that registers routes
Graceful shutdown sketch
go func() {
log.Fatal(srv.ListenAndServe())
}()
// on signal:
ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
_ = srv.Shutdown(ctx)Production processes should finish in-flight requests instead of hard-killing connections.
Rules of thumb
| Do | Don’t |
|---|---|
Put app code under internal/ |
Export everything as a public library by accident |
| Inject repositories into handlers | Reach for package-level global DB from every file |
Keep main thin |
Grow a 2k-line main.go of handlers |
| Validate in domain or a small service | Duplicate validation in every handler |
Try next
- Scaffold the directories above and compile with an empty mux.
- Move
healthzintohttpapiand construct it frommain. - Add a
UserRepositoryinterface stub for the auth chapter.