- My project: Hermes IDE | GitHub
- Me: gabrielanhaia
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
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
}
// 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)
}
// ...
}
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
}
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
}
%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
}
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
%wif you're okay with callers depending on the underlying error type. If you want to hide the implementation detail,%vis 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
}
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
}
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()
}
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
}
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)
// ...
}
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)
// ...
}
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,
}
}
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"
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
}
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)