name: golang description: Rules and best practices when writing and editing Go (Golang) code metadata: relevant_files: - "server//*.go" - "functions//.go" - "cli/**/.go"
This codebases uses features from Go 1.25 and above.
- Be pragmatic about introducing third-party dependencies beyond what is available in go.mod and lean on the standard library when appropriate.
- Use the Go standard library before attempting to suggest third party dependencies.
- Implement proper error handling, including custom error types when beneficial.
- Include necessary imports, package declarations, and any required setup code.
- Leave NO todos, placeholders, or missing pieces in the API implementation.
- Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms.
- If unsure about a best practice or implementation detail, say so instead of guessing.
- Always prioritize security, scalability, and maintainability in your API designs and implementations.
- Avoid editing any source files that have a "DO NOT EDIT" comment at start of them.
- Store dependencies on service structs via constructor-based dependency injection. Do NOT hide dependencies in session manager state.
- Avoid shallow helpers that are just a one-line wrapper around another method, especially when they are only used once.
- When using a slog logger, always use the context-aware methods:
DebugContext,InfoContext,WarnContext,ErrorContext. - When logging errors make sure to always include them in the log payload using
attr.SlogError(err). Example:logger.ErrorContext(ctx, "failed to write to database", attr.SlogError(err)). - Any functions or methods that relate to making API calls or database queries or working with timers should take a
context.Contextvalue as their first argument. - Always run linters as part of finalizing your code changes. Use
mise lint:serverto run the linters on the server codebase. - The
exhaustructlinter requires all struct fields to be explicitly set in struct literals. When adding new fields to a type, update ALL call sites — including places that construct the struct with zero values (e.g.,MyStruct{}→MyStruct{NewField: nil}).
Updating the API
We use Goa to design our API and generate server code. All Goa code lives in server/design. The Goa DSL is documented in https://pkg.go.dev/goa.design/goa/v3/dsl.
To make an API change such as creating a new service or update an existing one:
- Update the Goa design files in
server/designto reflect the API change. - Run
mise run gen:goa-server - This will regenerate the server code in
server/genwith the new API changes. It's best to usegitto discover the added/changed files.
When implementing Goa services:
- Ensure the service lives in a separate go package with an impl.go file such as
server/internal/<service>/impl.go. - The general layout of the impl.go file should be as follows:
package assets
import (
"context"
"log/slog"
goahttp "goa.design/goa/v3/http"
gen "github.com/speakeasy-api/gram/server/gen/assets"
srv "github.com/speakeasy-api/gram/server/gen/http/assets/server"
"github.com/speakeasy-api/gram/server/internal/auth"
)
type Service struct {
tracer trace.Tracer
logger *slog.Logger
auth *auth.Auth
// dependencies
}
func NewService(
logger *slog.Logger,
tracerProvider trace.TracerProvider,
auth *auth.Auth,
// dependencies
) *Service {
return &Service{
// initialize dependencies
}
}
var _ gen.Service = (*Service)(nil)
var _ gen.Auther = (*Service)(nil)
func Attach(mux goahttp.Muxer, service *Service) {
endpoints := gen.NewEndpoints(service)
endpoints.Use(middleware.MapErrors())
endpoints.Use(middleware.TraceMethods(service.tracer))
srv.Mount(
mux,
srv.New(endpoints, mux, goahttp.RequestDecoder, goahttp.ResponseEncoder, nil, nil),
)
}
func (s *Service) APIKeyAuth(ctx context.Context, key string, schema *security.APIKeyScheme) (context.Context, error) {
return s.auth.Authorize(ctx, key, schema)
}
func (s *Service) ListAssets(ctx context.Context, payload *gen.ListAssetsPayload) (*gen.ListAssetsResult, error) {
// implementation
}
If you are creating a new Goa service, then make sure to attach it to the http server in server/cmd/gram/start.go.
Dependency injection
- Always inject dependencies directly into service structs via the constructor.
- Do NOT use a session manager to stash dependencies that the service needs later.
- When a service needs database access, inject the DB connection and initialize query helpers (
repo.New) when needed in functions. - Do NOT store
repo.Queriesdirectly on a service struct for a new service.
type Service struct {
queries *repo.Queries
}
func NewService(db *pgxpool.Pool) *Service {
return &Service{
queries: repo.New(db),
}
}
This makes the service depend on a concrete query helper instance up front, which is not the pattern we want for new services.
type Service struct {
db *pgxpool.Pool
}
func NewService(db *pgxpool.Pool) *Service {
return &Service{db: db}
}
func (s *Service) Handler(ctx context.Context) error {
queries := repo.New(s.db)
if err := queries.DoThing(ctx); err != nil {
return fmt.Errorf("do thing: %w", err)
}
return nil
}
This keeps the service dependency simple and avoids baking repo.Queries into the service shape.
Auth context assumptions
- When reading
authctx, assumeActiveOrganisationIDis present. - Do NOT add defensive empty checks for
ActiveOrganisationIDunless there is a concrete code path proving otherwise.
Avoid patterns that treat ActiveOrganisationID as optional when reading authctx. That adds defensive code around an invariant that should already hold.
Third-party clients
- Constructors for third-party clients should always return a usable client implementation.
- Avoid designs where internal code has to repeatedly check whether a client is
nilbefore calling it. - Provide a stub implementation for local development and tests, but choose between the real and stub implementation in
deps.gobased onc.String("environment"). - Do NOT expose third-party request/response types from your wrapper to the rest of our codebase. Define our own types at the boundary.
type Service struct {
client *vendor.Client
}
func NewService(cfg Config) *Service {
if cfg.APIKey == "" {
return nil
}
return &Service{client: vendor.New(cfg.APIKey)}
}
func (s *Service) Send(ctx context.Context, req *vendor.Request) error {
if s.client == nil {
return nil
}
return s.client.Send(ctx, req)
}
This leaks vendor types into internal code and spreads nil handling into runtime call paths.
type Client interface {
Send(ctx context.Context, message Message) error
}
type Message struct {
To string
Subject string
Body string
}
type Service struct {
client Client
}
func NewService(client Client) *Service {
return &Service{client: client}
}
Wire the real or stub implementation in deps.go so the service always receives a valid Client, and keep vendor-specific types inside the wrapper implementation.
Transactional email (Loops)
Sending transactional email goes through server/internal/email. The package wraps Loops and enforces a strongly typed Template interface.
Adding a new template
Follow these four steps:
- Add a
TransactionalIDconstant toserver/internal/email/templates.go— single registry, grep-friendly. - Create
server/internal/email/template_<name>.gowith a struct implementing theTemplateinterface (TransactionalID(),Variables(),AddToAudience()). - Append a zero value of the struct to
RegisteredTemplatesintemplates.goso tests catch duplicate IDs (e.g.AccessRequestCreated{}). - Write
server/internal/email/template_<name>_test.gocovering:TransactionalIDreturns the expected constant,Variablesreturns the correct snake_case keys with all keys present,AddToAudiencereturns the expected bool.
To send: call s.emailSvc.Send(ctx, recipientEmail, tmpl) where tmpl is your populated template struct.
Variable key naming
Variables() must return snake_case keys. Loops substitutes these keys directly into template variables — camelCase keys silently render as blank fields in the delivered email.
Every declared key must be present in the returned map even when the value is empty. A missing key causes partial template rendering.
func (t MyTemplate) Variables() map[string]string {
return map[string]string{
"approvalUrl": t.ApprovalURL,
"requesterEmail": t.RequesterEmail,
}
}
camelCase keys silently render as blank fields in Loops — no error, no warning.
func (t MyTemplate) Variables() map[string]string {
return map[string]string{
"approval_url": t.ApprovalURL,
"requester_email": t.RequesterEmail,
}
}
AddToAudience semantics
Controls whether Loops upserts the recipient as a contact in the audience when the email is sent.
- Return
truefor user-facing emails that are part of the recipient's product journey (team invites, onboarding). - Return
falsefor operational/admin emails where the recipient is incidental (admin alerts, system notifications).
Testing patterns
Base test setup — never pass nil for *email.Service:
loopsClient := loops.New(ctx, logger, nil, "") // nil guardian policy is safe when key is empty; returns noop client
noopEmailSvc := email.NewService(logger, loopsClient)
Asserting on sent emails — use a capture client:
loops.Client is our own interface (not a vendor type), so a hand-rolled capture client is appropriate here. The capture pattern lets tests assert on the exact payload sent — use it instead of testify/mock for Loops email assertions.
type captureLoopsClient struct {
mu sync.Mutex
sent []loops.SendTransactionalInput
}
func (c *captureLoopsClient) SendTransactional(_ context.Context, input loops.SendTransactionalInput) error {
c.mu.Lock()
defer c.mu.Unlock()
c.sent = append(c.sent, input)
return nil
}
func (c *captureLoopsClient) Sent() []loops.SendTransactionalInput {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]loops.SendTransactionalInput, len(c.sent))
copy(out, c.sent)
return out
}
To use it in a test, declare an instance and swap it into the service:
captured := &captureLoopsClient{}
svc.emailSvc = email.NewService(testenv.NewLogger(t), captured)
(This assigns an unexported field — works from within the same package, which is the convention for access package tests.)
Optional display fields — use conv.Default:
DisplayName: conv.Default(request.DisplayName, "(unknown resource)"),
Never send a template with a blank field that produces broken email copy. Apply a meaningful fallback at the Go layer, not in the Loops template.
Function shape
- Avoid helper functions and methods that only forward to another method with no meaningful logic.
- Avoid extracting single-use one-liners into separate methods just for indirection.
- Prefer inlining trivial behavior at the call site unless the extracted function adds reuse, naming value, or non-trivial logic.
func (s *Service) listWidgets(ctx context.Context) error {
return s.repo.ListWidgets(ctx)
}
func (s *Service) List(ctx context.Context) error {
return s.listWidgets(ctx)
}
The wrapper adds no abstraction and is only used once.
func (s *Service) List(ctx context.Context) error {
return s.repo.ListWidgets(ctx)
}
Error handling
In low-level functions, use fmt.Errorf to wrap errors with distinct and useful context:
func SaveUser(repo Repository, u User) error {
err := repo.Save(u)
if err != nil {
return fmt.Errorf("failed to save user: %w", err)
}
return nil
}
Do not need to use "failed to" language.
func SaveUser(repo Repository, u User) error {
err := repo.Save(u)
if err != nil {
return fmt.Errorf("run database query: %w", err)
}
return nil
}
Do not use generic language that doesn't add any context and doesn't improving searching for errors in the codebase.
func SaveUser(repo Repository, u User) error {
err := repo.Save(u)
if err != nil {
return fmt.Errorf("save user: %w", err)
}
return nil
}
This is much better. The error message is concise and to the point and unique to the call site.
In higher-level functions of the server/ codebase, which include HTTP service handlers, use the server/internal/oops package which allows us to wrap internal errors with user-facing error messages.
func (s *Service) ListDeployments(ctx context.Context, form *gen.ListDeploymentsPayload) (res *gen.ListDeploymentResult, err error) {
var cursor uuid.NullUUID
if form.Cursor != nil {
c, err := uuid.Parse(*form.Cursor)
if err != nil {
return nil, oops.E(oops.CodeBadRequest, err, "invalid cursor").Log(ctx, s.logger)
}
cursor = uuid.NullUUID{UUID: c, Valid: true}
}
}
Logging
- Use log/slog for logging.
- ALWAYS use logging attributes defined in
server/internal/attr/conventions.gowhen logging in the server codebase. - Where appropriate, create child loggers using
logger.With(attr.SlogXXX(...))to capture contextual attributes for logging in later parts of code. - DO NOT spam the codebase with log statements. Focus on logging errors where appropriate and reduce the noise from excessive info-level logs.
logger.InfoContext(ctx, "user created", "user_id", userID)
This is bad because it doesn't use the attributes from the convention package.
import "github.com/speakeasy-api/gram/functions/internal/attr"
func Example() {
logger.Error("failed to create user", attr.SlogError(err))
}
This is bad because it uses logger.Error instead of logger.ErrorContext.
import "github.com/speakeasy-api/gram/functions/internal/attr"
func Example(ctx context.Context) {
logger.ErrorContext(ctx, "failed to create user", attr.SlogError(err))
}
This is great because:
- It uses
logger.ErrorContextwhich is the convention for logging in the server codebase. - It uses the
attr.SlogErrorattribute from the attr package.
Conversion utilities (server/internal/conv)
Use the conv package for common type conversions instead of writing inline helpers. Key functions:
conv.PtrEmpty(v)— If v is not the zero value, return a pointer to v; otherwise, return nil.conv.PtrValOr(ptr, default)— dereference a pointer with a fallback default.conv.Default(val, default)— returnvalunless it is the zero value, then returndefault.conv.ToPGText,conv.ToPGTextEmpty,conv.PtrToPGText,conv.PtrToPGTextEmpty— convert strings topgtype.Text.conv.FromPGText,conv.FromPGBool— convertpgtypevalues to Go pointer types.conv.PtrToPGBool— convert a*booltopgtype.Bool.conv.Ternary(cond, trueVal, falseVal)— inline conditional expression.
Do NOT reimplement pointer helpers, ternary expressions, or pgtype conversions inline. Always reach for conv first.
Observability (server/internal/o11y)
Use the o11y package for deferred cleanup and error logging. Two key functions:
o11y.LogDefer
func LogDefer(ctx context.Context, logger *slog.Logger, cb func() error) error
Use LogDefer when a cleanup operation's error should be logged. Wrap cleanup calls with defer o11y.LogDefer(...) so failures are always visible in logs.
defer o11y.LogDefer(ctx, logger, func() error { return file.Close() })
o11y.NoLogDefer
func NoLogDefer(cb func() error)
Use NoLogDefer when a cleanup operation's error can be silently discarded — for example, rolling back a database transaction (which is a no-op if the transaction already committed) or closing an HTTP response body.
dbtx, err := s.repo.DB().Begin(ctx)
if err != nil {
return nil, oops.E(oops.CodeUnexpected, err, "error accessing resource").Log(ctx, logger)
}
defer o11y.NoLogDefer(func() error { return dbtx.Rollback(ctx) })
defer o11y.NoLogDefer(func() error { return resp.Body.Close() })
- ALWAYS use
o11y.LogDeferoro11y.NoLogDeferfor deferred cleanup instead of baredefer resource.Close()calls. Bare defers silently discard errors with no traceability. - Choose
LogDeferwhen the error matters for debugging (file I/O, critical resource cleanup). - Choose
NoLogDeferwhen the error is expected or inconsequential (transaction rollbacks, response body closes).
Testing
- When writing assertions, use
github.com/stretchr/testify/requireexclusively. - In tests, use
t.Context()instead ofcontext.Background(), except insidet.Cleanup(func())callbacks. - IMPORTANT: avoid using
t.Runto create subtests. Prefer writing separate test functions instead. - All test setup which includes spinning up databases, caches and background workers must go in
setup_test.gofiles. Look for these across the codebase for inspiration and guidance. - NEVER write raw SQL in tests for any Postgres operation —
SELECT,INSERT,UPDATE,DELETE, transactions (Begin/BeginTx),CopyFrom, andSendBatchare all covered. Use SQLc-generated methods. Default to adding new fixture queries in the relevant domain package's ownqueries.sql(e.g. atoolsets-shaped fixture goes inserver/internal/toolsets/queries.sql, not intestenv). Reach forserver/internal/testenv/queries.sql(andtestenv/testrepo) only when a fixture query is genuinely reused across multiple packages. Theglintno-testing-raw-sqlrule enforces this against*pgxpool.Pool,*pgx.Conn,pgx.Tx, andpgx.Querierreceivers in*_test.go. ClickHouse uses a different driver and is not flagged. - Use
github.com/stretchr/testify/mockfor mocking third-party libraries in tests instead of ad hoc fakes around vendor types. - Use
testenv.NewLogger(t),testenv.NewTracerProvider(t), andtestenv.NewMeterProvider(t)instead of constructing loggers or noop OTel providers inline.testenv.NewLogger(t)discards in normal runs and emits pretty logs undergo test -v, which inlineslog.New(slog.DiscardHandler)andslog.New(slog.NewTextHandler(os.Stdout, nil))do not. Exception: tests that assert on log output should use a capturing handler over abytes.Buffer.
ctx := context.Background()
This loses the test lifecycle context that Go now provides directly on *testing.T.
ctx := t.Context()
type mockEmailClient struct {
mock.Mock
}
func (m *mockEmailClient) Send(ctx context.Context, message Message) error {
args := m.Called(ctx, message)
return args.Error(0)
}
Use testify/mock when mocking integrations so expectations stay explicit and consistent across tests.