A small Go backend lab: the useful parts are the boring ones
golang-engineering-lab is a small backend service, not a giant catalogue of concurrency tricks. That makes it useful in a different way. The repository shows the basic pieces you need for a Go HTTP service: config loading, database connection, handlers, middleware, services, and a main.go that wires them together.
Small repos are good for reading because there is nowhere for structure to hide.
main.go owns wiring
The entry point loads config, connects to Postgres, builds services, builds handlers, registers routes, and starts the server:
cfg := config.Load()
database, err := db.Connect(cfg.DBConn)
if err != nil {
log.Fatal(err)
}
defer database.Close()
userSvc := service.NewUserService(database)
authSvc := service.NewAuthService(cfg.JWTSecret)
mux := http.NewServeMux()
mux.HandleFunc("POST /login", authHandler.Login)
mux.Handle("GET /users", middleware.Auth(authSvc)(http.HandlerFunc(userHandler.List)))
That is the right amount of ceremony for a learning service. main composes packages but does not contain SQL, token validation, or JSON response logic.
Configuration stays simple
The config package reads environment variables with fallbacks:
type Config struct {
Port string
DBConn string
JWTSecret string
}
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
DBConn: getEnv("DB_CONN", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"),
JWTSecret: getEnv("JWT_SECRET", "dev-secret"),
}
}
For production you would want stricter validation and no insecure default secret. For a lab, this keeps the moving parts visible.
Database connection uses a timeout
The Postgres package opens a database/sql connection and pings it with a bounded context:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, err
}
This is a small habit worth keeping. Startup should fail clearly when the database is unavailable. A hung Ping during deploy is not useful.
Services take context from handlers
The user service accepts a caller-provided context, then derives its own timeout:
func (s *UserService) List(ctx context.Context) ([]User, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT id, email FROM users`)
// ...
}
That preserves request cancellation. If the client disconnects, the handler’s context can still propagate into the database call. The service adds a deadline so a slow query does not sit forever.
Middleware is just function composition
The auth middleware returns a function that wraps an http.Handler:
func Auth(auth *service.AuthService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if err := auth.ValidateToken(token); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
This is one of the nicest parts of Go’s standard HTTP package. You can build authentication, logging, request IDs, and rate limits without framework-specific middleware types.
What I would improve before production
The lab is intentionally small, but a few changes would make it sturdier:
- parse
Authorization: Bearer <token>instead of treating the whole header as the token - validate config and fail if
JWT_SECRETis still the dev value - return structured JSON errors instead of plain
http.Error - add request IDs to the logging middleware
- make SQL migrations part of startup or deployment
Those are useful exercises because they build directly on the existing structure.
What to take from it
The useful lesson is package shape. main wires dependencies. Handlers translate HTTP to service calls. Services own business/database operations. Middleware wraps handlers. Config and database setup sit behind small packages.
That structure is enough for a lot of Go services. Learn it before reaching for a heavier framework.