The structure of a project (1): the standard that wasn't a standard
By Dorian Chávez · founder and CEO of Hábil ·
The most popular Go structure repo says it is not official. Go's guide asks for cmd/ and internal/, and the tool enforces only internal/. With a template.
The structure of a project · installment 1 of 7 · Go
Scenario: the characters and the story are made up. The technical data and the quotes carry their source at the end.
In 1 minute
- The most popular Go "standard layout" repository (56.7 thousand stars on October 22, 2026) says in its README that it is not an official standard and that, for a simple project, it is overkill [2][3].
- Go's official guide asks for little for a server: the programs in
cmd/and the logic ininternal/[1]. - Of the folders where your code lives, only
internal/sets a boundary that thegotool enforces: in practice, another module cannot import it [6][7]. - You take away a template that you clone and that runs on the first try, with a script that proves that boundary by turning red when it should.
- For Monday: open your repository and ask each folder what problem it solves today. The one that can't answer is surplus.
In this article
- What happened at 23:40?
- What does Go's guide really ask for?
- What are the five most common mistakes?
- Exercise: can you turn the boundary red?
- Key takeaways
- What's next?
- The template and the course
- What does Hábil do?
- References
What happened at 23:40?
Iván inherited a 300-line program spread across fourteen folders, and none of them told him where to put a new function.
Monday, 23:40. Empty office, third floor.
It was his first day as a work-study student. Seventh semester, coffee cold since nine, and a single task on the board: "Make the checker warn when a service takes longer than one second."
The checker was a Go program that queried a list of services over HTTP and reported which ones responded. Rodrigo had written it, the previous work-study student, who left on Friday leaving nothing behind but the repository.
Iván cloned it and sat staring at the screen.
api/ assets/ build/ cmd/ configs/ deployments/ docs/
examples/ init/ internal/ pkg/ scripts/ test/ web/Fourteen folders for about 300 lines of Go. api/: a README.md that said "the contract goes here." web/: empty. deployments/: a file from 2019 about a server that no longer existed. pkg/util: eleven functions with nothing to do with one another.
The task was small. Ten lines, maybe. But where? In pkg/util, next to a function that formatted dates? In internal/? Were there rules, or was it habit? Rodrigo's README said: "Standard Go structure," with a link.
He followed it: golang-standards/project-layout, more than 56 thousand stars. Iván breathed out. If that many people used it, it had to be the standard. And in the second paragraph of the README's introduction, in bold, he found something he didn't expect:
«NOT an official standard defined by the core Go dev team» [2].
It is not an official standard from the Go team. The repository itself says so.
Iván leaned back in his chair. If the standard wasn't a standard, which part of those fourteen folders really mattered?
At 23:58 he wrote to Lucía, the tech lead. The answer came in two minutes:
—Of those fourteen, which one does the tool watch? Which one changes what the go command lets you import?
Iván looked at the list again. None of them looked like it was watching anything.
—I don't know —he wrote.
—One. Tomorrow I'll show you which one, and why the other thirteen are opinion.
What does Go's guide really ask for?
For a server, Go's official guide fits in two folders: the program in cmd/ and everything else in internal/.
It is called Organizing a Go module [1] and it goes from smallest to largest, in seven cases: a package, a command, either of the two with supporting packages, multiple packages, multiple commands, packages and commands together, and finally a server. For that last case it says two things.
First: a server usually has no packages for others to import, so its logic goes in internal/ [1]. Second: since it tends to have many non-Go folders, it is a good idea to gather the programs in cmd/ [1].
That's all. It doesn't name pkg/; non-Go folders, like api/ or web/, it leaves up to you [1].
In 2021, in a discussion on that same repository, Russ Cox put it more briefly: the minimum standard for an importable Go repository (one whose code others import) is a LICENSE and a go.mod at the root, and the code wherever suits you. «That's it. That's the "standard"», he wrote [5]. And he added that not even cmd/ is required [5].
This installment's template, estructura-go, follows the guide to the letter:
estructura-go/
├── go.mod
├── README.md
├── LICENSE
├── LICENSE-CONTENIDO.md
├── cmd/
│ └── revisor/main.go el programa: lee la configuración, arma y arranca
├── internal/
│ ├── servicio/ el vocabulario: Servicio, Estado. Sin red ni archivos
│ ├── revisar/ consulta muchos servicios a la vez
│ ├── httpapi/ rutas, JSON y errores
│ └── config/ banderas, entorno y la lista de servicios
├── testdata/servicios.json
├── herramientas/probar-frontera.sh
└── .github/workflows/verificar.ymlLet's go folder by folder, and in each one the question is the same: what problem does it solve today?
Why does the program live in cmd/?
main.go is thin on purpose: if something in there deserves a test, it no longer belongs there.
cmd/revisor/main.go reads the configuration, assembles the pieces from internal/ and starts the server. It doesn't decide what a healthy service is or how it is queried. Why so lean? Because main ends the process with os.Exit and reads the real environment: whatever stays there stays untested. The template uses an ejecutar function that returns an error, and the test calls it with a free port.
A second program, such as a nightly job, would go in cmd/tarea-nocturna/ and would share everything in internal/.
What is special about internal/?
internal/ is the only folder in the tree with a boundary that the go tool enforces: the code that lives there cannot be imported from outside the tree that contains it; in practice, from another module.
According to the go command documentation, a package inside an internal folder can only be imported by code that shares the path above that folder [6]. If your module is github.com/tu-usuario/revisor, that whole module can use revisor/internal/servicio; an outside module cannot. Watch out: it goes by path, not by module. A module named github.com/tu-usuario/revisor/otro could, and the boundary script uses that case.
It is not enforced by the language but by the go command. The Go 1.4 release notes put it this way: the language has no way to impose that distinction, and the go command introduces a mechanism to define «internal» packages [7]. Since Go 1.5 it applies to any repository [7][8].
And what does a server get out of it? Whatever nobody outside imports, you can change without breaking anything for anyone. That is why the guide advises keeping as much as possible in internal/ [1].
And Iván's other thirteen folders? None of them decides who imports whom. The go command treats two other names differently, and neither is on his list. It ignores testdata/ [6].
vendor/ does block the way: if a module imports something through another module's vendor/ path, the tool answers use of vendored package not allowed (measured with Go 1.27.1). But your code doesn't go there: it holds copies of your dependencies, and go mod vendor deletes and rebuilds it every time [20]. For your code, the boundary is internal/.
Why does servicio/ import no one?
The package everyone depends on depends on none, and that is why it cannot close a cycle.
internal/servicio/ defines Servicio (name, URL, time limit) and Estado (what it answered, how long it took, why it failed). It doesn't open connections, doesn't read files, knows nothing about JSON. Every package in internal/ imports it, and it imports no one in the project:
| Package | Imports |
|---|---|
cmd/revisor | config, httpapi, revisar |
httpapi | revisar, servicio |
revisar | servicio |
config | servicio |
servicio | no one in the project |
It isn't aesthetics: the specification forbids a package from importing itself, directly or indirectly [11]. Since servicio imports no one, it cannot be part of a cycle. And since the other arrows go in one direction only (cmd → httpapi → revisar → servicio), today there are none.
The name counts too: by convention, packages have lowercase, single-word names [17]. servicio says what is inside; models or types would only say what kind of thing it is.
Where does the interface go?
The interface lives in the package that uses it, not in the one that implements it.
revisar queries many services at once, and for that it needs someone who queries one. It declares it right there:
// Consultor es lo único que Todos necesita: alguien que consulte UN servicio y
// diga cómo lo encontró. Un método, y nada más.
type Consultor interface {
Consultar(ctx context.Context, s servicio.Servicio) servicio.Estado
}
func Todos(ctx context.Context, c Consultor, servicios []servicio.Servicio, paralelo int) []servicio.EstadoThe real query is a concrete type, *revisar.HTTP, which satisfies that interface without declaring it. The test double, consultorFalso, lives in a _test.go and doesn't ship with the program.
That is what Go Code Review Comments, the project's own review guide, asks for: generally, interfaces belong in the package that uses the values, not in the one that implements them [10]. It is mistake 4 below.
And the HTTP door?
httpapi only translates: from request to call, and from result to JSON.
It has three routes: GET /salud, GET /servicios and GET /servicios/{nombre}, with the standard library's router. Since Go 1.22, net/http understands the method and wildcards in the pattern, and it answers a POST with 405 Method Not Allowed, listing the valid methods in the Allow header [12]. Measured on the template: Allow: GET, HEAD.
The errors the template writes come out in the format of RFC 9457, Problem Details [13]; the ones the router answers on its own, like that 405 or a route that doesn't exist, come out as plain text (measured). Without an error type of its own, it uses about:blank, and the title is the code's standard phrase, such as «Not Found» [13]:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"about:blank","title":"Not Found","status":404,"detail":"No hay ningún servicio llamado \"nomina\".","instance":"/servicios/nomina"}One more thing, because a template also teaches bad habits: each service's URL comes out without a username, password or parameters. https://usuario:clave@... or ?token=... carry a secret, and whatever goes out through an API ends up in other people's logs. The configuration rejects the first and the door strips the second, each with its own test. The path does come out, because it says what is being checked: a token stuck in the path would be published in full.
What is testdata/ and why doesn't it get in the way?
The go command ignores folders named testdata: they are the place for supporting data.
That leaves it available for the data the tests need [6]. In the template it holds servicios.json, the example list, and a test validates it: if someone breaks it, they find out before the first reader does.
So where do Iván's ten lines go?
The rule goes in the domain; the door only publishes it.
"Warn if a service takes longer than one second" has two parts. Deciding what is slow is the program's vocabulary, just like deciding what is healthy: it goes in servicio, as a method of Estado next to OK(), and its test needs no network. Saying it to the outside is httpapi's job: one more field in its JSON. If the one second must change without recompiling, config reads it. No new folder.
On Tuesday, Iván wrote Lento() in servicio, its test, and a field in httpapi. Ten lines, as he thought. None in pkg/util.
How do I know the boundary exists?
Because CI breaks it on purpose on every change and demands that the tool reject it.
A rule that nobody checks is a rule that nobody knows is still alive. That is why the template ships herramientas/probar-frontera.sh, with two experiments in a temporary folder:
- Control. A module whose path starts the same as the template's (
…/estructura-go/control) importsinternal/servicio. The rule goes by path, so it lets it through: it has to compile. If it doesn't compile, the experiment is set up wrong and the script stops in red. - Violation. A module with an outside path (
ejemplo.com/intruso) imports the same package, the same way. It has to fail, and with the rule's message.
This is the real output:
1/2 control: github.com/HabilMX/estructura-go/control importa github.com/HabilMX/estructura-go/internal/servicio
✅ compiló: con la misma ruta de arriba, la regla lo deja pasar
2/2 violación: ejemplo.com/intruso importa github.com/HabilMX/estructura-go/internal/servicio
package ejemplo.com/intruso
main.go:3:8: use of internal package github.com/HabilMX/estructura-go/internal/servicio not allowed
✅ la herramienta go lo rechazó por la regla de internal/
✅ frontera: se sostieneWhy the control? Because an experiment that only expects a red also turns "green" when it fails for the wrong reason, like a mistyped path. The control looks up the package the same way as the violation, so a broken setup turns it red instead (we checked by breaking the path).
And the violation demands the rule's exact message, not just any failure. Between the two steps only the importer changes: the rule is proven from both sides.
GitHub's CI runs that script along with gofmt, go vet, the tests with the race detector and the build of cmd/, on every change, manually and every Monday, in case a new version of Go breaks something. If the repository goes 60 days without activity, GitHub turns off the Monday run and you have to turn it back on [21].
What are the five most common mistakes?
The first three come from creating the folder before the problem it solves; the last two, from putting code on the wrong side of an arrow.
1. Copying golang-standards/project-layout into a small project
The repository itself advises against it for that.
The README says that, if you are learning Go or building a proof of concept or a simple project, that structure «is an overkill» [2]. And it recommends starting with a main.go and a go.mod [2]. The repository has 19 folders at the root [3]. Copying them all means inheriting nineteen unanswered questions, as happened to Iván.
2. Creating pkg/ out of habit
The official guide doesn't mention it, and Russ Cox pointed out that the vast majority of the ecosystem doesn't use it.
In issue #117 of that repository, on April 9, 2021, Russ Cox wrote that «the vast majority of packages in the Go ecosystem do not put the importable packages in a pkg subdirectory» [4]. The official guide doesn't use pkg/ in any example [1]. And a server usually has nothing to export, so its logic goes in internal/ [1].
3. util, common or misc packages
A name that doesn't say what's inside becomes a drawer where everything lands.
The Go blog explains it in Package names: «Packages named util, common, or misc provide clients with no sense of what the package contains» [9]. Over time they pile up dependencies and slow down compilation [9]. Go Code Review Comments adds api, types and interfaces [10]. The way out: split the drawer and name each part for what it does (fecha, notificar).
4. Declaring the interface where it is implemented, "for the mocks"
The interface goes on the side of whoever uses it; the implementing side returns a concrete type.
Go Code Review Comments says it plainly: «Do not define interfaces on the implementor side of an API “for mocking”» [10]. And it asks you not to declare one before using it: without a real use you can't know which methods it needs [10]. In the template, revisar declares Consultor because it uses it, and the double lives in a _test.go.
5. Splitting by layers until you close a cycle
If models needs something from services and services imports models, the program stops compiling.
It happens with folders by layer (handlers/, services/, models/): sooner or later, the lower one needs something from the upper one. The specification forbids it [11], and go build puts it this way (measured):
package ejemplo.com/capas/models
imports ejemplo.com/capas/services from models.go
imports ejemplo.com/capas/models from services.go: import cycle not allowedThe opposite extreme doesn't help either: Andrew Gerrand warned in 2012 that splitting into packages that are too small leaves you stuck designing interfaces instead of getting the job done [16]. The template's way out: a domain package that imports no one, and the arrows pointing at it.
| Mistake | What gives it away | Source |
|---|---|---|
| 1. Copying the full layout | Empty folders or folders with a single file | [2] |
2. pkg/ out of habit | A server with a pkg/ that nobody imports | [1][4] |
3. util, common, misc | Functions that have nothing to do with one another | [9][10] |
| 4. Interface on the implementing side | An interface with a single implementation and a Mock next to it | [10] |
| 5. Layers that import each other | import cycle not allowed | [11] |
Exercise: can you turn the boundary red?
You are going to break the boundary on purpose, and the tool will tell you, on its own, whether you managed it.
You need Go 1.27 or newer (go version), Git and bash, about 20 minutes. If your system ships an older Go, like the Linux Mint repositories, install it from go.dev [22]. Linux and macOS already ship bash; on Windows, use WSL or Git Bash.
Step 1. Clone and check that everything is green.
git clone https://github.com/HabilMX/estructura-go
cd estructura-go
go test ./...
bash herramientas/probar-frontera.shWhat you should see: five lines starting with ok and, at the end, ✅ frontera: se sostiene.
Step 2. Predict before you touch anything. Write down what happens if you rename internal/ to interno/ and fix the imports. Does it compile? What does the script say?
Step 3. Rename and fix the imports.
Copy only the sed line for your system: the macOS one needs an empty argument after -i.
mv internal interno
# En Linux:
grep -rl 'estructura-go/internal/' --include='*.go' . | xargs sed -i 's#estructura-go/internal/#estructura-go/interno/#'
# En macOS:
grep -rl 'estructura-go/internal/' --include='*.go' . | xargs sed -i '' 's#estructura-go/internal/#estructura-go/interno/#'
go build ./...If go build says nothing, it compiled: the program doesn't need internal/ to work.
Step 4. Run the script.
bash herramientas/probar-frontera.shIt should come out red, but look at which red:
❌ el control NO compiló; el experimento está mal montado:The script is still looking for internal/servicio, which no longer exists, and it refused to give you a green. That is the control at work.
Step 5. Point the script at the new package. Open herramientas/probar-frontera.sh, find the line that starts with paquete= and change internal to interno. Run the script again:
❌ COMPILÓ: otro módulo pudo importar github.com/HabilMX/estructura-go/interno/servicio. La frontera no existe.Now it does: an outside module imported your code and nobody stopped it. With another name, the folder is just a folder.
Step 6. Put everything back.
git checkout -- . && git clean -fd
bash herramientas/probar-frontera.shWatch out: git clean -fd also deletes any new files you created there, like your notes.
How you know you got it right: you saw three different outcomes in four runs, in this order: green in step 1, red "el control NO compiló" in step 4, red "COMPILÓ" in step 5, and green again in step 6. If "el control NO compiló" repeats in step 5, the paquete= line didn't end up with interno (or you didn't save). If it comes out green, internal/ still exists: check step 3.
To go further (optional): add a function to servicio that uses something from revisar. Before compiling, predict the message. Then compare it with the one from mistake 5.
Key takeaways
Three questions to ask your repository this week.
- For each folder at the root: what problem does it solve today? If the answer is "just in case someday," delete it and note it in the README.
- If another team imported your code tomorrow, which packages would you let them use? Is everything else in
internal/? - Which is the package everyone depends on? Does it import anything from the project? If it does, that's where the next cycle is.
What's next?
In Go, the tool watches the boundary. In Python, the installation watches it, and it is easy not to notice until it blows up.
Three days later, Iván was moving folders around without fear. Then he got a Python project. The tests, green. His teammate installed the package: ModuleNotFoundError.
The file existed. It was in the folder. The tests found it. But it had never made it into the package.
How does a test pass on code that doesn't exist for whoever installs it? In installment 2: a folder called src/, and why it changes everything.
The template and the course
- Template: github.com/HabilMX/estructura-go. Use it as a GitHub template or clone it [19]. The README explains each folder, what it doesn't include, when to add each thing and how to remove the example. The code is MIT; the text and explanations are CC BY-SA 4.0.
- Versions:
go.modrequires Go 1.27, the version released in August 2026 [15]. It was tested with Go 1.27.3, the latest stable release on October 22 [14], and it uses only the standard library. CI takes the version fromgo.modand looks for the latest 1.27 patch [18]. - Hábil's Go course: if
internal/, packages or table-driven tests are new to you, lesson 5 covers them from scratch: Go course · Lesson 5, Modules and tests.
I write from what we see every day at Hábil, where we build systems for banking, insurance and companies in Mexico. If at work or at school you run into a repository nobody knows where to touch, let's talk: habil.mx
What does Hábil do?
We teach programming with the same things we use to build: tested, explained and open code.
This series comes from the same place as our courses. The Go course goes from zero to writing a good program, and we publish it openly, under a Creative Commons license; its lesson 5 covers the modules, packages and tests that this article's template uses. And the catalog has more programming courses, free and with code tested and explained step by step.
See the Go course and the Programming courses catalog.
If your team inherits a system nobody knows where to touch, or needs to build one that can keep being maintained, we do that too: see Build your custom system. Request your first conversation, at no cost. In it we review whether it makes sense to configure what you already have, use a product or build, and what you would get in each case. Write to us on WhatsApp.
Sources accessed on October 22, 2026. Quotes in English are reproduced as is, without the original's italics or bold.
References
- Go Team, «Organizing a Go module», go.dev. https://go.dev/doc/modules/layout
- golang-standards, «Standard Go Project Layout» (README), GitHub. https://github.com/golang-standards/project-layout
- GitHub, data for the
golang-standards/project-layoutrepository (56,702 stars; 19 folders at the root; first by stars in the search «go project layout», 812 results, the next one with 954), measured on October 22, 2026. https://api.github.com/repos/golang-standards/project-layout - Russ Cox, «this is not a standard Go project layout», issue #117, April 9, 2021. https://github.com/golang-standards/project-layout/issues/117
- Russ Cox, comment on issue #117, April 28, 2021. https://github.com/golang-standards/project-layout/issues/117#issuecomment-828503689
- Go Team, «Command go»: «Internal packages» and «Test packages», pkg.go.dev. https://pkg.go.dev/cmd/go
- Go Team, «Go 1.4 Release Notes»: «Internal packages», go.dev. https://go.dev/doc/go1.4
- Go Team, «Go 1.5 Release Notes», go.dev. https://go.dev/doc/go1.5
- Sameer Ajmani, «Package names», The Go Blog, February 4, 2015. https://go.dev/blog/package-names
- Go Team, «Go Code Review Comments»: «Interfaces» and «Package Names», go.dev. https://go.dev/wiki/CodeReviewComments
- Go Team, «The Go Programming Language Specification»: «Import declarations» (version go1.27, May 26, 2026). https://go.dev/ref/spec#Import_declarations
- Jonathan Amsterdam, «Routing Enhancements for Go 1.22», The Go Blog, February 13, 2024. https://go.dev/blog/routing-enhancements
- M. Nottingham, E. Wilde and S. Dalal, «RFC 9457: Problem Details for HTTP APIs», IETF, July 2023. https://www.rfc-editor.org/rfc/rfc9457
- Go Team, Go downloads (current stable: go1.27.3). https://go.dev/dl/
- Go Team, «Go 1.27 Release Notes», go.dev. https://go.dev/doc/go1.27
- Andrew Gerrand, «Organizing Go code», The Go Blog, August 16, 2012. https://go.dev/blog/organizing-go-code
- Go Team, «Effective Go»: «Package names», go.dev. https://go.dev/doc/effective_go#package-names
- GitHub, «actions/setup-go» v5, README. https://github.com/actions/setup-go/blob/v5/README.md
- GitHub Docs, «Creating a template repository». https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-template-repository
- Go Team, «Go Modules Reference»: «Vendoring», go.dev. https://go.dev/ref/mod#vendoring
- GitHub Docs, «Disabling and enabling a workflow». https://docs.github.com/en/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows
- Go Team, «Download and install», go.dev. https://go.dev/doc/install
Does your operation face these challenges?
Prefer email? Write to us at hola@habil.mx