Day 75 — Package boundaries

Updated

July 30, 2026

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 reason

Return *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:

  1. Move shared entities/errors to domain (or app if only one consumer layer).
  2. Wire concretes only in cmd/*/main.go.
  3. Define interfaces where used; implement in store.
  4. Never let domain import 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.

  1. List packages: go list ./...
  2. Draw current package diagram (ASCII) in BOUNDARIES.md.
  3. Mark cycles, god packages, and “main does too much.”
  4. 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 → domain

Lab 2 — Extract consumer interface + adapter

  1. Introduce interface at consumer (app or handler).
  2. Move SQL (or filesystem) details into store / adapter package.
  3. Return concrete *SQLiteUser from constructor.
  4. Compile; fix import cycles by moving types to domain.
go build ./...

Lab 3 — Fake + unit test without DB

  1. Implement in-memory fake of the consumer interface.
  2. Table-test one use case: happy path + invalid input + not found.
  3. Ensure test file does not import database/sql drivers.
go test ./internal/app -count=1 -v

Lab 4 — Self-review questions

Answer in BOUNDARIES.md:

  • Can domain/app compile without importing net/http?
  • Can you swap store implementation without editing handlers?
  • Are interfaces small enough to implement in <30 lines for a fake?
  • Does main own all wiring?
  • Any remaining internal/utils?
# crude check: app should not import net/http
go list -f '{{.ImportPath}} {{.Imports}}' ./internal/app

Lab 5 — Optional second boundary

If time remains:

  • Extract a Clock or IDGen interface 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.