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.
final class User {
private final String name;
User(String name) {
this.name = name;
}
String greeting() {
return "Hello " + name;
}
}type User struct {
Name string
}
func (u User) Greeting() string {
return "Hello " + u.Name
}
// Funksiya daxilində:
user := User{Name: "Aysel"}
message := user.Greeting()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.
public class Main {
public static void main(String[] args) {
var name = "Aysel";
System.out.println("Hello " + name);
}
}package main
import "fmt"
func main() {
name := "Aysel"
fmt.Println("Hello " + name)
}Unused local variables and imports are compile errors in Go. gofmt automatically handles indentation and standard formatting.
public class Account {
public long id;
private String secret;
}package account
type Account struct {
ID int64
secret string
}
var applicationName = "Proxy Academy"
const MaxRetry = 5The 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.
| Java | Closest Go choice | Important difference |
|---|---|---|
byte | int8 | Java byte is signed. Go byte is an alias for uint8, with a range of 0-255. |
short | int16 | Both are signed 16-bit integers. |
int | int32 | Java int is always 32 bits. Go int is 32 or 64 bits, depending on the platform. |
long | int64 | Suitable for fields with a fixed size, such as IDs. |
float, double | float32, float64 | These are binary floating-point numbers. Money requires a separate precision policy. |
boolean | bool | true and false. |
char | rune | Java char is a UTF-16 code unit. Go rune is an alias for int32 and represents a Unicode code point. |
String | string | A 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.
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"] = 95Writing 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.
List<String> names = new ArrayList<>();
names.add("Ali");
names.add("Aysel");
String first = names.get(0);
int size = names.size();names := []string{"Ali"}
names = append(names, "Aysel")
first := names[0]
size := len(names)
copyOfNames := make([]string, len(names))
copy(copyOfNames, names)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.
Map<String, Integer> scores = new HashMap<>();
scores.put("Ali", 0);
if (scores.containsKey("Ali")) {
System.out.println(scores.get("Ali"));
}scores := map[string]int{"Ali": 0}
score, exists := scores["Ali"]
if exists {
fmt.Println(score)
}
delete(scores, "Ali")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.
for (String name : names) {
System.out.println(name);
}
Runnable greet = () -> {
System.out.println("Hello");
};
greet.run();for _, name := range names {
fmt.Println(name)
}
greet := func() {
fmt.Println("Hello")
}
greet()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.
static int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException(
"division by zero");
}
return a / b;
}func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}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.
| Expression | Meaning |
|---|---|
user := User{} | Creates a User value. |
&user | Takes the address of the variable user, producing a *User. |
var p *User | Declares a pointer to User, initially nil. |
*p | Accesses the value the pointer points to; p == nil causes a panic. |
p.Name | Convenient 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.
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.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: MuradA 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.
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;
}
}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
}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.
interface UserFinder {
User findById(long id);
}
final class MemoryUsers implements UserFinder {
@Override
public User findById(long id) {
return new User(id, "Aysel");
}
}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)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.
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();type User struct { Name string }
type AdminUser struct {
User
Permissions []string
}
// Funksiya daxilində:
admin := AdminUser{
User: User{Name: "Aysel"},
Permissions: []string{"read"},
}
name := admin.NameThe 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.
try {
return repository.findById(id);
} catch (SQLException cause) {
throw new UserReadException(
"read user " + id, cause);
}user, err := repository.FindByID(ctx, id)
if err != nil {
return User{}, fmt.Errorf(
"read user %d: %w", id, err,
)
}
return user, nilInclude 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.
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) // falseWhen 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.
try (var input = Files.newInputStream(path)) {
byte[] data = input.readAllBytes();
return data;
}func readFile(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
return io.ReadAll(file)
}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.
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);type UserService struct {
repository UserRepository
}
func NewUserService(r UserRepository) *UserService {
return &UserService{repository: r}
}
// Tətbiqin qurulması:
repository := NewPostgresUsers(db)
service := NewUserService(repository)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.
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.
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.
public record UserResponse(
@JsonProperty("user_id") long id,
String name
) {}type UserResponse struct {
ID int64 `json:"user_id"`
Name string `json:"name"`
}
response := UserResponse{
ID: user.ID, Name: user.Name,
}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.
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.
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();
}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()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.
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> 10 + 20);
int result = future.join();ch := make(chan int, 1)
go func() {
ch <- 10 + 20
}()
result := <-chBoth 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
nilchannel blocks. Create a channel withmake(chan T). Inside aselect, anilchannel 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
okis false invalue, ok := <-ch. - Not every channel needs to be closed.
closesignals that no more values will arrive. It lets a receiver'srangeloop finish; it is not mandatory for reclaiming memory.
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.
final class Counter {
private int value;
synchronized void increment() {
value++;
}
synchronized int value() {
return value;
}
}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
}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.
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.
static <T> Optional<T> first(List<T> items) {
if (items.isEmpty()) {
return Optional.empty();
}
return Optional.of(items.get(0));
}func First[T any](items []T) (T, bool) {
if len(items) == 0 {
var zero T
return zero, false
}
return items[0], true
}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.
enum Status {
ACTIVE,
BLOCKED,
DELETED
}type Status string
const (
StatusActive Status = "ACTIVE"
StatusBlocked Status = "BLOCKED"
StatusDeleted Status = "DELETED"
)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.
myapp/
├── cmd/api/main.go
├── internal/user/
│ ├── handler.go
│ ├── service.go
│ ├── repository.go
│ └── model.go
├── go.mod
└── go.sumThis 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:
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.
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 concept | Go mechanism | Remember |
|---|---|---|
| Object model | struct and methods | A struct is copied as a value. |
this | A receiver name, such as u | Value and pointer receivers differ. |
| Constructor | A NewX function | Not a special language mechanism. |
List<T> | []T | A slice can share its underlying array. |
Map<K,V> | map[K]V | Check existence with value, ok. |
| Exception | An error result | Check the error and use %w when wrapping it. |
implements | Matching method sets | T and *T are not the same. |
null | nil | An interface containing a typed nil may not be nil. |
| Thread / virtual thread | Goroutine | Manage completion as well as startup. |
| Future | A mechanism such as a result channel | Not exactly the same abstraction. |
CountDownLatch | sync.WaitGroup | Does not carry errors itself. |
synchronized | sync.Mutex | Apply the same synchronization rule to reads and writes. |
try-with-resources | Cleanup with defer | Deferred calls run when the function exits. |
| Java enum | A named type and constants | The 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.
