DEV Community

Cover image for Stop Writing Go Like It's Java: 5 Patterns You Need to Unlearn
Gabriel Anhaia
Gabriel Anhaia

Posted on

Stop Writing Go Like It's Java: 5 Patterns You Need to Unlearn


You came from Python/Java/TypeScript. You picked up Go syntax in a weekend. Your code compiles, it runs, the tests pass. And it's still wrong — in ways that'll bite you at 3 AM when your service handles real traffic.

The problem isn't that you don't know Go. It's that you know too much of something else. Years of Java design patterns, Python idioms, and TypeScript habits have trained your instincts, and those instincts are actively working against you in Go. This post covers five patterns that are perfectly valid in other languages but turn into anti-patterns the moment you bring them into a Go codebase. Each one follows the same shape: what you'd do in Java or Python, why it's wrong in Go, and what idiomatic Go looks like instead.

Mistake 1: Defining Interfaces Before You Need Them

In Java, you interface everything upfront. UserService gets a UserServiceInterface. OrderRepository gets an OrderRepositoryInterface. You do it because dependency injection frameworks expect it, because testability demands it, because your architect said so in 2014 and nobody questioned it since.

In Go, this is backwards.

Here's what the Java-trained instinct produces:

// Don't do this.
package user

type UserRepository interface {
    GetByID(ctx context.Context, id string) (*User, error)
    Create(ctx context.Context, u *User) error
    Update(ctx context.Context, u *User) error
    Delete(ctx context.Context, id string) error
}

type PostgresUserRepository struct {
    db *sql.DB
}

func (r *PostgresUserRepository) GetByID(ctx context.Context, id string) (*User, error) {
    // ...
}

// ... all other methods
Enter fullscreen mode Exit fullscreen mode

You defined the interface right next to the only implementation. There's no second implementation. There probably never will be. You've added a layer of indirection that does nothing except make your code harder to navigate.

The Go way: accept interfaces, return structs. Define interfaces at the call site — in the package that uses the dependency, not the one that provides it.

// package user — the implementation. No interface here.
package user

type Repository struct {
    db *sql.DB
}

func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
    row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, fmt.Errorf("getting user %s: %w", id, err)
    }
    return &u, nil
}

