There are plenty of resources on the internet for how to write Go code. This guide is about applying those rules to the Librarian codebase.
It covers the most important tools, patterns, and conventions to help you write readable, idiomatic, and testable Go code in every pull request.
One of the core philosophies of Go is that clear is better than clever, a principle captured in Go Proverbs.
While simplicity is complicated, writing simple, readable Go can easily be achievable by following the conventions the community has already established.
For guidance, refer to the following resources:
- Effective Go: The canonical guide to writing idiomatic Go code.
- Go Code Review Comments: Common feedback and best practices used in Go code reviews.
- Google's Go Style Guide: Google’s guidance on Go style and design decisions.
- Idiomatic Go: Common rules and conventions for writing idiomatic Go.
For brands or words with more than 1 capital letter, lowercase all letters when unexported. See details
- Good:
oauthToken,githubClient - Bad:
oAuthToken,gitHubClient
Comments for humans always have a single space after the slashes. See details
- Good:
// This is a comment. - Bad:
//This is a comment.
Use singular form for collection repo/folder name. See details
- Good:
example/,image/,player/ - Bad:
examples/,images/,players/
Use consistent spelling of certain words, following https://go.dev/wiki/Spelling. See details.
- Good:
unmarshaling,marshaling,canceled - Bad:
unmarshalling,marshalling,cancelled
When naming packages, follow these two principles:
-
Avoid redundancy. Go uses package names to provide context, so avoid repeating the package name within a type or function name.
- Good:
git.ShowFile,client.New - Bad:
git.GitShowFile,client.NewClient
- Good:
-
Describe the purpose. Good package names are short and descriptive. Avoid generic names.
- Good:
command,fetch - Bad:
common,helper,util
- Good:
See details.
"Doc comments" are comments that appear immediately before top-level package, const, func, type, and var declarations with no intervening newlines. Every exported (capitalized) name should have a doc comment.
See Go Doc Comments for details.
These comments are parsed by tools like go doc, pkg.go.dev, and IDEs via gopls. You can also view local or private module docs using pkgsite.
Go doesn’t use exceptions. Errors are returned as values and must be explicitly checked.
For guidance on common patterns and anti-patterns, see the Go Wiki on Errors.
When working with generics, refer to these resources for idiomatic error handling:
To keep the main logic flow linear and reduce indentation, return early or
continue early instead of using else blocks.
// Good
if err != nil {
return err
}
// process success case
// Bad
if err == nil {
// process success case
} else {
return err
}Similarly, in a loop, use continue to skip to the next iteration instead of
wrapping the main logic in an else block.
// Good
for _, item := range items {
if item.skip {
continue
}
// process item
}
// Bad
for _, item := range items {
if !item.skip {
// process item
}
}When a function modifies a pointer parameter, return the modified value to make the mutation explicit. This makes it so that functions are clear about their side effects.
// Good: Returns the modified value to signal mutation
func UpdateConfig(cfg *Config) (*Config, error) {
// ... update fields ...
cfg.Version = newVersion
return cfg, nil
}
// Usage makes mutation visible
cfg, err := UpdateConfig(config)
// Bad: Mutation is hidden
func UpdateConfig(cfg *Config) error {
// ... update fields ...
cfg.Version = newVersion
return nil
}
// Usage hides that config was modified
err := UpdateConfig(config)This pattern helps readers understand at a glance which functions modify their inputs versus which functions only read them.
When writing tests, we follow the patterns below to ensure consistency, readability, and ease of debugging. See Go Test Comments for conventions around writing test code.
Always use t.Context() instead of context.Background() in tests to ensure
proper cancellation and cleanup.
Example:
err := Run(t.Context(), []string{"cmd", "arg"})Always use t.TempDir() instead of manually creating and cleaning up temporary
directories.
Example:
err := Run(t.Context(), []string{"cmd", "-output", t.TempDir()})Avoid verbose or redundant failure messages. If an error occurs, pass it directly
to t.Fatal or t.Error. The testing package automatically includes the file
and line number, and well-constructed errors already provide their own context.
Good:
t.Fatal(err)Bad:
t.Fatalf("failed: %v", err)Only use t.Fatalf if you need to provide extra context not present in the
error, such as:
t.Fatalf("failed to process user %d: %v", userID, err)Use go-cmp instead of
reflect.DeepEqual for clearer diffs and better debugging.
Always compare in want, got order, and use this exact format for the error
message:
t.Errorf("mismatch (-want +got):\n%s", diff)Example:
func TestGreet(t *testing.T) {
got := Greet("Alice")
want := "Hello, Alice!"
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}This format makes test failures easier to scan, especially when comparing multiline strings or nested structs.
Use table-driven tests to keep test cases compact, extensible, and easy to scan. They make it straightforward to add new scenarios and reduce repetition.
Use this structure:
-
Write
for _, test := range []struct { ... }{ ... }directly. Don't name the slice. This makes the code more concise and easier to grep. -
Use
t.Run(test.name, ...)to create subtests. Subtests can be run individually and parallelized when needed.
Example:
func TestTransform(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{"uppercase", "hello", "HELLO"},
{"empty", "", ""},
} {
t.Run(test.name, func(t *testing.T) {
got := Transform(test.input)
if diff := cmp.Diff(test.want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}Splitting success and failure cases into separate test functions can simplify your test code. See details.
When writing error tests, use a test function name like TestXxx_Error, and
when possible use errors.Is for comparison
(see details).
Example:
func TestSendMessage_Error(t *testing.T) {
for _, test := range []struct {
name string
recipient string
message string
wantErr error
}{
{
name: "recipient does not exist",
recipient: "Does Not Exist",
message: "Hello, Mr. Not Exist",
wantErr: errRecipientDoesNotExist,
},
{
name: "empty message",
recipient: "Jane Doe",
message: "",
wantErr: errEmptyMessage,
},
}{
t.Run(test.name, func(t *testing.T) {
_, gotErr := SendMessage(test.recipient, test.message)
if !errors.Is(gotErr, test.wantErr) {
t.Errorf("SendMessage(%q, %q) error = %v, wantErr %v", test.recipient, test.message, gotErr, test.wantErr)
}
})
}
}Large table-driven tests and/or those that are I/O bound e.g. by making
filesystem reads or network requests are good candidates for parallelization via
t.Parallel(). Do not
parallelize lightweight, millisecond-level tests.
Important: A test cannot be parallelized if it depends on shared resources,
mutates the process as a whole e.g. by invoking t.Chdir(), or is dependent on
execution order.
func TestTransform(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{"uppercase", "hello", "HELLO"},
{"empty", "", ""},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel() // Mark subtest for parallel execution.
got := Transform(test.input)
if diff := cmp.Diff(test.want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}This guide will continue to evolve. If something feels unclear or is missing, just ask. Our goal is to make writing Go approachable, consistent, and fun, so we can build a high-quality, maintainable, and awesome Librarian CLI and system together!