Folders aren't the framework. Responsibilities are.
If you've watched a Go backend tutorial, you've probably seen a project structured like this:
project/
├── cmd/api/main.go
├── internal/
│ ├── handlers/
│ ├── services/
│ ├── repository/
│ ├── database/
│ └── config/
├── migrations/
├── go.mod
├── go.sum
└── Makefile
It looks like a rulebook. It isn't. This is one convention among several, and the value isn't in the folder names — it's in the idea that different kinds of code shouldn't get tangled together. This post walks through why that separation exists, where the real decision points are (interfaces, validation, testing), and where people — including tutorials — tend to overstate things as "rules" when they're really trade-offs.
1. Follow One Request Through the System
The fastest way to understand the layout is to trace POST /users through it:
Client → Handler → Service → Repository → Database
-
cmd/api/main.go— wires everything together (dependency injection). It doesn't do business work; it assembles the app: config → database → repository → service → handler → router → server. -
internal/handlers/— the HTTP layer. Parses requests, does shape-level checks, calls the service, writes the response. -
internal/services/— business logic. Doesn't know or care whether the caller was HTTP, gRPC, or a CLI. -
internal/repository/— data access. Knows how to talk to the database; the service doesn't need to know the SQL. -
internal/database/— connection setup, pooling, health checks. Distinct from repository, which is about queries, not connections. -
internal/config/— environment-driven values (DATABASE_URL,PORT,JWT_SECRET) instead of hardcoded strings. -
migrations/— versioned, reproducible schema history (001_create_users.up.sql, etc.) via tools likegolang-migrateorgoose. -
go.mod/go.sum— module definition and dependency checksums, both committed to source control. -
Makefile— shortcuts (make run,make test) so nobody has to memorizego build -o bin/api ./cmd/api.
internal/ isn't decorative, either — Go enforces it at compile time. A package inside internal/ can only be imported by code within the parent module, which is exactly what you want for application-specific code that isn't a public library.
2. The Full File & Folder Catalog
Beyond the core layers, a realistic Go backend accumulates a handful of other files and folders. Here's what each one is actually for — and which ones are load-bearing vs. optional.
Root-level files
-
go.mod— declares the module (module github.com/you/project), the Go version, and lists dependencies. Updated automatically when you rungo get. -
go.sum— cryptographic checksums for every dependency, so builds are verifiable and reproducible. Think ofgo.modas "what I need" andgo.sumas "and here's proof it's the expected content." Both get committed; you basically never hand-editgo.sum. -
Makefile— not Go-specific, just a shortcut layer (make run,make test,make build,make fmt) so nobody has to remember the fullgo build -o bin/api ./cmd/apiincantation. -
.env/.env.example—.envholds real environment values (DATABASE_URL=...,JWT_SECRET=...) and should be gitignored, never committed..env.exampleis the checked-in template with blank or fake values, so anyone cloning the repo knows what variables exist without seeing real secrets. -
.gitignore— keeps.env, build output (/bin), temp files, and logs out of version control.
cmd/ — your executables
cmd/
├── api/main.go → HTTP server
├── worker/main.go → background job processor
└── migrate/main.go → migration runner
cmd/ answers "what programs does this repo produce?" — one subfolder per binary. A project can have just api/, or grow to include a worker process, a migration CLI, etc., all sharing the same internal/ packages.
internal/main.go — what it's actually doing
func main() {
cfg := config.Load()
db := database.Connect(cfg.DatabaseURL)
repo := repository.NewUserRepository(db)
service := services.NewUserService(repo)
handler := handlers.NewUserHandler(service)
router := setupRouter(handler)
http.ListenAndServe(":8080", router)
}
This is dependency injection / wiring, not business logic. Its only job is assembling the chain: config → database → repository → service → handler → router → server.
Other common internal/ folders
-
middleware/— code that runs around handlers, not inside them: logging, authentication, rate limiting, panic recovery. Order matters — a request typically passes through logging → auth → rate limiting → handler. -
models/— plain data structs (type User struct {...}). Not mandatory: many Go projects skip a dedicatedmodels/folder and instead colocate amodel.goinside a feature package (e.g.internal/user/model.go). -
routes/— maps URLs to handlers (router.POST("/users", userHandler.Create)). Some projects keep this directly inmain.goinstead, or call itserver/,router/, orhttp/. There's no universal convention here. -
dto/— Data Transfer Objects, used when your database model shouldn't be exposed as-is over the API (e.g. aUserstruct hasPasswordHash, butUserResponsestrips it out before serializing to JSON). Useful once an API's public shape diverges from its storage shape; unnecessary for small projects where they're identical.
Outside internal/
-
migrations/— versioned schema changes (001_create_users.up.sql/.down.sql), run through a tool likegolang-migrateorgooseso any empty database can be brought up to the current expected schema. -
api/— API contract files, typically OpenAPI/Swagger specs (openapi.yaml). Can be used to generate docs, client SDKs, or server interfaces. -
docs/— project documentation (architecture.md,deployment.md) — plain and self-explanatory. -
scripts/— one-off operational scripts that aren't application code:seed.sh,setup.sh,deploy.sh. -
tests/— usually not needed. Go's idiom is to keep tests beside the code they test (user.gonext touser_test.go), not in a separate top-level folder.go test ./...finds them regardless of nesting.
pkg/ — the controversial one
pkg/
├── logger/
├── auth/
└── validator/
The intent behind pkg/ is "code here is meant to be imported by external projects." It's popular in some repos, but it's not required, and it's one of the most commonly cargo-culted folders — people arrive assuming internal = private and pkg = public as a hard Go rule and sort everything into one or the other without asking whether they're actually building a reusable library. If you are genuinely building something importable, it can just live at the repo root as its own package; if it's application-specific, it belongs in internal/ regardless of whether pkg/ exists.
Core vs. optional, at a glance
| Tier | Folders/files |
|---|---|
| Core, almost always present |
cmd/, internal/, go.mod, go.sum
|
| Very common in backend APIs |
handlers/, services/, repository/, database/, config/, migrations/
|
| Common, depending on project size |
middleware/, models/, routes/, api/, docs/, scripts/
|
| Add deliberately, not by default |
pkg/, utils/, helpers/, common/, dto/
|
3. Why Bother With a Repository Interface at All?
This is the first place people either over-engineer or under-engineer. The point of an interface isn't the interface — it's what it decouples.
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, u *User) error
}
type UserService struct {
repo UserRepository // depends on the interface, not the struct
}
Two real benefits:
- Testability. In a service-layer unit test, you can hand the service a fake repository that returns canned data — no real database required.
- Swappability. Move from Postgres to something else, or add a caching layer in front of it, and the service code doesn't change. It only knows about method signatures.
The Go-specific twist: define the interface in the consuming package (next to UserService), not next to the implementation (postgresUserRepo). This is the opposite of the Java/C# habit of pairing an interface with its implementation. Go uses structural typing — no implements keyword — so the consumer just declares "here's what I need," and any type with matching methods satisfies it automatically.
When to actually add one — ask:
- Will there be more than one implementation? (Real DB + test mock counts as two.)
- Am I crossing a layer boundary where I want to decouple the consumer from the implementation?
- Will this need to be swapped or faked in a test?
If a function has no external dependency (no DB, network, or filesystem), it usually doesn't need an interface — test it directly. For a small CLI tool or a single-implementation package nobody's mocking, skip it. Adding one later, when a second implementation shows up, is a five-minute refactor in Go — not the architectural commitment it is in some other languages.
4. Validation Isn't One Thing — It's (At Least) Two
A common misconception is "validation happens in the service layer." In practice it splits cleanly by what kind of question is being asked:
| Layer | Validates | Example |
|---|---|---|
| Handler | Is the request well-formed? | JSON parses, required fields exist, types are right, string fields match an expected shape (email, UUID, date) |
| Service | Is this operation semantically allowed? | Is this email already registered? Does the user have permission? Is the order in a cancellable state? |
Type checking mostly happens for free during JSON decoding — send a string where an int is expected and it fails at bind time. Struct tags plus a library like go-playground/validator handle shape-level checks declaratively:
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,gte=18"`
}
Normalization (trimming whitespace, lowercasing an email) also tends to live at the boundary — it's not quite validation, it's cleanup before validation runs.
The service layer then checks things that require business context or a database lookup — things the handler structurally cannot know:
func (s *UserService) CreateUser(ctx context.Context, req CreateUserRequest) error {
existing, _ := s.repo.GetByEmail(ctx, req.Email)
if existing != nil {
return ErrEmailAlreadyExists // business rule, not a shape check
}
// ...
}
Why not just put everything in the handler? Because the moment you add a second entry point — a CLI tool, a background import job, a gRPC service — business rules enforced only in the HTTP handler get silently skipped or duplicated. Centralizing them in the service means every entry point shares the same rules.
One thing worth flagging explicitly: this isn't double validation of the same fact. The handler checks shape; the service checks meaning. They're answering different questions, not repeating the same one.
5. What Actually Needs a _test.go File?
Not everything, despite what "one test file per source file" suggests as an ideal. The convention (foo.go + foo_test.go, same package, same directory) is real and idiomatic — Go's tooling assumes it — but applying it everywhere isn't a rule people actually follow.
-
Write tests for: anything with branching logic, business rules, edge cases, parsing/validation, error paths. This is most of what lives in
services/. -
Usually skip: trivial getters,
main.go, pure wiring/DI code, thin pass-through structs with no logic.
A decent gut check: if you can't imagine a bug living in that file, you probably don't need a test for it.
Test types map onto the layers differently:
- Service tests are usually the highest-value unit tests — swap in a fake repository, and you're testing business rules without touching a database.
-
Handler tests use
net/http/httptestwith a fake service, checking status codes and response shape for well-formed vs. malformed input. - Repository tests are more often integration tests — you're really verifying the SQL and scanning logic work against a real (often ephemeral/test) database, not something a fake can substitute for.
- Config tests are worth having when there's real logic (defaults, required-field checks) — not just for the sake of coverage.
A rough test pyramid: many unit tests (services, pure logic), some integration tests (repositories, key HTTP flows), few end-to-end tests (critical user journeys only).
6. Misconceptions and Pitfalls Worth Naming Explicitly
These are the places where tutorials (and the source material this post is based on) tend to state something as more universal than it actually is, or where beginners commonly overreach:
"This is the standard Go structure." It isn't. There's no official Go backend layout. The layered structure (
handlers/services/repository/) and the feature-oriented structure (internal/user/,internal/order/, each bundling its own handler+service+repo+model) are both legitimate, with different scaling trade-offs. Layered structure keeps related types of logic together; feature-oriented keeps related domains together. Neither is "correct" — they're different answers to "what will I need to find quickly six months from now?""
internal/is private,pkg/is public" as a default. This is a carried-over assumption from other ecosystems. Go doesn't require it. If something is application-specific,internal/is usually right regardless of whether you also have apkg/. Don't createpkg/just because a template has one.Interfaces "because clean architecture says so." Defining a
UserRepositoryinterface with every CRUD method the concrete type has — without ever swapping the implementation or mocking it in a test — is abstraction for its own sake. The interface should describe what the consumer needs, not mirror the implementation's full surface area.Repositories as an assumed requirement. For a small application,
handler → databasedirectly is a legitimate choice. Repositories earn their keep as the codebase and test surface grow, not by default.utils/as a junk drawer. A package should have one clear responsibility. Autils/folder that accumulatesstring.go,date.go,auth.go,email.gois a sign that logic wasn't given a real home — and it tends to grow, unlike everything else, without anyone questioning it.Sanitization vs. validation vs. escaping, treated as one thing. They're not interchangeable:
strings.TrimSpaceis normalization, checking an email format is validation, escaping output for HTML display is contextual encoding, and preventing SQL injection is about parameterized queries — never manual string sanitization. Conflating these leads to either redundant checks or, worse, gaps (e.g., assuming "I validated the input" also means "I'm safe from SQL injection," which it doesn't — parameterization is a separate, non-optional step).Assuming
internal → repository → databaseis the only safe direction, without saying why. The dependency direction matters because it keeps lower layers ignorant of higher ones: a repository shouldn't import anything HTTP-related, and a database package shouldn't know a handler exists. If you find a lower layer importing from a higher one, that's usually a sign a responsibility landed in the wrong place — not just a style nit.Treating 100% test-file coverage as a proxy for correctness. A
main.go_test.goor a test for a one-line getter doesn't reduce risk much; it mostly pads a coverage number. Test where a bug is plausible, not where a file exists.
The One Thing to Actually Remember
Don't memorize folder names — memorize the three questions:
- Is this about HTTP? → Handler.
- Is this a business rule? → Service.
- Is this about storing or retrieving data? → Repository.
And a fourth question, orthogonal to the first three: does this layer need to know the concrete implementation it's talking to, or just the capability? That's what an interface is for — nothing more mystical than that.
Start simple. A 500-line service might genuinely only need main.go, a couple of packages, and go.mod. Add structure when the pain of not having it shows up — not because a tutorial's folder tree looked complete.
Top comments (0)