Go for Pythonistas: Building Web Apps with Go
Section 13 of 13

Building a Complete Go Web App Project

Putting It All Together: Project Structure, Idioms, and Where to Go Next

You've made it through Go's testing culture — you understand httptest, table-driven tests, and how to write tests that survive refactoring. That test file for the GetUser endpoint isn't just an example; it's a template you'll use for every handler you write. Now it's time to zoom out and answer the question that comes next when developers start building real things: how do I structure all of this?

When you're working alone in a small project, structure doesn't matter much. But when you're shipping code that other engineers will maintain, or when your own project grows beyond a handful of files, the way you organize your code becomes as important as the code itself. This section is about going from "I can write Go and test it" to "I can ship Go that scales." That means learning the standard project layouts your community uses, understanding the enforced boundaries Go provides, and internalizing the idiomatic habits that make codebases pleasurable to work in.

We'll also close the loop on the trade-off thesis that started this entire course — because knowing when not to use Go matters as much as knowing how to use it well.

Structuring Real Projects: The cmd/ and internal/ Pattern

Most Go projects follow a simple, predictable structure that's immediately recognizable to anyone who's worked in Go before. This consistency is underrated. It means new developers onboarding to a codebase spend less time asking "where does this code live?" and more time actually building things.

The pattern has three key directories, and the good news is they're obvious once you know what they mean.

cmd/: Your Entry Points

cmd/ is short for "commands." It's where your executable entry points live. Each subdirectory under cmd/ becomes one binary your project can produce.

myapp/
├── cmd/
│   ├── api/
│   │   └── main.go          # The API server binary
│   └── worker/
│       └── main.go          # A background job worker binary
├── go.mod
└── go.sum

Running go build ./cmd/api produces an api binary. Running go build ./cmd/worker produces a worker binary. Each main.go is thin — it parses configuration, wires up dependencies, and starts the service. The actual application logic lives elsewhere.

Why separate them? Because you might need multiple entry points. A web API and a background worker. A CLI tool and a daemon. Each one gets its own thin main.go that imports the same internal packages and uses them differently.

internal/: Application-Specific Code

internal/ is where the meat of your application lives. Anything in internal/ can only be imported by packages within your module. If you try to import github.com/you/myapp/internal/handlers from somewhere outside your module, the Go compiler rejects you.

Why does this matter? Because it lets you build the messy, coupled, application-specific code without worrying that some other team or project will depend on it. Your internal/ packages are free to change without it being a breaking change for anyone but you.

Remember: internal/ is where your application-specific code lives. It's not a dumping ground — it's a deliberate boundary. Put your handlers, business logic, and data access layer here.

pkg/: Genuinely Reusable Code

pkg/ is for code that's genuinely designed to be used by other packages — potentially even outside your module. A logging utility, a custom middleware, a validation helper. Things that have a stable API and could theoretically live in their own module.

Here's the honest advice: don't start with pkg/. Most applications don't need it. If you're building a single service, everything probably belongs in internal/. pkg/ makes more sense for platform teams building shared libraries, or for open-source packages that are part of a larger project.

The Go standard library itself is the best example of what belongs in pkg/-style packages: well-defined, stable interfaces with minimal dependencies.

graph TD
    A[cmd/api/main.go] --> B[internal/handlers]
    A --> C[internal/store]
    B --> C
    B --> D[pkg/logger]
    C --> D
    E[external package] -. blocked by compiler .-> B
    E -. blocked by compiler .-> C

    style E fill:#ffcccc,stroke:#cc0000

Flat Layouts for Small Projects

For a small API — something under a few thousand lines — don't over-engineer this. A flat layout is perfectly fine:

myapp/
├── main.go
├── handlers.go
├── models.go
├── store.go
├── go.mod
└── go.sum

The project layout conventions exist to manage complexity. If you don't have the complexity yet, the structure just adds friction. Start flat, refactor toward cmd/+internal/ when you have more than two or three logical subsystems or multiple entry points.


