Skip to content
gohacks.dev
Basics

Learn Go by Rewriting Small Java Programs You Know

Five small Java 21 programs rewritten in Go with matching output: records, interfaces, exceptions, generics and executors, and what drops away.

17 min read
Summarize in AI
Flat illustration of an editor window full of code.

Java developers come to Go with excellent instincts about types, interfaces, testing and concurrency, and then spend a few weeks being annoyed that the language is missing the features they relied on. There are no classes, no inheritance, no exceptions, no annotations and no overloading, and the annoyance mostly fades once you see what replaced them. This article rewrites five small Java programs in Go, runs both, and compares the output. The Java is real Java 21, run through its single-file launcher, and the Go is the way I would write it today, not a line-by-line transliteration. The comparison is a project in the Go basics path, and the finished programs are all in the article. Set up a module for the Go side with go mod init example.test/java2go.

Tested with Go 1.25.6 and Java 21 (Eclipse Temurin in a container), macOS on Apple Silicon (arm64)

What we are building, and what it will not do

The output is five programs in two languages that print the same thing, and a map of Java idioms to Go ones. It does not cover the Spring-style frameworks, the JVM's tooling or Java's ecosystem, and it does not claim Go is better for everything. It uses only the Go standard library, so a Go 1.25 toolchain is all you need. Java is only needed for comparing outputs, and the Java blocks are optional to run. Allow two hours.

The four changes to hold in mind. Go has structs and methods and no classes: data and behavior are declared separately, and methods attach to any named type. Go has interfaces without implements: a type satisfies an interface by having the methods. Go has errors as return values: no exception hierarchies, and a function that can fail says so in its signature. And Go has no framework culture: the standard library carries an HTTP server, JSON, testing and templates, so you write less configuration and more code.

1. Records and streams

Java 21 gives you records, immutable data classes, and streams for filtering and sorting:

Java
import java.util.*;
import java.util.stream.*;

public class Main {
    record User(String name, int age, boolean active) {}

    public static void main(String[] args) {
        var users = List.of(new User("ada", 36, true), new User("kid", 9, true),
                            new User("grace", 45, false), new User("linus", 28, true));
        var names = users.stream()
            .filter(u -> u.active() && u.age() >= 18)
            .sorted(Comparator.comparingInt(User::age).reversed())
            .map(User::name)
            .collect(Collectors.toList());
        System.out.println(names);
        System.out.println(users.get(0));
    }
}
Text
[ada, linus]
User[name=ada, age=36, active=true]

In Go the record becomes a struct, and the stream pipeline becomes a loop followed by a sort:

main.goGo
package main

import (
	"cmp"
	"fmt"
	"slices"
)

type User struct {
	Name   string
	Age    int
	Active bool
}

func main() {
	users := []User{{"ada", 36, true}, {"kid", 9, true}, {"grace", 45, false}, {"linus", 28, true}}

	var adults []User
	for _, u := range users {
		if u.Active && u.Age >= 18 {
			adults = append(adults, u)
		}
	}
	slices.SortFunc(adults, func(a, b User) int { return cmp.Compare(b.Age, a.Age) })

	names := make([]string, 0, len(adults))
	for _, u := range adults {
		names = append(names, u.Name)
	}
	fmt.Println(names)
	fmt.Printf("%+v\n", users[0])
}
Text
[ada linus]
{Name:ada Age:36 Active:true}

Both programs print the two adult names in age order, with different brackets and separators: Java's [ada, linus] against Go's [ada linus]. The record's generated toString prints User[name=ada, age=36, active=true], and Go's %+v prints {Name:ada Age:36 Active:true}, as printing structs describes. What changed is the pipeline. Java chains filter, sorted and map on a stream, with lambdas. Go uses plain loops, and the cost is a few lines. The benefit is that every step is visible code with no hidden allocation, and a debugger or a stack trace points at your line and not into stream internals. The Go field names are capitalized, because that is how they are exported, which is the visibility system that replaces public and private, explained in packages and exported names.

2. Interfaces without implements

In Java a class declares which interfaces it implements, and the declaration is part of the class:

Java
import java.util.*;

