Skip to content
Software Atlas

Creational Patterns

How to build objects so they're never half-made: Go constructors and JS factory functions, factories and registries, builders, Go's functional options, singletons (and why to avoid them), copying safely, and pooling.

⏱ ~40 min read14 sections6 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

Why creating objects needs patterns

In one sentenceCreational patterns are recipes for building objects so that callers get something complete and ready to use — without needing to know, or repeat, every detail of how it's put together.

Creating an object sounds trivial: new User() in JavaScript, User{} in Go. And for small things, it is. The trouble starts when the object grows: more settings, more things it depends on, more rules about which combinations are valid. Then the creation code becomes the messiest part of the program.

A design pattern is a named, proven answer to a problem that keeps coming up. The classic "Gang of Four" book lists five creational ones (factory method, abstract factory, builder, prototype, singleton). This page covers those, plus two that matter a lot in real Go and JavaScript code: functional options and object pools.

Three ways object creation goes wrong

1. The constructor with nine arguments. Which true is which? What does 0 mean? Nobody can read this call, and swapping two arguments compiles just fine:

srv := NewServer(":8080", 30, 100, true, false, "cert.pem", "key.pem", nil, 0)
//                         ↑   ↑    ↑     ↑   … what do these mean?

2. The half-built object. You create it, then set fields one by one. Anyone who uses it before you're done — or if you forget a field — gets a broken object that fails somewhere far away:

const client = new HttpClient();
client.baseUrl = "https://api.example.com";
// forgot: client.timeoutMs = 5000;  ← nothing stops you; requests now hang forever

3. The hidden dependency. The object quietly grabs a global database connection, or reads environment variables, inside its constructor. Calling code can't see that, and tests can't swap it out. (A Dependency is anything your code needs to do its job.)

The map

Every pattern in this topic answers one specific question. If you don't have that question, you don't need the pattern.

