Back to all articles
ARTICLE

Go for Java Developers

A practical Go guide for Java developers: side-by-side code comparisons, pointers and interfaces, error handling, and real backend examples.

35 min read
-
Java and Go code side by side, with the Java logo and Go mascot.

How to start learning Go

If you have written services in Java, learning Go does not mean learning business rules, HTTP, or databases all over again. The main task is to understand how familiar problems are expressed in another language: where data lives, who can change it, how errors propagate, and how concurrent operations are managed.

This guide follows those questions. Read the Java example, compare it with its Go counterpart, and then examine the key difference in behavior. The goal is not to translate Java code line by line. It is to write readable, reliable backend code in Go.

Reading map: start with syntax and collections, then value and pointer semantics, followed by interfaces and error handling, and finish with a practical service and concurrent requests.

Most examples are snippets that demonstrate one concept. Required imports and helper types are not repeated in every snippet. Near the end, you will find a complete program you can run on its own. The Java examples use syntax compatible with Java 21+, and the Go examples with Go 1.22+.

From classes to types and behavior

In Java, you often combine data and methods in a class. In Go, a struct defines fields, while methods are declared separately and attached to a type. Go has no class inheritance or abstract classes. Composition and small interfaces provide ways to reuse behavior.

This does not mean Java is necessarily complex or Go is simple in every situation. Clear design is possible in both languages. Go offers a different set of language features and idiomatic choices.

The same model, organized differently
Java
final class User {
    private final String name;

    User(String name) {
        this.name = name;
    }

    String greeting() {
        return "Hello " + name;
    }
}
Go
type User struct {
    Name string
}

func (u User) Greeting() string {
    return "Hello " + u.Name
}

// Funksiya daxilində:
user := User{Name: "Aysel"}
message := user.Greeting()
Behavior difference

In Go, (u User) is the method receiver: it identifies the type the method belongs to. The name u works like an ordinary parameter name; it is not a special this keyword. Methods are not limited to structs. Under certain conditions, they can also be attached to other named types declared in the package.

Your first program, variables, and visibility

An executable Go program starts at the func main() function in package main. The func keyword declares a function, and its result type follows the parameter list. Semicolons are usually inserted automatically at line endings; in a three-part for header, you write the separators explicitly.

:= is a short variable declaration and works only inside functions. It requires at least one new variable other than _ in the same scope. Use = to update an existing variable.

An executable entry point
Java
public class Main {
    public static void main(String[] args) {
        var name = "Aysel";
        System.out.println("Hello " + name);
    }
}
Go
package main

import "fmt"

func main() {
    name := "Aysel"
    fmt.Println("Hello " + name)
}
Behavior difference

Unused local variables and imports are compile errors in Go. gofmt automatically handles indentation and standard formatting.

Visibility follows package boundaries, not class boundaries
Java
public class Account {
    public long id;
    private String secret;
}
Go
package account

type Account struct {
    ID     int64
    secret string
}

var applicationName = "Proxy Academy"
const MaxRetry = 5
Behavior difference

The names Account, ID, and MaxRetry are exported because they start with uppercase letters. Other files in the same package can also access secret, so it is not exactly equivalent to Java's class-level private. A const is for a compile-time constant value; it is not a counterpart for every Java final field.

Numeric types, strings, and zero values

Similar names do not guarantee identical sizes or behavior. Pay particular attention to these differences when working with byte, int, and text.

JavaClosest Go choiceImportant difference
byteint8Java byte is signed. Go byte is an alias for uint8, with a range of 0-255.
shortint16Both are signed 16-bit integers.
intint32Java int is always 32 bits. Go int is 32 or 64 bits, depending on the platform.
longint64Suitable for fields with a fixed size, such as IDs.
float, doublefloat32, float64These are binary floating-point numbers. Money requires a separate precision policy.
booleanbooltrue and false.
charruneJava char is a UTF-16 code unit. Go rune is an alias for int32 and represents a Unicode code point.
StringstringA Go string is an immutable sequence of bytes. len returns the byte count, not the character count.

len("Go") returns 2, and len("ə") also returns 2 in UTF-8. Over a string, for range works with code points; a single visible character can still consist of several code points.

In Go, a variable without an explicit initial value receives its zero value: numbers get 0, bool gets false, string gets "", and pointers, slices, maps, channels, and interfaces get nil. Java provides default values for fields, but local variables must be assigned before they are read.

