How to Migrate a Java Application to Go

How do I migrate a Java application to Go?

TL;DR

Constraints

Quick Reference

Java PatternGo EquivalentExample
class Foo extends BarStruct embeddingtype Foo struct { Bar }
class Foo implements IfaceImplicit interfaceJust implement the methods — no implements keyword
try { } catch (Exception e) { }Multiple return valuesval, err := doSomething(); if err != nil { return err }
@Autowired / DI containerConstructor injection via paramsfunc NewService(repo Repo) *Service { return &Service{repo: repo} }
Thread / ExecutorServiceGoroutines + channelsgo process(item); results <- output
synchronized / ReentrantLocksync.Mutex or channelsmu.Lock(); defer mu.Unlock()
Optional<T>Pointer or comma-ok idiomval, ok := m[key] or *T (nil = absent)
Stream.map().filter().collect()For loops with slicesfor _, v := range items { if v > 0 { out = append(out, v) } }
HashMap<K, V>Built-in map[K]Vm := map[string]int{"a": 1}
ArrayList<T>Slice []Ts := []int{1, 2, 3}; s = append(s, 4)
interface Foo { void bar(); }type Foo interface { Bar() }Uppercase = exported; no access modifiers
public / private / protectedCapitalizationExported (public) vs unexported (package-private)
@Override annotationNo equivalent neededImplicit interface satisfaction — compiler checks at use site
try-with-resources / finallydeferdefer file.Close() — runs when function returns
package com.foo.bar (deep nesting)Flat packagespackage 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 [src8]
|   +-- 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

# Standard Go project layout
mkdir -p cmd/myservice internal/handler internal/service internal/repository
myservice/
  cmd/myservice/main.go    # Entrypoint (like public static void main)
  internal/handler/         # HTTP handlers (like @RestController)
  internal/service/         # Business logic (like @Service)
  internal/repository/      # Data access (like @Repository)
  go.mod                    # Dependencies (like pom.xml / build.gradle)

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); }
// Go equivalent:
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
}

// Java: public class User { private long id; private String name; }
// Go equivalent:
type User struct {
    ID   int64  `json:"id" db:"id"`
    Name string `json:"name" db:"name"`
}

// Java: public class PostgresUserRepo implements UserRepository { ... }
// Go equivalent (implicit interface satisfaction):
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 pattern. Wrap errors with context using fmt.Errorf("context: %w", err) for stack-trace-like debugging. [src1, src7]

// Go equivalent:
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
}

// Define sentinel errors (like custom exception classes)
var ErrNotFound = errors.New("not found")
var ErrForbidden = errors.New("forbidden")

Verify: go build ./... compiles; error paths are tested with if errors.Is(err, ErrNotFound).

4. Replace Spring DI with constructor injection

Go does not use annotation-based DI containers. Instead, wire dependencies explicitly through constructor functions. This eliminates runtime reflection and makes the dependency graph visible in main(). [src2, src3]

// Go equivalent: explicit constructor injection
type UserService struct {
    repo  UserRepository  // interface, not concrete type
    email EmailSender     // interface for testability
}

func NewUserService(repo UserRepository, email EmailSender) *UserService {
    return &UserService{repo: repo, email: email}
}

// Wire everything 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: Application starts and all dependencies are injected. go run ./cmd/myservice starts the server.

5. Convert Java thread pools to goroutines and channels

Replace ExecutorService, CompletableFuture, and synchronized blocks with goroutines and channels. [src1, src4]

