DEV Community

Cover image for 10 unusual moments in Go for Java developer
Rostislav Dugin
Rostislav Dugin

Posted on

10 unusual moments in Go for Java developer

A few years ago, I started adding Go to my arsenal of languages (being a Java developer at the time). It was very unfamiliar to me. Moreover, I did not accept the language at the first attempt. And I had to adopt it more because of circumstances than by choice.

But as time passed, Go became my main language and, I dare to say, my favorite. In the article below I will tell you why the language seemed unfamiliar to me, what paradigms I had to change in my head, and why in many ways it turned out to be more effective.

Let me make it clear: the article is aimed more at those who are planning to switch to Go than for experienced developers.

Go meme


I was first introduced to Go in 2022. I had heard that Go was a simple and concise language that produced cleaner, more maintainable and faster code. Some imaginary image of performance, stability, and simplicity in one formed in my head. Looking ahead: in the end, it turned out to be true.

I decided to explore it - and... after a few approaches, I gave up. Unfamiliar syntax, slightly perverted model of open and closed by capitalization, no inheritance... Creepy!

After a couple more months, Go turned out to be the most suitable tool for the project (related to cryptocurrency) and the team already had expertise in Go. The language was taken for the project. Consequently, Go had to be learned regardless of preferences. After all, taste is taste, and work is work.

I studied the documentation, went through the official tutorial, got acquainted with a couple of libraries, wrote a few CRUD's and got used to Go. I couldn't love the language yet, but I accepted it and started writing in it regularly.

After a couple tens of thousands of lines of code, I noticed that I had reorganized myself. I wrote 70% of my code in Go, it became habitual for me. All third-party utilities, pet-projects and small proofs of concept became easier for me to write in Go (though I would have taken Java or Python earlier). And now, a few years later, the share of Go in my work has smoothly grown from ~10% to almost 100%.

In this article, I decided to share the moments that I personally found unusual when changing languages. Among other things, I talked to some developers I know who have also changed their stack from Java \ C# to Go in recent years and synchronized our feelings.

Table of content

  1. The main priority of a language is visibility (but almost always at the expense of verbosity)
  2. Implicit implementation of interfaces and point-of-use declaration
  3. During structure definition, errors are not highlighted if some fields are missing
  4. You can call methods on nil structures
  5. Tags instead of annotations
  6. Working with strings: string, rune and []byte
  7. There is a common way of formatting, but not really
  8. Control field visibility by capitalizing the first letter in the field name
  9. No cyclic dependencies between modules (packages)
  10. Short variable names…. too short

Before you read: I open sourced my project for PostgreSQL backups. I would be grateful for star on GitHub :)

1) The main priority of a language is visibility (but almost always at the expense of verbosity)

In Go, everything is done in favor of explicitness and readability. From error handling to data validation in API routes to explicit transactions. DI is not common in the Go community, especially in medium-small projects and microservices (although there is Uber FX). No annotations, minimal syntactic sugar.

In Java it is customary to think about abstractions first (proper interfaces, decompose into classes, think about factories). In Go, it is customary not to think about abstractions beforehand, but to make a minimally adequate structure and just write code. And introduce abstractions when needed. By the way, there is no inheritance either, only composition and interfaces.

Regarding errors: this is something that immediately catches your eye in Go and that brings a significant share of “explicitness”. Go has no “thrown” exceptions (and no try block in general). All errors are returned from functions as one of the parameters. However, there is panic that still can be thrown to stop your app (for business applications it is considered an antipattern in all style guides).

I will show some examples of how the amount of code increases, but the explicitness of error handling increases.

Example 1: config read

Java code with single try \ catch block:

try {
    String raw = Files.readString(Path.of("cfg.json"));

    Config cfg = mapper.readValue(raw, Config.class);
    cache.put("cfg", cfg);

    return cfg;
} catch (IOException | JsonProcessingException ex) {
    throw new IllegalStateException("cannot init config", ex);
}
Enter fullscreen mode Exit fullscreen mode