Go
var count int          // 0
var active bool        // false
var name string        // ""
var user *User         // nil
var names []string     // nil; append işləyir
var scores map[string]int // nil; oxuma mümkündür

names = append(names, "Aysel")
scores = make(map[string]int) // yazmadan əvvəl yarat
scores["Aysel"] = 95

Writing to a nil map causes a panic. Having a zero value does not mean every operation is safe on that value.

Arrays, slices, and maps: where the similarities end

An array's length is part of its type in Go: [3]int and [5]int are different types. Assigning an array value copies its elements. For a sequence with a variable length, you usually use []T, a slice.

A slice does not have the same object model as a Java List. It is a small value holding a view of an underlying array, a length, and a capacity. Copying a slice can share a reference to that array. Always keep the result returned by append, because it can allocate a new array when capacity is insufficient.

Adding an element to a sequence
Java
List<String> names = new ArrayList<>();
names.add("Ali");
names.add("Aysel");

String first = names.get(0);
int size = names.size();
Go
names := []string{"Ali"}
names = append(names, "Aysel")

first := names[0]
size := len(names)

copyOfNames := make([]string, len(names))
copy(copyOfNames, names)
Behavior difference

Here, copy copies the string elements into a separate array. If the elements were pointers, maps, or slices, it would not deeply copy the data they reference. Reading an index outside the bounds causes a panic.

Is the key missing, or is its value zero?
Java
Map<String, Integer> scores = new HashMap<>();
scores.put("Ali", 0);

if (scores.containsKey("Ali")) {
    System.out.println(scores.get("Ali"));
}
Go
scores := map[string]int{"Ali": 0}

score, exists := scores["Ali"]
if exists {
    fmt.Println(score)
}

delete(scores, "Ali")
Behavior difference

Reading only scores["Ali"] returns 0 for a missing key too. The value, ok form distinguishes the two cases. Map iteration order is not guaranteed, and concurrent reads and writes require synchronization.

Conditions, loops, and functions

Go has no while: for handles counted loops, conditional loops, and infinite loops. Over a slice, range returns an index and an element; _ discards a result you do not need. The element is a value copy, so modifying a struct element often means working with items[i].

An if condition does not require parentheses, though they are allowed. A matching switch case does not automatically continue into the next case. Execution enters the next case's body only with an explicit fallthrough.

A loop and an anonymous function
Java
for (String name : names) {
    System.out.println(name);
}

Runnable greet = () -> {
    System.out.println("Hello");
};
greet.run();
Go
for _, name := range names {
    fmt.Println(name)
}

greet := func() {
    fmt.Println("Hello")
}
greet()
Behavior difference

Go functions can be assigned to variables and passed to other functions. func() { ... }() calls an anonymous function; prefixing it with go starts it in a separate goroutine.

Returning multiple results
Java
static int divide(int a, int b) {
    if (b == 0) {
        throw new IllegalArgumentException(
            "division by zero");
    }
    return a / b;
}
Go
func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
Behavior difference

In Go, the result and error appear in the same signature. This function performs integer division. Java can also return several values in a record; multiple return values are directly supported by Go syntax. Go does not support method overloading, so use distinct names such as FindByID and FindByEmail.

Values, addresses, and pointers: what gets copied?

Both Java and Go pass arguments by value. In Java, the value of an object reference is copied. In Go, a User parameter copies the struct, while a *User parameter copies the pointer value. The pointer lets you reach the same struct and modify its fields.

ExpressionMeaning
user := User{}Creates a User value.
&userTakes the address of the variable user, producing a *User.
var p *UserDeclares a pointer to User, initially nil.
*pAccesses the value the pointer points to; p == nil causes a panic.
p.NameConvenient field access through a struct pointer, equivalent to (*p).Name.

Copying a struct does not deeply copy data referenced by fields such as pointers and slices. Using a pointer does not automatically improve performance: the compiler's escape analysis and measurements determine allocation behavior and costs.

Changing a field on the same object
Java
static void rename(User user) {
    user.name = "Murad";
}

// user.name sahəsi dəyişə bilir.
// user parametrinə başqa obyekt vermək isə
// çağıranın istinadını yeniləmir.
Go
func renameCopy(user User) {
    user.Name = "Murad"
}

func renameOriginal(user *User) {
    user.Name = "Murad"
}