Configuration Patterns: Doing This Right

Every web app needs configuration — database URLs, API keys, ports, feature flags. How you handle this is one of the first things that separates a hobby project from something production-ready.

The gold standard is the Twelve-Factor App methodology, specifically factor III: store configuration in environment variables. The reasoning is solid: environment variables work everywhere (Docker, Kubernetes, bare metal), they're easy to override without code changes, and they keep secrets out of your repository.

Reading Environment Variables

The standard library gives you os.Getenv and the slightly more useful os.LookupEnv:

port := os.Getenv("PORT")
if port == "" {
    port = "8080" // sensible default
}

dbURL, ok := os.LookupEnv("DATABASE_URL")
if !ok {
    log.Fatal("DATABASE_URL is required")
}

LookupEnv returns a boolean indicating whether the variable was set — useful for distinguishing "not set" from "set to empty string."

The envconfig Pattern

For real projects, managing environment variables one-by-one gets tedious. A common pattern is to define a config struct and populate it from the environment:

type Config struct {
    Port        string
    DatabaseURL string
    JWTSecret   string
    Debug       bool
}

func LoadConfig() (Config, error) {
    cfg := Config{
        Port: "8080", // default
    }
    
    if port := os.Getenv("PORT"); port != "" {
        cfg.Port = port
    }
    
    dbURL, ok := os.LookupEnv("DATABASE_URL")
    if !ok {
        return Config{}, fmt.Errorf("DATABASE_URL is required")
    }
    cfg.DatabaseURL = dbURL
    
    // ... etc
    return cfg, nil
}

Libraries like kelseyhightower/envconfig automate this using struct tags — similar to how json tags work. For most projects, though, the manual approach above is clear enough and has zero dependencies.

Config Files

Some projects use YAML or TOML config files, particularly for local development. Viper is the most popular Go library for this — it supports config files, environment variables, flags, and remote config, with a clear precedence order. If you're building something with rich local configuration, Viper is worth the dependency.

Tip: Even if you use config files for local development, read configuration as environment variables in production. This is what container platforms expect, and it keeps your deployment story simple.


Logging in Go: Meet log/slog

Go 1.21 added log/slog to the standard library, and it's now the right default choice for logging in new Go projects. Before slog, you'd reach for third-party libraries like zerolog or zap. Those are still excellent, but slog covers 90% of use cases without a dependency.

The key idea behind structured logging: instead of logging strings, you log key-value pairs. This makes logs searchable and parseable by tools like Loki, CloudWatch, or Datadog.

import "log/slog"

// Default text logger
slog.Info("server starting", "port", 8080)
// Output: 2024/01/15 10:00:00 INFO server starting port=8080

// JSON logger (for production)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request completed",
    "method", "GET",
    "path", "/users",
    "status", 200,
    "duration_ms", 42,
)
// Output: {"time":"2024-01-15T10:00:00Z","level":"INFO","msg":"request completed","method":"GET","path":"/users","status":200,"duration_ms":42}

For web services, JSON logging in production and text logging in development is the pattern. Set the handler based on an environment variable:

func newLogger(debug bool) *slog.Logger {
    level := slog.LevelInfo
    if debug {
        level = slog.LevelDebug
    }
    
    opts := &slog.HandlerOptions{Level: level}
    
    if os.Getenv("LOG_FORMAT") == "json" {
        return slog.New(slog.NewJSONHandler(os.Stdout, opts))
    }
    return slog.New(slog.NewTextHandler(os.Stdout, opts))
}

Pass your logger around as a dependency — don't use package-level globals for logging in large applications. It makes testing easier and keeps your code explicit.


Go Idioms to Embrace

These are the patterns that show up in every well-written Go codebase. Internalize them and your code will immediately look more native.

Accept Interfaces, Return Structs

This is probably the single most important Go idiom. When writing a function that takes a dependency, accept an interface — not a concrete type. When returning a value, return the concrete type.

