Factory Pattern — Interview Preparation¶
1. What interviewers test for¶
The Factory pattern is the most overloaded word in Go interviews. "Factory" can mean a one-line NewX, a registry indexed by string, an abstract factory returning a family of related types, or a generic builder. Interviewers probe four areas:
- Recognition — Can you see that every
NewXin Go is already a factory function? Do you know when to graduate fromNewXto a registry or abstract factory? - Selection criteria — Pick between
NewX, registry-based, type-selecting, and abstract factory for a given scenario. Justify the choice. - Runtime vs compile-time — When does the caller know which concrete type they need (compile-time, pick
NewX), and when does the input decide (runtime, pick registry)? - Production traps — Init order, registry races, factories doing I/O, leaking concrete types, factory-vs-singleton confusion.
Signals by level:
| Level | What they're looking for |
|---|---|
| Junior | "Why have a factory at all when Go has struct literals?" — articulate hidden invariants and interface returns |
| Middle | Pick the right shape (plain NewX vs registry vs abstract) and explain trade-offs |
| Senior | Design factories that survive interface evolution, plugin systems, multi-tenant configs; debug init-order bugs |
A red flag at any level: claiming Go "doesn't really need factories because struct literals exist". That misses the point — factories enforce invariants, return interfaces, and pick implementations. The struct-literal critique works only when there are no invariants, no interface return, and no selection logic. That's rare.
2. Table of Contents¶
- What interviewers test for
- Table of Contents
- Junior questions
- Middle questions
- Senior questions
- Live coding challenges
- System design starters
- Traps and red flags
- Questions to ASK the interviewer
- Cross-references
3. Junior questions¶
Q1. What is the Factory pattern? When would you use it in Go?¶
Answer: A function (or method) whose job is to construct a value of some type, hiding the construction details from the caller. In Go, the most common form is the NewX constructor: bufio.NewReader, http.NewRequest, sql.Open. Use it when:
- Construction has invariants the zero value can't express (a
*sync.Mutexis fine zero, but a*bufio.Readerneeds a buffer size and a source). - You want to return an interface, not a concrete type, so consumers depend on the contract.
- The choice of concrete type depends on a runtime argument (driver name, content type, config flag).
Common wrong answer: "Factories are for Java; Go uses struct literals." Partly true for trivial types. False as soon as you need invariants or interface returns.
Follow-up: Name three factories in the standard library.
bufio.NewReader(rd io.Reader) *bufio.Reader,sql.Open(driverName, dataSourceName string) (*sql.DB, error),regexp.MustCompile(expr string) *regexp.Regexp. Each hides setup the caller shouldn't need to know.
Q2. Show me the simplest factory.¶
Answer:
type Logger struct {
prefix string
out io.Writer
}
func NewLogger(prefix string, out io.Writer) *Logger {
if out == nil {
out = os.Stderr
}
return &Logger{prefix: prefix, out: out}
}
The factory: - Takes the minimum required inputs. - Picks a sane default when the caller passes nil. - Returns a pointer (cheaper to copy, allows methods with pointer receivers).
Junior signal: Knowing when to default and when to error. Defaulting out=nil to os.Stderr is friendly. Defaulting an empty prefix to "app" is invasive — users may want empty.
Q3. What's the difference between Factory and Constructor?¶
Answer: In Go they're usually the same thing — there is no class { constructor() { ... } } syntax. The convention is a top-level function named NewX (or MakeX, OpenX, Dial, Connect for specific verbs). When people say "factory" in Go, they almost always mean a NewX function.
The distinction matters when you have:
- Constructor = one fixed type.
NewReader(io.Reader) *Reader. - Factory = picks among multiple concrete types based on input.
sql.Open("postgres", ...)returns a*sql.DBbacked by a Postgres driver; with"mysql"it returns one backed by MySQL.
Some people use "constructor" only for the first case and "factory" only for the second. Most Go code uses them interchangeably.
Q4. Why does NewX return a pointer instead of a value?¶
Answer: Three reasons:
- Identity matters. If the type has a mutex or a connection inside, copying loses the only safe handle. Returning a pointer makes the caller share, not copy.
- Cheaper passing. A 200-byte struct passed by pointer is 8 bytes; by value, 200.
- Methods with pointer receivers. If any method needs
*T, returningTforces the caller to take&result, which is awkward.
Exception: Small immutable value types (time.Time, image.Point). Return them by value. Pointers add no value and increase GC pressure.
Follow-up: When would NewX return a non-pointer?
When
Xis value-like:time.Now(),image.Pt(1, 2),big.NewInt(0). Wait —big.NewIntreturns*big.Intbecausebig.Intcarries internal state and the methods mutate. Even "value-like" types often need pointers in Go.
Q5. What's the difference between Must and a regular factory?¶
Answer: Must variants panic on error instead of returning it. regexp.MustCompile, template.Must, text/template.Must. Use them only when:
- The argument is a compile-time constant (a literal regex string).
- A failure means the program is broken, not the input is bad.
- The factory is called at package init or test setup, not at request time.
Wrong usage: Calling regexp.MustCompile(userInput) at request time. User-supplied input failing should return an error, not crash the service.
Junior signal: Knowing when Must is appropriate is the answer; reciting that they panic is just trivia.
Q6. What's wrong with this factory?¶
type DB struct{ conn *sql.DB }
func NewDB() *DB {
db, _ := sql.Open("postgres", "host=...")
return &DB{conn: db}
}
Answer: Two bugs:
- Error discarded.
sql.Opencan fail (bad DSN, driver missing). The caller gets a*DBholding a broken or nil*sql.DBand discovers it only at the first query. Return(*DB, error). - Config hardcoded. The DSN is a string literal. The factory can't be used in tests, in CI, or with a different DB. Take the DSN as a parameter.
Fix:
func NewDB(dsn string) (*DB, error) {
conn, err := sql.Open("postgres", dsn)
if err != nil { return nil, err }
return &DB{conn: conn}, nil
}
Q7. What's a registry-based factory?¶
Answer: A factory where the choice of concrete type is keyed by a string (or other identifier) registered at startup. Canonical example: database/sql:
import _ "github.com/lib/pq" // pq.init() calls sql.Register("postgres", &pq.Driver{})
db, err := sql.Open("postgres", dsn)
The _ import runs pq's init(), which registers the driver. sql.Open then looks up "postgres" in the registry and dispatches.
Use when: The set of types is open — third parties add new ones. Avoid when: The set is closed and known at compile time; a switch is clearer.
Q8. Should a factory return a concrete type or an interface?¶
Answer: It depends — and this is a recurring Go debate:
- Return concrete (pointer to struct) is the default. Callers can use any method on the type, including ones added later. Easy to evolve.
- Return interface when there are multiple possible implementations and the caller doesn't care which one. The classic case is registry factories:
sql.Openreturns*sql.DB(a concrete type that holds adriver.Driverinterface inside) — but for abstract factories, returning an interface is right.
The slogan "accept interfaces, return structs" applies to most factories. The exception is when the factory's whole point is to pick among implementations — then it has to return the interface.
Common wrong answer: "Always return interfaces — it's more flexible." False. Returning an interface from a factory that has one implementation is over-engineering and prevents callers from accessing implementation-specific methods.
Q9. What does image.Decode show us about factories?¶
Answer: It's a content-detecting factory. The caller passes an io.Reader. image.Decode reads enough bytes to identify the format (PNG, JPEG, GIF), looks up the decoder in a registry, and dispatches:
Decoders are registered via image.RegisterFormat. The PNG package self-registers in init(); importing _ "image/png" enables PNG support.
Lesson: Factories can dispatch on content, not just on a name string. Same registry mechanism, different lookup key.
Q10. Factory or Singleton?¶
var defaultLogger *Logger
var once sync.Once
func DefaultLogger() *Logger {
once.Do(func() {
defaultLogger = &Logger{out: os.Stderr}
})
return defaultLogger
}
Answer: Both. DefaultLogger is a factory function that returns a singleton (the same instance every call). The pattern names aren't mutually exclusive — they describe different aspects of the same code:
- Factory: how the value is constructed.
- Singleton: there is exactly one of it.
Common wrong answer: "It's a singleton, not a factory." It's both. Calling it just a singleton ignores that the construction is non-trivial (lazy, thread-safe via sync.Once).
4. Middle questions¶
Q1. Walk me through designing a registry-based factory.¶
Answer: Four pieces:
- An interface the factory returns. Small, focused. E.g.,
Driver,Decoder,Provider. - A registry — usually
map[string]Factory, whereFactoryisfunc(config) (Iface, error). - A
Register(name, factory)function with mutex protection (or a guarantee that registration happens only atinit()). - A
New(name, config)function that looks up the name and dispatches.
type Driver interface {
Connect(dsn string) (Conn, error)
}
var (
mu sync.RWMutex
drivers = map[string]Driver{}
)
func Register(name string, d Driver) {
mu.Lock(); defer mu.Unlock()
if _, dup := drivers[name]; dup {
panic("driver " + name + " registered twice")
}
drivers[name] = d
}
func Open(name, dsn string) (Conn, error) {
mu.RLock(); d, ok := drivers[name]; mu.RUnlock()
if !ok { return nil, fmt.Errorf("unknown driver: %s", name) }
return d.Connect(dsn)
}
Middle signal: Mentioning the duplicate-registration panic. It's how database/sql and image behave; surfacing the mistake at startup beats silent overwrite.
Follow-up: Why RWMutex instead of Mutex?
Registerhappens atinit()(a few times, total).Openhappens per request (many times). RWMutex lets concurrent reads proceed without blocking each other. Mutex would serialize all reads.
Q2. When would you use an Abstract Factory in Go?¶
Answer: When you need a family of related objects whose implementations are chosen together. Classic example: a tracing backend that exposes a Tracer, a Meter, and a Logger, all of which must come from the same vendor (Jaeger, Datadog, OpenTelemetry).
type Telemetry interface {
Tracer(name string) Tracer
Meter(name string) Meter
Logger(name string) Logger
}
func NewJaegerTelemetry(cfg JaegerConfig) Telemetry { ... }
func NewDDTelemetry(cfg DDConfig) Telemetry { ... }
Telemetry is the abstract factory: one call gets you the whole family.
When NOT to use: When the objects aren't related. Three independent factories (NewTracer, NewMeter, NewLogger) are simpler when they're independent. Abstract factory pays off only if mixing vendors is incoherent.
Q3. What's the difference between Factory Method and Factory Function in Go?¶
Answer: In OO languages, Factory Method means a method on a class that subclasses override. Go has no inheritance, so the GoF Factory Method has no direct translation. The Go idiom is:
- Factory function — a top-level
NewXorOpenorDial. - Factory method on a type —
client.NewRequest(...)where the method captures shared state (auth token, base URL). Still a function with a receiver.
func (c *Client) NewRequest(method, path string) (*Request, error) {
req, err := http.NewRequest(method, c.BaseURL+path, nil)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+c.Token)
return req, nil
}
The method form is useful when construction depends on the receiver's state.
Q4. How does bufio.NewReader decide buffer size?¶
Answer: It exposes two factories:
bufio.NewReader(rd) *Reader— default size (4096 bytes).bufio.NewReaderSize(rd, size) *Reader— caller-specified size. Ifsizeis too small, it's silently bumped up tominReadBufferSize.
The two-factory pattern (default + configurable) is common in Go's stdlib: NewX for the common case, NewXSize/NewXConfig for tuning. Better than a single NewX(rd, size) because most callers don't care about size.
Follow-up: Why not use functional options?
bufiois old (Go 1.0). Functional options weren't established yet. Today, you'd seebufio.NewReader(rd, bufio.WithSize(8192)). The two-factory shape is grandfathered; new packages tend to use options.
Q5. What does sql.Open actually return?¶
Answer: A *sql.DB, which is a handle — not a connection. It's lazy. The first call that actually needs a connection (a Query, Exec, or Ping) triggers the dial. sql.Open itself rarely fails — it only validates the driver name.
db, err := sql.Open("postgres", "garbage") // succeeds — driver "postgres" exists
err = db.Ping() // FAILS — DSN is garbage
Middle signal: Knowing this lets you avoid the bug of treating sql.Open returning nil-error as "the DB is reachable". It isn't. Always db.PingContext(ctx) after open to verify connectivity.
Q6. How do you make a factory accept a varying number of options?¶
Answer: Functional options. The factory takes a base argument plus a variadic option slice:
type Logger struct { /* ... */ }
type Option func(*Logger)
func WithPrefix(p string) Option { return func(l *Logger) { l.prefix = p } }
func WithOutput(w io.Writer) Option { return func(l *Logger) { l.out = w } }
func NewLogger(opts ...Option) *Logger {
l := &Logger{prefix: "app", out: os.Stderr} // defaults
for _, opt := range opts { opt(l) }
return l
}
Used: NewLogger(WithPrefix("api"), WithOutput(os.Stdout)). Adding a new option doesn't break existing callers.
Trade-off: Discoverability suffers — IDEs autocomplete struct fields better than option function names. For very simple cases, a config struct works fine: NewLogger(LoggerConfig{Prefix: "api"}).
Q7. What's a "lazy" factory and when do you use it?¶
Answer: A factory whose actual work is deferred until first use. Two patterns:
Lazy initialization with sync.Once:
type DB struct {
once sync.Once
conn *sql.DB
err error
dsn string
}
func (d *DB) Conn() (*sql.DB, error) {
d.once.Do(func() {
d.conn, d.err = sql.Open("postgres", d.dsn)
})
return d.conn, d.err
}
Lazy factory returning a function:
func LazyOpener(dsn string) func() (*sql.DB, error) {
var (
once sync.Once
db *sql.DB
err error
)
return func() (*sql.DB, error) {
once.Do(func() { db, err = sql.Open("postgres", dsn) })
return db, err
}
}
Use when construction is expensive and might not be needed (rare code path, optional feature). Avoid when construction always happens at startup — eager is simpler.
Q8. Show me a type-selecting factory.¶
Answer: A factory that picks the concrete type by inspecting an argument:
type Handler interface { Handle(req Request) Response }
func NewHandler(kind string) (Handler, error) {
switch kind {
case "echo": return &EchoHandler{}, nil
case "static": return &StaticHandler{Path: "./public"}, nil
case "proxy": return &ProxyHandler{URL: "http://upstream"}, nil
default: return nil, fmt.Errorf("unknown handler: %s", kind)
}
}
Middle signal: Knowing when this beats a registry. A switch is fine when the type set is closed and lives in one place. A registry wins when types come from different packages or third parties.
Follow-up: Refactor to a registry. When is the registry overkill?
Registry is overkill when (a) the types are all in the same package, (b) the set is small (< 5), (c) adding a new type doesn't happen often. A
switchkeeps everything in one place; a registry scatters registration across init functions.
Q9. How do factories interact with context.Context?¶
Answer: Two questions:
-
Does the factory take a context? Yes if it does I/O (DNS, dial, query). No if it only builds in-memory state.
sql.Opendoesn't take a context (no network);pgx.Connect(ctx, dsn)does (dials immediately). -
Does the constructed object hold a context? Usually no. Holding a context in a struct field is an antipattern — it's surprising and prevents callers from passing their own. Take context per call, not at construction.
// Bad
client := NewClient(ctx) // client has ctx forever; can't change
// Good
client := NewClient()
client.Do(ctx, req)
Middle signal: Spotting the "context in struct" antipattern and articulating why it's wrong (ownership, cancellation propagation, testability).
Q10. What's the trade-off between factory functions and exported struct literals?¶
Answer:
| Aspect | Factory NewX(...) | Struct literal X{...} |
|---|---|---|
| Defaults | Hidden, applied automatically | Caller must remember |
| Invariants | Enforced in code | Hope and prayer |
| Evolution | Add params (or use options) | Add fields silently |
| Discoverability | IDE shows NewX | IDE shows the struct fields |
| Verbosity | One line | Sometimes shorter |
Rule of thumb: If construction has any invariant (nil-check, default, computed field), use a factory. If T{} is a fully usable value, struct literal is fine (and idiomatic in Go).
The stdlib uses both — bytes.Buffer{} is fine; bufio.NewReader(...) is required.
5. Senior questions¶
Q1. How do you design a factory API for a library?¶
Answer: Six principles:
- Default plus options. Provide
NewX(required)for the common case andNewX(required, opts...)for tuning. - Return concrete by default. Return an interface only when picking among implementations or when you genuinely need substitutability.
- No I/O in constructors. Defer network/disk to first use or to an explicit
Verify(ctx)/Ping(ctx)method. - Errors over panics. Reserve
MustXfor compile-time inputs and tests. - Validate aggressively. Return errors for nil dependencies, invalid configs, missing required fields. Failing at the factory is cheaper than failing at request time.
- Stable signature. Changing
NewX's parameters breaks every caller. Use options or config structs to evolve.
Senior signal: Articulating the "I'm designing for someone else's three-year migration" view. Your factory's signature is a contract you can't easily change.
Q2. How do you handle plugin loading via factories?¶
Answer: Two approaches in Go:
-
Static linking via
init()registration. Each plugin is a Go package; importing it (import _ "plugins/foo") triggers itsinit(), which calls a globalRegister. This is howdatabase/sqldrivers, image formats, andexpvarhandlers work. Pros: type-safe, no runtime loading cost. Cons: every plugin must compile into the binary. -
Dynamic loading via
plugin.Open. Go'spluginpackage loads.sofiles at runtime, looks up symbols, and casts. Pros: extensibility without rebuild. Cons: severe restrictions (matching Go version, matching build flags, Linux/macOS only), no Windows, hard to debug. Most teams avoid it.
Senior signal: Knowing that Go's plugin package exists but recommending against it for most use cases. The static-link-with-init pattern covers 95% of plugin needs.
Follow-up: What about gRPC plugins (HashiCorp go-plugin)?
Run each plugin as a subprocess, communicate via gRPC. Strong isolation (a plugin crash doesn't kill the host), language-independent (Python plugins for Go hosts work). Used by Terraform, Vault, Packer. Heavier than
init()registration, lighter thanplugin.Openin misery.
Q3. The registry pattern has a concurrency bug — find it.¶
var drivers = map[string]Driver{}
func Register(name string, d Driver) {
drivers[name] = d
}
func Open(name string) Driver {
return drivers[name]
}
Answer: Two bugs:
-
Unprotected concurrent access. If
Registeris called afterOpenhas started serving (e.g., dynamic plugin loading), Go's race detector flags it and the program may corrupt the map. -
No duplicate detection. Two
Register("postgres", ...)calls silently overwrite the first.
Fix:
var (
mu sync.RWMutex
drivers = map[string]Driver{}
)
func Register(name string, d Driver) {
mu.Lock(); defer mu.Unlock()
if _, dup := drivers[name]; dup { panic("duplicate driver: " + name) }
drivers[name] = d
}
func Open(name string) Driver {
mu.RLock(); defer mu.RUnlock()
return drivers[name]
}
Senior signal: Spotting the race and the silent-overwrite without prompting. Both are real bugs in real codebases.
Q4. How do you test code that depends on a factory?¶
Answer: Three layers:
-
Replace the factory. Make the factory a variable so tests can swap it:
var NewClient = newClientallows tests to doNewClient = func() Client { return fakeClient }. Restore int.Cleanup. Works but has global state. -
Inject the factory. Pass the factory as a parameter. Tests pass a fake factory; production passes the real one. Clean but verbose.
-
Inject the constructed object. Best when the object's interface is small. Tests pass a fake instance directly; the factory is invoked once at startup.
// Layer 3: inject the constructed object
type Server struct { Client Client }
func NewServer(c Client) *Server { return &Server{Client: c} }
// Tests
srv := NewServer(&FakeClient{})
// Production
srv := NewServer(NewRealClient())
Senior signal: Recommending option 3 (inject the object) over option 1 (swap the factory). Global swapping is a recipe for parallel-test failures.
Q5. The "god factory" anti-pattern — recognise and refactor.¶
Answer: A NewX(...) taking 12 parameters, half of them options, returning a struct with 30 fields. Symptoms:
- Tests need 5 lines just to construct the dependency.
- New parameters get added every sprint.
- Half the parameters are nil in most call sites.
Refactor:
- Split the type. A factory bloated to 30 fields usually conceals 3-4 distinct types. Extract them.
- Functional options for tuning. Required params are positional; everything else is
WithX. - Composition over flags. Booleans like
WithCache bool,WithMetrics boolshould become decorators applied at wiring time, not factory flags. - Builder for very complex cases. When options have ordering or co-dependence, a builder is clearer than options.
Senior signal: Knowing when to graduate from NewX(opts...) to a builder. Builders win when (a) construction is stateful and stepwise, or (b) some combinations are invalid and you want a fluent API to express the valid ones.
Q6. Walk me through the init-order trap with registry factories.¶
Answer: Go's init() functions run in dependency order (package A imports B → B's init runs first), but within a package, init order is file alphabetical. Registry factories are vulnerable to two problems:
-
Import-only registration. A driver registers itself in
init(). If you forget the_ "github.com/lib/pq"blank import, the driver is not registered, andsql.Open("postgres", ...)returns"unknown driver: postgres"at runtime — not at compile time. Subtle, painful. -
Cross-package init order. Package C registers a default that package D depends on. If D's
init()runs first (D doesn't import C), D sees an empty registry. Symptom: works in dev (where you import everything via main), breaks in tests (where only a subset is imported).
Fixes:
- Use
Registerwith a panic on duplicate so accidental double-import is caught immediately. - Defer first lookup until after all packages have initialized (i.e., from
main, not frominit). - Have integration tests that exercise the full registry — if a driver is missing, the test fails, not production.
Q7. Generics and factories — when do they help?¶
Answer: Go 1.18 generics let you write factory functions that work for multiple types without interface{} and assertions. Useful for:
-
Container factories.
NewSet[T comparable]()returns a typed set without boxing. -
Pool factories.
NewPool[T any](newFn func() T) *Pool[T]returns a typedsync.Poolwrapper. -
Generic builders. A generic state machine factory parameterized over event and state types.
Where generics don't help:
- Registry factories — the registry's whole point is runtime dispatch on a string. Generics don't compose with string-keyed lookup.
- Factories returning interfaces — once you've committed to an interface return, generics add noise without benefit.
Senior signal: Knowing when generics are not the right tool. New Go engineers often try to genericize everything; senior engineers reach for them only when type-parameter substitution genuinely helps.
Q8. Factory with options — design problem.¶
Prompt: "Design a HTTP client factory. It needs: required base URL; optional timeout (default 30s), retries (default 3), custom transport, auth provider. Show me the public API."
Answer:
type Client struct {
baseURL string
timeout time.Duration
retries int
transport http.RoundTripper
auth AuthProvider
}
type Option func(*Client)
func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } }
func WithRetries(n int) Option { return func(c *Client) { c.retries = n } }
func WithTransport(t http.RoundTripper) Option { return func(c *Client) { c.transport = t } }
func WithAuth(a AuthProvider) Option { return func(c *Client) { c.auth = a } }
func NewClient(baseURL string, opts ...Option) (*Client, error) {
if baseURL == "" { return nil, errors.New("baseURL required") }
c := &Client{
baseURL: baseURL,
timeout: 30 * time.Second,
retries: 3,
transport: http.DefaultTransport,
}
for _, opt := range opts { opt(c) }
return c, nil
}
Discussion points:
- Required goes positional; optional goes as
Option. - Defaults applied before options so callers can override.
- Validation after options so we can validate combinations.
- Return concrete
*Client, not an interface — caller may want client-specific methods.
Follow-up: What if retries should be ≤ 10? Where does that check live?
After applying options, before returning. Validate the final state, not each option in isolation. Some options interact (a 1ms timeout with 10 retries is probably a misconfiguration).
Q9. How do you evolve a factory function without breaking callers?¶
Answer: Two scenarios:
-
Adding a new optional parameter. Use functional options. Existing callers don't see the change; new callers can pass
WithNewThing(...). -
Adding a required parameter. Painful. You have to break the signature. Options:
- Introduce
NewClientV2(...)alongsideNewClient. Deprecate the old one. Remove in next major version. - Make the parameter required-but-defaulting in
Optionform, validate after options applied. Existing callers panic at startup if they don't supply it — visible early.
Anti-pattern: Silently bumping a parameter to interface{}. Loses type safety, breaks callers in subtle ways.
Senior signal: Mentioning that a major version bump (v2) is the right escape hatch when a factory signature genuinely must change. Go modules support this; don't pretend a v2-worthy change is non-breaking.
Q10. Factory in a dependency-injection framework — pros and cons.¶
Answer: Frameworks like wire (Google) and fx (Uber) treat factory functions as providers. The framework analyses the parameter list, builds a dependency graph, and wires everything at startup.
Pros:
- Boilerplate disappears. You declare provider functions; the framework constructs the right things in the right order.
- Compile-time errors (wire) or startup errors (fx) catch missing dependencies before runtime.
- Easy to swap implementations by changing the provider set.
Cons:
- Magic. Newcomers don't understand where things come from.
- Debugging is harder. The stack trace through generated code is alien.
- Generated code (wire) lives in the repo and needs regeneration after factory changes.
- Performance overhead (fx, reflection-based) for non-trivial graphs.
Senior signal: Recommending wire for static graphs, fx for dynamic ones, and no framework for small services. The pattern is fine; the framework choice depends on team size and service complexity.
6. Live coding challenges¶
Challenge 1: Registry-based factory¶
Prompt: Design a registry-based factory for a Cache interface. Cache implementations (memory, redis, memcached) self-register via init(). The factory should:
- Panic on duplicate registration.
- Return an error for unknown cache names.
- Be safe for concurrent registration and lookup.
What's being tested: Registry mechanics, mutex placement, error vs panic decision.
Solution:
package cache
import (
"fmt"
"sync"
)
type Cache interface {
Get(key string) ([]byte, bool)
Set(key string, val []byte) error
}
type Factory func(config map[string]string) (Cache, error)
var (
mu sync.RWMutex
factories = map[string]Factory{}
)
func Register(name string, f Factory) {
if name == "" || f == nil { panic("cache: invalid registration") }
mu.Lock(); defer mu.Unlock()
if _, dup := factories[name]; dup {
panic(fmt.Sprintf("cache: %q registered twice", name))
}
factories[name] = f
}
func New(name string, config map[string]string) (Cache, error) {
mu.RLock(); f, ok := factories[name]; mu.RUnlock()
if !ok { return nil, fmt.Errorf("cache: unknown backend %q", name) }
return f(config)
}
Follow-ups:
- Why panic on duplicate but error on unknown? (Duplicate = programmer bug, surface immediately. Unknown = config bug, recoverable.)
- How would you list registered backends? (Add a
Names()function that reads underRLock.) - What's the cost of
RWMutexhere? (Reads are common, writes happen atinitonly. RWMutex is right.)
Challenge 2: Type-selecting factory¶
Prompt: Implement a NewParser(format string) (Parser, error) that returns JSONParser, XMLParser, or YAMLParser based on the format string. No registry — closed set of types in one file.
What's being tested: When to not use a registry. Simple dispatch via switch.
Solution:
type Parser interface {
Parse(data []byte) (map[string]any, error)
}
type JSONParser struct{}
func (JSONParser) Parse(data []byte) (map[string]any, error) {
var out map[string]any
return out, json.Unmarshal(data, &out)
}
type XMLParser struct{}
func (XMLParser) Parse(data []byte) (map[string]any, error) {
// ... XML parsing ...
return nil, nil
}
type YAMLParser struct{}
func (YAMLParser) Parse(data []byte) (map[string]any, error) {
// ... YAML parsing ...
return nil, nil
}
func NewParser(format string) (Parser, error) {
switch strings.ToLower(format) {
case "json": return JSONParser{}, nil
case "xml": return XMLParser{}, nil
case "yaml", "yml": return YAMLParser{}, nil
default: return nil, fmt.Errorf("unknown format: %s", format)
}
}
Discussion:
- Why no registry? Closed set, one file, three options. A registry would scatter the knowledge for no benefit.
- Why value receivers? Parsers are stateless. No need for pointer overhead.
- When to graduate to a registry? When
formatstrings start coming from third-party packages.
Challenge 3: Factory with options¶
Prompt: Write a NewHTTPServer(addr string, opts ...ServerOption) (*Server, error) that supports:
- Required:
addr. - Optional: read timeout, write timeout, max header bytes, custom logger, TLS config.
- Validates: timeouts must be positive;
addrmust be non-empty.
What's being tested: Functional options pattern, defaults, validation order.
Solution:
type Server struct {
addr string
readTimeout time.Duration
writeTimeout time.Duration
maxHeaderBytes int
logger *slog.Logger
tls *tls.Config
httpServer *http.Server
}
type ServerOption func(*Server)
func WithReadTimeout(d time.Duration) ServerOption {
return func(s *Server) { s.readTimeout = d }
}
func WithWriteTimeout(d time.Duration) ServerOption {
return func(s *Server) { s.writeTimeout = d }
}
func WithMaxHeaderBytes(n int) ServerOption {
return func(s *Server) { s.maxHeaderBytes = n }
}
func WithLogger(l *slog.Logger) ServerOption {
return func(s *Server) { s.logger = l }
}
func WithTLS(cfg *tls.Config) ServerOption {
return func(s *Server) { s.tls = cfg }
}
func NewHTTPServer(addr string, opts ...ServerOption) (*Server, error) {
if addr == "" { return nil, errors.New("addr required") }
s := &Server{
addr: addr,
readTimeout: 10 * time.Second,
writeTimeout: 10 * time.Second,
maxHeaderBytes: 1 << 20,
logger: slog.Default(),
}
for _, opt := range opts { opt(s) }
if s.readTimeout <= 0 { return nil, errors.New("read timeout must be > 0") }
if s.writeTimeout <= 0 { return nil, errors.New("write timeout must be > 0") }
s.httpServer = &http.Server{
Addr: s.addr,
ReadTimeout: s.readTimeout,
WriteTimeout: s.writeTimeout,
MaxHeaderBytes: s.maxHeaderBytes,
TLSConfig: s.tls,
}
return s, nil
}
Follow-ups:
- Why validate after options? Some options interact; final state is the only state worth validating.
- What if two options conflict (e.g., TLS config + plaintext addr)? Add cross-field validation after the loop.
- Could options return an error? Yes —
type ServerOption func(*Server) error. Pricier syntax; useful when option failure must be reported precisely.
Challenge 4: Lazy factory via sync.Once¶
Prompt: Build a factory that lazily opens a database connection on first use, caches it, and is safe for concurrent first-callers.
What's being tested: sync.Once mechanics, error propagation in lazy init.
Solution:
type LazyDB struct {
dsn string
once sync.Once
db *sql.DB
err error
}
func NewLazyDB(dsn string) *LazyDB {
return &LazyDB{dsn: dsn}
}
func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
l.once.Do(func() {
db, err := sql.Open("postgres", l.dsn)
if err != nil { l.err = err; return }
if err := db.PingContext(ctx); err != nil {
db.Close()
l.err = err
return
}
l.db = db
})
return l.db, l.err
}
Discussion:
sync.Onceguarantees the init function runs exactly once across all goroutines.- Both success and failure are cached. If the first attempt fails, every subsequent caller gets the same error — they don't retry.
- For retry semantics, replace
sync.Oncewith custom logic usingsync.Mutexand adone bool, resetting on failure.
Follow-ups:
- What if you want retries on failure? Replace
sync.Oncewith a mutex + result tracking; on error, leavedbnil and let the next caller try again. - What about
Close? Add aClose() errormethod that closes the DB if initialized. Be careful: closing during a concurrentDB()call is racy.
Challenge 5: Abstract factory for a tracer¶
Prompt: Design an abstract factory that returns a family of related telemetry objects (Tracer, Meter, Logger). Implementations: Jaeger, OpenTelemetry, NoOp.
What's being tested: Abstract factory pattern, interface design for families of related types.
Solution:
// Family interfaces.
type Span interface {
End()
SetAttribute(key string, val any)
}
type Tracer interface {
Start(ctx context.Context, name string) (context.Context, Span)
}
type Meter interface {
Counter(name string) Counter
}
type Counter interface {
Inc(delta float64, attrs ...Attr)
}
type Logger interface {
Info(msg string, kv ...any)
Error(msg string, kv ...any)
}
// Abstract factory.
type Telemetry interface {
Tracer(name string) Tracer
Meter(name string) Meter
Logger(name string) Logger
Shutdown(ctx context.Context) error
}
// Concrete factories.
func NewJaegerTelemetry(cfg JaegerConfig) (Telemetry, error) {
// ... initialize Jaeger exporter, return a *jaegerTelemetry ...
return &jaegerTelemetry{...}, nil
}
func NewOTelTelemetry(cfg OTelConfig) (Telemetry, error) {
return &otelTelemetry{...}, nil
}
func NewNoopTelemetry() Telemetry {
return &noopTelemetry{}
}
// Usage in main:
tel, err := NewOTelTelemetry(otelConfig)
if err != nil { log.Fatal(err) }
defer tel.Shutdown(context.Background())
tracer := tel.Tracer("api")
meter := tel.Meter("api")
logger := tel.Logger("api")
Discussion:
- The abstract factory (
Telemetry) is one interface returning a family. - Implementations come together — you can't mix Jaeger tracer with OTel meter; the family stays consistent.
- The
NoopTelemetryexists for tests and as a default — never returns nil, always safe to call.
Follow-ups:
- Why is
Shutdownon the abstract factory and not on each sub-type? Because they share a backend (one exporter, one batch flusher). Shutting them down individually risks leaking the batcher. - How would you make it composable (some real, some noop)? Wrap a
Telemetryin a struct that delegates some methods to real impl and others to noop. That's a Decorator, not a new abstract factory.
7. System design starters¶
Starter 1: Plugin system¶
Prompt: "We're building a CLI that supports user plugins. How do we let third parties add new commands?"
Direction:
- Define a
Plugininterface withName(),Description(),Run(args []string) error. - Provide a
RegisterPlugin(p Plugin)function backed by a registry map. - Plugins are Go packages users import in their fork's
main.go. Each plugin'sinit()callsRegisterPlugin. - The CLI's main loop reads command name, looks up in registry, dispatches.
Trade-offs:
- Static linking via Go packages = users must rebuild the binary to add plugins. Simple, type-safe.
- Dynamic loading via Go's
pluginpackage = users can drop.sofiles. Fragile, Linux-only. - Subprocess + RPC (hashicorp/go-plugin) = full isolation, language-agnostic. Heaviest but most robust.
For most CLIs, the static-link-with-init approach is best. Reserve subprocess plugins for ecosystems where third parties need to ship binaries that work across multiple host versions (Terraform, Vault).
Starter 2: Multi-tenant factory¶
Prompt: "Our SaaS has 10,000 tenants. Each tenant can have different cache settings, different storage backend, different feature flags. How do we factor object construction?"
Direction:
- Per-tenant
Configstruct holding all knobs. - A
TenantContextfactory that, given a tenant ID, loads config (from a config service or cache) and constructs the right family of objects for that tenant. - Cache constructed
TenantContextinstances — building one per request is too slow. - Invalidate cache on config change (via webhook or pub/sub).
type TenantContext struct {
Cache Cache
Storage Storage
Flags FlagSet
}
type TenantFactory struct {
configs ConfigLoader
cache sync.Map // tenantID -> *TenantContext
}
func (f *TenantFactory) Get(ctx context.Context, tenantID string) (*TenantContext, error) {
if tc, ok := f.cache.Load(tenantID); ok {
return tc.(*TenantContext), nil
}
cfg, err := f.configs.Load(ctx, tenantID)
if err != nil { return nil, err }
tc, err := buildContext(cfg)
if err != nil { return nil, err }
f.cache.Store(tenantID, tc)
return tc, nil
}
Trade-offs:
- Cache size: 10K tenants might be too many to hold. Add LRU eviction.
- Cache invalidation: when does a tenant's config change? Subscribe to config service updates.
- Cold start: first request for a tenant pays the build cost. Pre-warm hot tenants at startup if needed.
Starter 3: Hot-reload configuration¶
Prompt: "Our service reads a config file at startup. We want config changes to take effect without restart. How do we restructure the factories?"
Direction:
- Wrap each component in a "current pointer" that the factory updates atomically.
atomic.Valueoratomic.Pointer[T](Go 1.19+). - The factory watches the config file (via
fsnotifyor signal-based reload), rebuilds the component, and swaps the atomic pointer. - Consumers always read through the pointer; they don't hold the constructed object directly.
type LiveLogger struct {
current atomic.Pointer[slog.Logger]
}
func (l *LiveLogger) Logger() *slog.Logger {
return l.current.Load()
}
func (l *LiveLogger) Reload(cfg LogConfig) {
newLogger := buildLogger(cfg)
l.current.Store(newLogger)
}
Trade-offs:
- In-flight requests: a request using
oldLoggerkeeps using it until completion. Usually fine. - Resource cleanup: the old logger may hold file handles. Close it after a grace period (or after no requests reference it — harder).
- Partial reload: some components survive reload (DB connection pool), others rebuild (rate limiter config). Decide per component.
Starter 4: Abstract factory for metrics backend¶
Prompt: "We need to support Prometheus, Datadog, and CloudWatch metrics in our service. The backend is chosen by deployment environment. How do we structure this?"
Direction:
- Define a
Metricsabstract factory:Counter,Gauge,Histogram. - Implementations:
PrometheusMetrics,DatadogMetrics,CloudWatchMetrics. Each is a complete family. - A registry (
Register("prometheus", NewPrometheusMetrics)) keyed by string letsmain()pick by env var. - Domain code accepts the abstract
Metricsinterface; onlymain()knows the concrete backend.
type Metrics interface {
Counter(name string, labels ...string) Counter
Gauge(name string, labels ...string) Gauge
Histogram(name string, buckets []float64, labels ...string) Histogram
Flush(ctx context.Context) error
}
func NewMetrics(backend string, cfg map[string]string) (Metrics, error) {
switch backend {
case "prometheus": return newPrometheus(cfg)
case "datadog": return newDatadog(cfg)
case "cloudwatch": return newCloudWatch(cfg)
case "noop": return &noopMetrics{}, nil
default: return nil, fmt.Errorf("unknown metrics backend: %s", backend)
}
}
Trade-offs:
- API mismatch across backends: Prometheus has labels at metric definition time; Datadog at emit time. The abstract API has to accommodate the strictest backend, or it has to do internal conversion.
- Performance: counter increments on hot paths must be cheap. Avoid string allocation per increment; pre-resolve labels.
- Testing: provide
NoopMetricsso tests don't need real backends.
Starter 5: DI for tests¶
Prompt: "Our service has 20 dependencies. Production wires them all together. Tests need to swap most of them with fakes. How do we structure the factories?"
Direction:
- Each component has a constructor
NewX(deps)taking only the dependencies it needs. - A
wire.go(or hand-rolledBuildApp()) function composes them for production. - Tests provide their own
BuildAppthat swaps the fakes in. Or usewire.Buildwith a different provider set. - Avoid global state: no
init()registering production services. Tests should not need to undo state.
type App struct {
Server *Server
}
// Production wiring.
func BuildApp(ctx context.Context) (*App, error) {
db, err := NewDB(ctx, "postgres://...")
if err != nil { return nil, err }
cache := NewRedisCache("redis://...")
repo := NewRepo(db, cache)
service := NewService(repo)
server := NewServer(service)
return &App{Server: server}, nil
}
// Test wiring.
func BuildTestApp(t *testing.T) *App {
db := &FakeDB{}
cache := &FakeCache{}
repo := NewRepo(db, cache)
service := NewService(repo)
server := NewServer(service)
return &App{Server: server}
}
Trade-offs:
- Verbosity: hand-wired DI is repetitive in large apps. Consider
wire(compile-time) orfx(runtime) when the graph exceeds ~30 nodes. - Test isolation: each test should call
BuildTestAppto get a fresh, isolated app. Shared state = flaky tests. - Production complexity: when production needs the same dependency wired into multiple places, factor a helper. Don't copy-paste construction.
8. Traps and red flags¶
Trap 1: Singleton-vs-Factory confusion¶
var instance *Logger
func GetLogger() *Logger {
if instance == nil {
instance = &Logger{} // race condition!
}
return instance
}
This is both a factory (it constructs) and a singleton (one instance). Two bugs:
- The nil check is not synchronized — two goroutines can both see
niland both construct. - Mutating a global from a factory makes testing miserable.
Fix: sync.Once-protected init, or inject the logger as a parameter instead of getting it from a global.
Trap 2: Init order surprise¶
If NewClient reads config from a global Settings map registered by package b's init, and a initializes before b, you get an empty client. Symptoms: works in some environments, not others.
Fix: Defer construction until after all init: use sync.Once and a factory function, or do all construction in main.
Trap 3: Registry race condition¶
var drivers = map[string]Driver{}
func Register(name string, d Driver) { drivers[name] = d }
func Open(name string) Driver { return drivers[name] }
If anything calls Register outside of init() (e.g., plugin loading at runtime), concurrent reads from Open race against the write. Map writes during concurrent reads = undefined behavior, often a crash.
Fix: sync.RWMutex around all access. Or document loudly that registration must happen at init() only.
Trap 4: Factory doing I/O at startup¶
func NewService(cfg Config) (*Service, error) {
conn, err := net.Dial("tcp", cfg.Addr) // network in constructor
if err != nil { return nil, err }
return &Service{conn: conn}, nil
}
Problems:
main()blocks on network at startup. If the dependency is down, the service doesn't boot.- Tests can't construct the service without faking the network.
- Retry logic doesn't apply — one shot at startup, then crash.
Fix: Construct in-memory; defer dialing to first call (lazy via sync.Once) or to an explicit Start(ctx) method.
Trap 5: Returning typed nil¶
func NewClient(addr string) Iface {
var c *Client
if addr != "" { c = &Client{addr: addr} }
return c // typed nil!
}
Returning c when it's nil produces (*Client, nil) — a non-nil interface wrapping a nil pointer. Callers checking if i == nil get false, then panic on method call.
Fix:
Trap 6: Factory function as a method on a value type¶
b is a copy. Mutations during Build aren't visible to the caller. Subtle, especially with chained builders.
Fix: Pointer receivers for builders. Or return (*Thing, Builder) from each step to force the caller to use the return.
Trap 7: Mutating shared state in a factory¶
var counter int
func NewWidget() *Widget {
counter++ // global mutation
return &Widget{id: counter}
}
Concurrent calls race on counter. Even single-threaded, this couples the factory to a global counter — testing is awkward, IDs are unpredictable across runs.
Fix: Use atomic.AddInt64 if you need monotonic IDs. Better: pass the ID source as a parameter (NewWidget(idGen IDGenerator)).
Trap 8: Forgetting Must is a footgun¶
Must is fine for literals (regexp.MustCompile("^[0-9]+$")) — a panic means the program is broken. For dynamic input, use the non-Must form and handle the error.
Trap 9: Adding required parameters silently¶
// v1: func NewService(name string) *Service
// v2: func NewService(name, region string) *Service // breaks every caller
Every caller now has a compile error. There's no migration path.
Fix: Add new required params via an options mechanism (default missing region to "us-east-1" with a deprecation log) or bump the major version and provide a migration guide.
Trap 10: Forgetting to close what the factory opens¶
func NewLogger(path string) (*Logger, error) {
f, err := os.Create(path)
if err != nil { return nil, err }
return &Logger{f: f}, nil // who closes f?
}
The factory opens a file but doesn't expose a way to close it. Leak.
Fix: Return a value with a Close() error method. Document that callers must defer logger.Close(). Or use a RunWithLogger(ctx, fn) pattern that handles lifecycle internally.
9. Questions to ASK the interviewer¶
Junior-level questions you can ask¶
- "Do you typically use
NewXconstructors with positional args, options, or config structs?" - "How do you decide when a factory needs to return an interface instead of a concrete type?"
Middle-level questions¶
- "Have you adopted any dependency injection framework (wire, fx, dig)? What drove that decision?"
- "How do you handle config reload — do factories rebuild the world, or do they patch in place?"
- "Do you allow registry-based dispatch, or do you prefer closed sets with switches?"
Senior-level questions¶
- "How do you evolve factory signatures across major versions without breaking the ecosystem?"
- "How do you decide between abstract factory and three independent factories?"
- "Do you use code generation (wire) for factory wiring, or hand-rolled?"
- "How do you handle init-order issues across packages with registry-based factories?"
- "What's your testing strategy for code that has 20 dependencies wired through factories?"
10. Cross-references¶
- ../01-functional-options/ — The canonical Go way to make factory functions configurable without breaking the signature. Almost every non-trivial factory uses functional options.
- ../02-builder-pattern/ — When functional options aren't enough (ordering, validation between steps), Builder is the next step up.
- ../03-strategy-pattern/ — Strategy picks an algorithm at runtime. A factory often returns the right Strategy implementation based on input. Often used together.
- ../05-adapter-pattern/ — Adapter constructors are factories. The naming overlap is real — a
NewStripeAdapteris both a factory function and an adapter constructor. - ../08-singleton-pattern/ — Singletons are factories that return the same instance every time. The patterns overlap; calling something "a singleton" doesn't mean it's not also a factory.
- ../18-registry-pattern/ — The registry pattern is the data-structure side of registry-based factories. Factory talks to a Registry to find implementations.
Factories in Go are everywhere — every NewX, every OpenX, every Dial is a factory. The pattern's value isn't in the name, it's in choosing the right shape: positional args for required, options for optional, builders for stateful, registries for open sets, abstract factories for families, lazy factories for expensive construction. Master the trade-offs and you master construction in Go.