Level 2 /

Functions & Errors

Track switches are the heart of a rail system. Get one wrong, and the train ends up in a field. Gopher stands among forking tracks at the switchyard, testing each one. Some flip green, others flash red. When a switch fails, you do not ignore it: you handle it before moving on.

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:

keywordfunction nameparametersreturn types (result, 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:

result variableerror variableshort declarationfunction call
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:

  1. Call a function that might fail
  2. Check if err != nil
  3. If there’s an error, wrap it with context and return
  4. If everything’s fine, return the result with nil error

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.

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:

  1. Extract the logger from context: l := ctxzap.Extract(ctx)
  2. Log success with structured fields: l.Debug("message", zap.String("key", val))
  3. 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.

LevelWhen to useExample
l.DebugRoutine operations”listed users”, “fetched page”
l.InfoSignificant actions”granting membership”, “created account”
l.ErrorFailures 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!

Explore the related connector code files

Next Lesson

Structs, Methods & Pointers

Continue