Asaduzzaman Pavel

How I Actually Structure My Go Services

Every time I see a new Go project with a pkg/ directory, I already know it's going to be a junk drawer. People treat pkg/ like a "public" folder for things they might want to share later, but they never do. It just ends up being a collection of half-baked string helpers and logger wrappers that leak into every other part of the app.

I’ve shifted almost everything into internal/. If a piece of code isn't meant to be imported by another service, it has no business being public. It's a simple boundary that saves me from "accidental coupling" where some random utility function in Service A starts getting used by Service B just because it was easy to find.

The "Manual" Wiring

...And that's why I stopped using dependency injection frameworks like Uber's fx or Google's wire. I know, I know—"it's just boilerplate." But I'd rather write thirty lines of explicit struct initialization in main.go than spend twenty minutes debugging why a provider didn't register or why some magic reflection-based container is throwing a cryptic error at startup.

When I look at my main.go, I see the entire dependency graph in plain text. I can see exactly where the database pool is created and exactly which services are using it. There's no magic. If something is missing, the compiler tells me.

The Layout I Actually Use

It’s not revolutionary, but it's flat enough that I don't get lost in a sea of nested folders.

.
├── cmd/service/main.go  # The Wiring
├── internal/
│   ├── api/             # Transport (HTTP/gRPC)
│   ├── domain/          # The Interfaces (Contracts)
│   ├── logic/           # The "Brain"
│   ├── store/           # The Persistence
│   └── config/          # Environment-first config
└── Makefile             # My actual UI

Hand-Crafted SQL (The Verbosity Tax)

I avoid ORMs. I also avoid most SQL generators. I’ve tried sqlc, and while it’s better than most, I still find myself fighting it when I need a complex join or a specific Postgres-ism that the parser doesn't like.

I write raw SQL and map it to structs. For a long time, I complained about the "verbosity tax" of Go—all those rows.Scan(&item.ID, &item.Name...) calls that felt like 1990s coding. But honestly, if you're using pgx/v5, it's not even that bad anymore. Between pgx.CollectOneRow and pgx.RowToAddrOfStructByName, the generic-based helpers make the manual scanning part almost disappear for most queries.

// Using pgx/v5 generics
p, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[Account])
if err != nil {
    return nil, err
}

It’s still manual in the sense that you’re writing the query and choosing the mapping strategy, but it’s not the carpal-tunnel-inducing slog I thought it was. It’s the right balance of control without the "black box" performance issues you get with a full-blown ORM.

The "Domain" as a Buffer

I treat internal/domain as a DMZ. It’s nothing but interfaces and structs. No database tags, no JSON tags, no business logic. It defines what the service does without caring about the how.

The logic package implements these interfaces. It never imports api or store. This is the only way I've found to keep testing from becoming a nightmare. I don't need a real Postgres instance to test a pricing calculation; I just pass a mock that satisfies the domain.Store interface and I'm done.

The One-Minute Rule

If I can't find a specific database query or a business rule within 60 seconds of opening the project, the structure has failed. I rely on fzf for everything, but the layout is also built so that LSP "goto definition" actually lands me in the right implementation, not some generated proxy or a massive interface file. internal/store/postgres/account.go tells me exactly what I'm getting.

I assumed that more "sophisticated" layouts with transport/ and endpoints/ and service/ folders would help as the project grew, but it turned out to just be more cognitive load. Simple and flat usually wins.

Asaduzzaman Pavel

About the Author

Asaduzzaman Pavel is a Software Engineer who actually enjoys the friction of a well-architected system. He has over 15 years of experience building high-performance backends and infrastructure that can actually handle the real-world chaos of scale.

Currently looking for new opportunities to build something amazing.