Exploring Go: A Guide for Java Developers

Background
I’ve been a software engineer for over 10 years now, with my go-to programming language of choice having always been Java. I started programming back in the days of Java 6 and I’ve grown alongside it, all the way up to the new six month release cadence and Java 24.
I love coding in Java and am glad to see all of the new additions that are added with each new release.
However, in the spirit of “when all you have is a hammer, everything looks like a nail”, I’ve tried my best to keep abreast of other programming languages across the years. I’ve dabbled in C++, Python, C#, Rust and more recently Go. I decided to learn Go because of its use within the Platform teams close to my own projects at work, where it’s widely used to develop Kubernetes operators.
These are my notes after having learned the basics of Go.
Overview
Notes based on this Udemy course.
The notes do not go in depth to cover each individual section of the course, but rather focus on the key points and differences that are relevant to a Java developer.
TLDR
Simple Syntax: Go emphasizes simplicity and readability
Concurrency Model: Goroutines and channels provide an easy-to-manage concurrency model
Error Handling: The lack of exceptions in Go forces explicit error handling
No Object-Oriented Paradigm: Go’s composition-over-inheritance model requires adapting away from traditional OO designs
Tooling: Go has a self-contained ecosystem, while Java relies on external tools like Maven and Gradle
Feature | Java | Go |
Compilation | Compiled to bytecode, runs on JVM. | Compiled to native machine code; no virtual machine. |
Concurrency Model | Thread-based concurrency with synchronized blocks. | Goroutines and channels with lightweight concurrency. |
Typing | Statically typed with generics (from Java 5 onward). | Statically typed with limited generics (added in Go 1.18). |
Garbage Collection | Automatic garbage collection. | Automatic garbage collection with lower latency optimizations. |
Error Handling | Exception-based (try-catch-finally ). | Explicit error handling using return values (if err != nil ). |
Standard Library | Rich and extensive standard library. | Minimalistic standard library, emphasizing simplicity. |
Dependency Management | Maven/Gradle for build and dependency management. | go mod for module management and dependencies. |
Compilation Speed | Relatively slower due to bytecode and JIT compilation. | Extremely fast due to direct compilation to machine code. |
Object-Oriented Support | Fully object-oriented with inheritance, polymorphism. | Not fully object-oriented; no inheritance, uses composition. |
Memory Safety | NullPointerException is common; no strict guarantees. | No null pointers; nil is safer with compile-time checks. |
Build Tools | Integrated with external tools like Maven/Gradle. | Built-in tools like go build , go fmt , go test . |
Annotations | Heavy use of annotations for metadata and behavior. | No annotations; configuration is explicit. |
Reflection | Supports advanced reflection with java.lang.reflect . | Limited reflection capabilities (reflect package). |
Error Propagation | Checked and unchecked exceptions. | Explicit error returns require manual handling at each level. |
Runtime Environment | Requires JVM to run applications. | Produces standalone executables; no runtime dependencies. |
Parallelism | Uses threads and thread pools. | Built-in concurrency with goroutines and channels. |
Package Management | Organized into packages; flexible but complex. | Enforced hierarchical package structure for simplicity. |
Language Features | Rich features like Lambdas, Streams, Annotations. | Simple language with minimal features for readability. |
Generics | Powerful and flexible with bounded types. | Limited support for generics, focusing on simplicity. |
IDE Support | Mature IDEs like IntelliJ IDEA, Eclipse. | Growing IDE support (e.g., Visual Studio Code, GoLand). |
Community | Established community with a vast ecosystem. | Smaller but growing community with focused tools. |
Cross-Platform Support | JVM provides cross-platform compatibility. | Cross-compiles to multiple OS/architecture binaries natively. |
Performance | High with JIT optimization; can vary due to JVM overhead. | Near-native performance due to direct machine code execution. |
Go Essentials
Go is statically typed
Go is pass-by-value, but you can use pointers to pass references to the actual objects, if you want to modify their content
The
main
package has a significance: it’s meant to contain the entrypoint of the applicationThe
go build
command generates an executable, which can be executed even without having Go installed; it only works if there is amain
package with amain
function inside itOnly one
main
function is allowed in themain
packageOne
module
consists of multiplepackages
; you can think of it as a projectVariables are declared with the
var
keyword and if no other quantifier is used, the type is inferredTypes can be explicitly specified as well, such as
var investmentAmount float64 = 10000
Instead of
var x = 100
you can write the shorthandx := 100
, but you can’t writevar x := 100
; you also can’t use:=
when you are re-assigning; it can only be used for declaration + initializationMultiple variables can be declared and initialized in a single line:
investmentAmount, years := 10000, 10
Unused variables are an error
You are not allowed to mix and match types (Go doesn’t support widening); you need to convert them, like
float64(someIntValue)
; for example,someFloatValue + someIntValue
will not compileGo basic types:
int
,float64
,string
,bool
,uint
,int32
,rune
,uint32
,int64
All basic types have a
null
value, which is a form of0
orfalse
Constants are declared using the
const
keywordConstants and variables can be declared outside any struct/function and they become scoped to the file where they are declared
Go uses pointers, but doesn’t allow pointer arithmetic. The classic
*
and&
characters are used to denote pointers and the values pointed at themfmt.Scan
can be used to fetch input from the user:fmt.Scan(&variableName)
; note that we need to use a pointer to the variable, instead of the variable reference (& is a pointer)You can declare a value, without initializing it, with the following syntax:
var variable float64
(note that the type is mandatory in this case)fmt.Sprint
can be used to store formatted strings into variablesBackticks (`) are used for multi-line strings
Function names, types and return values are declared in reverse order compared to java and :
func myFunc(x,y int, s string) int
is a method that takes two ints and a string and returns an intMultiple return values are allowed:
func myFun() (int, int, int) {return 1, 2, 3}
You can declare the return values in the function signature and then you don’t need to redeclare them or mention them in the return statement
func multiReturn() (x int, y int) {
x = 5
y = 6
return
}
for
is the only kind of loop that Go supports; for example,for choice != 4
works as a while loop,for {...}
acts as an infinite loopswitch
does not fall through, you don’t need to usebreak
break
can only be used inside afor
,select
orswitch
continue
can only be used inside afor
loopThe
os
package offers support for writing and reading to/from files
func writeBalanceToFile(balance float64) {
err := os.WriteFile("balance.txt", []byte(fmt.Sprintf("%.2f", balance)), 0644)
if err != nil {
return
}
}
func getBalanceFromFile() {
data, err := os.ReadFile("balance.txt")
if err != nil {
fmt.Println("Error reading file")
return
}
fmt.Println(string(data))
}
_
can be used as an unnamed variablestrconv
package offers utility functions that can parse values into strings, for example,strconv.ParseFloat
Go doesn’t use exceptions, it returns an
error
object along with the main return type (if any); you then typically add a check forif err != nil
to handle the issueYou can create errors, using the errors package:
errors.New
("Some error happened")
If you can’t continue the application when a certain issue occurs, use
panic()
Working with Packages
Any Go file must belong to a package
Functions are accessible across files that belong to the same package
Every package must be placed in a separate folder, with the same name as the package
When importing user-defined packages, you have to include the full path, including the module name
Only objects that start with capital letters are available outside of their package (variables, functions, constants, etc.) - capital case objects are said to be
exported
Use
go get
to download third party libraries and packages:go get
github.com/Pallinder/go-randomdata
which will add arequire
github.com/Pallinder/go-randomdata
v1.2.0
statement to your module file
Understanding Pointers
Pointers are variables that store addresses instead of values
They avoid unnecessary value copies (because Go is pass-by-value) and allow you to directly mutate values
&a
refers to the memory address of the variablea
, whereas*a
called on a variable of type pointer dereferences the pointer and extracts the valueThe
null
value of pointers isnil
You can’t use pointer arithmetic
Structs and Custom Types
Go does not have classes
Structs are used to group values together
Types are declared with the
type
keyword; they can appear outside functions, or inside functionsStructs are declared with
type StructName struct {}
type Student struct {
Name string
Age int
Address string
createdAt time.Time
}
student := structs.Student{
Name: "Radu", // field name can be omitted if order is the same as declaration
Age: 35,
CreatedAt: time.Now(), // trailing comma is required
}
None of the fields are required, when instantiating a
struct
. Unset fields get thenull
value default.Casing also matters for fields inside structs; only capitalized fields are exported and accessible outside the package where the struct is defined
When using pointers to structs, Go offers syntactic sugar that allows you to refer to struct fields without dereferencing; for example, instead of needing to do
(*s).Name = "Gigi"
you can simply uses.Name
= "Gigi"
Functions attached to structs are called
methods
You create methods by adding a
receiver
between thefunc
keyword and the method name
func (student Student) ToString() string {
return fmt.Sprintf("Name: %s, Age: %d, Address: %s, CreatedAt: %s", student.Name, student.Age, student.Address, student.CreatedAt)
}
- Methods can have either pointer or value receivers, Go recommends not mixing these for one type
func (student *Student) ClearName() {
student.Name = ""
}
- The idiomatic way of constructing
structs
in go is to use functions that begin with the wordnew
, where you can also do validations
func NewStudent(name string, age int, address string) (Student, error) {
if age < 0 {
return Student{}, errors.New("Age cannot be negative")
}
return Student{
Name: name,
Age: age,
Address: address,
CreatedAt: time.Now(),
}, nil
}
You can use the
new
methods for some form of encapsulation, where you make the fields of a struct non-exported, but you export theNew
method, which lives in the same package as thestruct
, and so is allowed to access non-exported fieldsIf the
New
function is used from a different package, the function name can simply beNew
, because you have to specify the package anyway; for exampleuser.New
()
which creates a newUser
struct, from theuser
packageStruct embedding is a hybrid of inheritance and composition in Java
type User struct {
firstName string
lastName string
birthDate time.Time
}
type Admin struct {
User User
role string
email string
}
func NewAdmin(firstName, lastName, role, email string, birthDate time.Time) *Admin {
return &Admin{
User: User{
firstName: firstName,
lastName: lastName,
birthDate: birthDate,
},
role: role,
email: email,
}
}
- Anonymous embedding allows you to call methods on the outer struct, without needing to use the embedded struct
type Admin struct {
User // note that there is no field name now
role string
email string
}
admin := ...
admin.someMethodAvailableOnUser()
Custom types can be used to assign aliases to existing types:
type str string
; you can then use the custom type in code:var myStr str
This allows you to add methods to built-in types, via the proxy alias:
func (s str) Log { fmt.Println(s) }
(this is similar to Kotlin’s extension functions)
func main() {
var s str = "Hello"
s.Log()
}
- For reading more complex text from the console, use the
bufio
package
value, _ := bufio.NewReader(bufio.NewReader(os.Stdin)).ReadString('\n')
- Struct tags are metadata that can be attached to struct fields, by using backticks (`); some libraries know how to handle it, for example marshaling to JSON
type Note struct {
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"createdAt"`
}
Interfaces and Generic Code
Interfaces are contracts
They are defined like this
type Saver interface { // Go conventions stipulate that if an interface has a single method, the interface name must be the method name + er
Save()
}
Structs don’t need to explicitly implement an interface. If they have methods that match the signature of the interface, they can automatically be used in place of the interface
Interfaces are types, even if they don’t have any state
Just like
structs
,interfaces
can be embedded
type outputable interface {
Saver // embed the Saver interface
Display() // add functionality to display
}
Any struct that wants to implement
outputable
would now need to have all the methods required bySaver
, as well as aDisplay
methodUsing the
interface{}
type means that any value can be passed inYou can switch on the type of the parameter passed in
func printSomething(value interface{}) {
switch value.(type) {
case string:
fmt.Println("String")
case int:
fmt.Println("Int")
default:
fmt.Println("Unknown")
}
}
value.(type)
only works inside of a switch statementAnother way to check for the type of a value is to use the
.
notation and try to effectively cast it to a specific type. The call returns two values: the typed value and a boolean flag that defines whether or not the cast succeeded:
func printSomething(value interface{}) {
typedVal, ok := value.(int)
if !ok {
fmt.Println("Value is not an integer")
return
}
fmt.Println(typedVal + 1) // typedVal is of type it, so we can use it as an int
}
- Go supports generics. Generic types are defined using the
[]
notation. You can explicitly define the types allowed, by separating them with|
func add[T int | float64 | string](a, b T) T {
return a + b
}
func useGenerics() {
var result int = add(1, 5)
fmt.Println(result)
}
Managing Related Data with Arrays, Slices and Maps
- Creating arrays
var productNames [4]string // declaration without initialization, initialized to all blanks (the zero value of strings)
prices := []float{1.99, 2.99, 3.99} // declaration and initialization
Indexing arrays:
prices[1] = 5.5
Slicing arrays
fmt.Println(prices[1:2])
fmt.Println(prices[2:])
fmt.Println(prices[:2])
Go does not support using negative indexes when slicing
Go supports slicing slices
Slices are views of the underlying array. Modifying slices modifies the array.
Slices are memory efficient, because arrays are not copied
Each slice has a length and capacity property. They can be accessed using the built-in
len
andcap
functionsLen = number of elements in the slice
Capacity = The available elements to the right, in the underlying array
Arrays have a static size. Slices can help to create dynamically sized arrays:
prices := []float64{}
creates an empty slice (you can also populate it) calledprices
backed by an array that Go automatically creates and re-creates, if the slice size grows beyond the array capacityYou can then append elements:
newSlice := append(prices, 5.99)
- note that it doesn’t modify the original slice; Go will create a brand new array, add the element and reflect the new array in thenewSlice
variableAppend can also be used to merge slices, by unpacking the values of the 2nd slice:
append(firstSlice, secondSlice...)
- the...
is called theellipsis
operatorMaps in Go:
myMap := map[string]string{}
myMap["name"] = "John"
fmt.Println(myMap["name"])
myMap["address"] = "Some Street"
delete(myMap, "name")
Maps vs Structs
In Maps, anything can be a key => more flexibility
The shape of a struct is set in stone, you can’t add/remove “keys”
If you know that you will be adding some elements to a slice, you can use the
make
function:userNames := make([]string, 2)
would create a slice of initial length 2 (creating a backing array of length 2, withnil
values). You should be usinguserNames[0]
anduserNames[1]
to assign values to existing slots, instead of directly usingappend
;make
also supports a third integer, for the capacity:make([]string, 2,5)
allocates memory for a string of 5 items, and creates an array with 2 slotsmake
can also be used for maps:myMap := make(map[string]float64, 3)
- pre-allocates memory for 3 entries; when adding the 4th element, Go will re-allocate memoryType aliases can be used as syntactic sugar to save some keystrokes:
type floatMap map[string]float64
, and then you can use it in code:, such as for a receiver functionfunc (m floatMap) {...}
Looping over collections
for idx, value := range mySlice {
...
}
for key, value := range myMap {
...
}
Functions Deep Dive
- Functions are first class objects in Go and they can be used as parameters for other functions and can also be assigned to variables:
func main() {
nums := []int{1, 2, 3, 4, 5}
result := Play(&nums, Double)
fmt.Println(*result)
}
func Play(nums *[]int, op func(int) int) *[]int { // takes a function as an argument
var result []int // an empty slice
for _, value := range *nums {
result = append(result, op(value)) // invokes the function
}
return &result
}
func Double(n int) int {
return n * 2
}
func Triple(n int) int {
return n * 3
}
- Functions can also be returned from other functions; anonymous functions are useful here
func GetTransform() func(int) int {
return func(n int) int { // this is an anonymous function
return n * 4
}
}
- Every anonymous function is a closure., i.e. it can use variables from the outer scope; the value is locked into the anonymous function, at the point of creation
func CreateTransformer(factor int) func(int) int { // returns a function that takes an int and returns an int
return func(n int) int {
return n * factor // factor is not defined in the anonymous function signature, but is taken from the surrounding scope (there is a closure around factor)
}
}
- Variadic functions allow you to pass a list of elements, instead of a slice/array
func sumUp(numbers ...int) int {
sum := 0
for _, num := range numbers {
sum += num
}
return sum
}
// the above can be called with sumUp(1,2,3,4,5), or sumUp(mySlice...)
Concurrency - Running Tasks in Parallel
- Goroutines allow the parallel execution of functions
func main() {
go concurrency.SlowFunction()
go concurrency.SlowFunction()
// main will end without waiting for the above goroutines to finish; it just dispatched the goroutines
}
- To prevent the program ending before the coroutines have a chance to execute, you can use
channels
func main() {
done := make(chan bool)
concurrency.SlowFunction(done)
<-done // make sure that you read from the channel the same amount of times that you write to it
}
func SlowFunction(ch chan bool) {
time.Sleep(3 * time.Second)
fmt.Println("Hello after sleeping")
ch <- true
}
- Channels support the
range
operator, to extract values from them; the below however causes the following error:fatal error: all goroutines are asleep - deadlock!
, because doesn’t know when there are no more values left ; you can useclose(done)
in theSlowFunction
method
func main() {
done := make(chan bool)
concurrency.SlowFunction(done)
concurrency.SlowFunction(done)
concurrency.SlowFunction(done)
for result := range done {
fmt.Println(result)
}
}
Goroutines don’t support return values, so you can’t return errors; but you can use an error
channel
for this purposeWhen using multiple channels, such as one for a successful result and one for an error, where only one is expected to receive values, you can make use of the
select
statement (similar to switch)
for index, _ := range taxRates {
select {
case err := <-errorChans[index]:
if err != nil {
fmt.Println(err)
}
case <-doneChans[index]: // we don't need to extract the value to a variable
fmt.Println("Done")
}
}
- Code execution can be deferred using the
defer
keyword; similar tofinally
in Java
defer file.Close() // code will be executed once the surrounding method/function is finished
Subscribe to my newsletter
Read articles from Radu Pana directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