The same code in Go:

raw, err := os.ReadFile("cfg.json")
if err != nil {
    return nil, fmt.Errorf("read cfg: %w", err)
}

var cfg Config
if err := json.Unmarshal(raw, &cfg); err != nil {
    return nil, fmt.Errorf("parse cfg: %w", err)
}

if err := cache.Set("cfg", cfg); err != nil {
    return nil, fmt.Errorf("cache cfg: %w", err)
}

return &cfg, nil
Enter fullscreen mode Exit fullscreen mode

On the one hand, Java code is more compact. On the other hand, in case of an error in Java code, we will have to look at the stack trace and try to figure out which line generated the error.

In Go, an error will immediately show where exactly and what broke (and we don't have the option not to handle the error). Another subjective plus: it is easier for a reader unfamiliar with the code to understand what and where exactly went wrong.

Example 2: transactions

Java code:

@Transactional
public void processPayment(Payment p) {
    try {
        accountService.debit(p);
        journalRepository.save(p);
        mq.send(new PaymentMessage(p));
    } catch (Exception e) {
        throw new PaymentFailed("processing error", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

Go code:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return fmt.Errorf("begin tx: %w", err)
}

defer tx.Rollback()

if err := debit(tx, p); err != nil {
    return fmt.Errorf("debit: %w", err)
}

if err := journalSave(tx, p); err != nil {
    return fmt.Errorf("journal: %w", err)
}

if err := mq.Send(ctx, newPaymentMsg(p)); err != nil {
    return fmt.Errorf("mq send: %w", err)
}

return tx.Commit()
Enter fullscreen mode Exit fullscreen mode

Again, Java code is shorter and more concise. Go code is longer and very explicit. No annotations. Transactions are called manually. Errors from each method are handled manually.

By the way, the absence of try reduces the code nesting, it becomes a kind of “one-level” code.

2) Implicit implementation of interfaces and point-of-use declaration

Go has interfaces similar to Java. For example:

type PaymentGateway interface {
    Charge(ctx context.Context, req ChargeRequest) (ChargeResponse, error)
}
Enter fullscreen mode Exit fullscreen mode

To implement it, we need to create a structure with methods with the same signature:

type StripeGateway struct {}

func (s *StripeGateway) Charge(ctx context.Context, req ChargeRequest,) (ChargeResponse, error) {
  // implementation
}
Enter fullscreen mode Exit fullscreen mode

As you can see, there are no implements words or anything that indicates that the structure implements the interface. This was very strange to me at first, because there is no explicit connection between interface and implementation. You can't press shift and go to an interface from a structure (at that time I was using VS Code, in Goland from JetBrains you can).

I was also confused by the situation when you change the name of a method or parameters, but the error is thrown somewhere at the other end of the project. Where someone is trying to use the interface. I was expecting an error like “wrong interface implementation, method arguments mismatch” in the place where the structure is defined, but the error was highlighted in the place where the interface is used.

It turned out to be an important idea of the language itself, which is related to the fact that interfaces are usually declared at the point of use, not implementations.

In Java, we stack interfaces (like PaymentGateway) and their implementations (like StripePaymentGateway or PayPalPaymentGateway) side by side in the same package.

In Go, it's common to put the interface in the module (similar to Java's package) that uses it. For example, in the billing or checkout package, where we will call the methods of the interface.

In other words, the interface lies in one part of the project. The implementations are in another. And there is no obvious connection between them, unless you look at the DI graph. It's not obvious. But it gives a bonus: reduced coupling. Easier to put mocks, easier to test, less dependency of modules on each other. It also allows you to avoid cyclic dependencies between modules (about it below is written).

However, it is important not to forget that even though there are fewer links in the code, the logical connectivity does not go anywhere.

3) During structure definition, errors are not highlighted if some fields are missing

Suppose we have the following structure:

