Go architectures · 3 MIN READ

Packages follow responsibilities, not a diagram

Choose cohesive package boundaries and keep concrete dependencies at the edges.

Go packages provide namespaces and compile-time dependency boundaries. More packages do not automatically create a cleaner design. Start with behavior that changes together, then separate a boundary when it hides an implementation detail or makes a dependency direction clearer.

Architecture foundations · A MAP OF THE IDEA

  1. Consumer states a responsibility
  2. Package exposes a focused API
  3. Implementation details stay internal

Useful when

  • A package has one clear reason to change
  • Import direction can be reviewed and tested

Watch out

  • A folder name does not enforce a boundary
  • Generic utils packages accumulate unrelated dependencies

Implementation & operation cost: Low to medium

01

Keep names useful at the call site

A package named orders can expose orders.Service or orders.Create without repeating a business prefix everywhere. A package called utils tends to accumulate unrelated dependencies because its purpose does not constrain what belongs there. Prefer a name that explains why its exported operations belong together.

02

Let the consumer name a small interface

The application needs the operation Save, not every method of the database client. Define that need near the code that uses it, and inject an implementation when wiring the process.

go
type OrderStore interface {
    Save(context.Context, Order) error
}
type Service struct { store OrderStore }
func NewService(store OrderStore) *Service {
    return &Service{store: store}
}
Read the code

The concrete SQL or Redis adapter can satisfy this interface implicitly. Do not create an interface for every struct by habit; add it where substitution or a stable boundary is useful.

03

Review dependency direction

HTTP handlers can depend on application operations; those operations should not need a router context. Keep protocol and storage types from spreading through every function. An internal directory can restrict which importers may use a package, but it does not make a design cohesive by itself. Watch for import cycles as a signal that responsibilities may be entangled.

04

Draw an import graph before a folder tree

Start from changes the team makes together: placing an order, pricing it and persisting it. A package should expose the vocabulary needed by its callers, not its database layout. Put transport decoding in an adapter and business decisions behind a narrow application API. Draw arrows for actual Go imports: a cycle means responsibilities must move or a smaller contract must be introduced. Renaming directories to domain, service and repository does not remove coupling if every package imports one shared mutable model.

text
cmd/shop/main.go
internal/orders/service.go
internal/orders/order.go
internal/postgres/orders.go
internal/httpapi/orders.go
Read the code

This is a focused layout or boundary fragment, not a second standalone program. Read it together with the complete worked example above.

05

Use internal without promising more than it enforces

The internal directory limits which importers may use a package; it does not make methods or data private between sibling packages. Unexport identifiers that clients do not need. Keep reusable libraries outside internal only when they have real independent consumers and a compatibility policy. Avoid a util package that accumulates unrelated helpers: move a helper beside its consumer until two callers reveal one coherent abstraction. Package boundaries and module version boundaries solve different problems.

06

Check a boundary with a change experiment

Imagine replacing Postgres with a fake for a service test. If the service imports SQL row types, the dependency crosses the intended boundary. Then imagine adding one order rule: it should be tested without HTTP. Finally inspect exported names with go doc and examine direct dependencies with go list. A good boundary makes these changes local; it does not require a dedicated interface and a separate file for every type. Record the ownership rule in package documentation so the next feature can preserve it.

TAKE THIS WITH YOU

A good boundary reduces what a caller must know. Use cohesive packages and small consumer-owned contracts where they remove real coupling.

Check your understanding

Which statement is correct about a consumer-owned interface?

Go to the source

Progress stays in this browser. No account needed.