// Funksiya daxilində:
u := User{Name: "Ali"}
renameCopy(u)       // u.Name: Ali
renameOriginal(&u)  // u.Name: Murad
Behavior difference

A Go function signature makes the sharing intent visible. A User can suit a small, independent value, while *User can suit modifications to the same model. If an API accepts a pointer, also define whether nil is allowed.

Receivers, constructors, and invariants

Use a pointer receiver for a method that modifies its receiver. A read-only method on a small value that is safe to copy may use a value receiver. Value receivers are unsuitable for structures that must not be copied, such as a type containing sync.Mutex. Consistent receiver choices for a type make its API easier to understand.

NewUser is an ordinary function, not a special language constructor. It can validate input and return (value, error) when needed. new(User) returns a pointer to a zero-valued User; make creates slices, maps, and channels. Keep these concepts distinct.

The name must not be empty
Java
final class User {
    private String name;

    User(String name) {
        rename(name);
    }

    void rename(String name) {
        if (name.isBlank()) {
            throw new IllegalArgumentException(
                "name is required");
        }
        this.name = name;
    }
}
Go
type User struct {
    name string
}

func NewUser(name string) (*User, error) {
    u := &User{}
    if err := u.Rename(name); err != nil {
        return nil, err
    }
    return u, nil
}

func (u *User) Rename(name string) error {
    if strings.TrimSpace(name) == "" {
        return errors.New("name is required")
    }
    u.name = name
    return nil
}
Behavior difference

An unexported field prevents other packages from changing it directly. However, callers can still create the zero value of the exported User type. Having a NewUser function does not by itself enforce that invariant at the language level. Account for zero-value behavior in your API design.

Interfaces describe behavior

Go does not use implements. A type satisfies an interface when it provides the required method set. This lets the caller describe only the behavior it needs. You do not need to create an interface in advance for every struct.

A small interface defined by its consumer
Java
interface UserFinder {
    User findById(long id);
}

final class MemoryUsers implements UserFinder {
    @Override
    public User findById(long id) {
        return new User(id, "Aysel");
    }
}
Go
type UserFinder interface {
    FindByID(id int64) (User, error)
}

type MemoryUsers struct{}

func (m *MemoryUsers) FindByID(
    id int64,
) (User, error) {
    return User{ID: id, Name: "Aysel"}, nil
}

var _ UserFinder = (*MemoryUsers)(nil)
Behavior difference

The final line checks conformance at compile time. Because the method has a pointer receiver, *MemoryUsers satisfies the interface, while a MemoryUsers value does not. Methods with value receivers belong to the method sets of both T and *T.

Composition and embedding are not inheritance

Storing another type as a field is composition in Go. Including a type without a separate field name is called embedding; it provides shorter ways to access fields and methods. It does not create a subclass or a dynamic override model.

Combining data
Java
record User(String name) {}
record AdminUser(
    User user,
    List<String> permissions
) {}

// Metod daxilində:
var admin = new AdminUser(
    new User("Aysel"), List.of("read"));
String name = admin.user().name();
Go
type User struct { Name string }

type AdminUser struct {
    User
    Permissions []string
}

// Funksiya daxilində:
admin := AdminUser{
    User: User{Name: "Aysel"},
    Permissions: []string{"read"},
}
name := admin.Name
Behavior difference

The expression admin.Name is shorthand for admin.User.Name. You cannot pass an AdminUser value directly to a function that expects a User; pass admin.User instead. Selection can be ambiguous when two embedded types have members with the same name.

Return errors as values and preserve their cause

For expected failures in Go, returning an error is the usual approach. The calling code handles it, propagates it with additional context, or turns it into a response. Go has panic and recover, but they should not serve as a try/catch replacement for everyday business errors.

Using %w in fmt.Errorf wraps an error while preserving its cause. You can then use errors.Is to check that cause or errors.As to check for a matching error type. Comparing error messages is fragile when their format changes.

Propagating an error without losing its cause
Java
try {
    return repository.findById(id);
} catch (SQLException cause) {
    throw new UserReadException(
        "read user " + id, cause);
}
Go
user, err := repository.FindByID(ctx, id)
if err != nil {
    return User{}, fmt.Errorf(
        "read user %d: %w", id, err,
    )
}
return user, nil
Behavior difference

Include the operation and a safe identifier in error messages for diagnosis. Do not include passwords, tokens, or personal information. At the HTTP boundary, return an appropriate status and a general message instead of exposing the internal error text to the user.

