With that in mind, and the concerns for library authors discussed previously, let’s talk about some recommendations for libraries.
| Scope | Recommendation |
|---|---|
Project |
|
Abstraction |
|
Functions |
|
Behavior |
|
Entity |
|
Maintenance |
|
|
📢 Important |
There are exceptions to everything I discuss below. |
3.1. Work backwards
When designing a new API, start with the desired usage and work backwards towards the implementation.
One way to do this is by writing pseudo-code that consumes the new API to get a sense of how it will be used. During this phase, consider all primary concerns. Think about questions like the following:
-
Can you tell what the API will do just by looking at the usage, without needing to know the implementation?
-
Is there room for misuse?
-
How will you test this usage to ensure it’s working as intended?
-
Depending on the complexity of the design, exercise its flexibility by sketching out a skeleton for a potential future feature or third-party extension. Is it flexible enough?
-
What’s the contract of the API? That is, what does it expect from its inputs, and what guarantees does it provide for its outputs?
Only after you have a rough sketch for the design should you begin implementing the new API. Thinking about these details early helps avoid surprises later. It ensures that the design guides the implementation, not the other way around.
As you flesh out details, you might find that your design doesn’t fit perfectly. That’s completely fine! The design isn’t immutable. Go back, tweak it, validate it, and continue implementing it.
3.2. Minimize surface area
Surface area refers to the public APIs your library exposes — all possible touch points that an application could have with your library.
The smaller the surface area, the more internal flexibility you get in your library. A larger surface area reduces how much you can refactor or modify, and commits you to keeping more things stable.
For most libraries, you should start with the smallest possible surface area in initial versions. As the library grows more stable and popular, and new features are added, the surface area grows organically.
Minimizing the surface area isn’t just for initial versions. Make it a practice to be deliberate about the APIs you export from your library, because once exported in a stable release, you cannot take them back. Don’t export something that satisfies a "maybe" use case — export it only when a compelling use case presents itself.
In short, when in doubt, leave it out.
Case Study
As an example, consider the following from a 0.X release of a library my team maintained:
type Value struct{ /* ... */ }
func (*Value) AsBool() bool
func (*Value) AsFloat64() float64
func (*Value) AsInt() int
func (*Value) AsString() string
func (*Value) Populate(target interface{}) error
func (*Value) TryAsBool() (_ bool, ok bool)
func (*Value) TryAsFloat64() (_ float64, ok bool)
func (*Value) TryAsInt() (_ int, ok bool)
func (*Value) TryAsString() (_ string, ok bool)
The methods behave roughly as follows:
TryAs*-
try to convert the value to the corresponding type, returning false if the operation failed.
As*-
try to convert the value to the corresponding type, panicking if the operation failed.
Populate-
decode the value into a pointer (like
json.Unmarshal), returning an error if the operation failed.
Even without looking at the implementations, you can probably guess at them:
As* |
TryAs* |
|---|---|
|
|
All those type-specific methods are redundant
because you can do the same with Populate.
In fact,
we found that their usage was quite rare — most users did indeed call Populate directly.
Since this was a pre-1.0 library,
we were able to reduce the surface area of Value
to the following before tagging a 1.0 release.
type Value struct{ /* ... */ }
func (*Value) Populate(target interface{}) error
We agreed that we’d add the type-specific helpers back
if enough users asked for them.
We never needed to — users were happy with Populate.
3.2.1. Internal packages
Any package you expose in your library is part of its permanent contract. A tool you can use to reduce the surface area of your library is internal packages.
Go packages inside an 'internal' directory can be imported from the directory’s direct parent and the parent’s other descendants. This allows other components in your library to import these packages, but hides them from users of your library.
Use internal packages liberally to control the surface area of your library. Place packages that are not part of your library’s core responsibilities, helpers and utilities written to aid in implementing the library, packages created just for better code organization but not meant to be part of the public API, all into an internal directory.
|
📢 Important |
If you use internal packages,
do not expose internal entities in exported, non-internal APIs.
For example, if you have internal/foo,
do not export a function from your library that references foo.Bar — that makes it impossible for users to reference that function signature.
|
3.3. Avoid unknown outputs
Unknown outputs refers to outputs from a library, the value for which is not clearly specified. This could be output that is non-deterministic, or output that is partially valid in error cases.
As a general rule, aim to reduce unknown outputs from your library because someone will eventually begin relying on the exact output, making it part of the contract of the API.
As Hyrum’s Law puts it:
With a sufficient number of users [..] all observable behaviors [..] will be depended on by somebody.
There’s no way to completely eliminate unknown outputs, but the following practices can move you in the right direction:
- Reduce input range
-
Validate input at entry points and be restrictive about what you accept. You can always loosen restrictions later.
- Guard against unexpected mutation
-
If an API accepts or returns a slice or a map, consider what happens if the caller changes the collection afterwards. Is the slice referencing internal state for an object? You can guard against issues from this by cloning the slice or map at the API boundary. Obviously, be careful about where you use this and how it affects performance.
- Return zero values with errors
-
When an operation fails and you need to return an error and a value, instead of returning a partial value (e.g.
return x, err), return the zero value of that type (e.g.return nil, err) — except when partial results are part of the contract of the function, as withWriter.Write. - Think about ordering
-
When returning slices, consider the order of items in the slice. Is the order part of the contract of that result? Or is it just determined by some internal state, changing which could break someone? In these cases, consider sorting the slice at the API boundary.
3.4. No global state
There are very few good reasons for a library to have global state. Global state makes testing more difficult and tightens coupling between components. Global state without an escape hatch — a way to localize it — reduces flexibility. For those and many other reasons, do not use global state in your libraries.
If your library needs state, put that inside an object. Turn the top-level functions that use this state into methods on the object.
| Bad | Good |
|---|---|
|
|
3.5. Accept, don’t instantiate
Dependencies of your library — things that "someone else" does — should be passed in as arguments at appropriate entry points. For example, your library should not establish a database connection if that’s not its purpose. Instead, it should accept a connection that was already establish as an argument somewhere. In short, don’t instantiate what you don’t own.
| Bad | Good |
|---|---|
The caller can only change the file name. They must always write a static file to disk. |
The caller can satisfy the common use case of static files,
but also pass in If the static file case is a common enough need, we can add the following function for convenience:
|
The second variant is an improvement over the first, but I think we can do better. That brings us to…
3.6. Accept interfaces
Accept interfaces for complex objects whose behavior you consume so that you’re not coupled to a specific implementation of that functionality. Doing so provides your users more flexibility and improves testability.
Improving on the previous example:
| Bad | Good |
|---|---|
The input must always be a file so tests must write a file to disk. It’s difficult to simulate read failures, and difficult to use non-file sources of input. |
Narrows down to functionality we actually need. The input can be an in-memory buffer or a network stream. We can mock the interface to simulate any kind of failure. |
You can declare your own interfaces
You’re not limited to using interfaces defined elsewhere. You can declare your own interfaces for objects you consume — even if you don’t own those types.
For example,
if New above additionally needs the file name,
it’ll need access to the Name method.
We can do something like the following:
type Source interface {
io.Reader
Name() string
}
var _ Source = (*os.File)(nil) (1)
func New(src Source) (*Parser, error) {
// ...
}
-
This verifies that
os.Fileimplements the interface at compile time. See Verify Interface Compliance.
The function above will accept *os.File
and any custom implementations of the interface.
Speaking of interfaces, library authors should internalize this:
3.7. Interfaces are forever
I mentioned this earlier in Breaking changes, but I want to call it out explicitly.
Any change to an exported interface is a breaking change.
Any change you can possibly make to an interface breaks someone.
- Adding a method
-
breaks implementations of that interface that aren’t part of your library — see Adding a method to an interface is a breaking change for an example
- Removing a method
-
breaks functions that consume the interface and call that method
- Changing a method signature
-
breaks both, external implementations of the interface and callers of the method
So how do we get flexibility here? How do we add new functionality to an abstraction? See…
3.8. Return structs
To keep your library flexible, return structs, not interfaces for core concepts in your library. This allows you to add new methods to the object in a completely backwards-compatible manner.
For example:
| Bad | Good |
|---|---|
It’s not uncommon to see packages that take the following shape:
This is inflexible because, among other things,
we cannot modify the |
It’s better to export the concrete implementation and return an instance to it from the constructor.
This version is more flexible.
We can add new methods to |
3.9. Upgrade with upcasting
We just discussed a couple related topics for designing abstractions: Return structs, Accept interfaces. Returning structs allows you to add new behaviors to the object. How do we grow the abstraction on the other end, though? How can we expect new behaviors from the interfaces we accept?
To get there, we need to first cover a feature of Go interfaces: Upcasting.
With upcasting in our hand, we have the tool we need to add a new method to an interface.
Recall the example from earlier.
type Source interface {
io.Reader
Name() string
}
func New(src Source) (*Parser, error) {
// ...
}
Suppose that the Parser wants to report meaningful error messages,
so it needs to look up its current position inside Source.
Maybe it can do this with a new Offset() int64 method
that reports the current position in the file[3].
However, we cannot add this method to Source — that’s a breaking change.
type Source interface {
io.Reader
Name() string
+ Offset() int64 // bad: breaking change
}
To do this in backwards compatible manner,
introduce a new interface that embed Source
and include the new method inside it.
Add a new interface
type OffsetSource interface {
Source
Offset() int64
}
Then, inside the New function,
attempt to upcast Source to this new interface.
If the upcast fails,
we can implement some kind of graceful degradation.
Upcast to the new interface
func New(src Source) *Parser {
osrc, ok := src.(OffsetSource)
if !ok {
osrc = &nopOffsetSource{src} (1)
}
return &Parser{
osrc: osrc,
// ...
}
}
type nopOffsetSource struct{ Source }
func (*nopOffsetSource) Offset() int64 {
return 0
}
-
If the provided source is not an
OffsetSource, wrap it with a type that implements the interface around a regularSource. This implementation just returns zero as the offset for all requests.
That’s it! This is a completely backwards compatible way of adding new optional requirements on interface arguments, and upgrading those code paths.
Case Study: io.WriteString
As an example from the standard library,
io.WriteString uses upcasting
to upgrade an io.Writer to io.StringWriter
in case the implementation has a more efficient way of encoding strings.
type StringWriter interface {
WriteString(s string) (n int, err error)
}
func WriteString(w Writer, s string) (n int, err error) {
if sw, ok := w.(StringWriter); ok { (1)
return sw.WriteString(s) (2)
}
return w.Write([]byte(s)) (3)
}
-
Attempt to upcast to
io.StringWriter. -
Use the implementation’s
WriteStringmethod, if available. -
Fallback behavior: Convert the string to a byte slice and write that.
3.10. Parameter objects
Once a function or method is published, you can’t add new parameters to its signature because that’s a breaking change.
However, you can plan for growth of functions at API boundaries by accepting your arguments in a struct — this is referred to as a parameter object.
| Bad | Good |
|---|---|
|
|
Adding fields to a struct is backwards compatible. Therefore, with parameter objects, we can add new optional parameters to the function by adding new fields to the parameter struct.
type Config struct {
URL string
+ Log *zap.Logger // optional (1)
}
func New(c *Config) *Client {
+ log := c.Log
+ if log == nil { (2)
+ log = zap.NewNop()
+ }
// ...
}
-
Add a new field to the struct.
-
Gracefully handle absence of the field.
As an additional benefit, parameter objects aid in readability and reduce risk of misuse when there are multiple parameters with similar types. Consider:
| Bad | Good |
|---|---|
Are we sure it’s in that order? Does the last name go first? Or the email address? |
No ambiguity — every parameter has a name. |
|
💡 Tip |
If a function has more than three parameters, it’ll eventually want more. Use a parameter object. |
3.11. Functional options
Parameter objects provide us a way to add new parameters to a function in a backwards compatible manner. However, they’re not the only tool available to us for this purpose. We also have functional options.
Functional options consist of
an opaque Option type[4]
and any number of constructors for the type,
each representing a user-facing option.
The target for these options (function or method)
accepts a variadic number of these Option objects
and "runs" all of them on some internal state
to have the options take effect.
This is best demonstrated with an example. Consider:
package db
type Option /* ... */ (1)
func Connect(addr string, opts ...Option) (*Connection, error) (2)
-
Optionis an opaque type. We’ll skip the implementation for now. -
A function that accepts a variadic number of these.
The Option type is accompanied by a few constructors:
each represents a separate user-facing option for Connect.
func WithTimeout(time.Duration) Option { /* ... */ }
func WithCache() Option { /* ... */ }
So the relationship between these functions is as follows:
This allows Connect to be used with any number of options, including zero:
db.Connect(addr)
db.Connect(addr, db.WithTimeout(time.Second))
db.Connect(addr, db.WithCache())
db.Connect(addr,
db.WithTimeout(time.Second),
db.WithCache(),
)
This pattern presents a few benefits:
-
It allows us to grow without limits: we can add any number of new optional parameters without breaking existing callers.
-
Similar to Parameter objects, it improves readability and reduces risk of misuse when there are multiple parameters with similar types.
-
With the
Optiontype being opaque, it keeps the surface area small — we get full control over all possible values ofOption.
3.11.1. How to implement functional options
There are multiple ways to implement functional options. This section discusses the version I recommend.
Declare a private struct to hold the final state for options. This struct will get a field for every user-facing option.
type connectOptions struct {
timeout time.Duration
cache bool
}
Declare the Option type,
and include an unexported apply method inside it
that operates on a pointer of this struct.
type Option interface {
apply(*connectOptions) (1)
}
-
Because this method is unexported, only types defines in the same package can implement
Option. This prevents unexpected third-party implementations ofOption.
For each user-facing option, declare:
-
an exported function named after the option, e.g.
WithTimeout, that returns anOptionvalue -
an unexported type named after the option, e.g.
timeoutOption, with a method satisfying theOptioninterface
From the function, return an instance of this new type. Record parameters of the option (if any) on this instance.
type timeoutOption struct{ d time.Duration } (1)
func WithTimeout(d time.Duration) Option {
return timeoutOption{d: d} (2)
}
-
The unexported option type contains a field to hold the option parameters (if any).
-
The option constructor returns an instance of this type, filled with the parameter that was passed in.
In the apply method of this type,
set the corresponding field on the full options struct
you declared earlier.
func (t timeoutOption) apply(o *connectOptions) {
o.timeout = t.d (1)
}
-
Records the value passed to
WithTimeoutontoconnectOptions.
Finally, at the top of Connect,
declare a connectOptions variable with your chosen default values,
and apply each provided Option to it:
func Connect(addr string, os ...Option) (*Connection, error) {
opts := connectOptions{
timeout: time.Second, (1)
}
for _, o := range os {
o.apply(&opts) (2)
}
// ... (3)
}
-
Declare
connectOptionswith the default settings. -
Apply the provided options.
-
opts.timeoutnow holds the user-specified timeout, if any.
3.11.2. Planning for functional options
You might find yourself designing an API
that currently does not have any functional options
but you foresee needing them in the future.
Unfortunately,
you cannot add a variadic Option parameter to a function
after publishing it — that’s a breaking change.
Breaking change: Adding variadic options
func Connect(
addr string,
+ opts ...Option, // bad: breaking (1)
) *Connection
-
Adding variadic arguments to an existing function is a breaking change. At minimum, it breaks function references. It’s better to add a new function, e.g.
ConnectWithOptions(string, …Option), if you’ve already published a version without options support.
You can plan for this expansion
by defining an Option interface
without any implementations.
// Option configures the behavior of Connect.
//
// There are no options at this time.
type Option interface {
unimplemented() (1)
}
func Connect(addr string, opts ..Option) *Connection
-
Adding an unexported method to
Optionprevents others from implementing it. This lets you change that toapply(..)in the future without breaking anyone.
3.11.3. When to use functional options
Functional options are very flexible, but you shouldn’t stick them everywhere.
-
Use them only for optional parameters. Required parameters should be positional arguments.
-
Make sure that the boilerplate is worth it. Don’t put yourself in a position to write all that boilerplate for every other function in your package.
-
Keep in mind that options exist in the package’s namespace. An option consumes that name in that package forever, so be sure that you won’t need to use that name for any other purpose — including other options for different targets.
-
Think about testability. Mocking functions that take functional options can be hard, especially because there’s usually no way for users to interpret the opaque
Optionobjects. You can help your users here by providing a means of converting[]Optioninto some kind ofOptionSnapshotstruct that mirrors your privateoptionsstruct.
3.11.4. Parameter objects vs functional options
Functional options and Parameter objects solve a similar problem: adding new optional parameters to a function. Picking between the two depends on API-specific needs, but the following can help reason about it.
| Functional options | Parameter objects | |
|---|---|---|
Required parameters |
All functional options must be optional |
Original fields on the struct can be required, but new fields must be optional |
No options |
Very easy to type — looks like a regular function call
|
Still clutters the line by instantiating a struct
|
Many options |
A lot of repetition in referencing the package name
|
No repetition once the struct is in place
|
Testing |
Requires auxiliary testing utilities |
Plain struct — easily testable |
In general, if the function has two or fewer required parameters, and passing in too many options is rare, functional options may be a good fit. Otherwise, parameter objects are likely a better choice.
Either way, don’t mix the two in the same function.
3.12. Result objects
Result objects are the return-value equivalent of Parameter objects.
With them, you define a struct meant to hold a function’s return values,
and you return only that struct from the function — with the exception of error which should be its own return value.
For example:
type UpsertResponse struct { (1)
Entries []*Entry
}
func (c *Client) Upsert(ctx context.Context, req *UpsertRequest) (*UpsertResponse, error) { (2)
// ...
return &UpsertResponse{
Entries: entries,
}, nil
}
-
UpsertResponseholds the values returned byClient.Upsert. -
Client.Upsertreturns onlyUpsertResponseand an error.
You can add new return values by adding new fields to the result object. This is completely backwards compatible.
type UpsertResponse struct {
Entries []*Entry
+ NewCount int
}
func (c *Client) Upsert(...) (*UpsertResponse, error) {
// ...
return &UpsertResponse{
Entries: entries,
+ NewCount: len(newEntries),
}, nil
}
|
💡 Tip |
If a function has three or more return values, it’ll need more. Use a result object. |
3.13. Errors
For libraries, I’d like to start with the following recommendations:
-
Do not log and return errors. This causes log statements to be duplicated because the caller will likely log them too. Either log the error and don’t return it, or return the error and don’t log it, allowing the caller to choose whether they want to log it.
-
Do not export error types. These don’t compose very well — layers above yours now need to know about your custom error type.
-
Do not use pkg/errors. It comes with a significant performance penalty because all errors capture a stack trace, even when not necessary.
3.13.1. Error matchers
|
🚨 Warning |
This section is inaccurate.
As of Go 1.13, your errors should be used with
errors.Is and errors.As
to match.
|
Error matchers are function that take an error
and report whether it’s a specific kind of error.
For example:
func IsNotFound(error) bool
// Elsewhere:
u, err := findUser(...)
if err != nil {
if !IsNotFound(err) {
return err
}
u = newUser()
}
Use Upcasting to implement this.
type errNotFound struct {
// ...
}
func IsNotFound(err error) bool {
_, ok := err.(*errNotFound)
return ok
}
3.13.2. Error metadata accessors
|
🚨 Warning |
This section is inaccurate.
As of Go 1.13, your errors should be used with errors.As
to extract metadata.
|
Error accessors are functions that accept an error and return library-specific metadata associated with them.
For example:
func MissingKeys(error) []string
// Elsewhere:
us, err := findUsers(...)
if err != nil {
if missing := MissingKeys(err); len(missing) > 0 {
log.Printf("Skipping missing users: %q", missing)
} else {
return err
}
}
Implement these similarly with Upcasting, and return an fallback value for when upcasting fails.
type errNotFound struct {
missing []string
}
func MissingKeys(error) []string {
if e, ok := err.(*errNotFound); ok {
return e.missing
}
return nil
}
3.14. Goroutines
If your library spawns goroutines to do work in the background, follow these basic guidelines:
- Don’t grow unbounded
-
The number of goroutines you spawn for a request should not be affected by the contents of the request. For example, you can spawn three goroutines for a request, provided that you always spawn no more than three goroutines per request, no matter the contents of the request.
This means that you should not spawn a goroutine to process each item in a slice. If you need to process a dynamic number of items concurrently, use a bounded pool of goroutines.
- Don’t leak goroutines
-
If the work offloaded to goroutines is asynchronous, always provide a means of stopping that goroutine. Your library shouldn’t do work that a user cannot stop when they’re done using your library.
You can use goleak to test for goroutine leaks in your library.
In short, don’t be greedy and clean up after yourself.
3.15. Reflection
Use the reflect package sparingly,
only if you’re confident in your understanding of it,
and only in situations where performance is not a concern.
The functions in this package panic when used with incorrect inputs,
so even having full code coverage does not guarantee
that your usage is correct.
3.16. Naming
Naming significantly affects usability of a library — especially discoverability of the right functionality — and it affects the readability of code that uses it.
Follow the standard Go naming conventions. There’s plenty of literature on this, so these links should help you get started:
On top of that, I’ve included some additional guidance. This is not intended to be comprehensive.
No managers
Don’t name objects FooManager — try to find a better noun
based on what that object does with the things it "manages."
This is a bit of a strawman argument, but imagine if everything that deals with more than one of something was named this way:
| Current name | Manager name |
|---|---|
http.RoundTripper |
http.RequestManager |
http.ServeMux |
http.HandlerManager |
flag.FlagSet |
flag.FlagManager |
sql.DB |
sql.ConnManager |
Search for a better name to improve readability and usability.
Package names
No generic package names
Avoid overly generic package names because they often require use of named imports, which can be inconvenient and annoying. Strive to make it rare for users to need named imports.
If you expect a package to be used frequently and its name is short and generic, you can qualify it with the name of its parent package. This will make the name more specific and reduce naming conflicts that necessitate named imports.
| Bad | Good |
|---|---|
|
|
|
|
|
|
Obviously, don’t go overboard. Be measured and deliberate about qualifying package names like this.
No kitchen sinks
Do not write packages with names like 'common' or 'util'. These become kitchen sinks of random unrelated components in lieu of better code organization.
3.17. Documentation
Lack of good documentation can become a significant hurdle in adoption of your library. There are better resources on this topic, but here are some tips:
- Write for the right audience
-
Documentation is intended for users, not maintainers. Don’t list implementation details in the documentation. Tell someone looking at an object’s documentation what they need to know about that object’s purpose, how to use it, and how it connects to other objects.
- Don’t bury the lede
-
Users don’t usually read documentation front-to-back like books. They search for keywords related to information they need, and they start by skimming the documentation to find what they want. Don’t bury key points inside a wall of text. Instead, use paragraphs and lists judiciously to highlight independent pieces of information.
- Provide examples
-
Users will prefer to have code samples that they can adapt to their needs over reading multiple paragraphs of text. Provide them with these. Make heavy use of inline code snippets for small code samples, and Go’s example tests for tested samples.
- No non-documenting documentation
-
Non-documenting documentation is documentation that doesn’t provide any new information. For example:
// RequestHandler handles requests. type RequestHandler struct{ /* ... */ } // Handler is an interface for handlers. type Handler interface{ /* ... */ }Don’t do this. Instead, explain what the object is, how and why to use it, and anything else the user may need to know.
3.18. Keep a changelog
A commit log does not a changelog make.
Do not dump your Git commit log into the release notes. Instead, track user-facing changes separately in a changelog.
Commit logs are intended for other developers, whereas a changelog is intended for users. Commit logs contain implementation details and changes that aren’t user-facing — it’s difficult for users to get value out of that.
It’s better to treat changelog like documentation, adding to it as new user-facing changes are committed, and editing it before each release. This will make it easier for users to determine how the release affects them: what got changed, added, or fixed.
If you’re new to maintaining a changelog, you can get started with the format specified at keepachangelog.com.