// Bad: tightly coupled to a specific implementation
func SaveUser(db *PostgresDB, user User) error { ... }

// Good: works with any database that implements the interface
type UserStore interface {
    Save(User) error
    FindByID(int64) (User, error)
}

func SaveUser(store UserStore, user User) error { ... }

Why return structs instead of interfaces? Because returning interfaces forces callers to work with the interface, hiding useful methods on the concrete type. It also makes the zero value confusing (a nil interface vs. a nil pointer to a struct behave differently). The caller can always assign a struct to an interface if they need to.

This pairs perfectly with Go's implicit interface satisfaction. You define small interfaces where they're needed — not upfront in a grand design — and concrete types satisfy them automatically.

Errors as Values

You've seen this throughout the course, but it's worth repeating as an idiom. In Go, you check errors immediately after the call that might produce them. Don't save them up, don't ignore them, don't catch them later.

// The standard pattern
result, err := doSomething()
if err != nil {
    return fmt.Errorf("doSomething failed: %w", err)
}
// use result

The %w verb wraps the error, preserving the original for errors.Is() and errors.As() checks up the call stack. This is how you build error chains in Go — not with stack traces, but with context added at each layer.

Composition Over Inheritance

Go has no inheritance. This isn't a bug; it's a deliberate choice that pushes you toward composition. Use struct embedding to compose behavior:

type BaseHandler struct {
    logger *slog.Logger
    store  UserStore
}

type UserHandler struct {
    BaseHandler
    // UserHandler-specific fields
}

func (h *UserHandler) GetUser(c *gin.Context) {
    h.logger.Info("fetching user") // promoted from BaseHandler
    // ...
}

BaseHandler's fields and methods are promoted to UserHandler. It's not inheritance — UserHandler isn't a BaseHandler — but it reads similarly. The difference is that you can compose multiple things, and the relationship is explicit rather than implied by a class hierarchy.


Go Antipatterns to Avoid

Just as important as knowing what to do is knowing what not to do. These are the patterns that show up in code written by people who know Go syntax but haven't fully internalized Go idioms.

Ignoring Errors

This is the most common mistake, full stop.

// Never do this
result, _ := json.Marshal(data)

Go makes it syntactically easy to discard error values with _. Resist the temptation. The blank identifier should only appear when you've genuinely considered the error and decided it cannot occur or doesn't matter — and that decision should usually be a comment explaining why.

Warning: Ignoring errors is the fastest path to debugging midnight production incidents where something silently failed three layers ago. Go's error-as-value model only works if you actually check the values.

Overly Nested Error Handling

Early Go code sometimes evolved into "pyramid of doom" nesting:

// Don't do this
func processRequest(id string) error {
    user, err := getUser(id)
    if err != nil {
        order, err := getOrder(id)
        if err != nil {
            // nested 4 levels deep...
        }
    }
    return nil
}

The idiomatic approach returns early on error:

// Do this instead
func processRequest(id string) error {
    user, err := getUser(id)
    if err != nil {
        return fmt.Errorf("getting user: %w", err)
    }
    
    order, err := getOrder(user.ID)
    if err != nil {
        return fmt.Errorf("getting order: %w", err)
    }
    
    return process(user, order)
}

This is the "guard clause" pattern — handle the error case immediately and return, keeping the happy path unindented and easy to follow.

Premature Interface Extraction

Python developers who have internalized dependency injection sometimes over-apply it in Go. Not every struct needs an interface. Not every function needs to accept an interface.

Create interfaces when you have multiple implementations, or when you need the interface for testing (to mock a dependency). Don't create interfaces speculatively "in case we need them later." Go's duck typing means you can always add the interface later with zero changes to the concrete type.

// Premature — there's only ever one UserService
type UserServiceInterface interface {
    CreateUser(User) error
    GetUser(int64) (User, error)
}

// Just use the struct directly until you need the interface
type UserService struct { ... }

Using panic for Expected Errors

