Go for Java Developers

What You Need to Know

Go provides Java developers with a simpler, compiled alternative featuring automatic memory management and built-in concurrency via goroutines.

You're not missing features

You open your editor, type package main, and realize there's no public class Main. You look for extends, implements, private, and static. They're gone. You feel like you've lost your toolkit. Go isn't Java with a new syntax. It's a different philosophy wrapped in a minimal language. You're not missing features. You're about to learn a different way to solve problems.

Java builds everything on classes. Go builds everything on structs and functions. Java uses inheritance to share behavior. Go uses composition. Java checks types at compile time with explicit declarations. Go checks types too, but interfaces are satisfied implicitly. You don't announce you implement an interface. You just have the methods.

Go is simple on purpose. Complexity is the enemy.

Structs, not classes

In Java, a class bundles data and behavior. In Go, a struct holds data. Methods attach to types via receivers. There's no inheritance. You build behavior by combining small structs or embedding them.

package main

import "fmt"

// Greeter holds a name and provides a greeting.
type Greeter struct {
    Name string
}

// Greet returns a formatted greeting string.
// The receiver g is passed by value.
func (g Greeter) Greet() string {
    return fmt.Sprintf("Hello, %s", g.Name)
}

// main prints a greeting using the Greeter struct.
func main() {
    g := Greeter{Name: "World"}
    fmt.Println(g.Greet())
}

The receiver name is usually one or two letters matching the type. You'll see (g Greeter) or (g *Greeter), never (this Greeter) or (self Greeter). This keeps code short and readable.

Visibility is controlled by capitalization. No keywords like public or private. A name starting with a capital letter is exported. A name starting with a lowercase letter is private to the package. Name is visible outside. name would be hidden.

Structs are values. They don't have a hidden this pointer. Methods take the receiver explicitly. If you want to mutate the struct, use a pointer receiver.

Pointers are explicit

Java passes references to objects. You rarely think about memory layout. Go passes values by default. If you want to mutate a value or avoid copying a large struct, you pass a pointer.

package main

import "fmt"

// Counter tracks an integer value.
type Counter struct {
    Count int
}

// Increment adds one to the count.
// A pointer receiver allows mutation of the original struct.
func (c *Counter) Increment() {
    c.Count++
}

// main demonstrates pointer usage.
func main() {
    c := Counter{Count: 0}
    c.Increment()
    fmt.Println(c.Count) // Prints 1
}

The method signature func (c *Counter) Increment() means the receiver is a pointer. The call c.Increment() works even if c is a value. Go automatically takes the address. This is a convenience that hides the pointer syntax at the call site.

Don't overuse pointers. Small structs are cheap to copy. Pass by value unless you need mutation or the struct is large. Strings are already cheap to pass by value. Never pass a *string.

Trust gofmt. Argue logic, not formatting. The community uses gofmt to format code. Most editors run it on save. Don't waste time debating indentation.

Interfaces happen by accident

Java requires explicit implements clauses. Go uses structural typing. If a type has the methods an interface requires, it satisfies the interface. No registration. No boilerplate.

package main

import "fmt"

// Speaker defines a type that can speak.
type Speaker interface {
    Speak() string
}

// Robot implements Speaker implicitly.
type Robot struct {
    Model string
}

// Speak returns the robot's greeting.
func (r Robot) Speak() string {
    return fmt.Sprintf("Beep boop, I am %s", r.Model)
}

// Introduce prints a message using any Speaker.
func Introduce(s Speaker) {
    fmt.Println(s.Speak())
}

// main demonstrates implicit interface satisfaction.
func main() {
    r := Robot{Model: "R2D2"}
    Introduce(r)
}

Robot doesn't mention Speaker. It just has a Speak() method. The compiler checks the match. This keeps interfaces small and decoupled. Interfaces are for the caller, not the implementer. Define the interface where you use it, not where you implement it.

Accept interfaces, return structs. Functions should take interfaces as parameters and return concrete structs. This gives flexibility at the boundary while keeping implementation details internal.

Errors are values