The nil interface trap

To understand a Go interface value, consider its dynamic type and dynamic value together. The interface is nil when both are absent. An interface containing a nil pointer still carries the pointer's type, so the interface itself is not nil.

Go
type ReadError struct{}
func (*ReadError) Error() string { return "read failed" }

var pointer *ReadError = nil
var err error = pointer

fmt.Println(pointer == nil) // true
fmt.Println(err == nil)     // false

When a successful operation returns an error result, return nil directly, rather than a pointer with a concrete type whose value is nil. This distinction is particularly easy to miss in mock repositories and conditional error-creation code.

defer: tie a resource's lifetime to a function

defer calls run when the function finishes. Multiple deferred calls run in reverse registration order. Their arguments are evaluated when the defer statement is executed. A defer inside a loop waits for the whole function to end, not just the current iteration.

Closing a file opened for reading
Java
try (var input = Files.newInputStream(path)) {
    byte[] data = input.readAllBytes();
    return data;
}
Go
func readFile(path string) ([]byte, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    return io.ReadAll(file)
}
Behavior difference

Place the defer after the resource has been opened successfully. This example is for reading and does not check the Close error. When writing to files, Close and Flush errors can affect whether data is saved and need separate handling. For SQL queries returning multiple rows, also close them with rows.Close() and check rows.Err() after iteration.

Dependency injection: make dependencies explicit

Dependency injection does not require a container. A common Go approach is to create dependencies at application startup and pass them into constructor functions. This also lets a test replace a repository with a fake implementation.

The same manual wiring is possible in Java. Spring additionally provides features such as bean lifecycle management and automatic wiring. The Go example below does not reproduce those container features.

Passing a service dependency through its constructor
Java
final class UserService {
    private final UserRepository repository;

    UserService(UserRepository repository) {
        this.repository = repository;
    }
}

// Tətbiqin qurulması:
var repository = new PostgresUsers(dataSource);
var service = new UserService(repository);
Go
type UserService struct {
    repository UserRepository
}

func NewUserService(r UserRepository) *UserService {
    return &UserService{repository: r}
}

// Tətbiqin qurulması:
repository := NewPostgresUsers(db)
service := NewUserService(repository)
Behavior difference

The constructor signature shows what the service needs. Pass dependencies explicitly instead of hiding them in global variables or a context. A small project does not require a separate abstraction for every layer.

Context: propagate request cancellation through every layer

context.Context carries a cancellation signal, a deadline, and small pieces of request-scoped metadata. Start with r.Context() in the HTTP handler and pass it through the service and repository layers. Creating a new context.Background() inside the repository would break the connection to cancellation of the incoming request.

Go
func (s *UserService) FindByID(
    ctx context.Context, id int64,
) (User, error) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    return s.repository.FindByID(ctx, id)
}

If the parent context has an earlier deadline, the new context does not extend it. Calling defer cancel() helps release allocated resources when the work finishes.

A context does not forcibly stop a goroutine. The called operation must observe ctx.Done() or use an API that accepts a context. A timeout alone will not stop an infinite computation loop that does not check the context. Do not turn a context into a container for optional parameters or ordinary dependencies.

A practical repository: queries, errors, and pointers

The repository below uses PostgreSQL placeholder syntax ($1). A *sql.DB is a shared connection pool, not a single connection. Driver registration, pool creation, and pool limits are configured at application startup.

With QueryRowContext, you retrieve the row through Scan. If no row matches, the scan returns sql.ErrNoRows. Converting that into a meaningful domain error separates the service layer from SQL details.

Go
var ErrUserNotFound = errors.New("user not found")

type User struct {
    ID   int64
    Name string
}

type UserRepository interface {
    FindByID(context.Context, int64) (User, error)
}

type PostgresUsers struct {
    db *sql.DB
}

func NewPostgresUsers(db *sql.DB) *PostgresUsers {
    return &PostgresUsers{db: db}
}

