Functions & Errors
Gopher says
Functions are where the work happens. In baton-junction, every API call
is a function that might fail - and Go makes you deal with that possibility up
front. No hidden exceptions, no surprises.
In baton-junction, we handle errors using Go’s (result, error) return
pattern combined with the zap structured logging package. Every method -
GetUser, GetGroups, AddRoleMember - returns a result and an error, then
logs the outcome with typed zap fields. That error-handling-plus-logging
pattern is the heartbeat of the codebase.
Multiple Return Values
Most languages return one value from a function. Go functions can return multiple values, and the convention is to return the result and an error:
func GetUser(ctx context.Context, userID string) (*User, error) Here is a signature from baton-junction/pkg/client/client.go. Every method on the Client struct follows this pattern: take a context and some arguments, return a result pointer and an error.
Capturing Multiple Returns
When you call a function that returns multiple values, you capture all of them on the left side of :=. This is the most common line shape in Go code:
user, err := client.GetUser(ctx, userID) The := declares both user and err in one shot. You then check err immediately on the next line. If you only care about the error (for example, on a delete operation), use _ to discard the result: _, err := client.Delete(ctx, id).
The Error Handling Pattern
Go has no exceptions and no try/catch. Every error is a value you check explicitly. That might feel different at first, but it means you always see exactly where failures are handled. Here’s how GetUser works in baton-junction:
func (c *Client) GetUser(ctx context.Context, userID string) (*User, error) {
var response SingleResponse[User]
if err := c.doGet(ctx, fmt.Sprintf("/api/users/%s", userID), "", &response); err != nil {
return nil, fmt.Errorf("baton-junction: failed to get user: %w", err)
}
return &response.Data, nil
}
The pattern repeats throughout the codebase:
- Call a function that might fail
- Check
if err != nil - If there’s an error, wrap it with context and return
- If everything’s fine, return the result with
nilerror
This is Go’s alternative to try/catch exceptions. It’s more verbose, but every error path is explicit and visible in the code.
The Inline if Declaration
Notice the GetUser code above uses a compact form: if err := ...; err != nil. This declares and checks the variable in a single statement. It’s a common Go idiom that keeps the error variable scoped to just the if block:
if err := c.doGet(ctx, path, cursor, &response); err != nil {
return nil, fmt.Errorf("failed: %w", err)
}
This is equivalent to writing err := c.doGet(...) and if err != nil { ... } on separate lines, but more compact. You’ll see this pattern throughout baton-junction.
Creating Errors
Go provides two ways to create error values. Use errors.New for simple, static messages, and fmt.Errorf when you need to include dynamic details or wrap an underlying error for debugging.
errors.New("something went wrong") // simple error
fmt.Errorf("failed to get user %s: %w", id, err) // formatted, wraps cause
The %w verb in fmt.Errorf wraps the original error, preserving the chain of what went wrong. In baton-junction, error messages always start with "baton-junction:" to identify which component produced the error.
Your Turn
Complete the GetRole function. It should validate the input by calling validateID, handle any error, and return a Role pointer and an error. On success, return a new Role with the roleID and any name. On failure, return nil and the error.
Go’s designers chose explicit error returns over exceptions for a reason: exceptions create invisible control flow. A function call might throw, and the error propagates silently up the call stack until something catches it (or crashes the program). In Go, if a function can fail, its signature says so. You can’t accidentally ignore an error without the compiler warning you about an unused variable. The tradeoff is more typing, but the benefit is that reading Go code tells you exactly where errors are handled.
Structured Logging with zap
Standard Go error handling is the foundation. But in baton connectors, returning errors is only half the story. You also need to log what happened with enough detail to debug it later. baton-junction pairs error returns with structured logging via zap and ctxzap. From this point forward, every builder method you see will follow the full pattern: extract the logger, do the work, log the outcome, return the result.
func (o *userBuilder) List(ctx context.Context, _ *v2.ResourceId, attrs resource.SyncOpAttrs,
) ([]*v2.Resource, *resource.SyncOpResults, error) {
l := ctxzap.Extract(ctx)
// ... fetch users ...
l.Debug("listed users", zap.Int("count", len(users)), zap.String("next_cursor", nextCursor))
// ...
}
The pattern is:
- Extract the logger from context:
l := ctxzap.Extract(ctx) - Log success with structured fields:
l.Debug("message", zap.String("key", val)) - Log errors with the error field:
l.Error("failed", zap.Error(err), zap.String("user_id", id))
Structured fields (zap.String, zap.Int, zap.Error) produce machine-parseable JSON logs instead of printf-style strings. This is critical for debugging.
Log Levels
Log levels let you control verbosity and importance. In development you might enable Debug to see every operation; in production you might keep only Info and Error so logs stay focused on what matters.
| Level | When to use | Example |
|---|---|---|
l.Debug | Routine operations | ”listed users”, “fetched page” |
l.Info | Significant actions | ”granting membership”, “created account” |
l.Error | Failures that need attention | ”failed to fetch user”, “API returned 500” |
Exercise: Add Structured Logging
Write FetchUser: extract the logger from context, call getUser, and handle the result. On error, log with l.Error including structured fields, then return the wrapped error. On success, log with l.Debug and return the user.
Gopher says
Error handling AND structured logging - that’s the full picture for solid Go code. Handle every error explicitly, and log every outcome with structured fields.
In baton-junction, every method follows this pattern: extract the logger from context, do the work, log the outcome with typed zap fields, return the result.
Next: structs, methods, and pointers!
Next Lesson
Structs, Methods & Pointers