Python developers are used to raising exceptions for error conditions. In Go, panic is reserved for truly unexpected programming errors — nil pointer dereferences, index out of bounds, "this should never happen" situations. Using panic for expected error conditions (a database query that returns no rows, invalid user input) breaks the error-handling contract the rest of your code expects.


A Complete Mini Project: Putting It All Together

Let's build a small but complete REST API that demonstrates the patterns we've discussed. This isn't a full tutorial with every line typed out — it's a blueprint showing how the pieces connect.

Project: A simple task management API

taskapi/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── handlers/
│   │   └── tasks.go
│   ├── models/
│   │   └── task.go
│   └── store/
│       ├── store.go          # interface definition
│       └── memory_store.go   # in-memory implementation
├── go.mod
└── go.sum

internal/models/task.go

package models

import "time"

type Task struct {
    ID        int64     `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

internal/store/store.go — define the interface

package store

import "github.com/you/taskapi/internal/models"

type TaskStore interface {
    Create(title string) (models.Task, error)
    List() ([]models.Task, error)
    Complete(id int64) error
}

internal/store/memory_store.go — a simple in-memory implementation for development

package store

import (
    "fmt"
    "sync"
    "time"
    
    "github.com/you/taskapi/internal/models"
)

type MemoryStore struct {
    mu      sync.RWMutex
    tasks   map[int64]models.Task
    nextID  int64
}

func NewMemoryStore() *MemoryStore {
    return &MemoryStore{tasks: make(map[int64]models.Task)}
}

func (s *MemoryStore) Create(title string) (models.Task, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    s.nextID++
    task := models.Task{
        ID:        s.nextID,
        Title:     title,
        CreatedAt: time.Now(),
    }
    s.tasks[task.ID] = task
    return task, nil
}

func (s *MemoryStore) List() ([]models.Task, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    
    tasks := make([]models.Task, 0, len(s.tasks))
    for _, t := range s.tasks {
        tasks = append(tasks, t)
    }
    return tasks, nil
}

func (s *MemoryStore) Complete(id int64) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    task, ok := s.tasks[id]
    if !ok {
        return fmt.Errorf("task %d not found", id)
    }
    task.Done = true
    s.tasks[id] = task
    return nil
}

Notice: MemoryStore satisfies the TaskStore interface without ever declaring that it does. If you add a PostgresStore later that implements the same methods, it slots right in.

internal/handlers/tasks.go — accept the interface

package handlers

import (
    "log/slog"
    "net/http"
    "strconv"
    
    "github.com/gin-gonic/gin"
    "github.com/you/taskapi/internal/store"
)

type TaskHandler struct {
    store  store.TaskStore
    logger *slog.Logger
}

func NewTaskHandler(s store.TaskStore, logger *slog.Logger) *TaskHandler {
    return &TaskHandler{store: s, logger: logger}
}

func (h *TaskHandler) ListTasks(c *gin.Context) {
    tasks, err := h.store.List()
    if err != nil {
        h.logger.Error("listing tasks", "error", err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
        return
    }
    c.JSON(http.StatusOK, tasks)
}

func (h *TaskHandler) CreateTask(c *gin.Context) {
    var req struct {
        Title string `json:"title" binding:"required"`
    }
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    task, err := h.store.Create(req.Title)
    if err != nil {
        h.logger.Error("creating task", "error", err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
        return
    }
    c.JSON(http.StatusCreated, task)
}

func (h *TaskHandler) CompleteTask(c *gin.Context) {
    id, err := strconv.ParseInt(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task ID"})
        return
    }
    
    if err := h.store.Complete(id); err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
        return
    }
    c.Status(http.StatusNoContent)
}

cmd/api/main.go — thin entry point, wires everything together

package main

import (
    "log/slog"
    "os"
    
    "github.com/gin-gonic/gin"
    "github.com/you/taskapi/internal/handlers"
    "github.com/you/taskapi/internal/store"
)

func main() {
    logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
    
    taskStore := store.NewMemoryStore()
    taskHandler := handlers.NewTaskHandler(taskStore, logger)
    
    r := gin.Default()
    
    v1 := r.Group("/api/v1")
    {
        v1.GET("/tasks", taskHandler.ListTasks)
        v1.POST("/tasks", taskHandler.CreateTask)
        v1.PUT("/tasks/:id/complete", taskHandler.CompleteTask)
    }
    
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    
    logger.Info("starting server", "port", port)
    if err := r.Run(":" + port); err != nil {
        logger.Error("server failed", "error", err)
        os.Exit(1)
    }
}

This is ~150 lines of actual application code that demonstrates: clean project structure, interface-based dependency injection, structured logging, proper error handling, environment-based configuration, and idiomatic handler patterns. It's ready to swap the MemoryStore for a real database without touching the handler code.

graph TD
    A["cmd/api/main.go"] -->|imports| B["internal/handlers"]
    A -->|imports| C["internal/store"]
    B -->|uses| C
    B -->|uses| D["pkg/logger"]
    C -->|uses| D
    E["external packages"] -. "blocked by compiler" .-> B
    E -. "blocked by compiler" .-> C
    
    style A fill:#e8f4f8,stroke:#0066cc
    style B fill:#f0f8f0,stroke:#228B22
    style C fill:#f0f8f0,stroke:#228B22
    style D fill:#fff8f0,stroke:#FF8C00
    style E fill:#ffcccc,stroke:#cc0000

The Go Community and Ecosystem

One of Go's genuine strengths is that its ecosystem is well-organized and relatively easy to navigate, compared to the sprawling Python package world.

pkg.go.dev is your first stop for any Go package. It automatically generates documentation from Go source code — every exported function, type, and method is documented there, with examples when the author wrote them. Search for "gin" and you'll find the Gin framework, its full API docs, its import path, and how many other modules depend on it.

Awesome Go is a curated list of Go packages organized by category — web frameworks, databases, testing, CLI tools, and more. When you need a library for something, start here rather than a generic search engine. The curation quality is generally high.

The Go Forum and the Gophers Slack are the main community spaces. The Go team is active in both. The community culture is notably low on flame wars — Go attracts people who like things settled and practical.

The official Go blog publishes posts from the core team on new features, design decisions, and best practices. It's worth subscribing to or checking periodically. The posts on generics, the memory model, and the slog package design are particularly good for understanding why Go makes the choices it does.


What to Learn Next

You've built the foundation. Here's the natural next sequence, ordered by what you'll likely need first.

Database Integration with pgx

The standard library's database/sql works with any SQL database, but for PostgreSQL specifically — which is where most Go web apps live — pgx is the better choice. It's a PostgreSQL-specific driver and toolkit that exposes PostgreSQL features that database/sql abstracts away, and its connection pool (pgxpool) is excellent. The official tutorial on accessing relational databases is a good starting point for database/sql, and then pgx's README covers the delta.

For database migrations, look at golang-migrate — it handles migration files in SQL, runs them in order, and tracks state in a table in your database.

Authentication Patterns

JWT authentication in Go is straightforward with golang-jwt/jwt. The pattern is a middleware that validates the token and attaches the claims to the Gin context. Session-based auth is also well-supported via gorilla/sessions. Either approach maps cleanly to the middleware patterns you've learned in Gin.

Docker Deployment

Go's compilation to a single static binary makes Docker deployment unusually clean:

# Build stage
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o api ./cmd/api

# Final stage — tiny image
FROM alpine:latest
COPY --from=builder /app/api /api
CMD ["/api"]

The final image can be under 20MB. This two-stage build pattern is standard Go Docker practice. Go's official Docker Hub page has more detail on build options.

Generics (Go 1.18+)

Generics arrived in Go 1.18 and are now stable and idiomatic for the right use cases — primarily utility functions that operate on collections (filter, map, reduce) and data structures that need to work across types. The Go generics tutorial is short and covers the core concepts well. Resist the urge to over-apply generics — Go's idiom still prefers concrete types in most application code.

gRPC

If you're building microservices that communicate internally, gRPC with Protocol Buffers is the Go ecosystem's preferred approach. It generates type-safe client and server code from .proto files, handles serialization, and gives you streaming RPCs for free. The learning curve is steeper than REST, but for internal APIs between services, it's worth it.

graph TD
    A["Current: REST APIs with Gin"] -->|natural next| B["Database: pgx + migrations"]
    A -->|natural next| C["Auth: JWT middleware"]
    B -->|enables| D["Docker: multi-stage builds"]
    C -->|enables| D
    D -->|unlock| E["Advanced: gRPC / microservices"]
    D -->|performance work| F["Profiling: pprof"]
    B -->|advanced DB| G["pgx batch, LISTEN/NOTIFY"]

    style A fill:#d4edda,stroke:#28a745
    style B fill:#f0f8f0,stroke:#228B22
    style C fill:#f0f8f0,stroke:#228B22
    style D fill:#e8f4f8,stroke:#0066cc
    style E fill:#fff8f0,stroke:#FF8C00

Keeping Your Python Skills Sharp Alongside Go

Here's the most honest thing this course can tell you: Go should not replace Python in your toolkit. It should extend it.

The trade-off we've examined throughout this course is real in both directions. Go gives you speed, explicit concurrency, and a compiler that catches entire categories of bugs before runtime. Python gives you iteration speed, an enormous ecosystem for data science and ML, scripting flexibility, and dynamic code that's hard to replicate in a statically typed language.

The working patterns that make sense for most developers:

  • Use Go for services that need to handle high concurrency or low latency: API servers, data pipelines with heavy I/O, CLI tools that need to ship as a single binary.
  • Use Python for ML/data work (nothing in Go touches PyTorch or pandas), for scripting and automation, for rapid prototyping where you need to validate an idea in an afternoon.
  • Keep Python fluency by continuing to use it for the things it's genuinely better at. The Pythonic instinct for clean, readable code translates well to Go — you just express it differently.

The developers who dismiss Go as "just another systems language" and the ones who dismiss Python as "a scripting language" are both leaving capability on the table. The senior engineers who are most effective tend to know two or three languages well and choose deliberately.


Recommended Resources for Continued Learning

For Go itself:

  • Effective Go — Required reading. Covers idiomatic Go from the team that designed the language. Some parts are dated (pre-generics, pre-modules) but the core guidance is timeless.
  • Go by Example — Annotated examples for every language feature. Your "how do I do X in Go" reference.
  • 100 Go Mistakes and How to Avoid Them by Teiva Harsanyi — The most useful Go book for intermediate developers. Covers exactly the antipatterns that trip people up in production.
  • The Go Programming Language by Donovan and Kernighan — The canonical Go textbook. Dense but authoritative.

For web-specific Go:

  • Let's Go by Alex Edwards — Comprehensive guide to web development with Go's standard library. Extremely practical.
  • Let's Go Further — The sequel, covering APIs, authentication, and production concerns.

For keeping up:

  • The Go Blog — Official, authoritative, worth reading everything.
  • Golang Weekly — Newsletter covering new packages, blog posts, and community discussion.

The Final Trade-Off

We started this course with a thesis: Go isn't a harder Python — it's a different trade-off. You give up flexibility and brevity in exchange for speed, explicitness, and a concurrency model that makes building reliable web services dramatically easier.

Having built REST APIs, written goroutines, structured real projects, and handled errors the Go way, you've now experienced that trade-off from the inside instead of just reading about it. The verbosity that felt tedious in section three is the same explicitness that makes reading someone else's Go code — or your own code from six months ago — feel predictable. The error handling that seemed repetitive is the same discipline that means crashes in production have a clear paper trail.

Go clicked differently for you than it does for developers learning it from scratch, because you had Python as a reference point. You knew what you were giving up, so you could see clearly what you were getting in return. That's the fastest path to genuine understanding — and now you have it.

Go build something real.