When Go Is Wrong
Series
Go for JVM Engineers← Part 8
Profiling
Every series that honestly covers a technology has to end here: where does it fall down? Go is an excellent language for network services, CLI tools, and infrastructure. It is a poor fit for some categories of problems. If you have spent the previous eight posts learning Go's idioms, you deserve a candid assessment of where those idioms run out.
This is not a complaint post. It is a professional due-diligence checklist for JVM engineers deciding where Go belongs in their stack.
Domain Modelling Without Sum Types
Algebraic data types — sealed classes in Java 17+, sealed interface + record combos — let you model a domain where a value is exactly one of a finite set of variants.
// Java 21 sealed types — exhaustive pattern matching enforced
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.w() * r.h();
// Missing Triangle is a COMPILE ERROR
};
}Go has no sum types. The idiomatic approach is an interface with unexported methods to restrict implementations, but the compiler does not enforce exhaustiveness on type switches.
type Shape interface {
shape() // unexported sentinel method
}
type Circle struct { Radius float64 }
type Rectangle struct { W, H float64 }
func (Circle) shape() {}
func (Rectangle) shape() {}
func area(s Shape) float64 {
switch v := s.(type) {
case Circle: return math.Pi * v.Radius * v.Radius
case Rectangle: return v.W * v.H
default: panic("unknown shape") // no compile-time guarantee
}
}The default: panic is load-bearing. Adding a Triangle type anywhere in the codebase does not produce a compile error at every switch site. For domains with many variants that evolve over time, this is a real maintenance hazard.
Generics Are Young and Limited
Go added generics in 1.18. They are sufficient for container types and utility functions, but several features that JVM engineers take for granted are absent or limited.
No generic methods on non-generic types:
// This is NOT valid Go
type Cache struct{}
func (c *Cache) Get[T any](key string) T { ... } // compile errorYou must make the whole type generic or use a top-level function.
No higher-kinded types:
You cannot write a generic map, flatMap, or filter that works over any container. The standard library's slices and maps packages (Go 1.21+) cover the common cases, but a fully generic functor/monad abstraction is not expressible.
Type constraints cannot reference themselves:
Writing a Comparable[T] constraint where T can be compared to itself requires workarounds. The cmp.Ordered constraint from the standard library covers numeric types and strings, but custom ordered types require interface constraints with explicit method sets.
// Works
func Min[T cmp.Ordered](a, b T) T {
if a < b { return a }
return b
}
// Cannot do: func Map[F Functor[A], A, B any](f F, fn func(A) B) Functor[B]For greenfield services, generics are adequate. For a rich domain library with complex type relationships, Go's generics will frustrate you.
Error Handling at Scale
The if err != nil pattern is correct, explicit, and readable at small scale. At large scale — functions with five or more consecutive operations that each return an error — it generates repetitive code that obscures the happy path.
func createOrder(ctx context.Context, req CreateOrderRequest) (Order, error) {
if err := validate(req); err != nil {
return Order{}, fmt.Errorf("createOrder: validate: %w", err)
}
user, err := fetchUser(ctx, req.UserID)
if err != nil {
return Order{}, fmt.Errorf("createOrder: fetchUser: %w", err)
}
inv, err := checkInventory(ctx, req.Items)
if err != nil {
return Order{}, fmt.Errorf("createOrder: checkInventory: %w", err)
}
order, err := saveOrder(ctx, user, inv, req)
if err != nil {
return Order{}, fmt.Errorf("createOrder: saveOrder: %w", err)
}
return order, nil
}Java's checked exceptions achieve similar guarantees with less repetition (though at the cost of throws clause pollution and unchecked exception escape). The Go community acknowledges this; proposals for improved error handling (including try expressions) have been discussed since 2019 without consensus. Until the language adds something, the verbosity is the cost of explicitness.
Ecosystem Gaps
Go's ecosystem is strong for infrastructure concerns — HTTP, gRPC, Kubernetes controllers, CLI tools — and thin for enterprise application concerns.
| Category | Java ecosystem | Go ecosystem |
|---|---|---|
| ORM | Hibernate, JPA, Spring Data | sqlc (good), gorm (rough edges), ent (opinionated) |
| Messaging | Spring Kafka, Spring AMQP | segmentio/kafka-go, rabbitmq/amqp091-go (adequate) |
| Observability | Micrometer, OpenTelemetry | OpenTelemetry Go (improving) |
| Security (OAuth2/OIDC) | Spring Security | coreos/go-oidc (adequate, no batteries-included equiv) |
| Batch processing | Spring Batch | No equivalent; implement manually |
| GraphQL | Spring GraphQL | 99designs/gqlgen (good) |
| Big data / ML | Spark, Deeplearning4j | Near-zero ecosystem; use Python |
If your organisation runs heavy Spring Batch jobs, integrates with Kafka using consumer group management abstractions, or needs enterprise security flows, Go will require more bespoke wiring.
Dependency Injection at Scale
Constructor injection scales well for medium-sized applications. For a service with dozens of components, the manual wiring in main.go becomes a maintenance concern. Google's wire tool generates the wiring code from dependency declarations, but it is code generation, not a DI container — debugging generated code is a different experience from Spring's reflection-based wiring.
No Built-in Null Safety at the Language Level
Go's pointers and interface nil semantics (covered in Post 1) mean nil-related panics are possible. There is no Optional<T>, no @NonNull, no Kotlin-style ?. The discipline is cultural: define your zero values, use non-pointer receivers for immutable types, and avoid returning nil slices and maps where an empty collection would be valid.
When to Reach for Go Anyway
Go is demonstrably the right choice when:
- Latency predictability matters — sub-millisecond GC pauses, no JIT warm-up period, instant startup.
- Operational simplicity matters — single static binary, no JVM tuning, trivial Dockerfile (
FROM scratch). - Concurrency at scale — a service managing thousands of simultaneous connections without thread pool tuning.
- Infrastructure tooling — the Kubernetes, Docker, Prometheus, and Terraform ecosystems are predominantly Go; contributing or extending them requires Go.
Go is the wrong choice when:
- You need a rich domain model with complex type relationships and exhaustive variant handling.
- Your team is building data pipelines or ML workflows.
- You are deeply integrated with Spring's enterprise ecosystem and the migration cost exceeds the operational benefit.
- Your application logic is primarily I/O-bound and already well-served by a JVM-based reactive stack.
Key Takeaways
- Go has no sum types; type switches lack compile-time exhaustiveness — for domains with many variants, this is a maintainability risk that Java 21 sealed classes solve cleanly.
- Generics (added in 1.18) are adequate for container types and utilities but do not support generic methods on non-generic types or higher-kinded abstractions — complex type-level programming will hit walls.
- The
if err != nilpattern is correct and explicit, but verbose at scale; no language-level improvement has shipped as of Go 1.23, and the verbosity is a real cost in large codebases. - The Go ecosystem excels at infrastructure concerns (HTTP, gRPC, observability, Kubernetes) and is thin for enterprise application concerns (batch processing, complex Spring Security equivalents, JPA-equivalent ORM).
- Use Go when latency predictability, operational simplicity, or high-concurrency are your primary drivers; stay on the JVM when rich domain modelling, Spring ecosystem depth, or data engineering are your primary needs.
- The answer to "Go or JVM?" is almost always "both" in a polyglot organisation — know Go's ceiling so you can make the right call per service.
Series
Go for JVM Engineers← Part 8
Profiling