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.
Why creating objects needs patterns
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 forever3. 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.
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.
Constructors: NewX in Go, factory functions in JS
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:
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 neededJavaScript: 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 outsideThis is called a Factory function. It's very common in modern JS, especially in code that prefers functions over classes.
const client = new HttpClient();
client.baseUrl = url;
client.timeoutMs = 5000;
// forgot one? nothing tells youconst client = createHttpClient({
baseUrl: url,
timeoutMs: 5000,
}); // checks everything, then returnsThe options-object style on the right is a pattern in its own right — §07 shows how to give it defaults safely.
Factories: pick the implementation at runtime
§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
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.
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:
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
}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:
main imports disk and s3, and both of those import storage. Nothing is set up yet.Here's the whole registry in motion, from the moment the program starts:
1// ---- storage/storage.go ----2var registry = map[string]Factory{}34func Register(name string, f Factory) {5 registry[name] = f6}78func 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}1516// ---- storage/disk/disk.go ----17func init() { storage.Register("disk", newDiskStore) }1819// ---- storage/s3/s3.go ----20func init() { storage.Register("s3", newS3Store) }2122// ---- main.go ----23import _ "example.com/app/storage/disk"24import _ "example.com/app/storage/s3"2526func 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}
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.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.
Abstract factory: families that must match
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.
Builder: step by step, checked at the end
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
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().
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.)
Functional options (Go)
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
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:
1func WithTimeout(d time.Duration) Option {2 return func(s *Server) { s.timeout = d }3}45func 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 s12}1314srv := NewServer(":8080",15 WithTimeout(5*time.Second),16 WithMaxConns(500))
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.Now try it yourself. Add options in any order (including the same one twice) and watch the resulting struct and the generated call:
srv := NewServer(":8080") // all defaultsconst srv = createServer(":8080"); // all defaultsWhy this is nicer than the alternatives
- Readable calls.
WithMaxConns(500)says what500means. - 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 existingNewServer(…)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
NewServerreturns, nobody can fliptimeoutbehind 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'sgrpc.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.
Options in JavaScript, and choosing a style
The JS options object
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?
| Situation | Go | JavaScript |
|---|---|---|
| 1–3 required inputs, nothing optional | Plain NewX(a, b) | Plain function / constructor |
| A few optional settings with real defaults; library used by others | Functional options | Options object with destructuring defaults |
| Many settings, mostly passed through from a config file; zero values are fine defaults | Config struct (NewX(cfg Config)) | Options object |
| Ordered steps that combine; rules span several parts | Builder (§05) | Builder (§05) |
Singleton: one instance — and why to avoid it
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 oneHow 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 objectThat'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
globalThisto 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:
// 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);
});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 usesdb.Poolandpayments.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.
Prototype: copy, then tweak (carefully)
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 itShallow 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.
| Shallow | Deep | |
|---|---|---|
| JavaScript | { ...obj }, Object.assign({}, obj), [...arr] | structuredClone(obj) |
| Go | c := 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:
const orig = { name: "base", tags: ["a", "b"], limits: { rps: 10 } };
const copy = { ...orig };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 unchangedAnd 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 backThe 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 safemaps.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 untouchedstructuredClone (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.)
Object pools and sync.Pool
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
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:
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.
Getmay 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 afterGet). 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, likebuf.Bytes(), which point into the buffer's memory. - Store pointers. Putting a non-pointer value (like a plain slice) into the
anycauses an allocation on everyPut— 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.DBis a connection pool. Unlikesync.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.
Creation in production
1. Constructor injection vs the service locator
Both answer "how does OrderService get its database?"
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
}func NewOrderService(db *sql.DB, mail Mailer) *OrderService {
return &OrderService{db: db, mail: mail}
}
// the signature IS the list of dependenciesA 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 DB | Found immediately; the deploy fails and rolls back | Found on the first request — maybe at 3 a.m. |
| Startup time | Slower | Faster |
| First request | Normal speed | Slower (pays the setup cost) |
| Concurrency | Nothing to coordinate | Needs 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():
- Imported packages, completely, before the package that imports them (each package only once).
- Inside a package: package-level variables, in dependency order.
- 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. - 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
initthat fails can only panic or log and carry on with a broken package. - Runs before
mainparses flags or loads config. Aninitthat readsos.Getenvor opens a connection can't be configured — and runs even ingo testand 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":
| Style | Adding a setting | Breaks callers? |
|---|---|---|
Positional NewServer(addr, timeout, maxConns) | New parameter | Yes, every call |
Config struct NewServer(Config{…}) | New field | No — but its zero value must mean "keep the old behavior" |
| Functional options | New WithX function | No |
| JS options object | New property with a default | No |
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.
Pitfalls gallery
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.
factory := NewUserServiceFactory(NewConfigProvider())
builder := factory.NewBuilder()
svc, err := builder.WithDB(db).Build()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".
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 mapEither 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.
“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.
Spread lets undefined overwrite a default
const cfg = { ...DEFAULTS, ...{ timeoutMs: process.env.TIMEOUT_MS } };
// TIMEOUT_MS unset → cfg.timeoutMs is undefined, not 30000Use destructuring defaults ({ timeoutMs = 30_000 } = {}), or drop undefined keys before merging (§07).
A required setting disguised as an option
srv := NewServer(WithAddr(":8080")) // forget WithAddr and it silently uses some defaultForget 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.
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.
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.
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 nowReset in a defer right after Get, and finish every use of the object — including slices from buf.Bytes() — before Put (§10).
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).
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.
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);a := Config{Name: "", Tags: []string{"x", "y"}}
b := a
b.Tags[0] = "z"
b.Name = "copy"
fmt.Println(a.Tags[0], a.Name == "")srv := NewServer(":80",
WithTimeout(5*time.Second),
WithTimeout(10*time.Second),
)
fmt.Println(srv.timeout)// 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") }function connect({ retries = 3 } = {}) {
return retries;
}
console.log(connect({ retries: null }));// 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);buf := pool.Get().(*bytes.Buffer)
buf.Reset()
pool.Put(buf)
again := pool.Get().(*bytes.Buffer)
// Is "again" always the same as "buf"?Cheat sheet
NewX(…) (*X, error). JS: a function or class. Return something complete, or an error.var x X can just work (like bytes.Buffer, sync.Mutex), you don't need a constructor at all.init().Build() checks everything. Returns an immutable result. strings.Builder is something else.NewServer(addr, WithTimeout(d)): defaults first, options in order, last one wins. Adding options never breaks callers.({ timeoutMs = 30_000 } = {}). Spread lets undefined overwrite; defaults don't apply to null.main and pass it in. A JS module export is already a singleton.c := orig and {...obj} share nested slices/maps/arrays. Use slices.Clone/maps.Clone or structuredClone.sync.Pool may drop anything at any GC. Reset before Put, never use after Put. Measure first.init() for registration only.| I want to… | Go | JavaScript |
|---|---|---|
| Build a ready object with a few required inputs | NewX(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 config | Factory returning an interface; registry + init() for plugins | Factory function with a switch, or an object/Map from name to factory |
| Many optional settings with defaults | Functional options (or a config struct if zero values are fine defaults) | Options object with destructuring defaults |
| Ordered steps, validated together | Builder that stores the first error, Build() (T, error) | Fluent builder (return this), build() throws |
| A matching family of objects | Abstract factory (rarely needed — usually just pass the pieces) | An object of factory functions |
| One shared instance | Create in main, pass it in; sync.OnceValues if it must be lazy | Create in the entry file, pass it in; module export if you must |
| A tweaked copy | c := orig + clone slices/maps | { ...orig, x: 1 } (shallow) or structuredClone |
| Reuse expensive objects in a hot path | sync.Pool (after profiling) | Rarely needed; pool connections, not objects |