Code style
Hammerhead utilises a free-form yet still clear style for its source code. As a rule of thumb when writing code, you should ask yourself “if this was my first time seeing Golang, would I be able to understand what is going on here”. If the answer’s no, you can probably clean it up. This document will give you some guiding advice on how to write acceptable, clean code for Hammerhead.
Formatting & linting
Hammerhead uses golangci-lint with a number of formatters and linters configured
(see: .golangci.yaml). This is extensively
configured to enforce specific code idioms, and catch signs of bad code patterns early.
While writing code, it is recommended you run golangci-lint run and golangci-lint fmt occasionally.
pre-commit (covered in TODO) will also run these before letting you commit.
If you are not familiar with writing high-quality/production-ready Golang, consider giving Effective Go - The Go Programming Language (go.dev) a read. It’s very long, but sets a very good baseline and will help you understand the differences in style between Golang and other languages you may be used to writing.
Configuring your IDE
If your IDE supports it, you should change your formatter & linter to golangci-lint:
GoLand
JetBrains’ GoLand supports this via Settings (ctrl+alt+s) -> Go -> Linters - enable both
Execute golangci-lint run and Execute golangci-lint fmt (you may also want to increase the parallelism).
Do not enable “Only fast linters”.
Visual Studio Code
Visual Studio code supports this via Settings (ctrl+alt+s) -> Extensions -> Go.
Set Lint Tool to golangci-lint-v2.
In order to configure golangci-lint as a formatter, you will need to edit settings.json and use the below config:
{
"go.alternateTools": {
"customFormatter": "/path/to/golangci-lint run"
}
}
And then set Settings / Extensions / Go / Format Tool to custom.
General rules
When writing code for Hammerhead, keep in mind:
- Names (functions, variables, etc.) should be
CamelCase(public) orlowerCamelCase(private). - Comments should be concise but descriptive. Don’t write essays, but explain your decisions.
- Code should be “self documenting” to the greatest extent it realistically can be.
- Use constant values over literal values where possible. For example,
http.MethodGetover"GET". This reduces the chance for unnoticed typos to slip into the code. - Hammerhead uses the British English locale where possible (
en_GBet al.).
Readable declarations
Declaring multiple, related variables should be done in a single var/const statement:
func main() {
// Bad!
x := 0
y := "awawawa"
z := false
// Good!
var (
a int
b = "awawawa" // no explicit type needed for non-zero inits
c bool
)
}
Furthermore, zero-value initialisations should be done with var, not type{}:
func main() {
// Bad!
x := myThing{}
// Good!
var x myThing
}
References and values
Hammerhead is expected to operate in a thin environment, where high performance/abundant resources may not be available. While the computer is smart, it still has to do what you tell it to do. If you tell it to copy values everywhere, it will. As such, you should generally pass variables around as pointers where it makes sense to do so. Usually, that’s any time you’re passing something that isn’t a primitive.
Passing values of types int, string, rune, bool, and floatX as values is generally fine since they typically
only use a couple bytes of memory anyway. Never pass slices or maps as pointers - maps and slices are both
“reference types”, meaning while they are not pointers themselves, they simply reference a pointer to their underlying
value. x := make([]int, 3); doThing(&x) is the same as x := make([]int, 3); doThing(x).
Small structs are also usually fine to pass around by value. “Small” in this context is usually a struct with up to three flattened primitive fields. Anything more than that, or a struct instance you’re expecting to live for a while, consider it a large struct.
Structs should (excluding the above “small” exclusion definition) practically always be passed around as references.
If you can, you should just initialise the struct as a reference (i.e. foo := &myType{X: Y}). The only time you
shouldn’t create an immediate pointer is if you are creating a zero struct, in which case the previous declaration
rule applies (zero-values should be initialised with var).
Tip
You should not need to dereference pointers when passing their data to a function to avoid mutating the data. Functions you’re calling should instead be written to avoid mutating the input unexpectedly, returning the data they want the caller to apply instead. If mutation cannot be avoided for some reason, cloning the value is acceptable.
Error Handling
Casing
Errors must be lowercase (not Uppercase or Title Case), and must not end in punctuation (like a period).
Good errors look like failed to do thing: operation timed out, bad errors look like
Failed to do thing: operation timed out.. This is because callers that are logging errors may end up wrapping them,
which can result in log lines such as Failed to create room: Failed to create room resolver: unsupported room version "1"...
Logging before returning
Do not log errors before returning them. If you want to include more context, wrap the error:
// Bad!
func main() {
logger := zerolog.Ctx(ctx)
result, err := myFunction(ctx)
if err != nil {
logger.Err(err).Msg("Operation failed!")
return err
}
// ...
}
// Good!
func main() {
logger := zerolog.Ctx(ctx)
result, err := myFunction(ctx)
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
// ...
}
Bubbling errors
In order to not lose context as errors propagate their way up the caller chain, you should wrap them where possible.
Using the %w format specifier with fmt.Errorf allows you to wrap the underlying error value, allowing receivers to
then use errors.Unwrap to get the underlying error, and correctly detect certain error subtypes with errors.Is.
If you want to return a root-level error that does not propagate another one, errors.New should be used instead.
Tip
Any root-level errors should be publicly exported variables, so that they can be detected as types by other callers.
var OperationalError = errors.New("operation failed")
func myFunction() error {
// do things here
return OperationalError
}
func mySecondFunction() error {
err := myFunction()
if err != nil {
return fmt.Errorf("failed to do operation: %w")
}
// ...
}
func main() {
err := mySecondFunction()
if err != nil {
// Because mySecondFunction wrapped the error from myFunction, that can be detected here:
if errors.Is(OperationalError, err) {
println("Operational error! " + err.Error())
}
}
}
Panics
If the building is on fire, panic! In Hammerhead, panics are an expected condition on a few layers of the program,
and will be caught accordingly - only the worst of the worst panics will cause the program to crash.
While you should prefer returning errors directly, sometimes a panic is also an acceptable condition. If you think the call chain cannot proceed without severe consequences (potentially even later panicking itself), or have encountered an impossible scenario, panicking is totally acceptable. Keep in mind that panics are ugly - they often produce long, unreadable stack traces, or accidentally lose their context along the way during propagation and become even harder to debug.
Panics are explicitly handled at these layers (top-down):
- runtime (
cmd/hammerhead/hammerhead.go) - “catch-all”, ensures database can shut down cleanly before propagating to a crash. - router/server - Catches any panic that is not captured during
handle, or subsequent calls towrite404and the passthrough serve. - router/handle - Catches any panic that is raised during the
handlefunction’s execution, ensuring metrics are always saved, the panic is logged, and a response in some form is written to the requesting client.
That third handler will be the one that is hit most often - any panic thrown while handling a request will be caught by that panic handler, and will thus limit the “blast radius” to just that request.
Caution
Panics in goroutines that themselves do not have an explicit
recover()call will cause the entire process to hard crash regardless of these above handlers. Always ensure yourecover()in spawned goroutines, unless you are sure the goroutine’s calls cannot panic.Hard crashes should be avoided at all costs, as they can leave the database and related external components in an unsafe inconsistent state, which may cause corruption or further operational errors after subsequent restarts.
Logging
Hammerhead uses zerolog for high-performance structured logging. Loggers are
accessed by fetching them from the current context.Context via zerolog.Context(context.Context).
Unlike other log libraries you may be used to, there isn’t really a concept of “named loggers”, “instruments”, or generally identifiable sub-loggers. This means you need to be very careful with logging - don’t be too noisy, since you can’t be silenced, but don’t be too quiet, otherwise you might go unheard.
You should use the following levels only when:
Panic(): NeverFatal(): NeverErr()/Error(): An unexpected error condition was encountered, and while the program is going to try to continue, the stability of the runtime may be in question, or the caller may produce equally unexpected behaviours. An example of this may be a database query failing or a file on disk is unexpectedly missing.Warn(): An expected error (or otherwise odd) condition was encountered. While the routine is going to handle this, it may be indicative of an underlying problem, one that the operator may want to investigate. For this reason, it can also be used to draw the operator’s attention. An example of this may be attempting to handle an effect on a resource we don’t know about (such as trying to persist an event to a room we aren’t joined to).Info(): An expected condition was encountered that the server operator may find interesting. Generally this is used to log when the server is doing something important that may potentially be interesting, such as: big operations like loading a room, starting an HTTP listener, or reporting the progress of a long-running/complex operation.Debug(): Information that is generally uninteresting to the average person, but may be interesting to developers, or operators trying to actively troubleshoot a problem. Such logs should contain context regarding what triggered their log. An example of this could be logging that the program is going to perform an operation (like a request), or timing information regarding an execution.Trace(): Information that is typically only useful while developing, to allow tracing function calls and decisions made by the program. This is the most verbose level and is expected to be such.
You should never use Msgf or related formatting functions - always put context in log fields:
- ❌
logger.Info().Msgf("I did a thing: %s", foo) - ✅
logger.Info().Str("foo", foo).Msg("I did a thing")
This is because formatted logs are more difficult to use than structured logs, and ultimately devalue the log itself.
You should also ensure that log messages are always capitalised and do not end with punctuation.
When a type has a String() function (i.e. it implements the Stringer interface), you should use
logger.Stringer("thing", myThing) instead of logger.Str("thing", myThing.String())
When logging an error, you should just use logger.Err(err) instead of logging.Error().Err(err), unless you want
to use a log level other than Err, such as logger.Warn().Err(err).