The map: each pattern answers one question
“I just need a ready-to-use object.”
→ Constructor / factory function§02
“Which implementation? Decided at runtime by config.”
→ Factory · registry§03
“A whole family of objects that must match.”
→ Abstract factory§04
“Many steps, check everything at the end.”
→ Builder§05
“A few required inputs, lots of optional ones.”
→ Functional options / options object§06–07
“Exactly one, shared by everyone.”
→ Singleton (usually: don't)§08
“Like that one, but slightly different.”
→ Prototype / copying§09
“Creating is expensive and happens constantly.”
→ Object pool§10
Green = what you'll use most days. Amber = know it, mostly avoid it. Everything else = reach for it when the pain is real.

All code on this page is Go and JavaScript, side by side. You don't need any background beyond basic syntax; every new idea is explained as it comes up.

Foundations§02

Constructors: NewX in Go, factory functions in JS

In one sentenceA constructor's one job: hand back an object that is ready to use — or refuse with an error. Never something in between.

The rule an object must always obey is called an invariant: "the URL is valid", "the map is never nil", "the timeout is positive". The constructor is the one place where you check those rules. After it returns, the rest of the code can simply trust the object.

Go: a function called NewX

Go has no class and no special constructor syntax. By convention, you write a plain function named New + the type name, which returns a pointer to a new Struct:

client.go
type Client struct {
    baseURL *url.URL      // lower-case fields: private to this package
    http    *http.Client
}

func NewClient(baseURL string) (*Client, error) {
    u, err := url.Parse(baseURL)
    if err != nil {
        return nil, fmt.Errorf("new client: %w", err) // ← refuse: no half-built Client
    }
    if u.Scheme != "https" {
        return nil, errors.New("new client: base URL must use https")
    }
    return &Client{
        baseURL: u,
        http:    &http.Client{Timeout: 10 * time.Second}, // ← a sensible default
    }, nil
}

The caller checks the error once, and from then on has a valid client:

c, err := NewClient(os.Getenv("API_URL"))
if err != nil {
    log.Fatal(err) // stop at startup, with a clear message
}

Sometimes you don't need a constructor at all

Go gives every type a Zero value — 0, "", nil, or a struct with all fields at their zero values. Good Go types are designed so that the zero value is already useful. The standard library does this a lot — bytes.Buffer (a growable byte buffer) and sync.Mutex (a mutex) both work without any setup:

var buf bytes.Buffer      // no NewBuffer() needed
buf.WriteString("hello")  // works immediately

var mu sync.Mutex         // an unlocked lock, ready to use
mu.Lock()

You can do the same in your own types. A map field is the usual problem — writing to a nil map panics — so create it lazily, inside the method:

type Counter struct {
    mu     sync.Mutex
    counts map[string]int // nil in the zero value
}

func (c *Counter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.counts == nil {
        c.counts = make(map[string]int) // create on first use
    }
    c.counts[key]++
}
// usage: var c Counter; c.Inc("a")   ← no constructor needed

JavaScript: three ways to build an object

1. A class constructor. The constructor() method runs when you call new. It can validate and throw:

class Client {
  #baseUrl;                     // #private field: invisible outside the class
  constructor(baseUrl) {
    const u = new URL(baseUrl); // throws TypeError on a malformed URL
    if (u.protocol !== "https:") throw new Error("base URL must use https");
    this.#baseUrl = u;
  }
}
const c = new Client("https://api.example.com");

2. A static factory method. A JS constructor can't use await — it has to return the object immediately. When setup is Asynchronous (opening a connection, say), put it in a static method that does the waiting and then calls the constructor:

class Database {
  #pool;
  constructor(pool) { this.#pool = pool; }   // just stores what it's given

  static async connect(url) {                // ← the factory
    const pool = await openPool(url);        // the async part
    return new Database(pool);
  }
}

const db = await Database.connect("postgres://localhost/app");

3. A plain factory function. No class, no new, no this — just a function that returns an object. Variables inside it stay private, because the returned methods are closures: they remember the variables from where they were created.

function createCounter(start = 0) {
  let count = start;                 // private: only the methods below can see it
  return {
    increment() { count += 1; return count; },
    value() { return count; },
  };
}

const c = createCounter();
c.increment();
console.log(c.value()); // 1
console.log(c.count);   // undefined — there's no way to reach it from outside

This is called a Factory function. It's very common in modern JS, especially in code that prefers functions over classes.

✕ Half-built: set fields after creating
const client = new HttpClient();
client.baseUrl = url;
client.timeoutMs = 5000;
// forgot one? nothing tells you
✓ Complete on return
const client = createHttpClient({
  baseUrl: url,
  timeoutMs: 5000,
}); // checks everything, then returns

The options-object style on the right is a pattern in its own right — §07 shows how to give it defaults safely.

Practitioner§03

Factories: pick the implementation at runtime

In one sentenceA factory is a function that decides which concrete type to build — often from a config value — and returns it behind a common Interface, so the caller doesn't care which one it got.

§02's constructors always build the same type. A factory is for when there are several implementations of the same idea, and you only find out at runtime which one you need. Classic example: files can be stored on the local disk or in a cloud bucket (Amazon S3), and a config file says which.

Step 1: an interface both implementations satisfy

type Store interface {
    Put(ctx context.Context, key string, data []byte) error
    Get(ctx context.Context, key string) ([]byte, error)
}

diskStore and s3Store each have those two methods, so both are a Store. (In Go, a type satisfies an interface just by having the methods — no implements keyword.)

Step 2: the simple factory

storage.go
type Config struct {
    Kind   string // "disk" or "s3"
    Dir    string // for disk
    Bucket string // for s3
}

func New(cfg Config) (Store, error) {
    switch cfg.Kind {
    case "disk":
        return newDiskStore(cfg)
    case "s3":
        return newS3Store(cfg)
    default:
        return nil, fmt.Errorf("storage: unknown kind %q", cfg.Kind)
    }
}

// caller: doesn't know or care which one it gets
st, err := storage.New(cfg)

That's it — no class hierarchy, no framework. The payoff: main decides once, from config, and everything else just uses Store. Tests can pass in an in-memory store (a Test double) through the same interface.

Step 3: the registry — let implementations add themselves

The switch has one downside: storage.New must import every implementation, even ones a given program never uses (and their heavy SDKs). A registry flips that around: the factory keeps a map from name → constructor, and each implementation registers itself.

storage/storage.go
type Factory func(cfg Config) (Store, error) // a constructor, stored as a value

var registry = map[string]Factory{}

func Register(name string, f Factory) {
    if _, dup := registry[name]; dup {
        panic("storage: Register called twice for " + name) // a programming bug, not a runtime error
    }
    registry[name] = f
}

func New(cfg Config) (Store, error) {
    f, ok := registry[cfg.Kind]
    if !ok {
        return nil, fmt.Errorf("storage: unknown kind %q (forgotten import?)", cfg.Kind)
    }
    return f(cfg)
}

And each implementation registers itself in a special function called init:

storage/s3/s3.go
package s3

func init() {
    storage.Register("s3", newS3Store) // runs automatically at program start
}

// Its signature matches storage.Factory exactly — that's why Register accepts it.
func newS3Store(cfg storage.Config) (storage.Store, error) {
    if cfg.Bucket == "" {
        return nil, errors.New("s3: bucket is required")
    }
    return &s3Store{bucket: cfg.Bucket}, nil
}
main.go
import (
    "example.com/app/storage"
    _ "example.com/app/storage/s3" // ← "load this package", nothing else
)

Which package gets set up first? Go's rule: a package is always ready before anything that imports it. Step through it:

Who gets set up first?
Go initializes a package only after every package it imports. Step through it.
main
package vars → init() → main()
imports →
storage/disk
init(): Register("disk")
storage/s3
init(): Register("s3")
import →
storage
var registry = map{}
The program starts. Arrows mean “imports”: main imports disk and s3, and both of those import storage. Nothing is set up yet.
1 / 6

Here's the whole registry in motion, from the moment the program starts:

A factory registry, from program start to lookup
Four files squeezed into one view. Step through with ← →.
Code
1// ---- storage/storage.go ----
2var registry = map[string]Factory{}
3
4func Register(name string, f Factory) {
5 registry[name] = f
6}
7
8func New(cfg Config) (Store, error) {
9 f, ok := registry[cfg.Kind]
10 if !ok {
11 return nil, fmt.Errorf("unknown kind %q", cfg.Kind)
12 }
13 return f(cfg)
14}
15
16// ---- storage/disk/disk.go ----
17func init() { storage.Register("disk", newDiskStore) }
18
19// ---- storage/s3/s3.go ----
20func init() { storage.Register("s3", newS3Store) }
21
22// ---- main.go ----
23import _ "example.com/app/storage/disk"
24import _ "example.com/app/storage/s3"
25
26func main() {
27 st, err := storage.New(storage.Config{Kind: "s3"})
28 _, err2 := storage.New(storage.Config{Kind: "ftp"})
29 fmt.Println(st, err, err2)
30}
registry mapname → constructor
doesn't exist yet
What happens
program starting…
main.go imports the two driver packages with a _ in front: a blank import. That means “I won't call anything from this package by name — just load it.” Loading a package runs its init() functions.
1 / 7

You've already used this: database/sql

Go's standard database/sql package is a registry. It knows nothing about Postgres or MySQL. Each driver calls sql.Register("postgres", …) in its init(), and you pick one by name:

import (
    "database/sql"
    _ "github.com/jackc/pgx/v5/stdlib" // registers the driver name "pgx"
)

db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))

Forget the blank import and sql.Open returns this error:

sql: unknown driver "pgx" (forgotten import?)

The image package works the same way: import _ "image/png" teaches image.Decode to read PNGs.

Practitioner§04

Abstract factory: families that must match

In one sentenceAn abstract factory builds a whole family of related objects that must match each other — pick the family once, and every object you create comes from it.

A factory (§03) answers "which Store?". An abstract factory answers "which set of things?" — when mixing parts from different sets would be a bug.

A concrete example: cloud providers

Your app needs blob storage and a message queue, and it can run on AWS or Google Cloud. An AWS bucket with a Google queue makes no sense — they'd need different credentials and regions. (In the code: S3 and SQS are Amazon's storage and queue services; GCS and Pub/Sub are Google's.) So you group them:

// The family: one method per kind of object.
type Cloud interface {
    Blobs() Store   // object storage
    Queue() Queue   // message queue
}

type awsCloud struct{ region string }

func (c awsCloud) Blobs() Store { return s3Blobs(c.region) }
func (c awsCloud) Queue() Queue { return sqsQueue(c.region) }

type gcpCloud struct{ project string }

func (c gcpCloud) Blobs() Store { return gcsBlobs(c.project) }
func (c gcpCloud) Queue() Queue { return pubsubQueue(c.project) }

// Chosen once, at startup:
func NewCloud(cfg Config) (Cloud, error) {
    switch cfg.Provider {
    case "aws":
        return awsCloud{region: cfg.Region}, nil
    case "gcp":
        return gcpCloud{project: cfg.Project}, nil
    default:
        return nil, fmt.Errorf("unknown cloud provider %q", cfg.Provider)
    }
}

The other textbook example is a UI theme: a LightTheme and a DarkTheme that each build a matching Button, Checkbox and Menu.

Practitioner§05

Builder: step by step, checked at the end

In one sentenceA builder collects the parts of a complicated object one step at a time, then checks everything in one final build() call that returns the finished — ideally unchangeable — object.

Builders shine when an object has many parts, some steps depend on others ("if you set a body, you need a content type"), and the rules can only be checked once you've seen everything. Building an HTTP request or a SQL query are the classic cases.

JavaScript: a fluent builder

request-builder.js
class RequestBuilder {
  #method = "GET";
  #url;
  #headers = {};
  #body = null;
  #timeoutMs = 10_000;