public class Main {
    interface Shape { double area(); }
    record Rect(double w, double h) implements Shape { public double area() { return w * h; } }
    record Circle(double r) implements Shape { public double area() { return Math.PI * r * r; } }

    public static void main(String[] args) {
        List<Shape> shapes = List.of(new Rect(3, 4), new Circle(1));
        for (Shape s : shapes) {
            System.out.printf("%s area=%.2f%n", s.getClass().getSimpleName(), s.area());
        }
    }
}
Text
Rect area=12.00
Circle area=3.14

In Go the types do not mention the interface at all:

main.goGo
package main

import (
	"fmt"
	"math"
)

type Shape interface {
	Area() float64
}

type Rect struct{ W, H float64 }
type Circle struct{ R float64 }

func (r Rect) Area() float64   { return r.W * r.H }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }

func main() {
	shapes := []Shape{Rect{3, 4}, Circle{1}}
	for _, s := range shapes {
		fmt.Printf("%T area=%.2f\n", s, s.Area())
	}
}
Text
main.Rect area=12.00
main.Circle area=3.14

The output is Rect area=12.00 and Circle area=3.14 in Java, and main.Rect area=12.00 and main.Circle area=3.14 in Go, since %T includes the package. The language-level difference is structural typing. Rect and Circle know nothing about Shape, so you can write an interface after the fact for types you did not write, and the interface can live in the package that uses it, as interfaces in Go develops. That is the reason Go code has few interface declarations in the package that defines the types, and many small ones near the callers. A Java habit to drop: the IShape interface with one implementation, and the interface-per-class rule that frameworks encourage. Go gives you the interface later, for free, when a second implementation or a test needs it.

3. Exceptions become errors

Java's checked exceptions put failure in the signature, and unchecked ones do not:

Java
public class Main {
    static int parsePort(String text) {
        int port;
        try {
            port = Integer.parseInt(text);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("port must be a number, got \"" + text + "\"");
        }
        if (port < 1 || port > 65535) {
            throw new IllegalArgumentException("port " + port + " out of range");
        }
        return port;
    }

    public static void main(String[] args) {
        for (String value : new String[]{"8080", "http", "70000"}) {
            try {
                System.out.println(parsePort(value));
            } catch (IllegalArgumentException e) {
                System.out.println("error: " + e.getMessage());
            }
        }
    }
}
Text
8080
error: port must be a number, got "http"
error: port 70000 out of range

The Go version returns the error and the caller handles it on the next line:

main.goGo
package main

import (
	"errors"
	"fmt"
	"strconv"
)

var ErrRange = errors.New("out of range")

func parsePort(text string) (int, error) {
	port, err := strconv.Atoi(text)
	if err != nil {
		return 0, fmt.Errorf("port must be a number, got %q", text)
	}
	if port < 1 || port > 65535 {
		return 0, fmt.Errorf("port %d: %w", port, ErrRange)
	}
	return port, nil
}

func main() {
	for _, value := range []string{"8080", "http", "70000"} {
		port, err := parsePort(value)
		if err != nil {
			fmt.Println("error:", err)
			continue
		}
		fmt.Println(port)
	}
}
Text
8080
error: port must be a number, got "http"
error: port 70000: out of range

The messages match apart from the colon in Go's wrapped range error. The control flow is not: Java jumps to the nearest catch, possibly many frames away, and Go returns to the caller, which looks at the value. There is no try, catch or finally. Cleanup that you would put in finally or try-with-resources is a defer, as defer and the order things run in explains. Wrapping an exception with a cause becomes fmt.Errorf with %w, and catch (SpecificException e) becomes errors.Is and errors.As, covered in the errors article. The part Java developers dislike most is if err != nil repeating down a function, and it is a genuine cost. In exchange, every failure point is visible, nothing skips frames and a function's signature is the only place you look to know it can fail.

4. Generics, lighter

Java generics use bounded type parameters and wildcards, and Go has a smaller version:

Java
import java.util.*;

public class Main {
    static <T extends Comparable<T>> T max(List<T> xs) {
        T best = xs.get(0);
        for (T x : xs) {
            if (x.compareTo(best) > 0) best = x;
        }
        return best;
    }

    public static void main(String[] args) {
        System.out.println(max(List.of(3, 9, 4)) + " " + max(List.of("pear", "apple", "fig")));
    }
}
Text
9 pear
main.goGo
package main

