Day 75 — Package boundaries
Day 75 — Package boundaries
Stage VIII · ~3h
Goal: Refactor toward clear package boundaries using the Go proverb accept interfaces, return structs—with dependency direction that tests can fake without mocks-everywhere frameworks. Baseline Go 1.26.
Why this day exists
Stage VIII is architecture and capstone. Spaghetti main + god packages will fight every remaining day. Good boundaries give you:
- Unit tests without a database
- Swappable adapters (HTTP, CLI, gRPC)
- Readable import graphs
- Safer refactors before Days 82–90
This is not enterprise ceremony—it is how maintainable Go is written at scale. Day 76 will name the same ideas as hexagonal lite; today you draw the lines and move real code.
Theory 1 — The import graph is the architecture
cmd/app → wires everything (main only)
internal/httpapi → HTTP adapters (driving)
internal/app → use cases / services
internal/domain → entities + small shared types/errors
internal/store → implements ports (Postgres/SQLite)
Rule: dependencies point inward toward domain/policy, not outward toward frameworks.
# good direction
httpapi → app → domain
store → domain (implements interfaces defined in app or domain)
# bad direction
domain → httpapi
domain → database/sql driver details
app → net/http status codes (prefer mapping at edge)
internal/ keeps packages private to the module (Day 9 revisited with teeth). Anything under internal/ cannot be imported by external modules—good API surface control.
Visualize before refactoring
# optional: go install golang.org/x/tools/cmd/package-repository...
# or simply:
go list -f '{{.ImportPath}} -> {{.Imports}}' ./...Hand-draw ASCII in BOUNDARIES.md first—tools help later.
Theory 2 — Accept interfaces, return structs
// consumer defines the small interface it needs
package app
type UserStore interface {
Find(ctx context.Context, id string) (User, error)
Save(ctx context.Context, u User) error
}
type Service struct {
users UserStore // interface field
}
func NewService(users UserStore) *Service {
return &Service{users: users}
}
func (s *Service) Rename(ctx context.Context, id, name string) (User, error) {
name = strings.TrimSpace(name)
if name == "" {
return User{}, ErrInvalid
}
u, err := s.users.Find(ctx, id)
if err != nil {
return User{}, err
}
u.Name = name
if err := s.users.Save(ctx, u); err != nil {
return User{}, err
}
return u, nil // return concrete struct, not an interface
}// producer returns concrete type
package store
type Postgres struct{ db *sql.DB }
func NewPostgres(db *sql.DB) *Postgres { return &Postgres{db: db} }
func (p *Postgres) Find(ctx context.Context, id string) (app.User, error) {
// QueryRowContext ...
return app.User{}, nil
}
func (p *Postgres) Save(ctx context.Context, u app.User) error {
// ExecContext ...
return nil
}| Do | Don’t |
|---|---|
| Interfaces at consumer | Giant interfaces.go nobody owns |
| Small interfaces (1–3 methods) | IUserRepository with 40 methods |
| Return concrete structs | Returning interfaces “for flexibility” by default |
*Service constructors |
Global mutable stores |
Pass context.Context first |
Hidden timeouts in globals |
Why “return structs”?
Returning interfaces from constructors forces every caller to only see the interface, hides useful methods, complicates debugging, and often indicates speculative abstraction. Return *Postgres; let the consumer decide the interface width.
Theory 3 — Where interfaces live
Two valid styles in modern Go:
A) Consumer-side (preferred default)
Interfaces in app / package that uses them. Multiple consumers may define tiny overlapping interfaces (Go structural typing).
B) Port package
domain or port package holds interfaces when multiple adapters exist—leads into Day 76 hexagonal.
Anti-pattern: interface + impl only for “hiding”
// anti-pattern: interface + impl in same package only for testing
type Store interface { ... }
type store struct { ... }
func NewStore() Store { return &store{} } // hides type for no reasonReturn *store or *Postgres; let tests invent fakes implementing the consumer interface.
Theory 4 — Fakes over mocks
type memUsers map[string]User
func (m memUsers) Find(ctx context.Context, id string) (User, error) {
u, ok := m[id]
if !ok {
return User{}, ErrNotFound
}
return u, nil
}
func (m memUsers) Save(ctx context.Context, u User) error {
m[u.ID] = u
return nil
}Table-test the service with memUsers. Reserve heavy mock codegen for rare cases (complex interaction order verification). Day 17 skills apply directly.
func TestService_Rename(t *testing.T) {
users := memUsers{"1": {ID: "1", Name: "Ada"}}
svc := NewService(users)
got, err := svc.Rename(context.Background(), "1", "Ada Lovelace")
if err != nil {
t.Fatal(err)
}
if got.Name != "Ada Lovelace" {
t.Fatalf("got %q", got.Name)
}
}Theory 5 — Circular imports and how to break them
Classic cycle:
httpapi → store → httpapi # DTO types lived in httpapi
app → store → app # shared User type owned by store
Fixes:
- Move shared entities/errors to
domain(orappif only one consumer layer).
- Wire concretes only in
cmd/*/main.go.
- Define interfaces where used; implement in
store.
- Never let
domainimport adapters.
// domain/user.go
package domain
type User struct {
ID string
Name string
}
var ErrNotFound = errors.New("not found")Adapters map columns ↔︎ domain; handlers map domain ↔︎ JSON DTOs.
Theory 6 — Package naming and junk drawers
| Bad | Better |
|---|---|
internal/utils |
internal/clock, internal/idgen, or stdlib |
internal/common |
name by capability |
internal/helpers |
absorb into caller package |
models + controllers |
domain + httpapi + app (Go style) |
Names should say what the package owns, not “misc.”
Worked example — boundary refactor sketch
Before:
main.go // sql + http + business rules mixed
After:
cmd/service/main.go
internal/domain/user.go
internal/domain/errors.go
internal/app/user_service.go
internal/app/user_service_test.go
internal/store/sqlite_user.go
internal/httpapi/user_handler.go
internal/httpapi/routes.go
// cmd/service/main.go — composition root
func main() {
cfg := config.Load()
db := mustOpen(cfg.DSN)
defer db.Close()
repo := store.NewSQLiteUser(db)
svc := app.NewService(repo)
h := httpapi.New(svc)
srv := &http.Server{Addr: cfg.Addr, Handler: h.Routes(), ReadHeaderTimeout: 5 * time.Second}
log.Fatal(srv.ListenAndServe())
}Only main knows about concrete SQLite + HTTP.
Lab 1 — Map current boundaries
Suggested workspace: Stage VI/VII service (preferred) or a trimmed module.
- List packages:
go list ./...
- Draw current package diagram (ASCII) in
BOUNDARIES.md.
- Mark cycles, god packages, and “main does too much.”
- Pick one vertical seam to fix today (e.g. item create).
# BOUNDARIES.md
## Before
cmd/service → everything
internal/httpapi → database/sql directly
## After (target)
cmd/service → store, app, httpapi
httpapi → app
app → domain (interfaces in app)
store → domainLab 2 — Extract consumer interface + adapter
- Introduce interface at consumer (
appor handler).
- Move SQL (or filesystem) details into
store/ adapter package.
- Return concrete
*SQLiteUserfrom constructor.
- Compile; fix import cycles by moving types to
domain.
go build ./...Lab 3 — Fake + unit test without DB
- Implement in-memory fake of the consumer interface.
- Table-test one use case: happy path + invalid input + not found.
- Ensure test file does not import
database/sqldrivers.
go test ./internal/app -count=1 -vLab 4 — Self-review questions
Answer in BOUNDARIES.md:
- Can
domain/appcompile without importingnet/http?
- Can you swap store implementation without editing handlers?
- Are interfaces small enough to implement in <30 lines for a fake?
- Does
mainown all wiring?
- Any remaining
internal/utils?
# crude check: app should not import net/http
go list -f '{{.ImportPath}} {{.Imports}}' ./internal/appLab 5 — Optional second boundary
If time remains:
- Extract a
ClockorIDGeninterface for deterministic tests, or
- Split a second use case behind the same store port
Do not rewrite the entire service—depth over breadth.
Hour-by-hour suggestion
| Time | Focus |
|---|---|
| 0:00–0:30 | BOUNDARIES.md before diagram |
| 0:30–1:30 | Extract interface + move SQL/HTTP details |
| 1:30–2:20 | Fake + table tests |
| 2:20–2:45 | Cycle fixes + self-review |
| 2:45–3:00 | After diagram; commit |
Common gotchas
| Gotcha | Fix |
|---|---|
| Circular imports | Move shared types to domain; wire in main |
| Interface pollution | Delete unused methods; split interfaces |
internal/utils junk drawer |
Name by domain capability |
| Returning interfaces from constructors | Return structs |
| Testing through HTTP only | Unit-test app with fakes |
One giant Store interface |
Per-consumer smaller interfaces |
| Moving files without updating tests | go test ./... after each seam |
| Over-extracting on day one | One vertical slice end-to-end |
Checkpoint
- ASCII/package map written (before + after)
- Consumer interface introduced
- Concrete adapter implements it
- Fake-based unit test green without DB
- No import cycles
go test ./...pass on Go 1.26
- Self-review questions answered
Commit
git add .
git commit -m "day75: package boundaries accept interfaces"Write three personal gotchas before continuing.
Tomorrow
Day 76 — Hexagonal lite: explicit ports and adapters so the domain stays free of frameworks—building on today’s dependency direction.