type BillingService struct {
    stripeService *stripeService
    receiptService *receiptService
}
Enter fullscreen mode Exit fullscreen mode

If you omit one of the values during the declaration - there will be no error during compilation. For example:

var billingService := BillingService{
   stripeService: getStripeService(),
   // there is no field, but it compiles fine
}
Enter fullscreen mode Exit fullscreen mode

At first, I considered the structure declaration above as an analog of a constructor in Java. You should always pass all the parameters to it, otherwise the code will not compile. But no. In Go, the declaration without passing all the parameters will be valid.

In this case, with high probability, when using receiptService, there will be a panic because of referring to a nil value (similar to NullPointerException). By the way, it may not even occur - that's what the next paragraph is about.

There is a way to check the passing of all parameters at the compilation stage. To do this, you need to pass values not by field name, but by comma:

var billingService := BillingService{
   getStripeService(), // first param
   // error: there should be second param
}
Enter fullscreen mode Exit fullscreen mode

In this case, the compiler will tell us that one more parameter is missing. This is the definition that is considered correct to use when building a DI graph. In order not to forget about new dependencies as they are added.

4) You can call methods on nil structures

Suppose we have a structure with a method (which accesses a pointer to the structure).

type Person struct {}

func (p *Person) Speak() { // attention: there is a pointer *, not value
    fmt.Println("Hey!")
}
Enter fullscreen mode Exit fullscreen mode

We define a nil variable with the type of this structure and call a method on this variable. In Java, such code would cause NullPointerException. And in Go... it depends. If the structure uses a pointer to itself in the * method, there will be no problem:

package main

import "fmt"


func main() {
    var person *Person = nil
    person.Speak()
}
Enter fullscreen mode Exit fullscreen mode

The code above will work and output Hey, even though the person variable is nil. Now let's remove the * from the Speak method:

type Person struct {}

func (p Person) Speak() { // use Person instead of *Person
    fmt.Println("Hey!")
}
Enter fullscreen mode Exit fullscreen mode

And execute the same code:

package main

import "fmt"


func main() {
    var person *Person = nil
    person.Speak()
}
Enter fullscreen mode Exit fullscreen mode

We're getting an error:

panic: runtime error: invalid memory address or nil pointer dereference

Why this is so - I realized after a very long time. After I read the book “Learning Go” and learned about pointer dereferencing. The book is tedious, practically a reference book, but I recommend reading it at least once.

The point is that when calling a method with a value, Go first dereferences the pointer to copy the structure and pass it to the method. But when calling a method with a reference, it just passes the pointer itself (even if it is nil) without trying to dereference it.

5) Tags instead of annotations

Java has annotations that can be attached to fields, methods and classes. They reduce code size a lot and hide a lot of logic underneath. I can't imagine developing in Spring and Hibernate without annotations anymore.

Annotations make magic. But... at some point I started to have a periodic feeling that there is too much magic. It's kind of hard to figure out where which proxy is calling whom and where which reflection is being used. Long canvases of stacktraces are the same way.

There are no annotations in Go.

Instead of annotations, there are “tags” that contain no logic. You can take a structure, write tags for its fields. Then pass the structure to some function that will extract those tags and do something based on their contents.

For example, you could write a structure like this, which we are waiting for in an API method:

type User struct {
    ID    int64  `json:"id"`
    Name  string `json:"name"  binding:"required"`
    Email string `json:"email" binding:"required,email"`
}
Enter fullscreen mode Exit fullscreen mode

Again, the tags themselves don't do anything and nothing happens at compile time.

Then, we can pass this structure to a method that decodes JSON:

if err := c.ShouldBindJSON(&user); err != nil { // decode request body to struct
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
}
Enter fullscreen mode Exit fullscreen mode

ShouldBindJSON will extract the tags and validate each field based on what the tag requires. ORM and SQL constructors work similarly, which define column names and other parameters via tags.