import (
	"cmp"
	"fmt"
)

func Max[T cmp.Ordered](xs []T) T {
	best := xs[0]
	for _, x := range xs {
		if x > best {
			best = x
		}
	}
	return best
}

func main() {
	fmt.Println(Max([]int{3, 9, 4}), Max([]string{"pear", "apple", "fig"}))
}
Text
9 pear

Both print 9 pear. The shape is the same: a type parameter with a constraint. The Go constraint cmp.Ordered lists the types that support <, which is an interface of types, and it also works for built-in types without wrapper objects, so []int is a real slice of integers and not a list of boxed Integers. Go generics are simpler on purpose. There are no wildcards, no variance and no generic methods on types, and the standard library already ships slices.Max for this exact function. Use them for containers and algorithms. For behavior, an interface is still the tool, as the interfaces guide says.

5. Executors become goroutines

Java's answer to parallel work is an executor service with a thread pool:

Java
import java.util.*;
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(4);
        List<Future<Integer>> futures = new ArrayList<>();
        for (int n = 0; n < 8; n++) {
            final int value = n;
            futures.add(pool.submit(() -> value * value));
        }
        List<Integer> results = new ArrayList<>();
        for (Future<Integer> f : futures) results.add(f.get());
        pool.shutdown();
        System.out.println(results);
    }
}
Text
[0, 1, 4, 9, 16, 25, 36, 49]

In Go you start a goroutine per task, write the result into its own slot and wait:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	results := make([]int, 8)

	var wg sync.WaitGroup
	for n := range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results[n] = n * n
		}()
	}
	wg.Wait()
	fmt.Println(results)
}
Text
[0 1 4 9 16 25 36 49]

Both print the first eight squares in order. The Go version has no pool, no Future and no shutdown: goroutines start with a few kilobytes of stack, so one per task is normal, and the runtime multiplexes them over the CPUs, as the scheduler article describes. Each goroutine writes a distinct slot of the slice, so no lock is needed. WaitGroup is the join. For work that must be bounded, you limit concurrency with a channel or errgroup, which bounded parallelism shows, and results that arrive as they finish go through a channel. Java's virtual threads in recent releases are closer to goroutines than the old thread pools were, and the programming model converges.

6. A service-shaped class: accounts with locking

The first five programs are all single functions. The thing Java developers actually build is a class with state, invariants and concurrent callers, so here is one: a bank account that refuses to overdraw, safe to call from several threads. In Java it is a class with synchronized methods and an exception for the failure:

Java
public class Main {
    static class InsufficientFundsException extends Exception {
        InsufficientFundsException(String message) { super(message); }
    }

    static class Account {
        private long balance;

        Account(long opening) { this.balance = opening; }

        synchronized void deposit(long amount) { balance += amount; }

        synchronized void withdraw(long amount) throws InsufficientFundsException {
            if (amount > balance) {
                throw new InsufficientFundsException("need " + amount + ", have " + balance);
            }
            balance -= amount;
        }

        synchronized long balance() { return balance; }
    }

    public static void main(String[] args) throws Exception {
        Account a = new Account(100);
        Thread[] threads = new Thread[50];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> a.deposit(10));
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        System.out.println(a.balance());

        try {
            a.withdraw(10_000);
        } catch (InsufficientFundsException e) {
            System.out.println("error: " + e.getMessage());
        }
    }
}
Text
600
error: need 10000, have 600

The Go version has a struct with a mutex, an error value instead of an exception class and goroutines instead of threads:

main.goGo
package main

import (
	"errors"
	"fmt"
	"sync"
)

var ErrInsufficientFunds = errors.New("insufficient funds")

type Account struct {
	mu      sync.Mutex
	balance int64
}

func NewAccount(opening int64) *Account { return &Account{balance: opening} }

func (a *Account) Deposit(amount int64) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.balance += amount
}

func (a *Account) Withdraw(amount int64) error {
	a.mu.Lock()
	defer a.mu.Unlock()
	if amount > a.balance {
		return fmt.Errorf("need %d, have %d: %w", amount, a.balance, ErrInsufficientFunds)
	}
	a.balance -= amount
	return nil
}

