Stage 7 · Master
Project Foundations & Workspace Layout
cmd/ and internal/ Conventions
main.go just picked up a second route and a bit of logic. That's the actual trigger for internal/ — not a rule that says 'good Go projects have one.'
The Trigger
The previous lesson left main.go with one route: /healthz. Now we're adding a second one — a placeholder /v1/ping endpoint that later lessons will build real handlers next to. The moment main.go has two routes plus the beginning of a pattern for adding more, it's worth asking: does main still want to own this, or is 'HTTP wiring' its own concern now?
This is the actual test for when to introduce internal/ — not 'day one, because best practice.' If main.go's job is still just 'start the process and stop,' leave it alone. The moment it starts accumulating route definitions, middleware, or anything with actual branching logic, that's the signal to extract a package. That signal just arrived.
Any package under internal/ can only be imported by code rooted at the same parent directory. It's not just convention — the compiler refuses the import for anyone outside that boundary. That's why it's worth reaching for once there's something worth protecting, and not before.
Extracting internal/httpserver
We're introducing exactly one new package: internal/httpserver. Its job is narrow — own the http.Handler and the route table. It is not a home for business logic, database access, or config parsing; those don't exist yet, and when they do (Modules 3 and 4), they'll get their own packages introduced the same way this one was: because a specific file started doing too much.
internal/httpserver/server.gocreateResponsibility: Builds the http.Handler: registers every route this service exposes, in one place.
Why now: main.go had two routes and no clear owner for 'what routes exist.' Extracting that into its own package means adding a third route later means editing one focused file instead of main.go.
Connects to: Replaces the inline mux.HandleFunc calls that were living directly in cmd/api/main.go from the previous lesson.
cmd/api/main.gomodifyResponsibility: Process entrypoint only: construct the handler, start the server, handle the top-level error.
Why now: Now that routing has a home, main.go goes back to doing exactly one job — starting the process — which is what it should always be doing.
Connects to: Calls httpserver.New() instead of building the mux inline.
What Stays in main.go
The rule going forward: if a piece of code answers 'how does this process start and stop,' it stays in main.go. If it answers any domain-shaped question — what routes exist, what a request means, how data is validated or stored — it belongs in a package under internal/. That boundary gets more valuable every module from here: by the time config loading, database access, and auth all exist, main.go should still be readable top to bottom in about ten seconds.
Why One Package, Not Six
It would be easy to justify creating internal/httpapi, internal/repository, internal/service, and internal/platform right now, all at once, 'since we know they're coming.' Resist that. There's no repository yet because there's no database. There's no service package yet because there's no domain logic — /v1/ping doesn't have any. Creating those packages empty, ahead of the code that would actually live in them, just recreates the same problem the previous lesson rejected: structure that describes a future decision instead of a present one.
| Package | Exists now? | What would trigger it |
|---|---|---|
| internal/httpserver | Yes — just created | main.go had more than one route to own |
| internal/repository | No | The first piece of code that needs to read or write Postgres (Module 3) |
| internal/service | No | The first piece of actual domain logic with branching rules (Module 3) |
| internal/config | No | The next lesson, once there's more than one setting to manage |
The compiler enforces the import boundary, not good naming or single responsibility. A package under internal/ can still turn into a junk drawer if it accumulates unrelated logic. The convention only pays off if every package name still describes exactly one job — which is also why we're keeping to one package this lesson, not four.
The Decision
- internal/httpserver is introduced now because main.go crossed the line from 'starts a process' to 'also decides what routes exist.'
- Exactly one new package, because exactly one new concern (route ownership) appeared. No other internal/ subpackages are created until their own trigger arrives.
- cmd/api/main.go returns to being a pure composition root: build the handler, run the server, handle the top-level error.
- Every future internal/ package in this course gets the same test applied before it's created: what specific file, doing too much, is this extraction solving for?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.