← All Posts

Go for Backend Development — Why We Bet on It

Matthias Bruns · · 7 min read
go backend engineering

The Paradox of Choice

When you start a new backend project, you’re spoiled for choice: Java, C#, Python, Node.js, Rust, Kotlin, Go — every language has its community, its frameworks, and its evangelists. Each has strengths. Each has weaknesses.

At Appetizer Labs, we chose Go. Not because it’s trendy (that was maybe 2018). Not because Google built it. But because for what we do — building cloud-native backend services — it’s simply the most pragmatic choice.

Here’s why.

Simplicity Is a Feature

Go has 25 keywords. Python has 35. Java has 67. That sounds like a nerd stat, but it has real-world impact.

Go forces you into simplicity. There’s no inheritance, no generics nightmare (yes, generics exist now, but intentionally constrained), no annotation magic, no hidden control flows. Code reads almost like pseudocode:

func GetUser(ctx context.Context, id string) (*User, error) {
    user, err := db.QueryUser(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("query user %s: %w", id, err)
    }
    if user == nil {
        return nil, ErrNotFound
    }
    return user, nil
}

No magic. No framework doing dependency injection behind the scenes. No @Autowired, no @Transactional, no hidden proxies. You see what happens. Every line.

Why this matters: In a consulting context, we hand code over to client teams. Simple code is code others can understand. And code others understand is code others can maintain. That’s not a nice-to-have — that’s the whole point.

Error Handling: Annoying but Honest

Yes, you write if err != nil a hundred times a day. Yes, it’s verbose. But you know what it’s not? Surprising.

In Java, an exception gets thrown somewhere, caught by some handler (or not), and you spend half an hour digging through a stack trace. In Go, every error is an explicit value that gets explicitly handled. That’s annoying to write and brilliant to debug.

// Every error is visible. Every error is handled.
file, err := os.Open(path)
if err != nil {
    return fmt.Errorf("open config %s: %w", path, err)
}
defer file.Close()

data, err := io.ReadAll(file)
if err != nil {
    return fmt.Errorf("read config %s: %w", path, err)
}

No try-catch with 5 different exception types. No throws in the method signature that everyone ignores. Errors are values. Period.

Concurrency That Actually Works

Go was built for concurrent systems. Not bolted on after the fact — from the ground up.

Goroutines are the core of it. A goroutine costs roughly 2 KB of stack memory at startup. A Java thread costs about 1 MB. That means you can run hundreds of thousands of goroutines simultaneously without running out of memory.

func ProcessOrders(ctx context.Context, orders []Order) error {
    g, ctx := errgroup.WithContext(ctx)

    for _, order := range orders {
        order := order // capture loop variable
        g.Go(func() error {
            return processOrder(ctx, order)
        })
    }

    return g.Wait()
}

This isn’t just syntactically elegant — it’s practically relevant. A typical backend service spends 90% of its time waiting on I/O: database queries, HTTP calls, filesystem operations. Goroutines let you parallelize that wait time without callback hell, without promise chains, without async/await viruses that spread through your entire codebase.

Channels: Communication Over Shared State

// Producer-consumer with channels: simple and safe
jobs := make(chan Job, 100)

// Start 5 workers
for i := 0; i < 5; i++ {
    go func() {
        for job := range jobs {
            process(job)
        }
    }()
}

// Feed in jobs
for _, j := range allJobs {
    jobs <- j
}
close(jobs)

No mutexes, no race conditions, no synchronized blocks. Channels are Go’s answer to “how do parallel processes communicate?” — and the answer is damn good.

Deployment: One Binary, Zero Dependencies

This is where it gets really practical. A Go program compiles to a single static binary. No JVM, no Node runtime, no Python dependencies, no pip install on the server.

# Multi-stage Docker build for Go
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server

FROM scratch
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

The resulting Docker image is 10–20 MB. No base image, no OS packages, no security vulnerabilities in libraries you don’t even need. Compare that to a typical Java image (200–500 MB) or a Node.js image (150–300 MB).

In practice, this means:

  • Faster container starts (critical for autoscaling)
  • Smaller attack surface (less in the container = less to hack)
  • Simpler debugging (one binary, not an ecosystem)
  • Lower memory footprint at runtime (no GC overhead like the JVM)

A typical Go HTTP service starts in under 100 milliseconds and uses 20–50 MB of RAM. A comparable Spring Boot service takes 5–15 seconds and 200–500 MB. For cloud-native workloads where you need to scale up and down quickly, that’s a massive difference.

The Standard Library Is King

Go’s standard library is absurdly good. You don’t need a framework for an HTTP server:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users/{id}", getUser)
    mux.HandleFunc("POST /api/users", createUser)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    log.Fatal(server.ListenAndServe())
}

Since Go 1.22, the standard mux can even route path parameters and HTTP methods. For many services, you truly need zero external dependencies for the HTTP layer.

JSON handling, crypto, templates, testing, benchmarking, profiling — all in the standard library. That means fewer dependencies, less supply-chain risk, and no node_modules folder weighing in at 500 MB.

The Hiring Factor

Now for the pragmatic bit: finding Go developers is easier than you think. And for a reason that’s often overlooked: Go is easy to learn.

An experienced Java or C# developer is productive in Go within 2–3 weeks. Not perfect, but productive. Because the language is small, the concepts are clear, and there’s usually only one way to do most things.

Compare that to Rust (learning curve: months) or the Spring ecosystem (learning curve: years, if you want to understand all of it).

For mid-market companies where you can’t buy unlimited specialists, this is decisive. You don’t need Go experts — you need good developers who can pick up Go quickly.

Where Go Isn’t the Answer

Honesty is part of the deal. Go isn’t the best choice for everything:

  • Data-heavy applications with complex queries: If your service is mostly complex SQL queries, ORMs like Hibernate/JPA are more powerful than anything Go offers.
  • Machine learning: Python dominates here, and for good reason. Go won’t change that.
  • Frontend-adjacent fullstack development: If your team also does frontend, Node.js/TypeScript as an end-to-end stack is worth considering.
  • Rapid prototyping: For a quick proof of concept, Python or Ruby still gets you there faster.

Our Stack

For the curious — here’s what our typical Go backend stack looks like:

  • HTTP: Standard library + chi for middleware
  • Database: pgx for PostgreSQL, sqlc for type-safe queries
  • Config: Environment variables + envconfig
  • Logging: slog (standard library since Go 1.21)
  • Testing: Standard library + testcontainers-go for integration tests
  • Observability: OpenTelemetry

No framework. No Spring Boot. No magic. Just libraries that do one thing well and compose cleanly.

The Bottom Line

Go isn’t perfect. No language is. But for cloud-native backend services, Go offers a combination that’s hard to beat: simplicity, performance, tiny deployments, and a flat learning curve.

For us at Appetizer Labs, Go is the language that lets us build robust services the fastest — services our clients can maintain on their own afterward. And that’s what counts in the end.

Not the most elegant language. Not the most powerful. The most pragmatic.

Reader settings

Font size