func (a *Account) Balance() int64 {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.balance
}

func main() {
	a := NewAccount(100)

	var wg sync.WaitGroup
	for range 50 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			a.Deposit(10)
		}()
	}
	wg.Wait()
	fmt.Println(a.Balance())

	if err := a.Withdraw(10_000); err != nil {
		fmt.Println("error:", err, errors.Is(err, ErrInsufficientFunds))
	}
}
Text
600
error: need 10000, have 600: insufficient funds true

Both end with a balance of 600 after 50 deposits of 10 on top of 100, and the failed withdrawal reports how much was needed. Everything in the Java class has a one-to-one counterpart. synchronized becomes a.mu.Lock() with a deferred unlock, which is explicit where Java's is a modifier, as mutexes in Go describes. The constructor becomes NewAccount. The exception class, with its constructor and message, becomes a package-level sentinel ErrInsufficientFunds that callers test with errors.Is, and the wrapped message adds the numbers. The fields are lowercase, so only methods can touch balance, which is Java's private expressed by naming. And the mutex sits inside the struct as a plain field, usable at its zero value, which is why NewAccount sets only the balance, following the zero value rule.

One caution carries over from Java into Go and goes wrong in a different way. Copying an Account by value copies the mutex, and the copy locks independently of the original. go vet reports it as passing a lock by value, and the fix is to keep the type behind a pointer, as the constructor does. Run the program with go run -race . and the race detector will stay silent here, because every access is locked, and it will complain loudly if you delete a Lock call, which is the cheapest regression test for this kind of code, as the memory model article explains.

A test for the class is the same size as a JUnit test and needs no framework:

account.goGo
package account

import (
	"errors"
	"fmt"
	"sync"
)

var ErrInsufficientFunds = errors.New("insufficient funds")

type Account struct {
	mu      sync.Mutex
	balance int64
}

func New(opening int64) *Account { return &Account{balance: opening} }

func (a *Account) Deposit(amount int64) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.balance += amount
}

func (a *Account) Withdraw(amount int64) error {
	a.mu.Lock()
	defer a.mu.Unlock()
	if amount > a.balance {
		return fmt.Errorf("need %d, have %d: %w", amount, a.balance, ErrInsufficientFunds)
	}
	a.balance -= amount
	return nil
}

func (a *Account) Balance() int64 {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.balance
}
account_test.goGo
package account

import (
	"errors"
	"sync"
	"testing"
)

func TestConcurrentDeposits(t *testing.T) {
	a := New(100)
	var wg sync.WaitGroup
	for range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			a.Deposit(10)
		}()
	}
	wg.Wait()
	if got := a.Balance(); got != 1100 {
		t.Fatalf("balance = %d, want 1100", got)
	}
}

func TestOverdraw(t *testing.T) {
	a := New(50)
	err := a.Withdraw(60)
	if !errors.Is(err, ErrInsufficientFunds) {
		t.Fatalf("err = %v, want ErrInsufficientFunds", err)
	}
	if a.Balance() != 50 {
		t.Fatalf("balance changed to %d", a.Balance())
	}
}
Text
=== RUN   TestConcurrentDeposits
--- PASS: TestConcurrentDeposits (0.00s)
=== RUN   TestOverdraw
--- PASS: TestOverdraw (0.00s)
PASS
ok  	example.test/lab	1.261s

I ran the tests with -race, and they pass: the race detector watched a hundred goroutines hit the account and had nothing to say. The second test asserts the invariant that matters, a failed withdrawal changes nothing, which is the property exception-based code often forgets to test.

Taking it further

Three extensions, in order of difficulty. Persist the account to a file with encoding/json and reload it, which exercises files and struct tags. Put the account behind an interface with a second implementation that records its calls, and use that fake in a test, as interfaces develops. Then expose the account over HTTP with the standard library's server and move on to the web material, where the structure you have seen here, a struct per resource with an error per failure, is the shape of every handler.

What drops away

A list of things you will reach for and not find:

JavaGo
class with fields and methodsstruct plus methods
extendsEmbedding, which is composition (embedding)
implementsNothing; satisfaction is implicit
nullZero values, nil for pointers, (T, bool) for optional results
Optional<T>A pointer, or the value and an ok boolean
ConstructorsNewThing() functions (constructors)
Getters and settersExported fields, methods only when needed
AnnotationsStruct tags and comments for tools
OverloadingDifferent function names
staticPackage-level functions and variables
Maven or Gradlego build, go test, go.mod
try/catch/finallyerror returns and defer
synchronizedsync.Mutex, or a channel

Each absence is a deliberate decision, and the replacement is usually smaller. Without inheritance there is no fragile base class, and without overloading you can search for a function by name. Without a framework, a Go service is often one main package and a handful of others, and a new teammate reads it in an afternoon.

Packages, imports and layout

Java organizes code by package names that mirror directories under src/main/java, with a pom.xml or build.gradle at the root listing coordinates. Go reduces this to a go.mod file and directories. A package is a directory, the import path is the module path plus the directory, and there is no src/main layer:

Text
myservice/
  go.mod                    module github.com/you/myservice
  main.go                   package main
  account/
    account.go              package account
    account_test.go         tests sit beside the code
  internal/
    store/store.go          not importable from outside the module

Tests live next to the code they test, in files ending _test.go, and there is no separate test tree. Visibility is a capital letter: the Deposit method is exported and balance is not, and internal/ is the closest thing to a module-private package. There are no wildcard imports, no static imports and no classpath, and an unused import is a compile error. Dependencies are listed in go.mod with exact versions and checked against go.sum, and go mod tidy keeps them in step, as packages and modules explains. The naming rules for packages, short and lowercase with no stutter, are in package naming, and they differ sharply from Java's reverse-domain convention.

What stays the same

Much of what you know transfers. The design instincts about small interfaces, composition over inheritance and dependency injection through constructors are all idiomatic Go, which is why the language feels familiar after the first week. Testing culture carries over: table-driven tests are the Go form of parameterized JUnit tests, and fakes written by hand replace mocking frameworks. Immutability helps in both languages, and final fields correspond to unexported fields without setters. Concurrency theory transfers directly, with the difference that the memory model is smaller and the standard tools are channels and a mutex, as the memory model and concurrency patterns describe. What you can leave behind is the ceremony: builders, factories for their own sake and layers of abstraction that exist because the language needed them. A good test of whether you have made the switch is to look at a Go program you wrote in your first month and count the interfaces that have exactly one implementation and no test using a second. Each of those is a Java habit, and deleting it makes the code shorter without losing anything the compiler was checking. Another is to count the getters that return a field unchanged: in Go the exported field does the job, and a method appears only when there is something to compute or protect.

Build and ship

The biggest day-to-day change is the toolchain. There is no pom.xml. go build produces one static binary, go test runs tests, and go vet and gofmt ship with the compiler, as your first program shows. The binary needs no JVM. On my machine the eclipse-temurin:21-jdk image I ran the Java examples in is 501 MB on disk, and a Go hello world is 2.4 MB, which is why Go services start in milliseconds and fit in a scratch container. Memory is similar in spirit: no heap tuning flags to learn first, and the garbage collector is configured by two environment variables, as the garbage collector article explains.

The decision I would revisit

I translated the stream pipeline into loops, and a Java developer will say that the Java reads better. For a short pipeline I agree. The Go answer is that loops compose with early returns, breaks and errors in a way lambdas do not, and that anything that needs more than three stages deserves a named function in either language. If you miss the style, generic helper functions are easy to write, and the iterator support in Go 1.23 makes lazy pipelines possible, as range over functions shows. I would not copy Java's habit of wrapping every pipeline in a helper object.

Where Java is still the better tool

The JVM has a decades-old ecosystem for enterprise integration, a very good profiler and debugger, mature tools for dynamic loading and plug-ins, and libraries for nearly everything. Android is Java and Kotlin. If your team has a large Java code base, rewriting it in Go is rarely the right move, and writing new services in Go is a decision about operations (a static binary, small containers, predictable memory) more than about the language. The strongest argument I know for a mixed shop is to write the services that need to be small, fast to start and easy to operate in Go, and to leave the rest where the team's expertise and libraries already are. The official Tour of Go is the best interactive next step, and the Effective Go document explains the idioms that replace the Java ones listed above. For what to read after this article, the order in the Go basics path is the one I would follow.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.