func (r *PostgresUsers) FindByID(
    ctx context.Context, id int64,
) (User, error) {
    var user User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, name FROM users WHERE id = $1`,
        id,
    ).Scan(&user.ID, &user.Name)

    if errors.Is(err, sql.ErrNoRows) {
        return User{}, ErrUserNotFound
    }
    if err != nil {
        return User{}, fmt.Errorf("query user: %w", err)
    }
    return user, nil
}

Scan(&user.ID, &user.Name) passes field addresses so the driver can write results into them. The query parameter is passed separately rather than concatenated into the SQL text. This example assumes the name column is NOT NULL; a nullable column requires a suitable type such as sql.NullString.

The returned model is small, so this example returns a User value. Returning a zero value on failure is normal with (User, error): the caller must check the error first. (*User, error) is also possible, but the contract should clearly state whether a successful result can be nil.

JSON and the HTTP boundary

Go's encoding/json processes exported fields. A struct tag specifies a field's JSON name and certain encoding options; it does not replace every capability of Java annotations.

The response contract
Java
public record UserResponse(
    @JsonProperty("user_id") long id,
    String name
) {}
Go
type UserResponse struct {
    ID   int64  `json:"user_id"`
    Name string `json:"name"`
}

response := UserResponse{
    ID: user.ID, Name: user.Name,
}
Behavior difference

In the exported model, ID must start with an uppercase letter. The tag json:"user_id" alone does not make an unexported field accessible. When decoding, Decode(&request) takes a pointer because it writes the result into the model.

Turning errors into HTTP responses

The handler makes transport-layer decisions: input validation, status codes, and response format. It should not expose the repository's internal error messages to the client. The example below takes an ID from the /users?id=42 request and uses the earlier UserService.

Go
func userHandler(service *UserService) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            w.Header().Set("Allow", http.MethodGet)
            http.Error(w, "method not allowed", 405)
            return
        }
        id, err := strconv.ParseInt(
            r.URL.Query().Get("id"), 10, 64,
        )
        if err != nil || id <= 0 {
            http.Error(w, "invalid user id", 400)
            return
        }
        user, err := service.FindByID(r.Context(), id)
        switch {
        case errors.Is(err, ErrUserNotFound):
            http.Error(w, "user not found", 404)
            return
        case errors.Is(err, context.DeadlineExceeded):
            http.Error(w, "upstream timeout", 504)
            return
        case err != nil:
            http.Error(w, "internal server error", 500)
            return
        }
        body, err := json.Marshal(UserResponse{
            ID: user.ID, Name: user.Name,
        })
        if err != nil {
            http.Error(w, "encoding failed", 500)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        if _, err := w.Write(body); err != nil {
            log.Printf("write user response: %v", err)
        }
    }
}

The JSON is prepared in memory first, so an encoding failure can be handled before sending the response. After a Write failure, it is not possible to write a second HTTP response. A real application also needs logging, user authorization, request-size limits, and server timeouts. When decoding a POST body, enforce a maximum size and business rules, and reject unknown fields and additional JSON values when appropriate.

Starting a goroutine is only half the job

go work() starts the function in a separate goroutine. The Go runtime schedules goroutines; it does not create a separate OS thread for each one. Comparing them with Java virtual threads can give an initial intuition, but the two runtimes do not have identical scheduling and blocking mechanisms.

When main returns, the program does not wait for other goroutines. You can use sync.WaitGroup to wait for them. It carries neither results nor errors; it only coordinates completion.

Waiting for two tasks to finish
Java
var pool = Executors.newFixedThreadPool(2);
try {
    Future<?> first = pool.submit(() -> work(1));
    Future<?> second = pool.submit(() -> work(2));
    first.get();
    second.get();
} finally {
    pool.shutdown();
}
Go
var wg sync.WaitGroup
for i := 1; i <= 2; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        work(id)
    }(i)
}
wg.Wait()
Behavior difference

Call Add before starting the goroutine. Java's Future.get() can also deliver a result and an execution error, while Go's WaitGroup does neither. Waiting does not automatically make concurrent writes to a shared variable safe. Newer Go versions also provide WaitGroup.Go; this example uses Add/Done for broader version compatibility.

Channels: exchanging results and coordinating work

ch <- value sends, and <-ch receives. On an unbuffered channel, the sender and receiver wait for one another. With a buffer, sending can complete without waiting for a receiver until the buffer is full. A channel is not a Future: it can carry multiple values, but it does not retain a result for multiple readers.

Receiving the result of a computation
Java
CompletableFuture<Integer> future =
    CompletableFuture.supplyAsync(() -> 10 + 20);

int result = future.join();
Go
ch := make(chan int, 1)
go func() {
    ch <- 10 + 20
}()

result := <-ch
Behavior difference

Both examples wait for the result of one computation. The Go buffer holds one result. A real operation should account for an error alongside the result and for context cancellation while waiting.

Four essential channel rules

  • Sending or receiving on a nil channel blocks. Create a channel with make(chan T). Inside a select, a nil channel can intentionally disable a case.
  • Sending to a closed channel causes a panic. The party that knows sending has finished is usually responsible for closing it. Multiple senders require coordination.
  • Receiving from a closed channel is allowed. Once the buffer is empty, receives return the zero value, and ok is false in value, ok := <-ch.
  • Not every channel needs to be closed. close signals that no more values will arrive. It lets a receiver's range loop finish; it is not mandatory for reclaiming memory.
Go
select {
case result := <-results:
    return result, nil
case <-ctx.Done():
    return Result{}, ctx.Err()
}

If a receiver exits because of cancellation, consider whether a sender could block forever. Possible solutions include using select with the context when sending, or choosing a buffer that fits the exact number of results. A buffer does not solve unbounded load or an uncontrolled number of goroutines.

Shared state and data races

A data race can occur when two goroutines access the same memory without synchronization and at least one writes to it. count++ is not a single atomic operation. Waiting with a WaitGroup at the end does not fix those concurrent writes.

Protecting a counter
Java
final class Counter {
    private int value;

    synchronized void increment() {
        value++;
    }

    synchronized int value() {
        return value;
    }
}
Go
type Counter struct {
    mu sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}
Behavior difference

Reads must follow the same synchronization rule. Do not copy a struct containing a mutex after the mutex has been used. A channel can suit data exchange, while a mutex can suit protecting shared state; neither should replace the other in every problem.

Coordinating three concurrent backend calls

If a profile endpoint retrieves user, order, and balance information from independent services, it can start those calls concurrently. With durations of 100, 150, and 200 ms, a sequential wait is approximately 450 ms. Ideal concurrent execution can approach 200 ms, but network costs, connection-pool queues, and scheduling overhead still remain.

In the snippet below, getUser, getOrders, and getWallet accept a context and return a value and an error. User, Order, Wallet, and Profile are application models. The example handles three fixed tasks.

Go
func loadProfile(parent context.Context) (Profile, error) {
    ctx, cancel := context.WithTimeout(parent, 2*time.Second)
    defer cancel()

    var profile Profile
    var wg sync.WaitGroup
    errs := make(chan error, 3)

    run := func(job func() error) {
        wg.Add(1)
        go func() {
            defer wg.Done()
            if err := job(); err != nil {
                errs <- err
                cancel()
            }
        }()
    }
    run(func() error {
        value, err := getUser(ctx)
        if err == nil { profile.User = value }
        return err
    })
    run(func() error {
        value, err := getOrders(ctx)
        if err == nil { profile.Orders = value }
        return err
    })
    run(func() error {
        value, err := getWallet(ctx)
        if err == nil { profile.Wallet = value }
        return err
    })

    wg.Wait()
    close(errs)
    if err, ok := <-errs; ok {
        return Profile{}, err
    }
    if err := ctx.Err(); err != nil {
        return Profile{}, err
    }
    return profile, nil
}

Why are writes to the result safe here? Each goroutine writes to a separate field, and the final read happens after Wait. Concurrent writes to the same map or concurrent append calls on a shared slice do not fall under this rule. Each task sends at most one error, so a buffer of three prevents those sends from blocking. The first error signals cancellation to the other tasks.

This code has an important condition: the called functions must respond to the context. Otherwise, Wait can remain blocked after the timeout. For a variable number of tasks, use a bounded worker pool or a concurrency limit. Starting thousands of goroutines per request can overload the connection pool and downstream service.

Generics and named constants

A slice of type []User exists in Go without writing a generic function. Type parameters let the same algorithm work safely with different types. Accounting for an empty collection in the function signature makes the API more reliable.

Safely retrieving the first element
Java
static <T> Optional<T> first(List<T> items) {
    if (items.isEmpty()) {
        return Optional.empty();
    }
    return Optional.of(items.get(0));
}
Go
func First[T any](items []T) (T, bool) {
    if len(items) == 0 {
        var zero T
        return zero, false
    }
    return items[0], true
}
Behavior difference

The Java example does not accept a null element, while Go can return a zero or nil value together with true. The bool here indicates only whether an element exists. If you need equivalent semantics, define the null/nil policy explicitly too.

Status constants
Java
enum Status {
    ACTIVE,
    BLOCKED,
    DELETED
}
Go
type Status string

const (
    StatusActive Status = "ACTIVE"
    StatusBlocked Status = "BLOCKED"
    StatusDeleted Status = "DELETED"
)
Behavior difference

This Go type does not define a closed set of values: constructing Status("UNKNOWN") is possible. External input requires validation. You can use iota for numeric constants, but changing their order may affect stored API and database values. Explicit values are safer for those contracts.

Packages and the everyday workflow

Start without dividing a small application into hundreds of abstractions. Package boundaries can follow responsibilities such as users, payments, and orders. handler.go, service.go, and repository.go can be separate files in the same package; creating a file does not itself create a visibility boundary.

Structure
myapp/
├── cmd/api/main.go
├── internal/user/
│   ├── handler.go
│   ├── service.go
│   ├── repository.go
│   └── model.go
├── go.mod
└── go.sum

This directory structure is not a mandatory standard. However, internal is more than a naming choice. Go's import rules enforce its boundary: only code beneath its parent directory can import the packages inside it.

Run the everyday checks from your project's module directory:

Terminal
go fmt ./...
go vet ./...
go test ./...
go test -race ./...

go fmt handles formatting, go vet checks suspicious constructs, and tests check expected behavior. The race detector finds data races only along execution paths that actually run. Passing a test is not proof that every possible concurrent scenario is correct.

Do not copy your Java architecture unchanged. Start with the concrete operation, then extract a small interface when behavior needs to vary or a test boundary emerges. This principle is useful in Java projects too.

Run it: a model, validation, and concurrent execution

Save this example as main.go and run it with go run main.go. The two users are created and updated first, and the goroutines only read them afterward. There are no concurrent writes to the shared model in this example.

Go
package main

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

type User struct {
    ID int64
    name string
}

func NewUser(id int64, name string) (*User, error) {
    u := &User{ID: id}
    if err := u.Rename(name); err != nil {
        return nil, err
    }
    return u, nil
}

func (u *User) Rename(name string) error {
    name = strings.TrimSpace(name)
    if name == "" {
        return errors.New("name is required")
    }
    u.name = name
    return nil
}

func (u *User) Greeting() string {
    return "Hello " + u.name
}

func main() {
    first, err := NewUser(1, "Ali")
    if err != nil {
        fmt.Println(err)
        return
    }
    second, err := NewUser(2, "Aysel")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err := first.Rename("Murad"); err != nil {
        fmt.Println(err)
        return
    }

    users := []*User{first, second}
    var wg sync.WaitGroup
    for _, user := range users {
        wg.Add(1)
        go func(u *User) {
            defer wg.Done()
            fmt.Println(u.Greeting())
        }(user)
    }
    wg.Wait()
    fmt.Println("Finished")
}

The first two lines can appear in either order; Finished appears after both tasks complete. If you extend the example to call Rename while the goroutines are running, synchronization will be necessary.

Cheat sheet and your next exercise

Familiar Java conceptGo mechanismRemember
Object modelstruct and methodsA struct is copied as a value.
thisA receiver name, such as uValue and pointer receivers differ.
ConstructorA NewX functionNot a special language mechanism.
List<T>[]TA slice can share its underlying array.
Map<K,V>map[K]VCheck existence with value, ok.
ExceptionAn error resultCheck the error and use %w when wrapping it.
implementsMatching method setsT and *T are not the same.
nullnilAn interface containing a typed nil may not be nil.
Thread / virtual threadGoroutineManage completion as well as startup.
FutureA mechanism such as a result channelNot exactly the same abstraction.
CountDownLatchsync.WaitGroupDoes not carry errors itself.
synchronizedsync.MutexApply the same synchronization rule to reads and writes.
try-with-resourcesCleanup with deferDeferred calls run when the function exits.
Java enumA named type and constantsThe set of values is not automatically closed.

Practical exercise: write an in-memory implementation of UserRepository and pass it to UserService. Test three cases: the user exists, the user is missing, and the context has already been canceled. Then replace it with a PostgreSQL implementation and check that the service stays unchanged.

Review your code with these questions: who changes which data, at which boundary is an error handled, when does a goroutine finish, and which signature exposes a dependency? Being able to answer from the code itself is a sign that you are becoming comfortable with Go.