Stage 7 · Master
Phase 1 — Project Foundation
Folder Structure
Give the organization module a real internal/ package layout — one the Go compiler itself starts enforcing — before any business logic exists.
internal/ Is a Compiler Rule, Not a Naming Convention
Go treats any package whose import path contains a directory literally named internal specially: it may only be imported by code rooted at the parent of that internal directory. For services/organization/internal/model, that parent is services/organization — so only code inside services/organization can import it. A future services/resident-service module physically cannot import services/organization/internal/model, even accidentally; the compiler refuses. This is the one architectural boundary from ADR 0001 that does not rely on code review to hold.
The Package Map Every Phase 2 Lesson Fills In
| Package | Owns | First filled in by |
|---|---|---|
| internal/config | Typed configuration loaded from the environment | Configuration Management (this phase) |
| internal/server | HTTP engine construction, startup, and graceful shutdown | Dependency Injection (this phase), then Service Bootstrap (Phase 2) |
| internal/model | Go structs that mirror database rows — nothing else | Models (Phase 2) |
| internal/dto | Request and response shapes exposed over HTTP | DTOs (Phase 2) |
| internal/repository | SQL and the interface the service layer depends on | Repository Layer (Phase 2) |
| internal/service | Business rules — the only place decisions get made | Service Layer (Phase 2) |
| internal/handler | Binding HTTP requests to service calls and back | Handler Layer (Phase 2) |
| internal/middleware | Cross-cutting request behavior: errors, logging, metrics | Error Handling, Logging, Metrics (Phase 2) |
The Repository Map Is a Contract for Every Later Phase
| Root | Permanent responsibility |
|---|---|
| services/<service> | One deployable service, its internal packages, migrations, Dockerfile, and service-local manifests |
| pkg/<package> | A deliberately shared Go module with no dependency on any service's internal package |
| api/proto/<domain>/v1 | Versioned service-to-service gRPC contracts and generated-code inputs |
| deploy | Platform-wide observability and production deployment assets |
| docker-compose.yml | The single local application stack; test-only variants may live under infra/ |
Later lessons extend this map; they never rebrand services/ as an application tree, relocate pkg/ into a shared-library directory, or rename docker-compose.yml. A path shown in Phase 20 must still modify the repository created here. The course integrity test enforces this decision so a future content edit cannot silently restart the project under a new layout.
Why cmd/api Deliberately Stays Nearly Empty
cmd/api/main.go is the only file allowed to import every internal package at once — it is the composition root, formalized properly in the Dependency Injection lesson. Keeping it thin (load config, construct dependencies, start the server) means it never accumulates business logic that would otherwise be untestable, since nothing outside _test.go files can import the main package.
Replacing the One-File Placeholder With a Real Composition Root
internal/server now owns constructing the HTTP listener. It is deliberately built on net/http alone — Gin does not arrive until the Service Bootstrap lesson in Phase 2 — so this lesson can prove the folder boundary works without pulling in a dependency that belongs to a later topic.
// Package server constructs the organization service's HTTP listener.
// Gin replaces the bare net/http mux in Phase 2's Service Bootstrap lesson;
// this placeholder exists only to prove the folder boundary compiles.
package server
import "net/http"
func New(addr string) *http.Server {
mux := http.NewServeMux()
return &http.Server{
Addr: addr,
Handler: mux,
}
}
package main
import (
"log"
"github.com/hoa-platform/backend/services/organization/internal/server"
)
func main() {
srv := server.New(":8080")
log.Printf("organization service listening on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("server stopped: %v", err)
}
}
log.Fatalf is temporary. The Logging lesson replaces every log.* call in this file with structured zerolog calls once pkg/logging exists.
What the Compiler Actually Catches
To see the internal/ rule enforce itself, imagine a second module trying to reach into this one before it is even created. If a hypothetical services/resident-service/cmd/api/main.go contained import "github.com/hoa-platform/backend/services/organization/internal/model", compilation would stop with a specific, unambiguous message — not a lint warning, a hard build error.
use of internal package github.com/hoa-platform/backend/services/organization/internal/model not allowed
One boundary the compiler does not catch: a handler in internal/handler calling internal/repository directly, skipping internal/service. That would compile without complaint. Preventing it is a code-review discipline for now — the golangci-lint lesson later in this phase adds an automated depguard rule so this stops depending on review alone.
Running the Placeholder End to End
Applied exercise
Prove the internal/ boundary yourself instead of taking the compiler error on faith
Rather than trust the error text printed above, reproduce it.
- Create a second, throwaway module at services/scratch with its own go.mod and add it to go.work.
- In services/scratch/main.go, attempt to import github.com/hoa-platform/backend/services/organization/internal/server.
- Run go build ./... from inside services/scratch and record the exact compiler error.
- Delete services/scratch entirely and remove its line from go.work — it was only a demonstration.
Deliverable
A short note (in your own scratch file, not committed) with the exact error text you saw, compared against the one printed in this lesson.
Completion checks
- The error explicitly names the internal package path that was rejected.
- services/scratch and its go.work entry are removed afterward — nothing from this exercise should remain in git status.
Which of these boundaries does the Go compiler enforce automatically, without any lint configuration?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.