Skip to content

gopherly/manifesto

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 

Repository files navigation

The Gopherly Manifesto

A set of values for designing Go packages. Last updated: 2026-05-11

We believe a Go package should feel inevitable to use. The first call should work. The hundredth should still feel coherent. Power should be available without being demanded, and the type system should catch mistakes before they reach review.

These values apply to any Go package: a configuration loader, a CLI framework, a web framework, a database driver, a logger. The principles are framework-agnostic. We extracted them from designing real packages across different domains and noticing which trade-offs we kept making the same way.

What "Following Gopherly" Means

Anyone can build a Gopherly package. There is no certification, no registry, no gatekeeper. If your package follows these principles, you can say it follows Gopherly.

The relationship works like this. The manifesto is the standard: open, framework-agnostic, applicable to anyone designing a Go package. The Gopherly team maintains reference packages, which are concrete implementations of the manifesto across common domains. They show what the principles look like in code and serve as a baseline that other packages can match or deviate from deliberately. Conformance is self-declared. If a package follows the principles, it follows Gopherly. The honest signal is in the API surface: a user picking up the package should recognize the shape.

If you build software with Gopherly packages, this manifesto explains why their APIs look the way they do. If you build a Gopherly package, whether on the team or independently, it tells you which trade-offs to make.

1. Developer Experience First

The developer using the package is the customer. Every API choice serves them, or it doesn't ship.

This sounds obvious until you watch what packages actually optimize for: the author's sense of architectural purity, the maintainer's convenience, internal consistency that nobody outside the package can see. We optimize for the person on the other side of the import statement. Their first five minutes should produce working code. Their hundredth minute should still feel coherent. The fast path is the default path: performance work happens behind the API, never at the cost of the call site.

Documentation is part of the API. A package without documentation is a package that hasn't shipped. The API a user sees is not just function signatures. It is the godoc page, the package overview, the runnable examples, the doc comment on every exported symbol. A package that is well-shaped but undocumented fails this principle just as much as a package with bad ergonomics: the developer on the other side of the import has to guess.

In practice:

  • Every exported symbol has a doc comment that explains what it is for, not what it is.
  • The package itself has a doc comment that gives the overview a newcomer needs in the first thirty seconds.
  • Runnable examples ship in _test.go files. They show users how to use the package, and they break the build when the package changes in a way the docs do not reflect.
  • Concurrency contracts are stated explicitly. "Safe for concurrent use" is part of the API. So is "not safe."

The rest of these principles are how we deliver on developer experience. The first one is just a commitment to whose problem we are solving.

2. Progressive Disclosure

Easy things stay easy. Advanced things are possible. Adding power must not make the basics harder.

We design for three levels:

  • Basic. Works right away with good defaults.
  • Intermediate. Common changes are short.
  • Advanced. Full control when needed.
// Level 1: basic. Just works.
client := pkg.MustNew()

// Level 2: common customization.
client := pkg.MustNew(
    pkg.WithTimeout(30 * time.Second),
    pkg.WithRetries(3),
)

// Level 3: full control.
client := pkg.MustNew(
    pkg.WithTransport(customTransport),
    pkg.WithMiddleware(authMW, loggingMW),
    pkg.WithHooks(beforeRequest, afterResponse),
)

A new user reads level 1 and ships. A team lead reaches for level 3 once they need it. None of those steps changes the others.

3. The Type System Is a Guide

The IDE should help users find what they need, and the compiler should catch what they get wrong. The Go type system can do both.

Discoverability. Type pkg.With and let autocomplete do the rest. Options are typed per context. The type system narrows what is valid at each call site. Inside a constructor, autocomplete shows constructor-relevant options. Inside a sub-component, it shows that component's options. Users do not scroll past names that do not apply to where they are standing.

Misuse becomes a build error. When an option only makes sense in one context, its type should reflect that. Misuse fails the build, not a runtime check or a test:

// Compiles. WithTimeout is a ClientOption.
pkg.New(pkg.WithTimeout(5 * time.Second))

// Does not compile. WithTimeout is not a RouteOption.
pkg.Route("/path", pkg.WithTimeout(5 * time.Second))

Shared concepts that span multiple targets, such as WithRequired across several option families, return anonymous interface types satisfying every relevant family, so one helper covers every site where the concept is meaningful. The cost is one extra interface declaration. The win is that every misuse becomes a build error, caught before code review.

The naming conventions that support this are documented in the Naming Conventions appendix.

4. Errors and Visibility Are Values

Errors are not surprises. Production behavior is not a black box. The package surfaces both, at the right moment, through standard seams.

Errors as values. Construction errors appear when New is called, with messages that say what went wrong and where. Registration errors are returned at the call site, or aggregated when batch construction makes that more useful. Runtime errors flow through the normal error chain, never panicked, never swallowed.

client, err := pkg.New(pkg.WithEndpoint(""))
// err: "pkg: endpoint cannot be empty"

client, err := pkg.New(nil)
// err: "pkg: option cannot be nil at index 0"

New never returns a non-nil value when it returns an error. Callers never receive a half-built object. Multiple validation errors collect into a single value that implements Unwrap() []error, so errors.Is and errors.As still work. For when panicking is the right call (MustNew and friends), see Principle 6.

