Go Backend Development in 2026: Patterns, Performance, and Production Readiness
on Go, Golang, Backend, Performance, Microservices, Software engineering
Go Backend Development in 2026: Patterns, Performance, and Production Readiness
Go has cemented its position as the language of choice for backend infrastructure. Kubernetes, Docker, Prometheus, Terraform — the tools that run the modern internet are written in Go. More importantly, the teams building those tools are choosing Go for the same reasons your team should: simplicity, performance, and operability.
This guide covers the patterns that experienced Go engineers use in production — not the “hello world” tutorials you find everywhere else.
Photo by Florian Olivo on Unsplash
Why Go in 2026?
Performance: Go binaries start in milliseconds, not seconds. Memory usage is predictable. Concurrency via goroutines is genuinely simple.
Operational simplicity: A single static binary with no runtime dependencies. Docker images under 20MB. No JVM tuning. No npm install.
Toolchain: go test, go bench, go vet, go build — everything you need, built in.
Recent improvements in Go 1.22-1.24:
- Range over integers (
for i := range 10) - Improved type inference
- Enhanced
net/httprouting (method + path parameters) - Better performance on Apple Silicon and RISC-V
slices,maps, andcmpstandard library packages
Project Structure
The most debated topic in Go. Here’s what works at scale:
myservice/
├── cmd/
│ └── server/
│ └── main.go # Entrypoint — wire everything together
├── internal/ # Private packages (not importable externally)
│ ├── domain/ # Business entities and interfaces
│ │ ├── user.go
│ │ └── order.go
│ ├── service/ # Business logic
│ │ ├── user_service.go
│ │ └── order_service.go
│ ├── repository/ # Data access
│ │ ├── user_repo.go
│ │ └── postgres/
│ ├── handler/ # HTTP handlers
│ │ ├── user_handler.go
│ │ └── middleware/
│ └── config/ # Configuration loading
├── pkg/ # Public packages (if any)
├── migrations/ # SQL migrations
├── Dockerfile
├── docker-compose.yml
└── Makefile
The Standard Library HTTP Router (Go 1.22+)
Go 1.22 upgraded net/http with method matching and path parameters — no need for gorilla/mux or chi for most APIs:
package main
import (
"encoding/json"
"log/slog"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Method + path matching (Go 1.22+)
mux.HandleFunc("GET /users", listUsersHandler)
mux.HandleFunc("POST /users", createUserHandler)
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("PUT /users/{id}", updateUserHandler)
mux.HandleFunc("DELETE /users/{id}", deleteUserHandler)
server := &http.Server{
Addr: ":8080",
Handler: withMiddleware(mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
slog.Info("server starting", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Go 1.22+
user, err := userService.Get(r.Context(), id)
if err != nil {
handleError(w, err)
return
}
respondJSON(w, http.StatusOK, user)
}
Error Handling: Do It Right
Go’s error handling is expressive when done well. The key is wrapping errors with context:
// Define sentinel errors
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
)
// Domain errors with context
type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Constructor helpers
func NotFound(resource, id string) *AppError {
return &AppError{
Code: http.StatusNotFound,
Message: fmt.Sprintf("%s with id %s not found", resource, id),
Err: ErrNotFound,
}
}
// Wrap errors at layer boundaries
func (r *userRepository) Get(ctx context.Context, id string) (*User, error) {
var user User
err := r.db.QueryRowContext(ctx,
"SELECT id, email, name FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Email, &user.Name)
if errors.Is(err, sql.ErrNoRows) {
return nil, NotFound("user", id)
}
if err != nil {
return nil, fmt.Errorf("userRepository.Get: %w", err) // Wrap with context
}
return &user, nil
}
// HTTP handler converts domain errors to HTTP responses
func handleError(w http.ResponseWriter, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
respondJSON(w, appErr.Code, map[string]string{"error": appErr.Message})
return
}
slog.Error("unexpected error", "error", err)
respondJSON(w, http.StatusInternalServerError,
map[string]string{"error": "internal server error"},
)
}
Dependency Injection: Wire
Avoid complex DI frameworks. Use Google Wire for compile-time dependency injection:
// wire.go — provider declarations
package main
import "github.com/google/wire"
func InitializeServer(cfg *config.Config) (*http.Server, error) {
wire.Build(
config.NewDB,
repository.NewUserRepository,
repository.NewOrderRepository,
service.NewUserService,
service.NewOrderService,
handler.NewUserHandler,
handler.NewOrderHandler,
NewServer,
)
return nil, nil
}
Wire generates the wiring code at compile time — no reflection, no runtime overhead.
For smaller services, manual wiring is often cleaner:
// cmd/server/main.go
func run(cfg *config.Config) error {
db, err := postgres.New(cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("connecting to database: %w", err)
}
defer db.Close()
userRepo := repository.NewUserRepository(db)
userService := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService)
mux := http.NewServeMux()
userHandler.Register(mux)
return startServer(mux, cfg.Port)
}
Context: The Right Way
Context propagates cancellation, deadlines, and request-scoped values. Get it right:
// Always pass context as the first parameter
func (s *UserService) Get(ctx context.Context, id string) (*User, error) {
// DB calls, HTTP calls, cache calls — all take ctx
return s.repo.Get(ctx, id)
}
// Set timeouts at the edge (handler level)
func getUserHandler(w http.ResponseWriter, r *http.Request) {
// Add a per-request timeout
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
user, err := userService.Get(ctx, r.PathValue("id"))
// ...
}
// Request-scoped values via typed keys (never use string keys)
type contextKey string
const (
ctxKeyRequestID contextKey = "request_id"
ctxKeyUserID contextKey = "user_id"
)
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = ulid.New()
}
ctx := context.WithValue(r.Context(), ctxKeyRequestID, requestID)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func requestID(ctx context.Context) string {
if id, ok := ctx.Value(ctxKeyRequestID).(string); ok {
return id
}
return ""
}
Structured Logging with slog (Go 1.21+)
The log/slog package is now the standard for structured logging:
import "log/slog"
// Initialize once at startup
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Usage — always include context for trace correlation
slog.InfoContext(ctx, "user created",
"user_id", user.ID,
"email", user.Email,
"duration_ms", time.Since(start).Milliseconds(),
)
slog.ErrorContext(ctx, "failed to send email",
"user_id", user.ID,
"error", err,
)
// Add request-level fields to all logs in a handler
func withRequestLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := slog.With(
"request_id", requestID(r.Context()),
"method", r.Method,
"path", r.URL.Path,
)
// Store logger in context for downstream use
ctx := ctxWithLogger(r.Context(), logger)
start := time.Now()
rw := &responseWriter{ResponseWriter: w}
next.ServeHTTP(rw, r.WithContext(ctx))
logger.Info("request completed",
"status", rw.status,
"duration_ms", time.Since(start).Milliseconds(),
"bytes", rw.bytes,
)
})
}
Concurrency Patterns
Photo by Alexandre Debiève on Unsplash
Fan-Out with errgroup
import "golang.org/x/sync/errgroup"
func (s *OrderService) GetOrderSummary(
ctx context.Context,
orderID string,
) (*OrderSummary, error) {
g, ctx := errgroup.WithContext(ctx)
var order *Order
var payments []Payment
var shipment *Shipment
g.Go(func() error {
var err error
order, err = s.orderRepo.Get(ctx, orderID)
return err
})
g.Go(func() error {
var err error
payments, err = s.paymentRepo.ListByOrder(ctx, orderID)
return err
})
g.Go(func() error {
var err error
shipment, err = s.shipmentRepo.GetByOrder(ctx, orderID)
if errors.Is(err, ErrNotFound) {
return nil // Shipment may not exist yet
}
return err
})
if err := g.Wait(); err != nil {
return nil, fmt.Errorf("GetOrderSummary: %w", err)
}
return &OrderSummary{Order: order, Payments: payments, Shipment: shipment}, nil
}
Worker Pool
func processJobs(ctx context.Context, jobs <-chan Job, numWorkers int) <-chan Result {
results := make(chan Result, len(jobs))
var wg sync.WaitGroup
for range numWorkers { // Go 1.22 range over integer
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
select {
case <-ctx.Done():
return
default:
result, err := processJob(ctx, job)
results <- Result{Job: job, Value: result, Err: err}
}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
return results
}
Database: pgx + sqlc
Skip ORMs. Use pgx (PostgreSQL driver) with sqlc (generate type-safe Go from SQL):
-- queries/users.sql
-- name: GetUser :one
SELECT id, email, name, created_at
FROM users
WHERE id = $1 AND deleted_at IS NULL;
-- name: CreateUser :one
INSERT INTO users (id, email, name)
VALUES ($1, $2, $3)
RETURNING *;
-- name: ListUsers :many
SELECT id, email, name, created_at
FROM users
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;
sqlc generate
# Generates: db/users.sql.go with fully type-safe functions
// Generated code (you never write this manually)
func (q *Queries) GetUser(ctx context.Context, id string) (User, error) {
row := q.db.QueryRow(ctx, getUser, id)
var i User
err := row.Scan(&i.ID, &i.Email, &i.Name, &i.CreatedAt)
return i, err
}
Testing: Table-Driven Tests
func TestUserService_Get(t *testing.T) {
tests := []struct {
name string
userID string
setup func(*mockUserRepo)
want *User
wantErr error
}{
{
name: "returns user when found",
userID: "usr_123",
setup: func(m *mockUserRepo) {
m.On("Get", mock.Anything, "usr_123").
Return(&User{ID: "usr_123", Email: "alice@example.com"}, nil)
},
want: &User{ID: "usr_123", Email: "alice@example.com"},
},
{
name: "returns not found error",
userID: "usr_999",
setup: func(m *mockUserRepo) {
m.On("Get", mock.Anything, "usr_999").
Return(nil, NotFound("user", "usr_999"))
},
wantErr: ErrNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &mockUserRepo{}
tt.setup(repo)
svc := NewUserService(repo)
got, err := svc.Get(context.Background(), tt.userID)
if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
Performance: What Actually Matters
Profile before optimizing:
import _ "net/http/pprof"
// Add pprof endpoint in dev
go http.ListenAndServe("localhost:6060", nil)
// Then profile:
// go tool pprof http://localhost:6060/debug/pprof/cpu
// go tool pprof http://localhost:6060/debug/pprof/heap
Common Go performance wins:
- Avoid allocations in hot paths — use
sync.Pool - Pre-allocate slices:
make([]Item, 0, expectedLen) - Use
strings.Builderfor string concatenation in loops - Avoid interface{} in hot paths (type assertions have cost)
- Buffer your I/O:
bufio.NewWriter
Benchmarking:
func BenchmarkUserLookup(b *testing.B) {
cache := NewCache(1000)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
cache.Get("usr_123")
}
})
}
// Run: go test -bench=. -benchmem -count=5
Resources
- Effective Go
- Go by Example
- sqlc documentation
- pgx — PostgreSQL driver
- Google Wire
- errgroup
- Ardan Labs Blog
- Go Performance Workshop
이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)
