How to Migrate a Java Application to Go

Type: Software Reference Confidence: 0.89 Sources: 8 Verified: 2026-02-23 Freshness: quarterly

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
|   +-- YES v
+-- DEFAULT --> Rewrite service in idiomatic Go: structs for data, interfaces for behavior, error returns for control flow

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

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.24 (2025)CurrentGeneric 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/mapsReplace third-party logging 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, 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

Related Units