Observability for the unexpected. Errors handle expected failures. Observability handles the unexpected: the slow request, the silent retry, the dependency degrading under load. We surface what the package is doing through structured logging, OpenTelemetry traces and metrics, and debug hooks, rather than hiding behavior behind black-box abstractions. Logging is injectable (WithLogger) and uses log/slog shapes by default. Spans and metrics follow OpenTelemetry semantic conventions where they exist. Internal state worth observing (queue depth, retry counts, circuit-breaker status) is exposed through stable accessors, not buried in unexported fields.

The default experience is quiet. The instrumented experience is one option away.

5. Stable, Additive Public Surface

Adding a new option must not break a call site that already exists. We add. We do not change.

Three mechanics make this work. First, options apply to a private config struct. The constructor validates the config and builds the public type from it. Options never mutate the public type directly, so we can rework internals without touching signatures. Second, implementation lives under internal/. What is exported is committed to. What is not exported is free to change. Third, deprecation is visible, not silent. Deprecated symbols stay in the API with a clear deprecation note. Removal happens at major versions, never as a surprise.

Versioning is part of this contract. Go puts the major version in the import path. pkg/v2 is a different package from pkg. The version is not a label on a release page; it is part of the API itself.

  • v0 is honest. While the package is at v0, the API is allowed to break between minor versions, and we say so.
  • v1 means committed. Once v1 ships, this principle takes effect: additive only within the major line.
  • A new major version is a deliberate event, not a fix. v2 exists when an abstraction needs to break, and it ships with a migration path: a MIGRATING.md, a deprecation period in v1 where the new shape is available alongside the old when possible, concrete before-and-after examples.
  • Old majors stay alive while they are in use. v1 keeps receiving security fixes after v2 ships, for as long as the team can support it.

The trade-off is that the option list grows over time. This is fine. Each option is one focused setter, and one autocomplete entry is a cheap price for letting users upgrade without rewriting code.

6. Convenience Without Sacrificing Control

We provide both an opinionated path and an escape hatch. The common case should be one line. The advanced case should still be possible.

Escape hatches are clearly named so users know when they are stepping outside the safe path:

  • New returns (*Type, error) for libraries and tests where errors must be handled.
  • MustNew panics for main() packages and test setup, where setup failures exit the process anyway. This is the convention from regexp.MustCompile and template.Must. Must* variants live here and only here. Anywhere else, return an error.
  • Unsafe* accessors expose underlying primitives when the abstraction does not cover a case. The Unsafe prefix is a Go convention (see unsafe.Pointer) marking sharp edges users opt into knowingly.
  • Custom hooks (WithLogger, WithIO, WithErrorHandler, WithTransport) let users replace defaults entirely when the defaults do not fit.

A framework that hides everything traps you. A framework with named escape hatches gives you the common path and a way out.

7. Testability

If something is hard to test, the design is wrong.

The escape hatches of Principle 6 have a second use: if users can replace defaults, they can replace them with fakes. We treat that as a design requirement, not an accident. Users must be able to test code that depends on a Gopherly package without os.Args, without a real terminal, without a real network, and without a real clock. The package provides the seams:

  • I/O is injectable through options.
  • Dependencies are interfaces, not concrete types tied to the OS.
  • context.Context is honored throughout, so cancellation and timeouts work with context.WithCancel and context.WithTimeout.
  • Constructors return errors that tests can check.
  • Test helpers ship in dedicated subpackages (e.g., pkgtest).

When we design a new feature, we ask: can someone test this easily? If the answer is no, the design changes, not the test.

8. Conventions Over Invention

Where a convention exists, follow it. Users should not have to learn package-specific dialects of things they already know.

Standard-library shapes, established RFCs, GNU and POSIX behavior, OpenTelemetry semantics, idiomatic Go patterns: all of these are existing knowledge in the user's head. A Gopherly package leverages that knowledge instead of inventing parallel conventions. context.Context flows through any operation that does I/O, takes time, or might need cancellation. MustNew mirrors regexp.MustCompile. Bind and Unmarshal mirror encoding/json. Industry standards (RFC 9457, OpenAPI 3.x, OpenTelemetry) are used where they exist.

Dependencies are part of this. Every dependency a package pulls in becomes a dependency of every project that imports the package. The user who imported a logger did not consent to forty transitive packages; they consented to a logger. We treat dependencies as a cost paid by the user, not the maintainer. The standard library is the default. A dependency outside the standard library has to earn its place by doing something the standard library cannot do well. The Go proverb "a little copying is better than a little dependency" is a value, not a slogan. The point is not zero dependencies; the point is that each one is a deliberate choice the maintainer can defend.

The hard part is knowing when to break a convention. Sometimes the convention is wrong for the problem, or two conventions conflict, or the surrounding ecosystem has moved on. When that happens, the break is deliberate, named in the documentation, and justified. A package that breaks conventions silently leaves users debugging surprises. A package that breaks them on purpose, and says so, teaches users something.


Naming Conventions

A reference for option and helper names across Gopherly packages:

  • With<Feature> enables or configures something.
  • Without<Feature> disables something when the default is on.
  • Plural combinators (Options, Middlewares) bundle multiple values of the same family.
  • Must<Verb> is the panic-on-error variant, for main() and test setup only.
  • Unsafe<Accessor> exposes underlying primitives. Sharp edges, opt-in.

Read aloud, an idiomatic call should sound like English: WithTimeout, WithoutDefaultLogger, WithMiddlewares(authMW, traceMW).


License

The Gopherly Manifesto is licensed under CC BY 4.0. You may copy, translate, and adapt it freely, with attribution.