// Go equivalent: goroutines with bounded concurrency
func processAll(ctx context.Context, tasks []Task) ([]Result, error) {
    results := make([]Result, len(tasks))
    errCh := make(chan error, 1)
    sem := make(chan struct{}, 10) // max 10 concurrent goroutines

    var wg sync.WaitGroup
    for i, task := range tasks {
        wg.Add(1)
        go func(i int, t Task) {
            defer wg.Done()
            sem <- struct{}{}        // acquire semaphore
            defer func() { <-sem }() // release semaphore

            result, err := process(ctx, t)
            if err != nil {
                select {
                case errCh <- err:
                default:
                }
                return
            }
            results[i] = result
        }(i, task)
    }

    wg.Wait()
    close(errCh)
    if err := <-errCh; err != nil {
        return nil, err
    }
    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 or a lightweight router (Chi, Echo). Middleware replaces Spring interceptors and filters. Go 1.22+ includes built-in pattern routing with path parameters, eliminating the need for third-party routers in many cases. [src1, src2]

// Java Spring: @RestController + @GetMapping
// Go equivalent using stdlib (Go 1.22+ pattern routing):
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 is <100ms.

7. Build and deploy the Go binary

Go compiles to a single static binary with no runtime dependencies. This replaces the JVM + JAR deployment model. 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

# Multi-stage Dockerfile (replaces JDK base image)
# FROM golang:1.24-alpine AS builder
# WORKDIR /app
# COPY go.mod go.sum ./
# RUN go mod download
# COPY . .
# RUN CGO_ENABLED=0 go build -o /myservice ./cmd/myservice
#
# FROM scratch               # <-- No OS, no runtime. ~10MB vs ~200MB+ for JVM
# COPY --from=builder /myservice /myservice
# ENTRYPOINT ["/myservice"]

# Compare image sizes:
# Java (Eclipse Temurin): ~200-400MB
# Go (from scratch):     ~10-20MB

Verify: ./myservice runs without JVM installed. Docker image is <20MB. Startup is <100ms.

Code Examples

Go: HTTP service with repository pattern (replaces Spring Boot REST API)

Full script: go-http-service-with-repository-pattern-replaces-s.go (94 lines)

// Input:  A Java Spring Boot @RestController + @Service + @Repository
// Output: Equivalent Go service with same API contract
package main
import (
    "context"
# ... (see full script)

Go: Concurrent worker pool (replaces Java ExecutorService)

Full script: go-concurrent-worker-pool-replaces-java-executorse.go (67 lines)

// Input:  Java ExecutorService with Callable tasks and Future results
// Output: Go worker pool with goroutines and channels
package main
import (
    "context"
# ... (see full script)

Go: Interface-based testing (replaces Mockito)

Full script: go-interface-based-testing-replaces-mockito.go (54 lines)

// Input:  Java unit test with Mockito mocks
// Output: Go test using interface-based test doubles (no framework needed)
package service
import (
    "context"
# ... (see full script)

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 // This is NOT inheritance
}
func (d *Dog) Speak() string { return "Woof" }

// Bug: Animal.Speak() is still accessible and returns "..."
// No polymorphism: []Animal cannot hold Dog values

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" }

// Polymorphism via interface
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)) // Don't do this
    }
    return user
}

func handler(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if r := recover(); r != nil {
            http.Error(w, "internal error", 500) // Catching panics like exceptions
        }
    }()
    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 (not idiomatic Go)
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"`
}

// Only add methods when validation or side effects are needed:
func (u *User) SetEmail(email string) error {
    if !strings.Contains(email, "@") {
        return errors.New("invalid email")
    }
    u.Email = email
    return nil
}

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)
// In the handler package:
type UserGetter interface {
    GetUser(ctx context.Context, id int64) (*User, error)
}

// The handler only depends on the methods it actually calls
type UserHandler struct {
    users UserGetter // Narrow interface, easy to test
}

// The concrete service satisfies this interface implicitly
// No need for a separate "UserServiceInterface"

Wrong: Ignoring context.Context (Java has no equivalent)

// BAD -- no context propagation (Java habit of ignoring cancellation)
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, and 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)
}

// Caller sets timeout:
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// defer cancel()
// data, err := fetchData(ctx, "https://api.example.com/data")

Common Pitfalls

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

VersionStatusBreaking ChangesMigration Notes
Go 1.26 (2026-02)CurrentGreen Tea GC default, new() accepts expressions, errors.AsType generic, cmd/doc removed, stricter net/url parsing10–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)SupportedContainer-aware GOMAXPROCS (respects Cgroup CPU limits), testing/synctest, experimental Green Tea GCDrop go.uber.org/automaxprocs — the runtime now sets GOMAXPROCS = min(CPU_limit, cores) and re-checks periodically
Go 1.24 (2025-02)SupportedGeneric type aliases, Swiss Table maps, tool directives in go.mod, FIPS 140-3 crypto15–25% GC pause improvement; use go get -tool for tool deps
Go 1.23 (2024)SupportedIterator support (range-over-func), enhanced net/httpUse range-over-func for custom iterators
Go 1.22 (2024)SupportedEnhanced net/http routing with path paramsmux.HandleFunc("GET /users/{id}", handler) replaces Chi/Gorilla for simple routing
Go 1.21 (2023)Supportedlog/slog structured logging, slices/maps packagesReplace third-party logging (logrus, zap) for simple cases
Go 1.18 (2022)MaintenanceGenerics (type constraints), fuzzingUse generics for data structures, not behavioral interfaces
Java 25 (2025)LTS (source)Virtual threads finalized, structured concurrency redesigned, scoped valuesVirtual threads narrow Go's concurrency advantage for I/O-bound work; synchronized no longer pins carrier threads
Java 21 (2023)LTS (source)Virtual threads (preview finalized), pattern matching, record patternsVirtual threads reduce Go's concurrency advantage for I/O-bound work
Java 17 (2021)LTS (source)Sealed classes, recordsRecords map well to Go structs

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Microservices with high concurrency needsHeavy 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 experienceInvest in Java optimization (GraalVM native)
Building CLI tools, infrastructure, or DevOps toolingComplex ORM-heavy CRUD applicationsJava + JPA/Hibernate or consider Kotlin
You want single-binary deployment with no runtime depsProject 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 codeIncremental migration or stay
Containerized microservices where image size matters (~10MB vs ~300MB)Application heavily uses Java reflection, bytecode manipulation, or annotation processingStay with JVM; Go has no equivalent runtime reflection power

Important Caveats