But, since tags don't have their own logic, Go doesn't have annotations like @ToString, @Builder, @Transactional, @Service and many others. On the one hand, we do everything by hand and there is more code. On the other hand, more explicity and control.

Important: I'm not saying annotations are bad. It is very good. It's just that languages have different approaches for different tasks with their own pros, cons and compromises.

6) Working with strings: string, rune and []byte

In Java, a string is an immutable sequence of char. In Go, the string type is an immutable slice of bytes (UTF-8 by default). To represent an individual character, we use rune, a 32-bit integer containing the character in Unicode.

Hence, when working with strings, we need to use len and trim strings carefully. Especially when we use non-English language and non-standard characters (for example, smiley faces).

Let's look at an example. Let's take an English word:

s := "hello"                // 5 chars, 5 bytes
fmt.Println(len(s))         // 5 🔢 (bytes)
fmt.Println(len([]rune(s))) // 5 🔣 (chars)
fmt.Println([]rune(s))      // [104 101 108 108 111]
Enter fullscreen mode Exit fullscreen mode

That makes sense. Now we take a Russian word and a couple of smiley faces:

s := "Привет 🌍😊"         // 8 chars, 20 bytes
fmt.Println(len(s))         // 20 🔢 (bytes)
fmt.Println(len([]rune(s))) // 8 🔣 (chars)
fmt.Println([]rune(s))      // [1055 1088 1080 1074 1077 1090 127757 128522]
Enter fullscreen mode Exit fullscreen mode

The number of characters and bytes diverged. If we trimmed or changed the string without converting to rune, we would run into errors and incorrect data.

7) There is a common way of formatting, but not really

In Java, there are several common codestyles and an infinite number of customizations in the IDE (at least in IDEA). Typically, projects take a commonly accepted codestyle (from Oracle or Google) and add local formatting rules in the IDE itself. This config is then passed between team members.

One of the reasons I wanted to try Go is the claimed uniformity. There is a built-in go fmt utility that comes with the language. All code in Go is formatted by it. I was intrigued by this!

Turns out... It's not that simple. Here is the same code manually formatted differently:

func SomeMethod(value1 string, value2 string, value3 string) error {
    return nil
}
Enter fullscreen mode Exit fullscreen mode
func SomeMethod(
    value1 string,
    value2 string,
    value3 string,
) error {
    return nil
}
Enter fullscreen mode Exit fullscreen mode
func SomeMethod(value1 string,
    value2 string,
    value3 string,
) error {
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The standard go fmt considers all these options valid and does not change them.... Also go fmt does not know how to limit line length and hyphenation rules, there are no rules for import list and many other things.

That's why teams still have to agree on some things, for some things third-party formatters are used. The most popular tool is golangci-lint. It is able to accumulate other formatters (and linters) in itself.

8) Control field visibility by capitalizing the first letter in the field name

This is one of the points that repulsed me a lot in Go. My mindset had a hard time adjusting to this logic.

In Java, scope is defined by the modifiers public / private / protected. Common English words, everything is clear. This approach is used in a large number of popular languages.

In Go, the scope is defined by the first character of the name: an uppercase letter makes the field public, a lowercase letter makes it private inside the module.

Here is an example of a public (or exported in Go terms) structure with public and private methods:

type Person struct {}       // public struct

func (Person) Talk() {}     // public method

func (Person) speak() {}    // private method
Enter fullscreen mode Exit fullscreen mode

Here is a private structure with a private method (and you can also make structures with a small letter, not like classes in Java):

type person struct { /*...*/ }  // private struct

func (person) talk() {}         // private method
Enter fullscreen mode Exit fullscreen mode

Here are the public and private functions:

func SomeFunc() { /*...*/ }     // public func

func someFunc() { /*...*/ }     // private func
Enter fullscreen mode Exit fullscreen mode

Whether this approach is good or bad is a matter of habit. It took me a long time to get used to it, but it's a matter of taste. Over time, I got used to it.