func (r *Repository) Create(ctx context.Context, u *User) error {
    _, err := r.db.ExecContext(ctx, "INSERT INTO users (id, name, email) VALUES ($1, $2, $3)", u.ID, u.Name, u.Email)
    if err != nil {
        return fmt.Errorf("creating user: %w", err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode
// package order — the consumer. Interface defined here, with only what it needs.
package order

type UserGetter interface {
    GetByID(ctx context.Context, id string) (*user.User, error)
}

type Service struct {
    users UserGetter
}

func (s *Service) PlaceOrder(ctx context.Context, userID string, items []Item) (*Order, error) {
    u, err := s.users.GetByID(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("placing order: %w", err)
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The order package declares exactly the interface it needs — one method, not four. The user.Repository satisfies it implicitly. No import cycle. No registration. No framework. If you later need to test PlaceOrder, you mock UserGetter with one method, not a four-method monster.

The rule: don't define an interface until a second package needs to decouple from a concrete type. If you have one implementation and one consumer, you don't need an interface yet.

Mistake 2: Losing the Error Chain

You know to check errors. Every Go tutorial hammers if err != nil. But there's a difference between handling errors and preserving them, and most newcomers strip away the context that matters.

Here's the version that looks right but isn't:

func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to load config: %v", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("failed to parse config: %v", err)
    }

    return &cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

That %v verb formats the error as a string. The original error is gone. Downstream code can't use errors.Is or errors.As to check what actually went wrong. Your logs say "failed to load config: open /etc/app/config.json: no such file or directory" and that's the end of the trail. You can't programmatically distinguish "file not found" from "permission denied" without parsing strings.

The fix is one character:

func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loading config %s: %w", path, err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config %s: %w", path, err)
    }

    return &cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

%w wraps the error. The chain is preserved. Now callers can do this:

cfg, err := LoadConfig("/etc/app/config.json")
if err != nil {
    if errors.Is(err, os.ErrNotExist) {
        // config file missing — use defaults
        return defaultConfig(), nil
    }
    return nil, err
}
Enter fullscreen mode Exit fullscreen mode

A few more guidelines that'll save you grief:

  • Don't start error messages with "failed to" or "error". The caller already knows it's an error. "loading config" reads better in a chain than "failed to load config: failed to read file: failed to open".
  • Include relevant values. "loading config %s" is more useful than "loading config" when you're looking at logs at 3 AM.
  • Only wrap with %w if you're okay with callers depending on the underlying error type. If you want to hide the implementation detail, %v is the right choice. But make that decision deliberately, not by accident.

The difference between good and bad error chains shows up in your logs. Bad chain: "failed to process request: failed to load config: failed to read file: open /etc/app/config.json: no such file or directory". Good chain: "processing request: loading config /etc/app/config.json: open /etc/app/config.json: no such file or directory". Same information, less noise, and errors.Is actually works.

Mistake 3: Reaching for Channels When a Mutex Will Do

"Don't communicate by sharing memory; share memory by communicating." Every Go developer has heard this. Most of them misread it as "always use channels." They then build this:

// Don't do this for a simple counter.
type Counter struct {
    ch chan func()
    val int
}

func NewCounter() *Counter {
    c := &Counter{ch: make(chan func())}
    go c.loop()
    return c
}

func (c *Counter) loop() {
    for fn := range c.ch {
        fn()
    }
}

func (c *Counter) Increment() {
    done := make(chan struct{})
    c.ch <- func() {
        c.val++
        close(done)
    }
    <-done
}

func (c *Counter) Value() int {
    result := make(chan int)
    c.ch <- func() {
        result <- c.val
    }
    return <-result
}
Enter fullscreen mode Exit fullscreen mode

That's 30 lines, a goroutine leak waiting to happen, and slower than the alternative. All to increment an integer. Here's what it should look like:

type Counter struct {
    mu  sync.Mutex
    val int
}

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

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.val
}
Enter fullscreen mode Exit fullscreen mode

Or, since it's just an integer, use atomic:

type Counter struct {
    val atomic.Int64
}

func (c *Counter) Increment() {
    c.val.Add(1)
}

func (c *Counter) Value() int64 {
    return c.val.Load()
}
Enter fullscreen mode Exit fullscreen mode

The decision rule is simple. If you're protecting shared state, use a mutex (or atomic operations). If you're coordinating work between goroutines — fan-out, pipelines, signaling completion — use a channel. A channel is a concurrency primitive for communication, not a lock.

Think of it this way: would you use a message queue to guard a database row? No. You'd use a lock. The same logic applies inside your process.

Mistake 4: Building Class Hierarchies with Struct Embedding

You come from Java. You think in Animal -> Dog -> GoldenRetriever. You discover Go has struct embedding and your eyes light up — "this is inheritance!"

It's not.

// This looks like inheritance. It isn't.
type BaseService struct {
    logger *log.Logger
    db     *sql.DB
}

func (b *BaseService) LogRequest(method string) {
    b.logger.Printf("handling %s request", method)
}

type UserService struct {
    BaseService
}

type OrderService struct {
    BaseService
}
Enter fullscreen mode Exit fullscreen mode

This works until UserService needs a slightly different logging format for one method, or OrderService needs to add tracing to LogRequest but not change UserService. You can't override. There's no super. The embedded struct's methods don't know about the outer struct. If BaseService.LogRequest calls another BaseService method, it calls BaseService's version, not UserService's "override." There's no virtual dispatch.

The Go way is explicit composition:

type UserService struct {
    logger *log.Logger
    db     *sql.DB
    users  *user.Repository
}

func NewUserService(logger *log.Logger, db *sql.DB, users *user.Repository) *UserService {
    return &UserService{logger: logger, db: db, users: users}
}

func (s *UserService) CreateUser(ctx context.Context, name, email string) (*user.User, error) {
    s.logger.Printf("creating user %s", email)
    // ...
}
Enter fullscreen mode Exit fullscreen mode
type OrderService struct {
    logger *log.Logger
    db     *sql.DB
    orders *order.Repository
    users  *user.Repository
}

func NewOrderService(logger *log.Logger, db *sql.DB, orders *order.Repository, users *user.Repository) *OrderService {
    return &OrderService{logger: logger, db: db, orders: orders, users: users}
}

func (s *OrderService) PlaceOrder(ctx context.Context, userID string, items []order.Item) (*order.Order, error) {
    s.logger.Printf("placing order for user %s", userID)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Yes, you're repeating logger and db fields. That's fine. Each service owns its dependencies explicitly. There's no hidden state, no fragile base class problem, no confusion about which method gets called. I cover this pattern in depth (with production examples) in my book.

Embedding is great for one thing: implementing interfaces by composing types that already satisfy parts of them. Use it for that. Don't use it to simulate extends.

Mistake 5: Designing Structs That Fight the Zero Value

In Java, every object needs a constructor. new ArrayList<>(), new HashMap<>(), new StringBuilder(). You bring that habit to Go and write this:

type ConnectionPool struct {
    conns   []*Connection
    maxSize int
    timeout time.Duration
    mu      sync.Mutex
}

// "You MUST call NewConnectionPool or things will break."
func NewConnectionPool(maxSize int, timeout time.Duration) *ConnectionPool {
    return &ConnectionPool{
        conns:   make([]*Connection, 0, maxSize),
        maxSize: maxSize,
        timeout: timeout,
    }
}
Enter fullscreen mode Exit fullscreen mode

Now every caller has to know NewConnectionPool exists. If someone writes var pool ConnectionPool and starts using it, maxSize is 0, timeout is 0, and nothing works. The struct requires initialization.

Compare that to how the standard library does it. bytes.Buffer works the moment you declare it:

var buf bytes.Buffer
buf.WriteString("hello")
fmt.Println(buf.String()) // "hello"
Enter fullscreen mode Exit fullscreen mode

No constructor. No NewBuffer. The zero value is useful. Design your structs the same way when you can:

type RateLimiter struct {
    mu       sync.Mutex
    limit    int
    window   time.Duration
    requests []time.Time
}

func (r *RateLimiter) Allow() bool {
    r.mu.Lock()
    defer r.mu.Unlock()

    // Zero value defaults: 10 requests per second.
    limit := r.limit
    if limit == 0 {
        limit = 10
    }
    window := r.window
    if window == 0 {
        window = time.Second
    }

    now := time.Now()
    cutoff := now.Add(-window)

    // Remove expired entries.
    valid := r.requests[:0]
    for _, t := range r.requests {
        if t.After(cutoff) {
            valid = append(valid, t)
        }
    }
    r.requests = valid

    if len(r.requests) >= limit {
        return false
    }

    r.requests = append(r.requests, now)
    return true
}
Enter fullscreen mode Exit fullscreen mode

var limiter RateLimiter — it works immediately with sensible defaults. Callers who want custom limits can set them, but nobody is forced to. The sync.Mutex zero value is an unlocked mutex. The nil slice is a valid, empty slice. The zero time.Duration is a reasonable thing to check against.

Not every struct can have a useful zero value — a database connection pool genuinely needs a connection string. But the question to ask yourself is: "Can I make the zero value do something reasonable?" If yes, do it. Your users (and your future self writing tests) will thank you.

Wrapping Up

These five mistakes have one thing in common: they import patterns that exist to solve problems Go doesn't have. Go doesn't have inheritance, so don't simulate it. Go doesn't need DI frameworks, so don't create interfaces for them. Go's error handling is explicit, so preserve the chain. Go's concurrency model has multiple tools, so pick the right one instead of defaulting to channels.

The fastest way to write good Go is to forget what "good code" looks like in Java for a while. Read the standard library. Look at how net/http, encoding/json, and io are structured. The patterns are all there — they just look nothing like what you're used to.


Want to go deeper?

I wrote a book that covers everything in this series — and a lot more: error handling patterns, testing strategies, production deployment, and the stuff you only learn after shipping Go to production.

Available in:

Top comments (0)