  constructor(url) { this.#url = url; }

  method(m)          { this.#method = m; return this; }        // ← return this
  header(name, value) { this.#headers[name] = value; return this; }
  timeout(ms)        { this.#timeoutMs = ms; return this; }
  json(data) {
    this.#body = JSON.stringify(data);
    return this.header("Content-Type", "application/json");   // one step can do several things
  }

  build() {
    if (this.#body !== null && this.#method === "GET") {
      throw new Error("a GET request can't have a body");     // ← rules checked here, once
    }
    return Object.freeze({                                     // ← the result can't be changed
      method: this.#method,
      url: this.#url,
      headers: Object.freeze({ ...this.#headers }),
      body: this.#body,
      timeoutMs: this.#timeoutMs,
    });
  }
}

const req = new RequestBuilder("https://api.example.com/users")
  .method("POST")
  .json({ name: "Ann" })
  .timeout(5_000)
  .build();

Why bother with the frozen result? Because a req that can't change can be shared safely — logged, retried, passed to three functions — and nobody can modify it behind your back.

Go: a builder that remembers the first error

Go has no exceptions, so a step can't "throw" halfway through a chain. The usual trick: the builder stores the first error, ignores problems after it, and reports it from Build().

query.go
type Query struct {
    SQL  string
    Args []any // values for the ? placeholders, in order
}

type QueryBuilder struct {
    table  string
    wheres []string
    args   []any
    limit  int
    err    error // first problem found, reported by Build
}

func Select(table string) *QueryBuilder { return &QueryBuilder{table: table} }

func (b *QueryBuilder) Where(cond string, arg any) *QueryBuilder {
    b.wheres = append(b.wheres, cond)
    b.args = append(b.args, arg)
    return b // ← Go's "return this"
}

func (b *QueryBuilder) Limit(n int) *QueryBuilder {
    if n <= 0 && b.err == nil {
        b.err = fmt.Errorf("limit must be positive, got %d", n)
    }
    b.limit = n
    return b
}

func (b *QueryBuilder) Build() (Query, error) {
    if b.err != nil {
        return Query{}, b.err
    }
    sql := "SELECT * FROM " + b.table
    if len(b.wheres) > 0 {
        sql += " WHERE " + strings.Join(b.wheres, " AND ")
    }
    if b.limit > 0 {
        sql += fmt.Sprintf(" LIMIT %d", b.limit)
    }
    return Query{SQL: sql, Args: b.args}, nil
}
q, err := Select("users").
    Where("age > ?", 18).
    Where("country = ?", "VN").
    Limit(10).
    Build()
// q.SQL  == "SELECT * FROM users WHERE age > ? AND country = ? LIMIT 10"
// q.Args == [18 VN]

strings.Builder is not this pattern

Go's strings.Builder has "builder" in the name, but it's something simpler: an efficient way to glue strings together without making a new string at every step.

var sb strings.Builder        // zero value is ready to use
for i := 0; i < 3; i++ {
    fmt.Fprintf(&sb, "row %d\n", i)
}
out := sb.String()

It has no separate parts to configure, no rules to check, and no Build() that can fail — it's an accumulator, just like bytes.Buffer. Same name, different idea. (One gotcha: if you copy a strings.Builder after writing to it, writing to the copy panics with illegal use of non-zero Builder copied by value. Pass a pointer instead.)

Practitioner§06

Functional options (Go)

In one sentenceFunctional options: the constructor takes the required arguments, then any number of small functions — WithTimeout(5*time.Second), WithMaxConns(500) — each of which changes one setting on top of sensible defaults.

This is the Go answer to "my constructor has too many parameters". You'll see it in gRPC (grpc.NewServer(opts...)), in the zap logger, in the OpenTelemetry SDK and in countless internal libraries. It was popularized in 2014 by Rob Pike and Dave Cheney.

The problem it solves

A server has one required setting (the address) and five optional ones. A positional constructor is unreadable (§01). A boolean or 0 in the middle of an argument list means nothing at the call site. We want calls that look like this:

a := NewServer(":8080")                            // all defaults
b := NewServer(":8080", WithTimeout(5*time.Second)) // one change
c := NewServer(":8080",
    WithTimeout(5*time.Second),
    WithMaxConns(500),
    WithTLS("cert.pem", "key.pem"),
)

Every call reads like a sentence, and options you don't mention keep their defaults. (In the code below, log/slog is Go's standard structured logger, and slog.Default() is its ready-made default logger.)

The whole pattern in four parts

server.go
type Server struct {
    addr     string
    timeout  time.Duration
    maxConns int
    certFile string
    keyFile  string
    logger   *slog.Logger
}

// 1. An Option is a function that changes a *Server.
type Option func(*Server)

// 2. Each WithX returns one of those functions.
func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }   // ← remembers d (a closure)
}

func WithMaxConns(n int) Option {
    return func(s *Server) { s.maxConns = n }
}

func WithTLS(certFile, keyFile string) Option {
    return func(s *Server) {
        s.certFile = certFile
        s.keyFile = keyFile
    }
}

// 3. The constructor: required args first, then any number of options.
func NewServer(addr string, opts ...Option) *Server {
    s := &Server{                // 4a. defaults for everything
        addr:     addr,
        timeout:  30 * time.Second,
        maxConns: 100,
        logger:   slog.Default(),
    }
    for _, opt := range opts {   // 4b. then apply each option, in order
        opt(s)
    }
    return s
}

Step through a real call and watch the struct get filled — first by defaults, then by each option:

Inside NewServer: defaults, then each option
Step through with ← →. Watch the options (left) get applied to the server (right).
Code
1func WithTimeout(d time.Duration) Option {
2 return func(s *Server) { s.timeout = d }
3}
4
5func NewServer(addr string, opts ...Option) *Server {
6 s := &Server{addr: addr, timeout: 30 * time.Second,
7 maxConns: 100, logger: slog.Default()}
8 for _, opt := range opts {
9 opt(s)
10 }
11 return s
12}
13
14srv := NewServer(":8080",
15 WithTimeout(5*time.Second),
16 WithMaxConns(500))
Options passed infunctions
none yet
*Serverthe struct being built
doesn't exist yet
We call NewServer with one required argument (the address) and two options. Before NewServer itself runs, Go first works out the value of every argument, left to right.
1 / 8

Now try it yourself. Add options in any order (including the same one twice) and watch the resulting struct and the generated call:

Functional options playground
Add options in any order. The constructor applies defaults, then each option, top to bottom.
Options, in orderclick ✕ to remove
No options — that's allowed. Every field has a default.
Resulting *Serverafter NewServer returns
addr:":8080"required argument
timeout:30sdefault
maxConns:100default
certFile:""default
keyFile:""default
logger:slog.Default()default
srv := NewServer(":8080") // all defaults
const srv = createServer(":8080"); // all defaults
Try it: add WithTimeout(5 * time.Second), then WithTimeout(time.Minute), and watch which one wins.

Why this is nicer than the alternatives

  • Readable calls. WithMaxConns(500) says what 500 means.
  • Defaults live in one place — the constructor — instead of being repeated by every caller.
  • Adding a setting never breaks anyone. Next year you add WithReadBufferSize(…); every existing NewServer(…) call still compiles and behaves the same. With a positional constructor, adding a parameter breaks every caller.
  • The struct fields stay private. Callers can only change what you expose an option for, and only at creation time. After NewServer returns, nobody can flip timeout behind your back.

When an option can fail

WithMaxConns(-5) makes no sense. If options need validation, let them return an error, and have the constructor stop at the first failure:

type Option func(*Server) error

func WithMaxConns(n int) Option {
    return func(s *Server) error {
        if n <= 0 {
            return fmt.Errorf("max conns must be positive, got %d", n)
        }
        s.maxConns = n
        return nil
    }
}

func NewServer(addr string, opts ...Option) (*Server, error) {
    s := &Server{addr: addr, timeout: 30 * time.Second, maxConns: 100}
    for _, opt := range opts {
        if err := opt(s); err != nil {
            return nil, fmt.Errorf("new server: %w", err) // no half-built server escapes
        }
    }
    return s, nil
}

Naming conventions you'll see

  • Options are named WithX (WithTimeout, WithLogger). Some libraries drop the prefix when it reads well, like gRPC's grpc.MaxRecvMsgSize(…).
  • Put each option next to the field it sets, and document the default in the option's doc comment (// Default: 30s.).
  • Required inputs stay as normal parameters. An option that the caller must pass isn't optional — make it an argument so the compiler enforces it.

How does this compare with a config struct, and what's the JavaScript version? That's next.

Practitioner§07

Options in JavaScript, and choosing a style

In one sentenceJavaScript doesn't need functional options: an options object with destructuring defaults gives you the same readable, extendable calls in one line.

The JS options object

server.js
export function createServer(addr, {
  timeoutMs = 30_000,     // ← default used when the caller doesn't pass it
  maxConns = 100,
  tls = null,
  logger = console,
} = {}) {                 // ← "= {}": calling with no options at all is fine
  if (maxConns <= 0) throw new Error(`maxConns must be positive, got ${maxConns}`);
  return Object.freeze({ addr, timeoutMs, maxConns, tls, logger });
}

// calls read like Go's functional options:
createServer(":8080");
createServer(":8080", { timeoutMs: 5_000 });
createServer(":8080", { timeoutMs: 5_000, maxConns: 500 });

A common alternative merges a defaults object with spread (...obj):

const DEFAULTS = { timeoutMs: 30_000, maxConns: 100 };

function createServer(addr, opts = {}) {
  const cfg = { ...DEFAULTS, ...opts };   // later keys overwrite earlier ones
  // …
}

Two traps with JS defaults

Trap 1 — spread lets undefined win. Spread copies every key that exists, even if its value is undefined. This often bites when options come from environment variables:

const opts = { timeoutMs: process.env.TIMEOUT_MS };  // undefined when the env var isn't set
const cfg = { ...DEFAULTS, ...opts };
cfg.timeoutMs; // undefined — the default was overwritten!

Destructuring defaults don't have this problem: they apply whenever the value is undefined. But careful when converting: env vars are strings, and Number(undefined) is NaN, not undefined — so a naive Number(process.env.TIMEOUT_MS) skips the default and gives you NaN. Convert only when the variable is set:

const raw = process.env.TIMEOUT_MS;
createServer(":8080", {
  timeoutMs: raw ? Number(raw) : undefined, // unset → undefined → default applies
});

Trap 2 — null is not "missing". Destructuring defaults apply only to undefined. createServer(":8080", { tls: null }) gives you null, which is what you want here — but { maxConns: null } would also skip the default and then fail the check. If both should mean "use the default", use ?? in the body: const n = maxConns ?? 100; (the ?? operator falls back only on null or undefined, unlike ||, which also replaces 0 and "").

Go's other option: a config struct

Go has a simpler alternative to functional options: pass a struct. The standard library does this for http.Server:

srv := &http.Server{
    Addr:         ":8080",
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    // every field you leave out keeps its zero value
}

It's plain and easy to read. Its weakness is the Zero value: Go can't tell "the caller didn't set this" from "the caller set it to 0". For http.Server, ReadTimeout: 0 means no timeout at all — so the default is the dangerous choice, and you can't make it 30s instead. Functional options don't have this problem: an option that wasn't passed simply doesn't run.

Which one when?

SituationGoJavaScript
1–3 required inputs, nothing optionalPlain NewX(a, b)Plain function / constructor
A few optional settings with real defaults; library used by othersFunctional optionsOptions object with destructuring defaults
Many settings, mostly passed through from a config file; zero values are fine defaultsConfig struct (NewX(cfg Config))Options object
Ordered steps that combine; rules span several partsBuilder (§05)Builder (§05)
Practitioner§08

Singleton: one instance — and why to avoid it

In one sentenceA singleton is a type with exactly one instance, reachable from anywhere in the program. The first half is often fine. The second half — "reachable from anywhere" — is Global state, and that's where the trouble is.

Typical candidates: the database connection pool, the logger, the app config, a cache. You really do want only one connection pool. The question is how the code that needs it gets it.

Singletons in Go

The simplest Go singleton is a package-level variable, created when the program starts (eagerly):

package db

var Pool = mustOpen(os.Getenv("DATABASE_URL")) // created before main() runs

// anywhere else in the program:
rows, err := db.Pool.QueryContext(ctx, "SELECT …")

If creating it is expensive, or might not be needed, make it lazy — created on first use — with sync.OnceValues (Go 1.21+), which runs the function exactly once even if many goroutines call it at the same moment:

var getPool = sync.OnceValues(func() (*sql.DB, error) {
    return sql.Open("pgx", os.Getenv("DATABASE_URL"))
})

// anywhere:
pool, err := getPool() // first call creates the pool; later calls return the same one

How sync.Once makes this safe (and why a plain if pool == nil check is a race) is explained in Locks §05: sync.Once. Here we care about the design question.

Singletons in JavaScript: every module is one

In JavaScript you rarely write a singleton on purpose — you get one for free. A module's top-level code runs once, the first time anything imports it. Every later import gets the same exports from the Module cache:

// db.js
export const pool = createPool(process.env.DATABASE_URL); // runs once

// orders.js and users.js
import { pool } from "./db.js";  // both get the SAME pool object

That's convenient, and for something like a shared connection pool it's exactly what you want. But it's still global state — and it has a few sharp edges:

  • "Once" means once per copy of the module. If two different versions of a library end up in node_modules, or a package ships separate CommonJS and ESM builds and both get loaded (the Dual package hazard), you get two "singletons".
  • Dev servers with hot reload re-run modules on every save. That's why Next.js projects often stash the instance on globalThis to avoid opening a new database pool each time.

Why singletons hurt: the test that fails only sometimes

Here's the most common way singletons bite. Two tests share a module-level cache. Each test is correct on its own. Run them in a different order:

Shared state leaks between tests
Same two tests. Change the order they run in, and compare a singleton cache with one passed in.
// cache.js — one shared instance
export const cache = new Map();

// users.js
import { cache } from "./cache.js";
export function saveUser(user) {
  cache.set(user.id, user);
}

// users.test.js
test("A: saveUser caches the user", () => {
  saveUser({ id: 1, name: "Ann" });
  expect(cache.size).toBe(1);
});
test("B: the cache starts empty", () => {
  expect(cache.size).toBe(0);
});
Run order:
Test runcache size seen
press run
Try A → B, then B → A, with the singleton cache.

With the singleton and the order A → B, test B fails even though B's code is fine: test A left data behind in the shared object. Flip the order and everything passes. Bugs like this are miserable: they pass on your laptop, fail in CI (the server that runs your tests on every push), and the failing test isn't the one with the problem.

The deeper issues, beyond tests:

  • Hidden dependencies. func ChargeCard(amount int) looks harmless — you can't see that it secretly uses db.Pool and payments.Client. The signature lies about what the function needs.
  • You can't have two. One day you need a second database (a read replica), or two configs in one test. A singleton makes that a rewrite.
  • Startup order gets fragile. Eager singletons are created during package setup, before main() can parse flags or load config (§11).

The alternative: create one, pass it in

Keep the "only one" part and drop the "reachable from anywhere" part. Create the instance once, in main, and pass it to whatever needs it:

type OrderService struct {
    db  *sql.DB          // the dependency is visible right here
    log *slog.Logger
}

func NewOrderService(db *sql.DB, log *slog.Logger) *OrderService {
    return &OrderService{db: db, log: log}
}

func main() {
    db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    orders := NewOrderService(db, slog.Default()) // one pool, shared on purpose
    users := NewUserService(db)
    // …
}

There's still exactly one pool. But now every dependency shows up in a constructor signature, and a test can pass in a fresh fake. This is called dependency injection, and it has its own deep-dive: Dependency Injection.

Practitioner§09

Prototype: copy, then tweak (carefully)

In one sentenceThe prototype pattern creates a new object by copying an existing, already-configured one and changing a few fields. The catch: most copies are shallow, so the "copy" can still share data with the original.

You do this all the time without calling it a pattern: copy the default config and change the port; copy a test fixture and change one field; copy the current state and apply an update.

base := Config{Name: "base", Tags: []string{"a", "b"}, Limits: map[string]int{"rps": 10}}

staging := base           // copy the struct…
staging.Name = "staging"  // …and tweak it

Shallow vs deep

A Shallow copy copies only the top level. If a field holds a plain value (a number, a string), the copy gets its own. But if a field holds a reference to other data — an array, an object, a Go slice or map — the copy gets the same reference, and both point at the same nested data.

A Deep copy copies everything, all the way down.

ShallowDeep
JavaScript{ ...obj }, Object.assign({}, obj), [...arr]structuredClone(obj)
Goc := orig (struct assignment)write a Clone() that also copies slices and maps

Try it. Make a copy, change the copy, and watch whether the original changes too:

Copy it, change the copy, check the original
Pick a language and a way to copy. Then change the copy and see whether the original changed too.
Spread copies the top level. Nested arrays/objects are shared.
const orig = { name: "base", tags: ["a", "b"], limits: { rps: 10 } };
const copy = { ...orig };
Objectstop level
object #1 ← orig
name: "base"
tags: → array A
limits: → object L1
object #2 ← copy
name: "base"
tags: → array A
limits: → object L1
Nested datareached through the fields
array ASHARED by orig.tags & copy.tags
ab
object L1SHARED by orig.limits & copy.limits
rps: 10
Try it: with { ...orig }, change name (safe), then tags[0] (not safe!).

Why Go's slices are sneaky

A Go slice variable doesn't hold the items. It holds a small Slice header: a pointer to a hidden Backing array, a length and a capacity. staging := base copies the header — so both Tags slices point at the same array:

staging := base
staging.Tags[0] = "z"        // writes into the SHARED array → base.Tags[0] is "z" too
staging.Limits["rps"] = 99   // maps are references too → base sees 99
staging.Name = "staging"     // strings are values → base.Name unchanged

And append makes it unpredictable. If the backing array is full, append allocates a new, bigger array — and from then on the two slices are independent. If there's spare room, append writes into the shared array:

base := make([]string, 0, 4) // length 0, room for 4
base = append(base, "a", "b")

x := append(base, "x") // spare room → writes "x" into slot 2 of the shared array
y := append(base, "y") // same slot! overwrites "x" with "y"
fmt.Println(x[2], y[2]) // y y   ← x was changed behind your back
Two appends, one slot
base has length 2 but room for 4. Append to it twice and watch the shared array.
base's backing arraycap 4
ab··
base["a", "b"]len 2 · cap 4 · → same array
xnot created yet
ynot created yet
Press x := append(base, "x"), then y := append(base, "y"), and look at x.

The fix is a real copy. Go 1.21 added slices.Clone and maps.Clone for exactly this:

func (c Config) Clone() Config {
    c.Tags = slices.Clone(c.Tags)     // c is already a copy (value receiver); replace the shared parts
    c.Limits = maps.Clone(c.Limits)
    return c
}

staging := base.Clone()
staging.Tags[0] = "z" // base is safe

maps.Clone is itself shallow: if the map's values are slices or pointers, those are still shared. "Deep" has to go as deep as your data does.

JavaScript: spread vs structuredClone

const shallow = { ...base };
shallow.tags.push("c");          // base.tags is now ["a", "b", "c"] too!

const deep = structuredClone(base);
deep.tags.push("c");             // base untouched

structuredClone (Node ≥ 17, all modern browsers) deep-copies objects, arrays, Map, Set, Date and more. Two limits: it throws on functions (a DataCloneError), and class instances come back as plain objects — their methods are gone. (JSON.parse(JSON.stringify(obj)), the old trick, also loses Dates, Maps and undefined values.)

Staff-level§10

Object pools and sync.Pool

In one sentenceAn Object pool keeps used objects around so you can borrow and return them instead of creating new ones. It's a performance tool — reach for it only when profiling shows that creating objects is the bottleneck.

Every new object is an allocation: a little time to get the memory, and later work for the garbage collector to free it. In a hot path — say, an HTTP server that formats a response buffer 50,000 times a second — those small costs add up. Reusing buffers removes most of them.

sync.Pool in Go

render.go
var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) }, // called when the pool is empty
}