9) No cyclic dependencies between modules (packages)

I like this part of Go a lot. It forces you to write cleaner code and think about the correct hierarchy of components and modules.

In Java, we can import objects from different packages to each other. For example:

// src/a/A.java
package a;

import b.B;

public class A {
    private B b;
}
Enter fullscreen mode Exit fullscreen mode
// src/b/B.java
package b;

import a.A;

public class B {
    private A a;
}
Enter fullscreen mode Exit fullscreen mode

In this example, both classes refer to each other via fields, and packages a and b import each other's classes. The Java compiler allows this (even though we need to remember about lazy initialization of values).

You can't do this in Go. For example:

// a.go
package a

import "project/b"  // a → b
Enter fullscreen mode Exit fullscreen mode
// b.go
package b

import "project/a"  // b → a ❌
Enter fullscreen mode Exit fullscreen mode

In this case, we will get an error:

import cycle not allowed

10) Short variable names.... too short

This is one of those practices that I haven't fully come to terms with.

In Go, it's common to use concise or single letter identifiers (ctx, db, err, s, r). Especially in small functions. For example, here's an example of idiomatic code:

func GetUser(c *gin.Context) {
    id := c.Param("id")

    u, err := userRepo.FindByID(id)
    if err != nil {
        c.JSON(404, gin.H{"error": err.Error()})
        return
    }

    c.JSON(200, u)
}
Enter fullscreen mode Exit fullscreen mode

context is shortened to c. user to u. Similarly, a lot of common conventions are ResponseWriter -> w, request -> r, payment -> p, etc. In “infrastructure” or “template” code, this is normal. But in business logic it is not so good, because code readability decreases.

I try to use more clear names. Especially in business logic. For example, the same concepts in gin and quite obvious things can be shortened to one letter (although the same req and res are clearer as for me).

But when it comes to business logic, I still advise you not to write like this:

func (s *BillingService) Pay(payments *[]Payment, b *Bank) (*Receipt, error) {
    r := &Receipt{ID: uuid.New(), Amounts: []int{}}

    for _, p := range *payments {
        r.Amounts = append(r.Amounts, p.Amount)
    }

    if err := b.Debit(p.Amount); err != nil {
        return nil, err
    }

    return r, nil
}
Enter fullscreen mode Exit fullscreen mode

Better give clearer and longer names:

func (s *BillingService) Pay(payments *[]Payment, bank *Bank) (*Receipt, error) {
    receipt := &Receipt{ID: uuid.New(), Amounts: []int{}}

    for _, payment := range *payments {
        receipt.Amounts = append(receipt.Amounts, payment.Amount)
    }

    if err := bank.Debit(p.Amount); err != nil {
        return nil, err
    }

    return receipt, nil
}
Enter fullscreen mode Exit fullscreen mode

This provides the following advantages:

  • reduces the cognitive load of the reader (no need to run upward with your eyes to figure out what that letter means);

  • less chance of mistaking the name or confusing something;

  • if the method is long - allows to understand in any part of the method what a variable is responsible for.

Of course, such conventions depend on the team and the practices adopted. If the team has adopted the standard approach with short names, there is no need to do otherwise (at least not without agreeing on it in advance).

Conclusion

Actually, above I tried to describe the main points that were unfamiliar to me (and not only to me). The languages are quite different. I would say that Go for me is something between the stability of Java and the flexibility of Python.

Time has shown that for me learning Go has been worth it. After all, a language is first and foremost a tool. Go has become an effective tool for me. I like it, it gives me fast and stable results. I really like the simplicity of the language and its clarity, despite the shortage of syntactic sugar and at the expense of a little more code. Also, AI (especially Cursor IDE) is good friends with it.

I hope my article will encourage at least a few developers to learn Go and make some things easier to understand.


If you like the article, you can loook at my project for PostgreSQL backups in open source. I would be very grateful for the star on GitHub. ❤️

Top comments (0)