Java uses exceptions to handle errors. Go returns errors as values. There's no try-catch. You check the error immediately.

package main

import (
    "fmt"
    "os"
)

// ReadConfig loads configuration from a file path.
// It returns the content and an error if the file cannot be read.
func ReadConfig(path string) (string, error) {
    data, err := os.ReadFile(path)
    // Check error immediately. Go makes the unhappy path visible.
    if err != nil {
        return "", fmt.Errorf("failed to read config: %w", err)
    }
    return string(data), nil
}

// main demonstrates error handling flow.
func main() {
    content, err := ReadConfig("config.txt")
    if err != nil {
        // Handle error explicitly. No try-catch blocks.
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Config:", content)
}

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it forces you to handle errors. You can't accidentally ignore a failure. Error wrapping with %w preserves the error chain for debugging.

Functions that take a context.Context should respect cancellation and deadlines. Context always goes as the first parameter, conventionally named ctx. This is plumbing. Run it through every long-lived call site.

Errors are values. Handle them or return them.

Concurrency is built in

Java uses OS threads. Threads are heavy. Creating thousands of threads exhausts memory. Go uses goroutines. Goroutines are lightweight, managed by the Go runtime. You can spawn millions.

package main

import (
    "fmt"
    "sync"
)

// FetchData simulates a concurrent task.
// It signals completion via the WaitGroup.
func FetchData(id int, wg *sync.WaitGroup) {
    // Decrement the WaitGroup counter when the function returns.
    defer wg.Done()
    fmt.Printf("Fetching data %d\n", id)
}

// main runs multiple goroutines and waits for them to finish.
func main() {
    var wg sync.WaitGroup
    // Add the number of goroutines to the WaitGroup.
    wg.Add(3)

    // Launch three goroutines.
    go FetchData(1, &wg)
    go FetchData(2, &wg)
    go FetchData(3, &wg)

    // Block until all goroutines call Done.
    wg.Wait()
    fmt.Println("All done")
}

The go keyword launches a goroutine. The sync.WaitGroup coordinates completion. Channels are another primitive for communication. Use channels when you need to pass data between goroutines. Use WaitGroup when you just need to wait.

Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always have a cancellation path. The worst goroutine bug is the one that never logs.

Goroutines are cheap. Channels are not magic.

Pitfalls to watch

Loop variable capture is a classic gotcha. In a loop, the variable is reused. If you launch a goroutine inside the loop, all goroutines might see the final value.

package main

import (
    "fmt"
    "time"
)

// main demonstrates loop variable capture.
func main() {
    items := []string{"a", "b", "c"}
    for _, item := range items {
        // Create a local copy to capture the current value.
        item := item
        go func() {
            fmt.Println(item)
        }()
    }
    // Wait for goroutines to finish.
    time.Sleep(100 * time.Millisecond)
}

The line item := item creates a new variable scoped to the loop iteration. This captures the correct value. In Go 1.22+, the compiler changed loop semantics to fix this automatically, but older code still uses the copy pattern. If you forget to capture the loop variable in older versions, the compiler rejects the program with loop variable i captured by func literal.

Dereferencing a nil pointer causes a panic. Java throws a NullPointerException. Go crashes the program. Check pointers before use.

The compiler complains with cannot use x (type T) as type U in argument if types don't match. Go has no implicit conversions. You must cast explicitly.

Decision: Go vs Java

Use Go when you need a simple, compiled language with fast build times and a single binary deployment. Use Java when you rely on a massive ecosystem of libraries, complex inheritance hierarchies, or enterprise frameworks that require heavy configuration. Use Go when you want explicit error handling that forces callers to acknowledge failure paths. Use Java when you prefer exceptions to propagate errors automatically through the call stack. Use Go when you need lightweight concurrency with thousands of concurrent tasks on a single machine. Use Java when you need thread-per-client models with fine-grained control over OS thread scheduling. Use Go when you value composition over inheritance and want to build behavior by combining small structs. Use Java when you need deep inheritance trees to share code across many related classes.

Go is simple on purpose. Complexity is the enemy.

Where to go next