func render(w io.Writer, name string) error {
    buf := bufPool.Get().(*bytes.Buffer) // borrow (or get a fresh one)
    defer func() {
        buf.Reset()                      // clean it: forget the old contents
        bufPool.Put(buf)                 // give it back
    }()

    buf.WriteString("hello, ")
    buf.WriteString(name)
    _, err := w.Write(buf.Bytes())       // finish using buf BEFORE it goes back
    return err
}

Play with it. Notice when New() gets called, and what a garbage collection does:

sync.Pool, step by step
Borrow buffers, give them back, and see what a garbage collection does to the pool.
In use by your codeafter Get()
empty
Poolready to reuse
empty
Victim cachesurvives one more GC
empty
New() called: 0 · freed by GC: 0
Try it: Get, Put, Get again (reused!). Then Put it back and press runtime.GC() twice before the next Get.

What sync.Pool does not promise

sync.Pool is a cache, not a storage box. The rules:

  • Items can vanish at any time. At each garbage collection, pool items move to a "victim cache"; at the next one, anything still there is freed (this two-step behavior exists since Go 1.13 — before that, every GC emptied the pool). So never store anything you can't recreate.
  • Get may return any item, or a new one. Pools keep separate lists per CPU to stay fast, so "the buffer I just put back" isn't guaranteed to be the one you get.
  • Always reset before Put (or right after Get). Otherwise the next borrower sees the previous request's data — a real source of data leaks between users.
  • Never use an object after Put. Another goroutine may already have it. That includes slices you got from it, like buf.Bytes(), which point into the buffer's memory.
  • Store pointers. Putting a non-pointer value (like a plain slice) into the any causes an allocation on every Put — the very thing you were trying to avoid. Linters (staticcheck SA6002) flag this.

