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.
Only visible to you
Sign in to take notes.