How to Migrate a Java Application to Go
How do I migrate a Java application to Go?
TL;DR
- Bottom line: Migrate service-by-service starting with a low-risk pilot microservice — replace Java class hierarchies with Go structs + interfaces, swap exceptions for explicit error returns, and use goroutines/channels instead of thread pools. Go produces a single static binary with sub-100ms startup vs. 5–10s for Spring Boot.
- Key tool/command:
go mod init myproject && go build -o myservice ./cmd/myservice - Watch out for: Translating Java inheritance hierarchies into Go — Go has no classes or inheritance; use composition via struct embedding and implicit interfaces instead.
- Works with: Go 1.22+ (1.24 current as of Feb 2025), Java 11–25 (source), any build system (Maven/Gradle source, go modules target).
Constraints
- Go has no exceptions — all error handling must use explicit return values; never use panic/recover for control flow.
- Go does not support class inheritance — use struct embedding for composition and implicit interfaces for polymorphism; do not attempt to replicate Java's
extendskeyword. - Never introduce circular package dependencies — Go's compiler rejects them. Restructure using interfaces at the consumer side or flatten the package layout.
- Always propagate
context.Contextas the first parameter in functions that perform I/O — Go has no implicit request-scoped state like Spring'sRequestContextHolder. - Go maps must be initialized before write (
make(map[K]V)or literal syntax) —var m map[K]Vcreates a nil map that panics on assignment. - Run
go vet ./...andgo test -race ./...before every deployment — Go's static analysis and race detector catch concurrency bugs that Java's type system does not prevent.
Quick Reference
| Java Pattern | Go Equivalent | Example |
|---|---|---|
class Foo extends Bar | Struct embedding | type Foo struct { Bar } |
class Foo implements Iface | Implicit interface | Just implement the methods — no implements keyword |
try { } catch (Exception e) { } | Multiple return values | val, err := doSomething(); if err != nil { return err } |
@Autowired / DI container | Constructor injection via params | func NewService(repo Repo) *Service { return &Service{repo: repo} } |
Thread / ExecutorService | Goroutines + channels | go process(item); results <- output |
synchronized / ReentrantLock | sync.Mutex or channels | mu.Lock(); defer mu.Unlock() |
Optional<T> | Pointer or comma-ok idiom | val, ok := m[key] or *T (nil = absent) |
Stream.map().filter().collect() | For loops with slices | for _, v := range items { if v > 0 { out = append(out, v) } } |
HashMap<K, V> | Built-in map[K]V | m := map[string]int{"a": 1} |
ArrayList<T> | Slice []T | s := []int{1, 2, 3}; s = append(s, 4) |
interface Foo { void bar(); } | type Foo interface { Bar() } | Uppercase = exported; no access modifiers |
public / private / protected | Capitalization | Exported (public) vs unexported (package-private) |
@Override annotation | No equivalent needed | Implicit interface satisfaction — compiler checks at use site |
try-with-resources / finally | defer | defer file.Close() — runs when function returns |
package com.foo.bar (deep nesting) | Flat packages | package bar — one level, short names |
Decision Tree
START
+-- Is this a monolithic Java application?
| +-- YES --> Break into service boundaries first, then migrate one service at a time
| +-- NO (already microservices) v
+-- Does the service have heavy Java framework dependencies (Spring, Hibernate)?
| +-- YES --> Map framework features to Go stdlib + lightweight libraries (see Quick Reference)
| +-- NO v
+-- Is the service CPU-bound or I/O-bound?
| +-- CPU-BOUND --> Go excels here -- goroutines + GOMAXPROCS for parallelism
| +-- I/O-BOUND --> Use goroutines + channels for concurrent I/O (replaces CompletableFuture)
+-- Does the service use complex ORM patterns (JPA/Hibernate)?
| +-- YES --> Replace with sqlx or pgx + raw SQL (Go favors explicit queries over magic ORM)
| +-- NO v
+-- Does the team have Go experience?
| +-- NO --> Start with a non-critical service as a pilot; invest 2-4 weeks in Go training first
| +-- YES v
+-- DEFAULT --> Rewrite service in idiomatic Go: structs for data, interfaces for behavior, error returns for control flow
Decision Logic
Structured if/then rules an agent can apply once it has the user's answers to inputs_needed. Each rule resolves to a concrete recommendation.
If the Java app is a single large monolith with shared in-process state
→ Do not lift-and-shift to one Go binary. First carve service boundaries inside the monolith, then migrate the lowest-coupling service to Go as a pilot. [src5, src8]
If the codebase depends heavily on Spring Boot + JPA/Hibernate + Spring Security
→ Reconsider the migration ROI: map @RestController to net/http (Go 1.22+ routing), JPA to pgx/sqlx with hand-written SQL, and Spring DI to constructor injection — but budget for losing the ORM abstraction layer entirely. [src2, src3]
If concurrency performance was the primary motivation and the workload is I/O-bound
→ Re-evaluate first: Java 25 virtual threads close most of the goroutine gap for I/O-bound work, so the infra savings may not justify a rewrite. Migrate only if startup time, memory footprint, or single-binary deployment also matter. [src4, src8]
If the team has zero production Go experience
→ Run a 2-4 week Go training spike and migrate one non-critical service before committing to the full path; rushing leads to "Java-in-Go" (getter/setter structs, interface-for-everything, panic-as-exception). [src5, src8]
If the service runs in a CPU-limited container (Kubernetes requests/limits)
→ Target Go 1.25+ so GOMAXPROCS auto-respects the Cgroup CPU quota; on older Go, add go.uber.org/automaxprocs or you will hit kernel CPU throttling and tail-latency spikes. [src10]
If the goal is the smallest possible image and fastest cold start (serverless, scale-to-zero)
→ Go is the right target: build with CGO_ENABLED=0 into a FROM scratch image (~10-20MB, sub-100ms start) vs. ~200-400MB JVM images with multi-second startup. [src2, src5]
If the app relies on JVM-only ecosystem libraries (Kafka Streams, Spark, bytecode/reflection frameworks)
→ Keep those components on the JVM and migrate only the stateless request-handling services; Go has no equivalent runtime reflection or bytecode-manipulation power. [src1, src4]
Step-by-Step Guide
1. Initialize Go module and project structure
Create the Go project with a standard layout. Go projects use a flat, simple directory structure compared to Java's deep package hierarchy. [src1]
mkdir myservice && cd myservice
go mod init github.com/yourorg/myservice
mkdir -p cmd/myservice internal/handler internal/service internal/repository
Verify: go mod tidy runs without errors.
2. Convert Java classes to Go structs and interfaces
Replace class hierarchies with composition. Define small, focused interfaces (1–3 methods) and let structs satisfy them implicitly. [src1, src3]
// Java: public interface UserRepository { User findById(long id); }
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
}
type User struct {
ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
}
// Implicit interface satisfaction (no "implements" keyword)
type PostgresUserRepo struct { db *sql.DB }
func (r *PostgresUserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Name)
if err != nil {
return nil, fmt.Errorf("find user %d: %w", id, err)
}
return &u, nil
}
Verify: go vet ./... passes with no issues.
3. Replace exception handling with error returns
Convert try/catch blocks to Go's explicit error checking. Wrap errors with fmt.Errorf("context: %w", err) for debugging. [src1, src7]
func (s *UserService) GetUser(ctx context.Context, id int64) (*UserDTO, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d not found: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return toDTO(user), nil
}
// Sentinel errors (like custom exception classes)
var ErrNotFound = errors.New("not found")
var ErrForbidden = errors.New("forbidden")
Verify: go build ./... compiles; error paths tested with errors.Is(err, ErrNotFound).
4. Replace Spring DI with constructor injection
Wire dependencies explicitly through constructor functions. No annotation-based DI containers. [src2, src3]
type UserService struct {
repo UserRepository
email EmailSender
}
func NewUserService(repo UserRepository, email EmailSender) *UserService {
return &UserService{repo: repo, email: email}
}
// Wire in main() -- this IS your DI container
func main() {
db := connectDB()
repo := repository.NewPostgresUserRepo(db)
emailer := email.NewSMTPSender(smtpConfig)
userSvc := service.NewUserService(repo, emailer)
handler := handler.NewUserHandler(userSvc)
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", handler.GetUser)
log.Fatal(http.ListenAndServe(":8080", mux))
}
Verify: go run ./cmd/myservice starts the server.
5. Convert Java thread pools to goroutines and channels
Replace ExecutorService and CompletableFuture with goroutines and channels. [src1, src4]
// Goroutines with bounded concurrency (replaces fixed thread pool)
func processAll(ctx context.Context, tasks []Task) ([]Result, error) {
results := make([]Result, len(tasks))
sem := make(chan struct{}, 10) // max 10 concurrent
var wg sync.WaitGroup
for i, task := range tasks {
wg.Add(1)
go func(i int, t Task) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
results[i], _ = process(ctx, t)
}(i, task)
}
wg.Wait()
return results, nil
}
Verify: go test -race ./... passes — the race detector catches concurrent access bugs.
6. Set up HTTP server and middleware
Replace Spring Boot's embedded Tomcat with Go's net/http stdlib. Go 1.22+ includes built-in pattern routing with path parameters, eliminating the need for third-party routers in many cases. Middleware replaces Spring interceptors. [src1, src2]
// Go 1.22+ stdlib pattern routing (replaces @RestController + @GetMapping)
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
user, err := h.svc.GetUser(r.Context(), id)
if err != nil {
if errors.Is(err, service.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// Middleware (replaces Spring Filter/Interceptor)
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
Verify: curl http://localhost:8080/users/1 returns JSON. Server startup <100ms.
7. Build and deploy the Go binary
Go compiles to a single static binary with no runtime dependencies. Replaces JVM + JAR deployment. Go 1.24+ automatically embeds version info from VCS tags into the binary. [src2, src5]
# Build for production (like mvn package -DskipTests)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o myservice ./cmd/myservice
# Docker image: ~10-20MB (Go from scratch) vs ~200-400MB (Java + JVM)
Verify: ./myservice runs without JVM. Docker image <20MB. Startup <100ms.
Code Examples
Go: HTTP service with repository pattern (replaces Spring Boot REST API)
// Input: A Java Spring Boot @RestController + @Service + @Repository
// Output: Equivalent Go service with same API contract
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
_ "github.com/lib/pq"
)
type Product struct {
ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Price float64 `json:"price" db:"price"`
}
type ProductRepository interface {
FindByID(ctx context.Context, id int64) (*Product, error)
FindAll(ctx context.Context) ([]Product, error)
Save(ctx context.Context, p *Product) error
}
type pgProductRepo struct { db *sql.DB }
func (r *pgProductRepo) FindByID(ctx context.Context, id int64) (*Product, error) {
var p Product
err := r.db.QueryRowContext(ctx,
"SELECT id, name, price FROM products WHERE id = $1", id,
).Scan(&p.ID, &p.Name, &p.Price)
if errors.Is(err, sql.ErrNoRows) { return nil, nil }
return &p, err
}
func (r *pgProductRepo) FindAll(ctx context.Context) ([]Product, error) {
rows, err := r.db.QueryContext(ctx, "SELECT id, name, price FROM products")
if err != nil { return nil, err }
defer rows.Close()
var products []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil { return nil, err }
products = append(products, p)
}
return products, rows.Err()
}
type ProductHandler struct { repo ProductRepository }
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
product, err := h.repo.FindByID(r.Context(), id)
if err != nil { http.Error(w, "internal error", 500); return }
if product == nil { http.Error(w, "not found", 404); return }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(product)
}
func main() {
db, _ := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable")
defer db.Close()
repo := &pgProductRepo{db: db}
handler := &ProductHandler{repo: repo}
mux := http.NewServeMux()
mux.HandleFunc("GET /products/{id}", handler.GetProduct)
log.Fatal(http.ListenAndServe(":8080", mux))
}
Go: Concurrent worker pool (replaces Java ExecutorService)
// Input: Java ExecutorService with Callable tasks and Future results
// Output: Go worker pool with goroutines and channels
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Job struct { ID int; Payload string }
type Result struct { JobID int; Output string; Err error }
func WorkerPool(ctx context.Context, workers int, jobs <-chan Job) <-chan Result {
results := make(chan Result, len(jobs))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobs {
select {
case <-ctx.Done():
results <- Result{JobID: job.ID, Err: ctx.Err()}
return
default:
output, err := processJob(job)
results <- Result{JobID: job.ID, Output: output, Err: err}
}
}
}(i)
}
go func() { wg.Wait(); close(results) }()
return results
}
func processJob(j Job) (string, error) {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("processed: %s", j.Payload), nil
}
Go: Interface-based testing (replaces Mockito)
// Input: Java unit test with Mockito mocks
// Output: Go test using interface-based test doubles (no framework needed)
package service
import (
"context"
"errors"
"testing"
)
type mockUserRepo struct {
users map[int64]*User
err error
}
func (m *mockUserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
if m.err != nil { return nil, m.err }
u, ok := m.users[id]
if !ok { return nil, ErrNotFound }
return u, nil
}
func TestGetUser_Success(t *testing.T) {
repo := &mockUserRepo{users: map[int64]*User{1: {ID: 1, Name: "Alice"}}}
svc := NewUserService(repo, nil)
user, err := svc.GetUser(context.Background(), 1)
if err != nil { t.Fatalf("unexpected error: %v", err) }
if user.Name != "Alice" { t.Errorf("got %q, want %q", user.Name, "Alice") }
}
func TestGetUser_NotFound(t *testing.T) {
repo := &mockUserRepo{users: map[int64]*User{}}
svc := NewUserService(repo, nil)
_, err := svc.GetUser(context.Background(), 999)
if !errors.Is(err, ErrNotFound) { t.Errorf("got %v, want ErrNotFound", err) }
}
Anti-Patterns
Wrong: Translating Java class hierarchy to Go with embedding as inheritance
// BAD -- treating embedding as inheritance (it's not)
type Animal struct { Name string }
func (a *Animal) Speak() string { return "..." }
type Dog struct { Animal } // NOT inheritance
func (d *Dog) Speak() string { return "Woof" }
// Bug: Animal.Speak() still accessible; no polymorphism
Correct: Use interfaces for polymorphism
// GOOD -- interfaces for polymorphic behavior
type Speaker interface { Speak() string }
type Dog struct{ Name string }
func (d Dog) Speak() string { return "Woof" }
type Cat struct{ Name string }
func (c Cat) Speak() string { return "Meow" }
func greetAll(speakers []Speaker) {
for _, s := range speakers { fmt.Println(s.Speak()) }
}
Wrong: Using panic/recover as try/catch
// BAD -- using panic for control flow (Java exception habit)
func findUser(id int64) *User {
user, err := db.FindByID(id)
if err != nil {
panic(fmt.Sprintf("user not found: %d", id))
}
return user
}
func handler(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
http.Error(w, "internal error", 500)
}
}()
user := findUser(42)
json.NewEncoder(w).Encode(user)
}
Correct: Return errors explicitly
// GOOD -- explicit error returns (idiomatic Go)
func findUser(ctx context.Context, id int64) (*User, error) {
user, err := db.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("find user %d: %w", id, err)
}
return user, nil
}
func handler(w http.ResponseWriter, r *http.Request) {
user, err := findUser(r.Context(), 42)
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", 404)
return
}
http.Error(w, "internal error", 500)
return
}
json.NewEncoder(w).Encode(user)
}
Wrong: Creating Java-style getter/setter methods
// BAD -- Java-style boilerplate
type User struct { name string; email string }
func (u *User) GetName() string { return u.name }
func (u *User) SetName(n string) { u.name = n }
func (u *User) GetEmail() string { return u.email }
func (u *User) SetEmail(e string) { u.email = e }
Correct: Use exported fields directly
// GOOD -- exported fields, no getters/setters needed
type User struct {
Name string `json:"name"`
Email string `json:"email"`
}
// Direct access: user.Name = "Alice"
Wrong: Over-using interfaces (Java interface-for-everything habit)
// BAD -- interface for a single concrete implementation
type UserServiceInterface interface {
GetUser(ctx context.Context, id int64) (*User, error)
CreateUser(ctx context.Context, u *User) error
DeleteUser(ctx context.Context, id int64) error
}
type UserServiceImpl struct { /* fields */ }
// Unnecessary abstraction when only one implementation exists
Correct: Define interfaces at the consumer, not the provider
// GOOD -- define interfaces where they are used (consumer side)
type UserGetter interface {
GetUser(ctx context.Context, id int64) (*User, error)
}
type UserHandler struct {
users UserGetter // Narrow interface, easy to test
}
// The concrete service satisfies this implicitly
Wrong: Ignoring context.Context (Java has no equivalent)
// BAD -- no context propagation
func fetchData(url string) ([]byte, error) {
resp, err := http.Get(url) // No timeout, no cancellation
if err != nil { return nil, err }
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Correct: Thread context through all function calls
// GOOD -- context for cancellation, timeouts, request-scoped values
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { return nil, err }
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, fmt.Errorf("fetch %s: %w", url, err) }
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Common Pitfalls
- Nil pointer panics from uninitialized maps: In Java,
new HashMap<>()is safe. In Go,var m map[string]intcreates a nil map that panics on write. Fix: Always initialize:m := make(map[string]int). [src4] - Goroutine leaks from missing channel consumers: Launching goroutines that write to unbuffered channels with no reader causes them to hang forever. Fix: Always ensure channels are read, or use buffered channels. Use
context.Contextfor cancellation. [src1] - Circular import errors from Java-style package structure: Java allows circular dependencies; Go does not. Fix: Flatten packages or use interfaces to break cycles. [src3]
- Ignoring
go vetandstaticcheck: Java developers rely on IDE warnings. Go has powerful static analysis. Fix: Rungo vet ./...andstaticcheck ./...in CI. [src1] - Using
init()for complex setup: Java developers replicate@PostConstructwithinit(). Creates hidden dependencies. Fix: Use explicit initialization inmain(). [src3] - Assuming Go generics work like Java's: No wildcards (
? extends T), no type erasure. Go 1.24 adds generic type aliases; Go 1.25 removes core types. Fix: Use generics sparingly for data structures; prefer interfaces for behavioral abstraction. [src4] - Forgetting to close resources without defer: Java's try-with-resources auto-closes. In Go, you must call
Close()explicitly. Fix:defer resource.Close()immediately after open. [src7] - Not using
context.Contextin APIs: Java has no equivalent; Go passes context explicitly. Fix: Acceptctx context.Contextas first parameter in all I/O functions. [src1] - Deploying Go in containers without adjusting GOMAXPROCS: Before Go 1.25, the Go runtime ignored Cgroup CPU limits, setting GOMAXPROCS to the host's CPU count and causing kernel CPU throttling (~100ms stalls) and tail-latency spikes. Fix: Upgrade to Go 1.25+ which sets
GOMAXPROCS = min(CPU_limit, cores)and re-checks periodically; on older Go usego.uber.org/automaxprocs. [src8, src10]
Diagnostic Commands
# Initialize a new Go project
go mod init github.com/yourorg/myservice
# Download dependencies (like mvn dependency:resolve)
go mod tidy
# Build and check for compilation errors
go build ./...
# Run all tests with race detector enabled
go test -race -v ./...
# Run static analysis (catches bugs go build misses)
go vet ./...
# Check test coverage (like JaCoCo)
go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out
# Format all code (like google-java-format but enforced)
gofmt -w .
# Profile CPU/memory (like JFR/VisualVM)
go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out ./...
go tool pprof cpu.out
# Cross-compile for Linux (no JVM needed on target)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o myservice ./cmd/myservice
# Track tool dependencies in go.mod (Go 1.24+)
go get -tool golang.org/x/tools/cmd/stringer
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Go 1.26 (2026-02) | Current | Green Tea GC default, new() accepts expressions, errors.AsType generic, cmd/doc removed, stricter net/url parsing | 10–40% GC overhead reduction (default); go mod init writes go 1.25.0; go fix is now a modernizer with source-level inliner |
| Go 1.25 (2025-08) | Supported | Container-aware GOMAXPROCS (respects Cgroup CPU limits), testing/synctest, experimental Green Tea GC | Drop go.uber.org/automaxprocs — the runtime now sets GOMAXPROCS = min(CPU_limit, cores) and re-checks periodically |
| Go 1.24 (2025-02) | Supported | Generic type aliases, Swiss Table maps, tool directives in go.mod, FIPS 140-3 crypto | 15–25% GC pause improvement; use go get -tool for tool deps |
| Go 1.23 (2024) | Supported | Iterator support (range-over-func), enhanced net/http | Use range-over-func for custom iterators |
| Go 1.22 (2024) | Supported | Enhanced net/http routing with path params | mux.HandleFunc("GET /users/{id}", handler) replaces Chi/Gorilla for simple routing |
| Go 1.21 (2023) | Supported | log/slog structured logging, slices/maps | Replace third-party logging for simple cases |
| Go 1.18 (2022) | Maintenance | Generics (type constraints), fuzzing | Use generics for data structures, not behavioral interfaces |
| Java 25 (2025) | LTS (source) | Virtual threads finalized, structured concurrency redesigned, scoped values | Virtual threads narrow Go's concurrency advantage for I/O-bound work; synchronized no longer pins carrier threads |
| Java 21 (2023) | LTS (source) | Virtual threads, pattern matching, record patterns | Virtual threads reduce Go's concurrency advantage for I/O-bound work |
| Java 17 (2021) | LTS (source) | Sealed classes, records | Records map well to Go structs |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Microservices with high concurrency needs | Heavy enterprise integration (ESB, JMS, BPMN) | Stay with Java + Spring Boot |
| You need fast startup and low memory (serverless, K8s) | Team has deep Java/Spring expertise, no Go experience | Invest in Java optimization (GraalVM native) |
| Building CLI tools, infrastructure, or DevOps tooling | Complex ORM-heavy CRUD applications | Java + JPA/Hibernate or consider Kotlin |
| You want single-binary deployment with no runtime deps | Project depends on JVM-only libraries (Kafka Streams, Spark) | Keep those components in Java |
| Team is willing to learn Go idioms (not just syntax) | Tight deadline — rewriting under pressure leads to Java-in-Go code | Incremental migration or stay |
| Containerized microservices where image size matters (~10MB vs ~300MB) | Application heavily uses Java reflection, bytecode manipulation, or annotation processing | Stay with JVM; Go has no equivalent runtime reflection power |
Important Caveats
- Go has no exceptions — all error handling is explicit via return values. This increases verbosity but makes error paths visible. Embrace
if err != nil. - Go interfaces are satisfied implicitly. You never write
implements. This is powerful but can confuse Java developers who expect explicit contracts. - Go has no method overloading and no default parameter values. Use functional options pattern (
WithTimeout(5*time.Second)) or config structs instead. - Go modules replaced GOPATH in 2019. Always use
go mod initfor new projects. - Memory management differs: Go has a garbage collector but no JVM tuning knobs. Profile with
pprofand reduce allocations for predictable latency. - Java's
nullmaps roughly to Go's nil, but Go's zero values are type-specific (0 for int, "" for string, nil for pointers/slices/maps). A nil slice is valid (length 0), but a nil map panics on write. - Java 25's virtual threads and structured concurrency significantly narrow Go's concurrency advantage for I/O-bound workloads. Evaluate whether the migration is still justified if concurrency performance was the primary motivation. [src4, src8]
- Go 1.24's Swiss Table maps and Go 1.26's Green Tea garbage collector (default, 10–40% GC overhead reduction) keep Go competitive for latency-sensitive services. [src9]
- Container deployments: Go 1.25+ makes GOMAXPROCS container-aware by default, so a Go service no longer over-schedules threads against a Cgroup CPU limit. On Go <1.25 you must add
go.uber.org/automaxprocs. [src10]