Pools you already use

  • A database or HTTP Connection pool is an object pool for expensive things: opening a TCP connection plus a TLS handshake takes milliseconds. database/sql's *sql.DB is a connection pool. Unlike sync.Pool, it keeps its connections on purpose and limits how many exist.
  • In JavaScript, you'll almost never write a pool for plain objects — V8's garbage collector is tuned for many short-lived objects, and manual pooling usually makes code slower and buggier. Pools show up in JS for connections (pg.Pool), worker threads, and occasionally in games, where a GC pause in the middle of a frame is visible.
Staff-level§11

Creation in production

In one sentenceIn production, creation code decides when things fail, what a component really depends on, and how your API can change later. Get those three right and the rest is detail.

1. Constructor injection vs the service locator

Both answer "how does OrderService get its database?"

✕ Service locator: ask a global registry
func (s *OrderService) Place(o Order) error {
    db := locator.Get("db").(*sql.DB)
    mail := locator.Get("mailer").(Mailer)
    // what else does it need? read every line to find out
}
✓ Constructor injection: receive it
func NewOrderService(db *sql.DB, mail Mailer) *OrderService {
    return &OrderService{db: db, mail: mail}
}
// the signature IS the list of dependencies

A Service locator looks flexible, but it hides dependencies (you can't see them in the signature), moves errors from compile time to runtime (Get("dbb") compiles fine), and makes tests set up global state (§08). Constructor injection keeps everything visible and checked by the compiler. All the real creation then happens in one place — main — sometimes called the composition root. More in Dependency Injection.

2. Errors from constructors: return them, don't panic

A constructor that can fail returns (*T, error) in Go, or throws / rejects in JS. The caller decides what to do — retry, use a fallback, or exit.

The one exception in Go is the Must convention: a wrapper that panics on error, meant only for values built from constants at program start, where an error means the code itself is wrong:

var emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`) // a typo here → crash at startup, not in a request
var tmpl = template.Must(template.ParseFS(files, "*.html"))

Never call a Must… function with user input or inside a request handler. A panic / recover there takes down the whole server.

3. Lazy vs eager: when should creation fail?

Eager creation builds everything at startup. Lazy creation waits until first use.

Eager (at startup)Lazy (on first use)
Bad config / unreachable DBFound immediately; the deploy fails and rolls backFound on the first request — maybe at 3 a.m.
Startup timeSlowerFaster
First requestNormal speedSlower (pays the setup cost)
ConcurrencyNothing to coordinateNeeds sync.Once / a cached promise (Locks §05)

For servers, prefer eager plus Fail fast: open the pool and check it actually works, before accepting traffic.

db, err := sql.Open("pgx", dsn) // dsn = the connection string; doesn't connect yet!
if err != nil {
    log.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil { // ← this actually connects
    log.Fatalf("database unreachable: %v", err)
}

Use lazy creation for things that are expensive and optional — a PDF renderer only some requests need, a client for a rarely-used API.

4. init() pitfalls

The order Go sets things up in, before main():

  1. Imported packages, completely, before the package that imports them (each package only once).
  2. Inside a package: package-level variables, in dependency order.
  3. Then that package's init() functions, in the order the files are given to the compiler (by file name) and top to bottom within each file.
  4. Finally, main().

That order is well defined, but init() is still a trap for anything beyond simple registration (§03):

  • No way to return an error. An init that fails can only panic or log and carry on with a broken package.
  • Runs before main parses flags or loads config. An init that reads os.Getenv or opens a connection can't be configured — and runs even in go test and in every tool that imports the package.
  • Invisible side effects. Importing a package can open files, start goroutines or contact the network, and the importer can't tell.

5. Evolving a constructor API without breaking callers

Once other teams call your constructor, its signature is a promise. How each style handles "we need a new setting":

StyleAdding a settingBreaks callers?
Positional NewServer(addr, timeout, maxConns)New parameterYes, every call
Config struct NewServer(Config{…})New fieldNo — but its zero value must mean "keep the old behavior"
Functional optionsNew WithX functionNo
JS options objectNew property with a defaultNo

Large libraries take functional options one step further: Option becomes an interface instead of a function type. gRPC does this (grpc.ServerOption):

type Option interface{ apply(*Server) } // lower-case method: only this package can implement it

type timeoutOption time.Duration

func (t timeoutOption) apply(s *Server) { s.timeout = time.Duration(t) }

func WithTimeout(d time.Duration) Option { return timeoutOption(d) }

Why the extra ceremony? An interface value can be inspected (for logging or debugging which options were passed), compared, and given extra methods later. And because apply is private, outsiders can't invent their own options that poke at private fields — you control the full list forever.

Practitioner§12

Pitfalls gallery

#1

Patterns for their own sake

An AbstractRepositoryFactoryBuilder for a type that's created in exactly one place is not "good design" — it's three files to read before you can find the code that runs. That's a Code smell, and it piles up as Technical debt.

✕ Don't
factory := NewUserServiceFactory(NewConfigProvider())
builder := factory.NewBuilder()
svc, err := builder.WithDB(db).Build()
✓ Do
svc := NewUserService(db)

Start with a plain constructor. Introduce a pattern when you can name the problem it fixes: "callers keep passing the wrong argument order", "we need disk and S3".

#2

A zero value that panics

A struct with a map field looks usable without a constructor — until the first write:

type Registry struct{ items map[string]Item }

var r Registry
r.items["a"] = Item{} // panic: assignment to entry in nil map

Either make the zero value work (create the map lazily in the method, §02), or make the fields private and require NewRegistry(). Don't leave a type that looks ready but isn't.

#3

“Copying” a config, then changing the original

cfg := defaultConfig     // copies the struct — but not the Tags slice or Headers map
cfg.Headers["X-Env"] = "staging" // also changes defaultConfig.Headers!

The next request that uses defaultConfig now sends X-Env: staging. Copy nested slices and maps explicitly (slices.Clone, maps.Clone) or give the type a Clone() method (§09). In JS, { ...obj } has the same problem — use structuredClone or don't mutate.

#4

Spread lets undefined overwrite a default

const cfg = { ...DEFAULTS, ...{ timeoutMs: process.env.TIMEOUT_MS } };
// TIMEOUT_MS unset → cfg.timeoutMs is undefined, not 30000

Use destructuring defaults ({ timeoutMs = 30_000 } = {}), or drop undefined keys before merging (§07).

#5

A required setting disguised as an option

srv := NewServer(WithAddr(":8080")) // forget WithAddr and it silently uses some default

Forget the option and the server quietly picks something — net/http, for example, treats an empty address as port 80. If the object can't work without it, make it a normal parameter: NewServer(":8080", opts...). Now the compiler refuses a call that forgets it. Options are for things that have a sensible default.

#6

A singleton that leaks between tests

Tests that read or write a module-level cache, a package-level var, or a global mock depend on which tests ran before them (§08). Symptoms: passes alone, fails in the full suite; passes locally, fails in CI. Fix: create the dependency per test and pass it in. Find them with go test -shuffle=on or your JS test runner's shuffle option.

#7

Forgetting the blank import

import "database/sql" // forgot: _ "github.com/jackc/pgx/v5/stdlib"

db, err := sql.Open("pgx", dsn) // sql: unknown driver "pgx" (forgotten import?)

With a registry (§03), the implementation only exists if its package is imported somewhere. Put the blank imports in main (or one drivers.go file) so they're easy to find.

#8

A pooled object that wasn't reset — or was used after Put

buf := pool.Get().(*bytes.Buffer)
buf.WriteString(secret)
pool.Put(buf)                 // forgot buf.Reset() → next borrower sees the secret
fmt.Println(buf.String())     // and this is a use-after-Put: another goroutine may own buf now

Reset in a defer right after Get, and finish every use of the object — including slices from buf.Bytes() — before Put (§10).

#9

Must… on runtime input

func handler(w http.ResponseWriter, r *http.Request) {
    re := regexp.MustCompile(r.URL.Query().Get("pattern")) // bad pattern → panic
}

MustX is for constants at startup. For anything a user or a config file controls, call the version that returns an error (regexp.Compile) and handle it (§11).

Practitioner§13

Check yourself

Predict the answer before you click. Each explanation points back to the section that covers it: Q1 and Q5 in §07, Q2 in §09, Q3 in §06, Q4 in §03 and §11, Q6 in §08, Q7 in §10.

QUESTION 1 / 7
Defaults with spread: what's the timeout?
const DEFAULTS = { timeoutMs: 30_000, retries: 3 };
// e.g. read from an env var that isn't set:
const userOpts = { timeoutMs: undefined };
const opts = { ...DEFAULTS, ...userOpts };
console.log(opts.timeoutMs);
QUESTION 2 / 7
Copying a struct with a slice inside
a := Config{Name: "", Tags: []string{"x", "y"}}
b := a
b.Tags[0] = "z"
b.Name = "copy"
fmt.Println(a.Tags[0], a.Name == "")
QUESTION 3 / 7
Two options set the same field
srv := NewServer(":80",
    WithTimeout(5*time.Second),
    WithTimeout(10*time.Second),
)
fmt.Println(srv.timeout)
QUESTION 4 / 7
In what order does this print?
// package config (imported by main)
func init() { fmt.Println("config init") }

// package main
import _ "example.com/app/config"

var port = readPort() // readPort prints "main var"

func init() { fmt.Println("main init") }
func main() { fmt.Println("main") }
QUESTION 5 / 7
Destructuring default with null
function connect({ retries = 3 } = {}) {
  return retries;
}
console.log(connect({ retries: null }));
QUESTION 6 / 7
Two modules import the same module
// stats.js
export const stats = { count: 0 };

// a.js
import { stats } from "./stats.js";
stats.count++;

// main.js
import "./a.js";
import { stats } from "./stats.js";
console.log(stats.count);
QUESTION 7 / 7
sync.Pool: Put, then Get
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
pool.Put(buf)
again := pool.Get().(*bytes.Buffer)
// Is "again" always the same as "buf"?
Answered 0 / 7
Foundations§14

Cheat sheet

🧱
Constructor firstGo: NewX(…) (*X, error). JS: a function or class. Return something complete, or an error.
0️⃣
Useful zero valuesIf var x X can just work (like bytes.Buffer, sync.Mutex), you don't need a constructor at all.
🏭
FactoryPicks the implementation at runtime and returns an interface. A registry lets implementations add themselves in init().
🥪
BuilderStep by step, then Build() checks everything. Returns an immutable result. strings.Builder is something else.
🎛️
Functional optionsNewServer(addr, WithTimeout(d)): defaults first, options in order, last one wins. Adding options never breaks callers.
📦
JS options object({ timeoutMs = 30_000 } = {}). Spread lets undefined overwrite; defaults don't apply to null.
☝️
Singleton = global stateCreate one in main and pass it in. A JS module export is already a singleton.
📄
Copies are shallowc := orig and {...obj} share nested slices/maps/arrays. Use slices.Clone/maps.Clone or structuredClone.
♻️
Pools are cachessync.Pool may drop anything at any GC. Reset before Put, never use after Put. Measure first.
⏰
Fail fast at startupBuild eagerly and ping dependencies before taking traffic. Keep init() for registration only.
I want to…GoJavaScript
Build a ready object with a few required inputsNewX(a, b) (*X, error)new X(a, b) / createX(a, b)
Build something async (connect first)NewX(ctx, …) (*X, error)static async connect() / await createX()
Choose disk vs S3 from configFactory returning an interface; registry + init() for pluginsFactory function with a switch, or an object/Map from name to factory
Many optional settings with defaultsFunctional options (or a config struct if zero values are fine defaults)Options object with destructuring defaults
Ordered steps, validated togetherBuilder that stores the first error, Build() (T, error)Fluent builder (return this), build() throws
A matching family of objectsAbstract factory (rarely needed — usually just pass the pieces)An object of factory functions
One shared instanceCreate in main, pass it in; sync.OnceValues if it must be lazyCreate in the entry file, pass it in; module export if you must
A tweaked copyc := orig + clone slices/maps{ ...orig, x: 1 } (shallow) or structuredClone
Reuse expensive objects in a hot pathsync.Pool (after profiling)Rarely needed; pool connections, not objects
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Design PatternsStructural Patterns
Read →