admond@portfolio:~/blog
← all posts
$ cat golang-for-nodejs-developers.md

Golang for Node.js Developers

Mon Sep 22 · 9 min read
[golang][node.js][backend]

Why Go clicks for Node.js developers

When I first read Go code after years of Node.js, it looked verbose. No classes, no closures over this, error values instead of exceptions, explicit types everywhere. It felt like a step backward.

Six months later, I find Go code easier to reason about than most TypeScript I've written. The verbosity is a feature — it makes implicit behavior explicit. The strict compiler is a feature — it catches entire categories of bugs before runtime. The error-as-value pattern is a feature — it forces you to handle failure at the call site.

This post is the guide I wish I'd had when making the transition.

The mental model shift: no event loop

Node.js is single-threaded with an event loop. Async I/O is non-blocking because callbacks and promises defer work until I/O completes. You think in terms of "don't block the event loop."

Go has goroutines — lightweight threads managed by the Go runtime, not the OS. You can spawn thousands of them with minimal overhead. Blocking a goroutine is fine; the runtime schedules other goroutines while it waits.

// This blocks the goroutine — that's okay
resp, err := http.Get("https://api.example.com/data")
if err != nil {
    return nil, err
}
defer resp.Body.Close()

The equivalent in Node.js requires await. In Go, synchronous-looking code is genuinely synchronous within the goroutine, and Go handles the concurrency for you.

Errors are values, not exceptions

In Node.js you're trained to throw errors and try/catch at a higher level. Go has no exceptions. Functions return errors as the last return value, and you check them at every call site:

func readConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, fmt.Errorf("readConfig: %w", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return Config{}, fmt.Errorf("readConfig: unmarshal: %w", err)
    }

    return cfg, nil
}

The %w verb wraps the original error so callers can use errors.Is() and errors.As() to inspect error chains. This is more verbose than try/catch. It's also unambiguous — you can read any function and know exactly which operations can fail and where errors are handled.

Structs instead of classes

Go has no classes and no inheritance. Use structs with methods:

type UserService struct {
    db     *sql.DB
    logger *slog.Logger
}

func NewUserService(db *sql.DB, logger *slog.Logger) *UserService {
    return &UserService{db: db, logger: logger}
}

func (s *UserService) FindByID(ctx context.Context, id string) (*User, error) {
    var user User
    err := s.db.QueryRowContext(ctx,
        "SELECT id, email FROM users WHERE id = $1", id).
        Scan(&user.ID, &user.Email)
    if err == sql.ErrNoRows {
        return nil, ErrUserNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("FindByID: %w", err)
    }
    return &user, nil
}

The (s *UserService) receiver syntax is the Go equivalent of a class method. Dependency injection happens through the constructor function — no framework required.

Interfaces are implicit

Go interfaces are satisfied implicitly. If your type has the right methods, it implements the interface — no implements keyword needed:

type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Create(ctx context.Context, user *User) error
}

// PostgresUserRepository satisfies UserRepository automatically
type PostgresUserRepository struct{ db *sql.DB }

func (r *PostgresUserRepository) FindByID(ctx context.Context, id string) (*User, error) { ... }
func (r *PostgresUserRepository) Create(ctx context.Context, user *User) error { ... }

This makes mocking in tests trivial. Define a MockUserRepository in your test file, implement the interface methods to return test data, and inject it.

Goroutines and errgroup

Concurrency in Go is explicit and composable. The sync/errgroup package is the idiomatic way to run multiple goroutines and collect errors:

import "golang.org/x/sync/errgroup"

func fetchDashboardData(ctx context.Context, userID string) (*Dashboard, error) {
    g, ctx := errgroup.WithContext(ctx)

    var profile *Profile
    var orders []Order

    g.Go(func() error {
        var err error
        profile, err = profileService.Get(ctx, userID)
        return err
    })

    g.Go(func() error {
        var err error
        orders, err = orderService.List(ctx, userID)
        return err
    })

    if err := g.Wait(); err != nil {
        return nil, err
    }

    return &Dashboard{Profile: profile, Orders: orders}, nil
}

Both fetches run concurrently. If either fails, the error propagates and the context cancels the other.

Where Node.js still wins

Go isn't universally superior. Node.js has:

Go wins on:

Getting started

The fastest path from Node.js to productive Go:

The compiler is strict and the error messages are clear. Trust the compiler. The verbosity you see at first becomes the clarity you appreciate at 2am when something is wrong in production.

← all posts
admond tamang · portfoliotheme: mono