Low Level DesignDesign patterns
Factory pattern
Factory method, abstract factory and registry factories through three real examples, from notification channels to payment providers to database clients, with diagrams, live demos and code in five languages.
- Read first
- StrategyOpen/closed
A factory is an object or method whose job is to create other objects. Then the code that uses them doesn’t have to. The caller says what it needs: “a notifier for SMS”, “payments for the EU”, “a client for this database URL”. It gets back something behind an interface. Which class that is, how it’s configured, and where it comes from are decided in one place.
It’s the most common pattern in real code, often without the name: Logger.getLogger,
List.of, http.NewRequest and every dependency injection container are factories. This
page builds from a simple registry to a factory that owns the lifecycle of what it creates.
The shape
Section titled “The shape”Every factory has the same parts:
- The product interface is what callers use, for example
Notifier. - Concrete products implement it, for example
EmailNotifierandSmsNotifier. - The factory decides which concrete product to create and how. Callers depend on it and on the interface, never on the concrete products.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
The client has no arrow to ProductA or ProductB. That missing arrow is the whole point:
adding ProductC touches the factory and nothing that uses it.
The three forms differ in who decides what gets created:
| Form | Who decides | Use it when | Example on this page |
|---|---|---|---|
| Simple factory or registry | A function or object, by a name or config value | The choice comes from data: a setting, a request, a URL | Easy, Hard |
| Factory method | A subclass, by overriding one creation method | A base class runs an algorithm and one step needs a different object per subclass | Below |
| Abstract factory | One object with a creation method per part | Several parts must come from the same family | Medium |
Factory method, in brief
Section titled “Factory method, in brief”The original Gang of Four pattern. A base class runs the shared algorithm and calls an overridable method to create the one object that varies. Each subclass returns its own:
import java.util.List;import java.util.function.Supplier;
interface Notifier { void send(String to, String text); }
class EmailNotifier implements Notifier { public void send(String to, String text) { System.out.println("email to " + to + ": " + text); }}
class SmsNotifier implements Notifier { public void send(String to, String text) { System.out.println("sms to " + to + ": " + text); }}
// Factory method: the base class runs the algorithm and leaves one decision, which object// to create, to a method that subclasses override. The base never names a concrete class.abstract class Campaign { // The algorithm every campaign shares. It calls the factory method instead of `new`. final void run(List<String> users, String text) { Notifier notifier = createNotifier(); for (String user : users) notifier.send(user, text); }
// The factory method. Each subclass decides which Notifier its campaign uses. protected abstract Notifier createNotifier();}
class EmailCampaign extends Campaign { protected Notifier createNotifier() { return new EmailNotifier(); }}
class SmsCampaign extends Campaign { protected Notifier createNotifier() { return new SmsNotifier(); }}
// The same flexibility by composition: pass the factory in instead of subclassing.// Usually the better choice today: no class per variant, and it can change at run time.// Factory method still fits when a framework owns the base class (see the page).class ComposedCampaign { private final Supplier<Notifier> notifiers; ComposedCampaign(Supplier<Notifier> notifiers) { this.notifiers = notifiers; }
void run(List<String> users, String text) { Notifier notifier = notifiers.get(); for (String user : users) notifier.send(user, text); }}
class Main { public static void main(String[] args) { new EmailCampaign().run(List.of("ana", "ben"), "sale starts today"); new ComposedCampaign(SmsNotifier::new).run(List.of("ana"), "sale ends tonight"); }}from abc import ABC, abstractmethodfrom typing import Callable, Protocol
class Notifier(Protocol): def send(self, to: str, text: str) -> None: ...
class EmailNotifier: def send(self, to: str, text: str) -> None: print(f"email to {to}: {text}")
class SmsNotifier: def send(self, to: str, text: str) -> None: print(f"sms to {to}: {text}")
# Factory method: the base class runs the algorithm and leaves one decision, which object# to create, to a method that subclasses override. The base never names a concrete class.class Campaign(ABC): # The algorithm every campaign shares. It calls the factory method instead of a constructor. def run(self, users: list[str], text: str) -> None: notifier = self.create_notifier() for user in users: notifier.send(user, text)
# The factory method. Each subclass decides which Notifier its campaign uses. @abstractmethod def create_notifier(self) -> Notifier: ...
class EmailCampaign(Campaign): def create_notifier(self) -> Notifier: return EmailNotifier()
class SmsCampaign(Campaign): def create_notifier(self) -> Notifier: return SmsNotifier()
# The same flexibility by composition: pass the factory in instead of subclassing.# Usually the better choice today: no class per variant, and it can change at run time.# Factory method still fits when a framework owns the base class (see the page).class ComposedCampaign: def __init__(self, notifiers: Callable[[], Notifier]): self._notifiers = notifiers
def run(self, users: list[str], text: str) -> None: notifier = self._notifiers() for user in users: notifier.send(user, text)
if __name__ == "__main__": EmailCampaign().run(["ana", "ben"], "sale starts today") ComposedCampaign(SmsNotifier).run(["ana"], "sale ends tonight")using System;using System.Collections.Generic;
public interface INotifier { void Send(string to, string text); }
public sealed class EmailNotifier : INotifier{ public void Send(string to, string text) => Console.WriteLine($"email to {to}: {text}");}
public sealed class SmsNotifier : INotifier{ public void Send(string to, string text) => Console.WriteLine($"sms to {to}: {text}");}
// Factory method: the base class runs the algorithm and leaves one decision, which object// to create, to a method that subclasses override. The base never names a concrete class.public abstract class Campaign{ // The algorithm every campaign shares. It calls the factory method instead of `new`. public void Run(IEnumerable<string> users, string text) { var notifier = CreateNotifier(); foreach (var user in users) notifier.Send(user, text); }
// The factory method. Each subclass decides which INotifier its campaign uses. protected abstract INotifier CreateNotifier();}
public sealed class EmailCampaign : Campaign{ protected override INotifier CreateNotifier() => new EmailNotifier();}
public sealed class SmsCampaign : Campaign{ protected override INotifier CreateNotifier() => new SmsNotifier();}
// The same flexibility by composition: pass the factory in instead of subclassing.// Usually the better choice today: no class per variant, and it can change at run time.// Factory method still fits when a framework owns the base class (see the page).public sealed class ComposedCampaign(Func<INotifier> notifiers){ public void Run(IEnumerable<string> users, string text) { var notifier = notifiers(); foreach (var user in users) notifier.Send(user, text); }}
public static class Program{ public static void Main() { new EmailCampaign().Run(["ana", "ben"], "sale starts today"); new ComposedCampaign(() => new SmsNotifier()).Run(["ana"], "sale ends tonight"); }}package main
import "fmt"
type Notifier interface{ Send(to, text string) }
type EmailNotifier struct{}
func (EmailNotifier) Send(to, text string) { fmt.Printf("email to %s: %s\n", to, text) }
type SmsNotifier struct{}
func (SmsNotifier) Send(to, text string) { fmt.Printf("sms to %s: %s\n", to, text) }
// Go has no inheritance, so the classic factory method (a subclass overriding a creation// method) doesn't exist here. The Go version is the composed one: the campaign holds a// function that makes its notifier. Same decision, moved out of the algorithm.type Campaign struct { NewNotifier func() Notifier // the "factory method", as a field}
// Run is the algorithm every campaign shares. It calls the factory instead of naming a type.func (c Campaign) Run(users []string, text string) { n := c.NewNotifier() for _, u := range users { n.Send(u, text) }}
// Named constructors play the role of the subclasses.func EmailCampaign() Campaign { return Campaign{func() Notifier { return EmailNotifier{} }} }func SmsCampaign() Campaign { return Campaign{func() Notifier { return SmsNotifier{} }} }
func main() { EmailCampaign().Run([]string{"ana", "ben"}, "sale starts today") SmsCampaign().Run([]string{"ana"}, "sale ends tonight")}interface Notifier { send(to: string, text: string): void;}
class EmailNotifier implements Notifier { send(to: string, text: string): void { console.log(`email to ${to}: ${text}`); }}
class SmsNotifier implements Notifier { send(to: string, text: string): void { console.log(`sms to ${to}: ${text}`); }}
// Factory method: the base class runs the algorithm and leaves one decision, which object// to create, to a method that subclasses override. The base never names a concrete class.abstract class Campaign { // The algorithm every campaign shares. It calls the factory method instead of `new`. run(users: string[], text: string): void { const notifier = this.createNotifier(); for (const user of users) notifier.send(user, text); }
// The factory method. Each subclass decides which Notifier its campaign uses. protected abstract createNotifier(): Notifier;}
class EmailCampaign extends Campaign { protected createNotifier(): Notifier { return new EmailNotifier(); }}
class SmsCampaign extends Campaign { protected createNotifier(): Notifier { return new SmsNotifier(); }}
// The same flexibility by composition: pass the factory in instead of subclassing.// Usually the better choice today: no class per variant, and it can change at run time.// Factory method still fits when a framework owns the base class (see the page).class ComposedCampaign { constructor(private readonly notifiers: () => Notifier) {} run(users: string[], text: string): void { const notifier = this.notifiers(); for (const user of users) notifier.send(user, text); }}
new EmailCampaign().run(['ana', 'ben'], 'sale starts today');new ComposedCampaign(() => new SmsNotifier()).run(['ana'], 'sale ends tonight');Today it’s mostly found inside frameworks. The framework owns the base class, and you override the
hook (onCreateView in Android, get_queryset in Django). In your own code, there’s a simpler way.
Pass a function that creates the object (the ComposedCampaign version). It does the same job,
without a class per variant. Go has no inheritance, so that’s the only way it can do it.
Worked examples
Section titled “Worked examples”Notification channels
- Creation in one place
- Pick by name
- A registry instead of a switch
An online shop sends messages by email and SMS. Order confirmations, shipping updates and on-call alerts all need “a notifier for channel X”. Each channel has its own settings: an SMTP host, an SMS API key. Push notifications are coming next quarter, then Slack.
Without the pattern
Section titled “Without the pattern”Each service builds its own notifier:
// Before: every caller decides which concrete class to build, and how.interface Notifier { void send(String to, String text); }
class EmailNotifier implements Notifier { private final String smtpHost; EmailNotifier(String smtpHost) { this.smtpHost = smtpHost; } public void send(String to, String text) { System.out.println("email via " + smtpHost + " to " + to + ": " + text); }}
class SmsNotifier implements Notifier { private final String apiKey; SmsNotifier(String apiKey) { this.apiKey = apiKey; } public void send(String to, String text) { System.out.println("sms to " + to + ": " + text); }}
class OrderService { void confirm(String channel, String user) { // flaw 1: the same if/else, with the same settings, is copied into every caller. // flaw 2: OrderService now depends on every concrete notifier and its constructor. Notifier n; if (channel.equals("email")) n = new EmailNotifier("smtp.internal"); else if (channel.equals("sms")) n = new SmsNotifier("sms-key"); else throw new IllegalArgumentException(channel); n.send(user, "order confirmed"); }}
class AlertService { void page(String channel, String user) { // flaw 3: adding push notifications means finding and editing every copy. Notifier n; if (channel.equals("email")) n = new EmailNotifier("smtp.internal"); else if (channel.equals("sms")) n = new SmsNotifier("sms-key"); else throw new IllegalArgumentException(channel); n.send(user, "disk almost full"); }}
class Main { public static void main(String[] args) { new OrderService().confirm("email", "ana"); // email via smtp.internal to ana: order confirmed new AlertService().page("sms", "ops"); // sms to ops: disk almost full }}# Before: every caller decides which concrete class to build, and how.class EmailNotifier: def __init__(self, smtp_host: str): self.smtp_host = smtp_host
def send(self, to: str, text: str) -> None: print(f"email via {self.smtp_host} to {to}: {text}")
class SmsNotifier: def __init__(self, api_key: str): self.api_key = api_key
def send(self, to: str, text: str) -> None: print(f"sms to {to}: {text}")
class OrderService: def confirm(self, channel: str, user: str) -> None: # flaw 1: the same if/else, with the same settings, is copied into every caller. # flaw 2: OrderService now depends on every concrete notifier and its constructor. if channel == "email": n = EmailNotifier("smtp.internal") elif channel == "sms": n = SmsNotifier("sms-key") else: raise ValueError(channel) n.send(user, "order confirmed")
class AlertService: def page(self, channel: str, user: str) -> None: # flaw 3: adding push notifications means finding and editing every copy. if channel == "email": n = EmailNotifier("smtp.internal") elif channel == "sms": n = SmsNotifier("sms-key") else: raise ValueError(channel) n.send(user, "disk almost full")
if __name__ == "__main__": OrderService().confirm("email", "ana") # email via smtp.internal to ana: order confirmed AlertService().page("sms", "ops") # sms to ops: disk almost fullusing System;
// Before: every caller decides which concrete class to build, and how.public interface INotifier { void Send(string to, string text); }
public sealed class EmailNotifier(string smtpHost) : INotifier{ public void Send(string to, string text) => Console.WriteLine($"email via {smtpHost} to {to}: {text}");}
public sealed class SmsNotifier(string apiKey) : INotifier{ public void Send(string to, string text) => Console.WriteLine($"sms to {to}: {text}");}
public sealed class OrderService{ public void Confirm(string channel, string user) { // flaw 1: the same switch, with the same settings, is copied into every caller. // flaw 2: OrderService now depends on every concrete notifier and its constructor. INotifier n = channel switch { "email" => new EmailNotifier("smtp.internal"), "sms" => new SmsNotifier("sms-key"), _ => throw new ArgumentException(channel), }; n.Send(user, "order confirmed"); }}
public sealed class AlertService{ public void Page(string channel, string user) { // flaw 3: adding push notifications means finding and editing every copy. INotifier n = channel switch { "email" => new EmailNotifier("smtp.internal"), "sms" => new SmsNotifier("sms-key"), _ => throw new ArgumentException(channel), }; n.Send(user, "disk almost full"); }}
public static class Program{ public static void Main() { new OrderService().Confirm("email", "ana"); // email via smtp.internal to ana: order confirmed new AlertService().Page("sms", "ops"); // sms to ops: disk almost full }}package main
import "fmt"
// Before: every caller decides which concrete type to build, and how.type Notifier interface{ Send(to, text string) }
type EmailNotifier struct{ SMTPHost string }
func (e EmailNotifier) Send(to, text string) { fmt.Printf("email via %s to %s: %s\n", e.SMTPHost, to, text)}
type SmsNotifier struct{ APIKey string }
func (SmsNotifier) Send(to, text string) { fmt.Printf("sms to %s: %s\n", to, text) }
func ConfirmOrder(channel, user string) error { // flaw 1: the same switch, with the same settings, is copied into every caller. // flaw 2: this function now depends on every concrete notifier and its fields. var n Notifier switch channel { case "email": n = EmailNotifier{"smtp.internal"} case "sms": n = SmsNotifier{"sms-key"} default: return fmt.Errorf("unknown channel %q", channel) } n.Send(user, "order confirmed") return nil}
func PageOps(channel, user string) error { // flaw 3: adding push notifications means finding and editing every copy. var n Notifier switch channel { case "email": n = EmailNotifier{"smtp.internal"} case "sms": n = SmsNotifier{"sms-key"} default: return fmt.Errorf("unknown channel %q", channel) } n.Send(user, "disk almost full") return nil}
func main() { ConfirmOrder("email", "ana") // email via smtp.internal to ana: order confirmed PageOps("sms", "ops") // sms to ops: disk almost full}// Before: every caller decides which concrete class to build, and how.interface Notifier { send(to: string, text: string): void;}
class EmailNotifier implements Notifier { constructor(private readonly smtpHost: string) {} send(to: string, text: string): void { console.log(`email via ${this.smtpHost} to ${to}: ${text}`); }}
class SmsNotifier implements Notifier { constructor(private readonly apiKey: string) {} send(to: string, text: string): void { console.log(`sms to ${to}: ${text}`); }}
class OrderService { confirm(channel: string, user: string): void { // flaw 1: the same switch, with the same settings, is copied into every caller. // flaw 2: OrderService now depends on every concrete notifier and its constructor. let n: Notifier; if (channel === 'email') n = new EmailNotifier('smtp.internal'); else if (channel === 'sms') n = new SmsNotifier('sms-key'); else throw new Error(channel); n.send(user, 'order confirmed'); }}
class AlertService { page(channel: string, user: string): void { // flaw 3: adding push notifications means finding and editing every copy. let n: Notifier; if (channel === 'email') n = new EmailNotifier('smtp.internal'); else if (channel === 'sms') n = new SmsNotifier('sms-key'); else throw new Error(channel); n.send(user, 'disk almost full'); }}
new OrderService().confirm('email', 'ana'); // email via smtp.internal to ana: order confirmednew AlertService().page('sms', 'ops'); // sms to ops: disk almost fullThe same branch, with the same settings, is copied into every service. Each one imports every notifier class. Adding push means finding every copy, and the one that gets missed fails only when a customer picks push.
With a factory
Section titled “With a factory”Move creation into one object. It maps a channel name to a function that builds that channel’s notifier, and services ask it by name:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Register a channel, then ask the service to use it:
Startup code (composition root)
factory.register("email", ...);
factory.register("sms", ...);Registry
- sms
OrderService 0 edits
Pick a channel and confirm. Then register a new one and try again.
import java.util.HashMap;import java.util.Map;import java.util.function.Supplier;
interface Notifier { void send(String to, String text); }
class EmailNotifier implements Notifier { private final String smtpHost; EmailNotifier(String smtpHost) { this.smtpHost = smtpHost; } public void send(String to, String text) { System.out.println("email via " + smtpHost + " to " + to + ": " + text); }}
class SmsNotifier implements Notifier { private final String apiKey; SmsNotifier(String apiKey) { this.apiKey = apiKey; } public void send(String to, String text) { System.out.println("sms to " + to + ": " + text); }}
// A registry factory: one place that knows names, concrete classes and their settings.// Callers ask for "email" and get a Notifier; they never see a constructor.class NotifierFactory { // Name -> how to build one. A Supplier instead of a switch, so a new channel is a // register() call at startup, not an edit to this class. private final Map<String, Supplier<Notifier>> makers = new HashMap<>();
void register(String name, Supplier<Notifier> maker) { // Fail loudly on duplicates: two modules silently fighting over "sms" is a bug. if (makers.putIfAbsent(name, maker) != null) throw new IllegalStateException("already registered: " + name); }
Notifier create(String name) { Supplier<Notifier> maker = makers.get(name); if (maker == null) throw new IllegalArgumentException("unknown channel: " + name); return maker.get(); }}
// Callers depend on the factory and the interface, nothing else.class OrderService { private final NotifierFactory notifiers; OrderService(NotifierFactory notifiers) { this.notifiers = notifiers; } void confirm(String channel, String user) { notifiers.create(channel).send(user, "order confirmed"); }}
class Main { public static void main(String[] args) { // Composition root: the only code that names concrete classes and their settings. NotifierFactory factory = new NotifierFactory(); factory.register("email", () -> new EmailNotifier("smtp.internal")); factory.register("sms", () -> new SmsNotifier("sms-key")); // A new channel is one line here. OrderService doesn't change. factory.register("push", () -> (to, text) -> System.out.println("push to " + to + ": " + text));
OrderService orders = new OrderService(factory); orders.confirm("email", "ana"); // email via smtp.internal to ana: order confirmed orders.confirm("push", "ana"); // push to ana: order confirmed }}from typing import Callable, Protocol
class Notifier(Protocol): def send(self, to: str, text: str) -> None: ...
class EmailNotifier: def __init__(self, smtp_host: str): self.smtp_host = smtp_host
def send(self, to: str, text: str) -> None: print(f"email via {self.smtp_host} to {to}: {text}")
class SmsNotifier: def __init__(self, api_key: str): self.api_key = api_key
def send(self, to: str, text: str) -> None: print(f"sms to {to}: {text}")
class PushNotifier: def send(self, to: str, text: str) -> None: print(f"push to {to}: {text}")
# A registry factory: one place that knows names, concrete classes and their settings.# Callers ask for "email" and get a Notifier; they never see a constructor.class NotifierFactory: def __init__(self): # Name -> how to build one. In Python a class is already a callable that builds # instances, so a dict of callables is the whole mechanism; no switch to edit. self._makers: dict[str, Callable[[], Notifier]] = {}
def register(self, name: str, maker: Callable[[], Notifier]) -> None: # Fail loudly on duplicates: two modules silently fighting over "sms" is a bug. if name in self._makers: raise ValueError(f"already registered: {name}") self._makers[name] = maker
def create(self, name: str) -> Notifier: if name not in self._makers: raise ValueError(f"unknown channel: {name}") return self._makers[name]()
# Callers depend on the factory and the protocol, nothing else.class OrderService: def __init__(self, notifiers: NotifierFactory): self._notifiers = notifiers
def confirm(self, channel: str, user: str) -> None: self._notifiers.create(channel).send(user, "order confirmed")
if __name__ == "__main__": # Composition root: the only code that names concrete classes and their settings. factory = NotifierFactory() factory.register("email", lambda: EmailNotifier("smtp.internal")) factory.register("sms", lambda: SmsNotifier("sms-key")) # A new channel is one line here. OrderService doesn't change. A class with no # arguments is its own maker. factory.register("push", PushNotifier)
orders = OrderService(factory) orders.confirm("email", "ana") # email via smtp.internal to ana: order confirmed orders.confirm("push", "ana") # push to ana: order confirmedusing System;using System.Collections.Generic;
public interface INotifier { void Send(string to, string text); }
public sealed class EmailNotifier(string smtpHost) : INotifier{ public void Send(string to, string text) => Console.WriteLine($"email via {smtpHost} to {to}: {text}");}
public sealed class SmsNotifier(string apiKey) : INotifier{ public void Send(string to, string text) => Console.WriteLine($"sms to {to}: {text}");}
public sealed class PushNotifier : INotifier{ public void Send(string to, string text) => Console.WriteLine($"push to {to}: {text}");}
// A registry factory: one place that knows names, concrete classes and their settings.// Callers ask for "email" and get an INotifier; they never see a constructor.public sealed class NotifierFactory{ // Name -> how to build one. A Func instead of a switch, so a new channel is a // Register() call at startup, not an edit to this class. private readonly Dictionary<string, Func<INotifier>> _makers = new();
public void Register(string name, Func<INotifier> maker) { // Fail loudly on duplicates: two modules silently fighting over "sms" is a bug. if (!_makers.TryAdd(name, maker)) throw new InvalidOperationException($"already registered: {name}"); }
public INotifier Create(string name) => _makers.TryGetValue(name, out var maker) ? maker() : throw new ArgumentException($"unknown channel: {name}");}
// Callers depend on the factory and the interface, nothing else.public sealed class OrderService(NotifierFactory notifiers){ public void Confirm(string channel, string user) => notifiers.Create(channel).Send(user, "order confirmed");}
public static class Program{ public static void Main() { // Composition root: the only code that names concrete classes and their settings. var factory = new NotifierFactory(); factory.Register("email", () => new EmailNotifier("smtp.internal")); factory.Register("sms", () => new SmsNotifier("sms-key")); // A new channel is one line here. OrderService doesn't change. factory.Register("push", () => new PushNotifier());
var orders = new OrderService(factory); orders.Confirm("email", "ana"); // email via smtp.internal to ana: order confirmed orders.Confirm("push", "ana"); // push to ana: order confirmed }}package main
import "fmt"
type Notifier interface{ Send(to, text string) }
type EmailNotifier struct{ SMTPHost string }
func (e EmailNotifier) Send(to, text string) { fmt.Printf("email via %s to %s: %s\n", e.SMTPHost, to, text)}
type SmsNotifier struct{ APIKey string }
func (SmsNotifier) Send(to, text string) { fmt.Printf("sms to %s: %s\n", to, text) }
type PushNotifier struct{}
func (PushNotifier) Send(to, text string) { fmt.Printf("push to %s: %s\n", to, text) }
// NotifierFactory is a registry: one place that knows names, concrete types and their// settings. Callers ask for "email" and get a Notifier; they never see a struct literal.// (Go has no constructors, so New... functions are already small factories; this one// adds choosing by name.)type NotifierFactory struct { // Name -> how to build one. A func instead of a switch, so a new channel is a // Register call at startup, not an edit to this type. makers map[string]func() Notifier}
func NewNotifierFactory() *NotifierFactory { return &NotifierFactory{makers: map[string]func() Notifier{}}}
func (f *NotifierFactory) Register(name string, maker func() Notifier) error { // Fail loudly on duplicates: two packages silently fighting over "sms" is a bug. if _, dup := f.makers[name]; dup { return fmt.Errorf("already registered: %s", name) } f.makers[name] = maker return nil}
func (f *NotifierFactory) Create(name string) (Notifier, error) { maker, ok := f.makers[name] if !ok { return nil, fmt.Errorf("unknown channel: %s", name) } return maker(), nil}
// OrderService depends on the factory and the interface, nothing else.type OrderService struct{ notifiers *NotifierFactory }
func (s OrderService) Confirm(channel, user string) error { n, err := s.notifiers.Create(channel) if err != nil { return err } n.Send(user, "order confirmed") return nil}
func main() { // Composition root: the only code that names concrete types and their settings. factory := NewNotifierFactory() factory.Register("email", func() Notifier { return EmailNotifier{"smtp.internal"} }) factory.Register("sms", func() Notifier { return SmsNotifier{"sms-key"} }) // A new channel is one line here. OrderService doesn't change. factory.Register("push", func() Notifier { return PushNotifier{} })
orders := OrderService{factory} orders.Confirm("email", "ana") // email via smtp.internal to ana: order confirmed orders.Confirm("push", "ana") // push to ana: order confirmed}interface Notifier { send(to: string, text: string): void;}
class EmailNotifier implements Notifier { constructor(private readonly smtpHost: string) {} send(to: string, text: string): void { console.log(`email via ${this.smtpHost} to ${to}: ${text}`); }}
class SmsNotifier implements Notifier { constructor(private readonly apiKey: string) {} send(to: string, text: string): void { console.log(`sms to ${to}: ${text}`); }}
// A registry factory: one place that knows names, concrete classes and their settings.// Callers ask for "email" and get a Notifier; they never see a constructor.class NotifierFactory { // Name -> how to build one. A function instead of a switch, so a new channel is a // register() call at startup, not an edit to this class. private readonly makers = new Map<string, () => Notifier>();
register(name: string, maker: () => Notifier): void { // Fail loudly on duplicates: two modules silently fighting over "sms" is a bug. if (this.makers.has(name)) throw new Error(`already registered: ${name}`); this.makers.set(name, maker); }
create(name: string): Notifier { const maker = this.makers.get(name); if (!maker) throw new Error(`unknown channel: ${name}`); return maker(); }}
// Callers depend on the factory and the interface, nothing else.class OrderService { constructor(private readonly notifiers: NotifierFactory) {} confirm(channel: string, user: string): void { this.notifiers.create(channel).send(user, 'order confirmed'); }}
// Composition root: the only code that names concrete classes and their settings.const factory = new NotifierFactory();factory.register('email', () => new EmailNotifier('smtp.internal'));factory.register('sms', () => new SmsNotifier('sms-key'));// A new channel is one line here. OrderService doesn't change. Any object with send() fits.factory.register('push', () => ({ send: (to, text) => console.log(`push to ${to}: ${text}`) }));
const orders = new OrderService(factory);orders.confirm('email', 'ana'); // email via smtp.internal to ana: order confirmedorders.confirm('push', 'ana'); // push to ana: order confirmedWhat to notice
Section titled “What to notice”- Callers never say
new.OrderServiceknowsNotifierFactoryandNotifier. It compiles withoutSmsNotifier, and a test can hand it a factory that builds fakes. - A registry beats a switch. A
switchinside the factory moves the problem into one place. That’s already a big improvement. A map from name to builder goes further. A new channel is aregistercall at startup, and the factory itself is never edited. That’s the open-closed principle applied to creation. - Settings live with the registration. The SMTP host and API key appear once, in the composition root, not in every caller. (The composition root is the startup code that wires the app together.)
- Fail loudly. An unknown name throws
unknown channel: slack, and registering a name twice throws too. Silently returningnullor a default channel hides configuration bugs until a customer finds them.
We’re adding Slack notifications. Walk me through what changes.
Whether the answer is “a SlackNotifier class and one register line at startup”, with no
service edited. Adding a new else if in each service is the red flag, and so is a factory that
returns null for unknown names.
Services shouldn’t know which notifier classes exist or how they’re configured. I’ll put a factory in front: it maps a channel name to a builder, services ask it by name and get a Notifier back. A new channel is one registration at startup.
Payment providers by region
- Abstract factory
- Parts that must match
- Choosing the family in one place
A checkout charges cards through Acme in the US. Since last quarter, it charges through Globex in the EU. Checkout needs two parts from the provider: a charger, and a refunder for cancellations. A charge made through one provider can only be refunded through the same provider. Each provider rejects ids it didn’t issue.
Without the pattern
Section titled “Without the pattern”Checkout picks each part separately, with one branch per part:
// Before: checkout picks the charger and the refunder separately, one switch each.interface Charger { String charge(int cents); } // returns the provider's charge idinterface Refunder { void refund(String chargeId); }
class AcmeCharger implements Charger { public String charge(int cents) { return "acme_ch_1"; }}class AcmeRefunder implements Refunder { public void refund(String id) { if (!id.startsWith("acme_")) throw new IllegalArgumentException("not an Acme charge: " + id); System.out.println("Acme refunded " + id); }}class GlobexCharger implements Charger { public String charge(int cents) { return "gx-1"; }}class GlobexRefunder implements Refunder { public void refund(String id) { if (!id.startsWith("gx-")) throw new IllegalArgumentException("not a Globex charge: " + id); System.out.println("Globex refunded " + id); }}
class Checkout { private final Charger charger; private final Refunder refunder;
Checkout(String region) { // flaw: two switches that must always agree, and nothing makes them. Last quarter, // EU payments moved to Globex. Someone updated the first switch and missed the second. charger = switch (region) { case "EU" -> new GlobexCharger(); default -> new AcmeCharger(); }; refunder = switch (region) { case "EU" -> new AcmeRefunder(); // still the old provider default -> new AcmeRefunder(); }; }
String pay(int cents) { return charger.charge(cents); } void cancel(String chargeId) { refunder.refund(chargeId); }}
class Main { public static void main(String[] args) { Checkout eu = new Checkout("EU"); String id = eu.pay(1999); // charged through Globex try { eu.cancel(id); // refunded through Acme } catch (IllegalArgumentException e) { // The customer was charged and can't get their money back. Found in production. System.out.println("refund failed: " + e.getMessage()); // refund failed: not an Acme charge: gx-1 } }}# Before: checkout picks the charger and the refunder separately, one branch each.class AcmeCharger: def charge(self, cents: int) -> str: return "acme_ch_1" # the provider's charge id
class AcmeRefunder: def refund(self, charge_id: str) -> None: if not charge_id.startswith("acme_"): raise ValueError(f"not an Acme charge: {charge_id}") print(f"Acme refunded {charge_id}")
class GlobexCharger: def charge(self, cents: int) -> str: return "gx-1"
class GlobexRefunder: def refund(self, charge_id: str) -> None: if not charge_id.startswith("gx-"): raise ValueError(f"not a Globex charge: {charge_id}") print(f"Globex refunded {charge_id}")
class Checkout: def __init__(self, region: str): # flaw: two branches that must always agree, and nothing makes them. Last quarter, # EU payments moved to Globex. Someone updated the first branch and missed the second. self._charger = GlobexCharger() if region == "EU" else AcmeCharger() self._refunder = AcmeRefunder() if region == "EU" else AcmeRefunder() # still the old provider
def pay(self, cents: int) -> str: return self._charger.charge(cents)
def cancel(self, charge_id: str) -> None: self._refunder.refund(charge_id)
if __name__ == "__main__": eu = Checkout("EU") charge_id = eu.pay(1999) # charged through Globex try: eu.cancel(charge_id) # refunded through Acme except ValueError as e: # The customer was charged and can't get their money back. Found in production. print(f"refund failed: {e}") # refund failed: not an Acme charge: gx-1using System;
// Before: checkout picks the charger and the refunder separately, one switch each.public interface ICharger { string Charge(int cents); } // returns the provider's charge idpublic interface IRefunder { void Refund(string chargeId); }
public sealed class AcmeCharger : ICharger { public string Charge(int cents) => "acme_ch_1"; }public sealed class AcmeRefunder : IRefunder{ public void Refund(string id) { if (!id.StartsWith("acme_")) throw new ArgumentException($"not an Acme charge: {id}"); Console.WriteLine($"Acme refunded {id}"); }}public sealed class GlobexCharger : ICharger { public string Charge(int cents) => "gx-1"; }public sealed class GlobexRefunder : IRefunder{ public void Refund(string id) { if (!id.StartsWith("gx-")) throw new ArgumentException($"not a Globex charge: {id}"); Console.WriteLine($"Globex refunded {id}"); }}
public sealed class Checkout{ private readonly ICharger _charger; private readonly IRefunder _refunder;
public Checkout(string region) { // flaw: two switches that must always agree, and nothing makes them. Last quarter, // EU payments moved to Globex. Someone updated the first switch and missed the second. _charger = region switch { "EU" => new GlobexCharger(), _ => new AcmeCharger() }; _refunder = region switch { "EU" => new AcmeRefunder(), _ => new AcmeRefunder() }; // still the old provider }
public string Pay(int cents) => _charger.Charge(cents); public void Cancel(string chargeId) => _refunder.Refund(chargeId);}
public static class Program{ public static void Main() { var eu = new Checkout("EU"); var id = eu.Pay(1999); // charged through Globex try { eu.Cancel(id); } // refunded through Acme catch (ArgumentException e) { // The customer was charged and can't get their money back. Found in production. Console.WriteLine($"refund failed: {e.Message}"); // refund failed: not an Acme charge: gx-1 } }}package main
import ( "fmt" "strings")
// Before: checkout picks the charger and the refunder separately, one switch each.type Charger interface{ Charge(cents int) string } // returns the provider's charge idtype Refunder interface{ Refund(chargeID string) error }
type AcmeCharger struct{}
func (AcmeCharger) Charge(int) string { return "acme_ch_1" }
type AcmeRefunder struct{}
func (AcmeRefunder) Refund(id string) error { if !strings.HasPrefix(id, "acme_") { return fmt.Errorf("not an Acme charge: %s", id) } fmt.Println("Acme refunded", id) return nil}
type GlobexCharger struct{}
func (GlobexCharger) Charge(int) string { return "gx-1" }
type GlobexRefunder struct{}
func (GlobexRefunder) Refund(id string) error { if !strings.HasPrefix(id, "gx-") { return fmt.Errorf("not a Globex charge: %s", id) } fmt.Println("Globex refunded", id) return nil}
type Checkout struct { charger Charger refunder Refunder}
func NewCheckout(region string) Checkout { // flaw: two switches that must always agree, and nothing makes them. Last quarter, // EU payments moved to Globex. Someone updated the first switch and missed the second. var c Checkout switch region { case "EU": c.charger = GlobexCharger{} default: c.charger = AcmeCharger{} } switch region { case "EU": c.refunder = AcmeRefunder{} // still the old provider default: c.refunder = AcmeRefunder{} } return c}
func (c Checkout) Pay(cents int) string { return c.charger.Charge(cents) }func (c Checkout) Cancel(id string) error { return c.refunder.Refund(id) }
func main() { eu := NewCheckout("EU") id := eu.Pay(1999) // charged through Globex if err := eu.Cancel(id); err != nil { // The customer was charged and can't get their money back. Found in production. fmt.Println("refund failed:", err) // refund failed: not an Acme charge: gx-1 }}// Before: checkout picks the charger and the refunder separately, one branch each.interface Charger { charge(cents: number): string; // returns the provider's charge id}interface Refunder { refund(chargeId: string): void;}
class AcmeCharger implements Charger { charge(): string { return 'acme_ch_1'; }}class AcmeRefunder implements Refunder { refund(id: string): void { if (!id.startsWith('acme_')) throw new Error(`not an Acme charge: ${id}`); console.log(`Acme refunded ${id}`); }}class GlobexCharger implements Charger { charge(): string { return 'gx-1'; }}class GlobexRefunder implements Refunder { refund(id: string): void { if (!id.startsWith('gx-')) throw new Error(`not a Globex charge: ${id}`); console.log(`Globex refunded ${id}`); }}
class Checkout { private readonly charger: Charger; private readonly refunder: Refunder;
constructor(region: string) { // flaw: two branches that must always agree, and nothing makes them. Last quarter, // EU payments moved to Globex. Someone updated the first branch and missed the second. this.charger = region === 'EU' ? new GlobexCharger() : new AcmeCharger(); this.refunder = region === 'EU' ? new AcmeRefunder() : new AcmeRefunder(); // still the old provider }
pay(cents: number): string { return this.charger.charge(cents); } cancel(chargeId: string): void { this.refunder.refund(chargeId); }}
const eu = new Checkout('EU');const id = eu.pay(1999); // charged through Globextry { eu.cancel(id); // refunded through Acme} catch (e) { // The customer was charged and can't get their money back. Found in production. console.log(`refund failed: ${(e as Error).message}`); // refund failed: not an Acme charge: gx-1}When EU moved to Globex, someone updated the charger’s branch and missed the refunder’s. Every EU charge now goes through Globex and every EU refund through Acme, which rejects it. The code compiles, each part works on its own, and the bug appears only when an EU customer cancels.
With an abstract factory
Section titled “With an abstract factory”Make the family the unit of choice. A PaymentProvider creates every part checkout needs, and
each provider class creates parts that belong together. Checkout receives one provider and
takes all its parts from it:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Switch between the two designs and run an EU refund:
// Abstract factory: build a family of objects that must match. A charge made through// one payment provider can only be refunded through the same provider, so the// charger and the refunder must always come from the same family.interface Charger { String charge(int cents); } // returns the provider's charge idinterface Refunder { void refund(String chargeId); }
// The abstract factory: one method per product in the family.interface PaymentProvider { Charger charger(); Refunder refunder();}
// The Acme family.class AcmeCharger implements Charger { private int next = 1; public String charge(int cents) { return "acme_ch_" + next++; }}class AcmeRefunder implements Refunder { public void refund(String chargeId) { // Each provider only understands its own ids. Mixing families fails here, at run time, // in production. The factory exists so this branch never runs. if (!chargeId.startsWith("acme_")) throw new IllegalArgumentException("not an Acme charge: " + chargeId); System.out.println("Acme refunded " + chargeId); }}class AcmeProvider implements PaymentProvider { public Charger charger() { return new AcmeCharger(); } public Refunder refunder() { return new AcmeRefunder(); }}
// The Globex family.class GlobexCharger implements Charger { private int next = 1; public String charge(int cents) { return "gx-" + next++; }}class GlobexRefunder implements Refunder { public void refund(String chargeId) { if (!chargeId.startsWith("gx-")) throw new IllegalArgumentException("not a Globex charge: " + chargeId); System.out.println("Globex refunded " + chargeId); }}class GlobexProvider implements PaymentProvider { public Charger charger() { return new GlobexCharger(); } public Refunder refunder() { return new GlobexRefunder(); }}
// Checkout receives one provider and takes both products from it, so it can't pair an// Acme charger with a Globex refunder. It never names a concrete class.class Checkout { private final Charger charger; private final Refunder refunder;
Checkout(PaymentProvider provider) { this.charger = provider.charger(); this.refunder = provider.refunder(); }
String pay(int cents) { return charger.charge(cents); } void cancel(String chargeId) { refunder.refund(chargeId); }}
class Main { // The one place that maps a region to a provider. Moving EU to Globex is one line here, // and it moves the charger and the refunder together. static PaymentProvider forRegion(String region) { return switch (region) { case "EU" -> new GlobexProvider(); default -> new AcmeProvider(); }; }
public static void main(String[] args) { Checkout eu = new Checkout(forRegion("EU")); eu.cancel(eu.pay(1999)); // Globex refunded gx-1
Checkout us = new Checkout(forRegion("US")); us.cancel(us.pay(1999)); // Acme refunded acme_ch_1 }}from typing import Protocol
# Abstract factory: build a family of objects that must match. A charge made through# one payment provider can only be refunded through the same provider, so the# charger and the refunder must always come from the same family.class Charger(Protocol): def charge(self, cents: int) -> str: ... # returns the provider's charge id
class Refunder(Protocol): def refund(self, charge_id: str) -> None: ...
# The abstract factory: one method per product in the family.class PaymentProvider(Protocol): def charger(self) -> Charger: ... def refunder(self) -> Refunder: ...
# The Acme family.class AcmeCharger: def __init__(self): self._next = 1
def charge(self, cents: int) -> str: charge_id = f"acme_ch_{self._next}" self._next += 1 return charge_id
class AcmeRefunder: def refund(self, charge_id: str) -> None: # Each provider only understands its own ids. Mixing families fails here, at run # time, in production. The factory exists so this branch never runs. if not charge_id.startswith("acme_"): raise ValueError(f"not an Acme charge: {charge_id}") print(f"Acme refunded {charge_id}")
class AcmeProvider: def charger(self) -> Charger: return AcmeCharger()
def refunder(self) -> Refunder: return AcmeRefunder()
# The Globex family.class GlobexCharger: def __init__(self): self._next = 1
def charge(self, cents: int) -> str: charge_id = f"gx-{self._next}" self._next += 1 return charge_id
class GlobexRefunder: def refund(self, charge_id: str) -> None: if not charge_id.startswith("gx-"): raise ValueError(f"not a Globex charge: {charge_id}") print(f"Globex refunded {charge_id}")
class GlobexProvider: def charger(self) -> Charger: return GlobexCharger()
def refunder(self) -> Refunder: return GlobexRefunder()
# Checkout receives one provider and takes both products from it, so it can't pair an# Acme charger with a Globex refunder. It never names a concrete class.class Checkout: def __init__(self, provider: PaymentProvider): self._charger = provider.charger() self._refunder = provider.refunder()
def pay(self, cents: int) -> str: return self._charger.charge(cents)
def cancel(self, charge_id: str) -> None: self._refunder.refund(charge_id)
# The one place that maps a region to a provider. Moving EU to Globex is one line here,# and it moves the charger and the refunder together.def for_region(region: str) -> PaymentProvider: return GlobexProvider() if region == "EU" else AcmeProvider()
if __name__ == "__main__": eu = Checkout(for_region("EU")) eu.cancel(eu.pay(1999)) # Globex refunded gx-1
us = Checkout(for_region("US")) us.cancel(us.pay(1999)) # Acme refunded acme_ch_1using System;
// Abstract factory: build a family of objects that must match. A charge made through// one payment provider can only be refunded through the same provider, so the// charger and the refunder must always come from the same family.public interface ICharger { string Charge(int cents); } // returns the provider's charge idpublic interface IRefunder { void Refund(string chargeId); }
// The abstract factory: one method per product in the family.public interface IPaymentProvider{ ICharger Charger(); IRefunder Refunder();}
// The Acme family.public sealed class AcmeCharger : ICharger{ private int _next = 1; public string Charge(int cents) => $"acme_ch_{_next++}";}public sealed class AcmeRefunder : IRefunder{ public void Refund(string id) { // Each provider only understands its own ids. Mixing families fails here, at run time, // in production. The factory exists so this branch never runs. if (!id.StartsWith("acme_")) throw new ArgumentException($"not an Acme charge: {id}"); Console.WriteLine($"Acme refunded {id}"); }}public sealed class AcmeProvider : IPaymentProvider{ public ICharger Charger() => new AcmeCharger(); public IRefunder Refunder() => new AcmeRefunder();}
// The Globex family.public sealed class GlobexCharger : ICharger{ private int _next = 1; public string Charge(int cents) => $"gx-{_next++}";}public sealed class GlobexRefunder : IRefunder{ public void Refund(string id) { if (!id.StartsWith("gx-")) throw new ArgumentException($"not a Globex charge: {id}"); Console.WriteLine($"Globex refunded {id}"); }}public sealed class GlobexProvider : IPaymentProvider{ public ICharger Charger() => new GlobexCharger(); public IRefunder Refunder() => new GlobexRefunder();}
// Checkout receives one provider and takes both products from it, so it can't pair an// Acme charger with a Globex refunder. It never names a concrete class.public sealed class Checkout(IPaymentProvider provider){ private readonly ICharger _charger = provider.Charger(); private readonly IRefunder _refunder = provider.Refunder();
public string Pay(int cents) => _charger.Charge(cents); public void Cancel(string chargeId) => _refunder.Refund(chargeId);}
public static class Program{ // The one place that maps a region to a provider. Moving EU to Globex is one line here, // and it moves the charger and the refunder together. static IPaymentProvider ForRegion(string region) => region == "EU" ? new GlobexProvider() : new AcmeProvider();
public static void Main() { var eu = new Checkout(ForRegion("EU")); eu.Cancel(eu.Pay(1999)); // Globex refunded gx-1
var us = new Checkout(ForRegion("US")); us.Cancel(us.Pay(1999)); // Acme refunded acme_ch_1 }}package main
import ( "fmt" "strings")
// Abstract factory: build a family of objects that must match. A charge made through// one payment provider can only be refunded through the same provider, so the charger// and the refunder must always come from the same family.type Charger interface{ Charge(cents int) string } // returns the provider's charge idtype Refunder interface{ Refund(chargeID string) error }
// PaymentProvider is the abstract factory: one method per product in the family.type PaymentProvider interface { Charger() Charger Refunder() Refunder}
// The Acme family.type AcmeCharger struct{ next int }
func (c *AcmeCharger) Charge(int) string { c.next++; return fmt.Sprintf("acme_ch_%d", c.next) }
type AcmeRefunder struct{}
func (AcmeRefunder) Refund(id string) error { // Each provider only understands its own ids. Mixing families fails here, at run // time, in production. The factory exists so this branch never runs. if !strings.HasPrefix(id, "acme_") { return fmt.Errorf("not an Acme charge: %s", id) } fmt.Println("Acme refunded", id) return nil}
type AcmeProvider struct{}
func (AcmeProvider) Charger() Charger { return &AcmeCharger{} }func (AcmeProvider) Refunder() Refunder { return AcmeRefunder{} }
// The Globex family.type GlobexCharger struct{ next int }
func (c *GlobexCharger) Charge(int) string { c.next++; return fmt.Sprintf("gx-%d", c.next) }
type GlobexRefunder struct{}
func (GlobexRefunder) Refund(id string) error { if !strings.HasPrefix(id, "gx-") { return fmt.Errorf("not a Globex charge: %s", id) } fmt.Println("Globex refunded", id) return nil}
type GlobexProvider struct{}
func (GlobexProvider) Charger() Charger { return &GlobexCharger{} }func (GlobexProvider) Refunder() Refunder { return GlobexRefunder{} }
// Checkout receives one provider and takes both products from it, so it can't pair an// Acme charger with a Globex refunder. It never names a concrete type.type Checkout struct { charger Charger refunder Refunder}
func NewCheckout(p PaymentProvider) Checkout { return Checkout{p.Charger(), p.Refunder()} }
func (c Checkout) Pay(cents int) string { return c.charger.Charge(cents) }func (c Checkout) Cancel(id string) error { return c.refunder.Refund(id) }
// ForRegion is the one place that maps a region to a provider. Moving EU to Globex is one// line here, and it moves the charger and the refunder together.func ForRegion(region string) PaymentProvider { if region == "EU" { return GlobexProvider{} } return AcmeProvider{}}
func main() { eu := NewCheckout(ForRegion("EU")) eu.Cancel(eu.Pay(1999)) // Globex refunded gx-1
us := NewCheckout(ForRegion("US")) us.Cancel(us.Pay(1999)) // Acme refunded acme_ch_1}// Abstract factory: build a family of objects that must match. A charge made through// one payment provider can only be refunded through the same provider, so the// charger and the refunder must always come from the same family.interface Charger { charge(cents: number): string; // returns the provider's charge id}interface Refunder { refund(chargeId: string): void;}
// The abstract factory: one method per product in the family.interface PaymentProvider { charger(): Charger; refunder(): Refunder;}
// The Acme family.class AcmeCharger implements Charger { private next = 1; charge(): string { return `acme_ch_${this.next++}`; }}class AcmeRefunder implements Refunder { refund(id: string): void { // Each provider only understands its own ids. Mixing families fails here, at run time, // in production. The factory exists so this branch never runs. if (!id.startsWith('acme_')) throw new Error(`not an Acme charge: ${id}`); console.log(`Acme refunded ${id}`); }}class AcmeProvider implements PaymentProvider { charger(): Charger { return new AcmeCharger(); } refunder(): Refunder { return new AcmeRefunder(); }}
// The Globex family.class GlobexCharger implements Charger { private next = 1; charge(): string { return `gx-${this.next++}`; }}class GlobexRefunder implements Refunder { refund(id: string): void { if (!id.startsWith('gx-')) throw new Error(`not a Globex charge: ${id}`); console.log(`Globex refunded ${id}`); }}class GlobexProvider implements PaymentProvider { charger(): Charger { return new GlobexCharger(); } refunder(): Refunder { return new GlobexRefunder(); }}
// Checkout receives one provider and takes both products from it, so it can't pair an// Acme charger with a Globex refunder. It never names a concrete class.class Checkout { private readonly charger: Charger; private readonly refunder: Refunder; constructor(provider: PaymentProvider) { this.charger = provider.charger(); this.refunder = provider.refunder(); } pay(cents: number): string { return this.charger.charge(cents); } cancel(chargeId: string): void { this.refunder.refund(chargeId); }}
// The one place that maps a region to a provider. Moving EU to Globex is one line here,// and it moves the charger and the refunder together.const forRegion = (region: string): PaymentProvider => (region === 'EU' ? new GlobexProvider() : new AcmeProvider());
const eu = new Checkout(forRegion('EU'));eu.cancel(eu.pay(1999)); // Globex refunded gx-1
const us = new Checkout(forRegion('US'));us.cancel(us.pay(1999)); // Acme refunded acme_ch_1What to notice
Section titled “What to notice”- The family is chosen once.
forRegionis the only code that maps a region to a provider. Moving EU to Globex is one line, and it moves every part together, so a mismatched pair can’t be built. - One method per part. The abstract factory has
charger()andrefunder(). Say a provider gains a third part, like a dispute handler. The interface gains a method, and the compiler lists every provider that must implement it. That’s the safety, and it’s also the cost. Adding a part touches every family. Adding a family touches nothing else. - Checkout never names a provider. It takes a
PaymentProviderand asks for parts. A test passes a fake provider whose parts record calls, without touching a payment API. - It’s two levels of factory.
forRegionis a simple factory that picks a family. Each provider is an abstract factory that builds the parts. Real code often stacks them like this.
We support two payment providers per region now, and refunds are failing in Europe. How would you make this class of bug impossible?
Whether you see that the charger and refunder are a family that must vary together. And whether you reach for one object that builds both, chosen in one place. The red flag is adding a check that the ids match at refund time. That finds the bug in production, instead of making it impossible to build.
Database clients by URL
- Choosing a driver by URL scheme
- Caching what it creates
- Thread-safe get-or-create
- Owning cleanup
A service talks to Postgres for orders and Redis for sessions, and its configuration names
each store by URL: postgres://db/orders, redis://cache:6379. Opening a client is expensive:
it dials the server and fills a connection pool. The database allows a fixed number of
connections in total, so each URL must get one shared client, opened once and closed on
shutdown. In tests, the same URLs should open in-memory fakes.
This factory doesn’t just choose a class. It owns what it creates: it caches one client per URL, makes sure two threads can’t both open one, and closes everything at the end. That last part is the one simple factories never need.
The race
Section titled “The race”The obvious cache is “look it up. If it’s missing, create it and store it”. On a cold start, many requests arrive at once. Every request that arrives while the first pool is still opening also finds the cache empty:
- req 1·
- req 2·
- req 3·
- req 4·
- req 5·
- req 6·
- req 7·
- req 8·
Connection pools to postgres://db/orders
Start the requests. Opening a pool takes a while, and requests keep arriving meanwhile.
With a factory that owns its products
Section titled “With a factory that owns its products”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- Color key
- Database
- Facade
import java.net.URI;import java.util.*;import java.util.concurrent.*;
// A store client: one open connection pool to a database or cache.interface Store extends AutoCloseable { String describe();
@Override void close(); // no checked exception, so shutdown code stays simple}
// One driver per URL scheme. Opening is expensive: it dials the server and fills a pool.interface StoreDriver { Store open(URI url);}
final class PooledStore implements Store { private final URI url;
PooledStore(URI url) { this.url = url; System.out.println("connecting to " + url); }
public String describe() { return "pool for " + url; }
public void close() { System.out.println("closing " + url); }}
// Fake used by tests: same interface, no network.final class MemoryStore implements Store { private final URI url;
MemoryStore(URI url) { this.url = url; }
public String describe() { return "memory store for " + url; }
public void close() {}}
// The factory chooses a driver by URL scheme and owns what it creates: one shared// client per URL, opened once, closed at shutdown. Callers only ever say get(url).final class StoreFactory implements AutoCloseable { private final Map<String, StoreDriver> drivers = new ConcurrentHashMap<>(); // computeIfAbsent runs the open function at most once per key, atomically, so eight // threads asking for the same URL on a cold start get one pool, not eight. // Tradeoff: it holds a lock on that map bin while a slow server is dialled; fine for a // handful of URLs opened at startup, not for thousands of keys. private final ConcurrentHashMap<String, Store> clients = new ConcurrentHashMap<>(); // Remembered in open order so shutdown closes them in reverse, like nested try blocks. private final Deque<Store> opened = new ConcurrentLinkedDeque<>(); private volatile boolean closed;
void register(String scheme, StoreDriver driver) { // Duplicates fail loudly: two modules silently fighting over "postgres" is a bug. if (drivers.putIfAbsent(scheme, driver) != null) { throw new IllegalStateException("driver already registered: " + scheme); } }
Store get(String url) { if (closed) throw new IllegalStateException("factory is closed"); return clients.computeIfAbsent(url, u -> { URI uri = URI.create(u); StoreDriver driver = drivers.get(uri.getScheme()); // Fail with what the caller needs to fix it: the bad scheme and the valid ones. // A thrown exception leaves no entry behind, so a later call can try again. if (driver == null) { throw new IllegalArgumentException("no driver for '" + uri.getScheme() + "' (registered: " + new TreeSet<>(drivers.keySet()) + ")"); } Store store = driver.open(uri); opened.push(store); return store; }); }
@Override public void close() { closed = true; // One store failing to close must not leave the others open. for (Store store : opened) { try { store.close(); } catch (RuntimeException e) { System.out.println("close failed: " + e.getMessage()); } } opened.clear(); clients.clear(); }}
class Main { public static void main(String[] args) throws Exception { StoreFactory stores = new StoreFactory(); stores.register("postgres", PooledStore::new); stores.register("redis", PooledStore::new);
// Eight request threads ask for the same database at once, as on a cold start. ExecutorService pool = Executors.newFixedThreadPool(8); List<Future<Store>> results = new ArrayList<>(); for (int i = 0; i < 8; i++) results.add(pool.submit(() -> stores.get("postgres://db/orders"))); Set<Store> distinct = Collections.newSetFromMap(new IdentityHashMap<>()); for (Future<Store> r : results) distinct.add(r.get()); pool.shutdown(); System.out.println("clients for 8 requests: " + distinct.size()); // 1
stores.get("redis://cache:6379"); try { stores.get("mongo://reports/db"); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); // no driver for 'mongo' (registered: [postgres, redis]) } stores.close(); // closing redis..., then closing postgres...
// In a test, a fresh factory maps the same scheme to a fake. The code under test // still asks for "postgres://db/orders" and never knows. StoreFactory test = new StoreFactory(); test.register("postgres", MemoryStore::new); System.out.println(test.get("postgres://db/orders").describe()); }}import threadingfrom concurrent.futures import ThreadPoolExecutorfrom typing import Callable, Protocolfrom urllib.parse import urlsplit
# A store client: one open connection pool to a database or cache.class Store(Protocol): def describe(self) -> str: ... def close(self) -> None: ...
# One driver per URL scheme: a callable that opens a store. Opening is expensive:# it dials the server and fills a pool.StoreDriver = Callable[[str], Store]
class PooledStore: def __init__(self, url: str) -> None: self.url = url print(f"connecting to {url}")
def describe(self) -> str: return f"pool for {self.url}"
def close(self) -> None: print(f"closing {self.url}")
# Fake used by tests: same shape, no network.class MemoryStore: def __init__(self, url: str) -> None: self.url = url
def describe(self) -> str: return f"memory store for {self.url}"
def close(self) -> None: pass
# Chooses a driver by URL scheme and owns what it creates: one shared client# per URL, opened once, closed at shutdown. Callers only ever say get(url).class StoreFactory: def __init__(self) -> None: self._drivers: dict[str, StoreDriver] = {} self._clients: dict[str, Store] = {} self._opened: list[Store] = [] # in open order, so shutdown can reverse it self._closed = False # Check-then-create must be one step. Without the lock, two threads both see # "missing" and both open a pool; one of them leaks. # Tradeoff: one lock means a slow server blocks gets for other URLs while it # dials. Fine for a few URLs opened at startup; use a lock per URL beyond that. self._lock = threading.Lock()
def register(self, scheme: str, driver: StoreDriver) -> None: # Duplicates fail loudly: two modules silently fighting over "postgres" is a bug. if scheme in self._drivers: raise ValueError(f"driver already registered: {scheme}") self._drivers[scheme] = driver
def get(self, url: str) -> Store: with self._lock: if self._closed: raise RuntimeError("factory is closed") store = self._clients.get(url) if store is not None: return store scheme = urlsplit(url).scheme driver = self._drivers.get(scheme) if driver is None: # Say what to fix: the bad scheme and the valid ones. raise ValueError(f"no driver for '{scheme}' (registered: {sorted(self._drivers)})") # If open() raises, nothing is cached and a later call can try again. store = driver(url) self._clients[url] = store self._opened.append(store) return store
def close(self) -> None: with self._lock: self._closed = True # One store failing to close must not leave the others open. for store in reversed(self._opened): try: store.close() except Exception as e: print(f"close failed: {e}") self._opened.clear() self._clients.clear()
stores = StoreFactory()stores.register("postgres", PooledStore)stores.register("redis", PooledStore)
# Eight request threads ask for the same database at once, as on a cold start.with ThreadPoolExecutor(max_workers=8) as pool: got = list(pool.map(lambda _: stores.get("postgres://db/orders"), range(8)))print(f"clients for 8 requests: {len({id(s) for s in got})}") # 1
stores.get("redis://cache:6379")try: stores.get("mongo://reports/db")except ValueError as e: print(e) # no driver for 'mongo' (registered: ['postgres', 'redis'])stores.close() # closing redis..., then closing postgres...
# In a test, a fresh factory maps the same scheme to a fake. The code under test# still asks for "postgres://db/orders" and never knows.test = StoreFactory()test.register("postgres", MemoryStore)print(test.get("postgres://db/orders").describe())using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.Linq;using System.Threading.Tasks;
// A store client: one open connection pool to a database or cache.public interface IStore : IDisposable{ string Describe();}
// One driver per URL scheme. Opening is expensive: it dials the server and fills a pool.public delegate IStore StoreDriver(Uri url);
public sealed class PooledStore : IStore{ private readonly Uri _url; public PooledStore(Uri url) { _url = url; Console.WriteLine($"connecting to {url.OriginalString}"); } public string Describe() => $"pool for {_url.OriginalString}"; public void Dispose() => Console.WriteLine($"closing {_url.OriginalString}");}
// Fake used by tests: same interface, no network.public sealed class MemoryStore : IStore{ private readonly Uri _url; public MemoryStore(Uri url) => _url = url; public string Describe() => $"memory store for {_url.OriginalString}"; public void Dispose() { }}
// The factory chooses a driver by URL scheme and owns what it creates: one shared// client per URL, opened once, disposed at shutdown. Callers only ever say Get(url).public sealed class StoreFactory : IDisposable{ private readonly ConcurrentDictionary<string, StoreDriver> _drivers = new(); // GetOrAdd alone is not enough: under a race it may run the value factory twice and // throw one result away, which here means a leaked connection pool. Wrapping it in // Lazy makes the race create two cheap Lazy objects, and only the winner ever opens. private readonly ConcurrentDictionary<string, Lazy<IStore>> _clients = new(); // Remembered in open order so shutdown disposes them in reverse, like nested usings. private readonly ConcurrentStack<IStore> _opened = new(); private volatile bool _disposed;
public void Register(string scheme, StoreDriver driver) { // Duplicates fail loudly: two modules silently fighting over "postgres" is a bug. if (!_drivers.TryAdd(scheme, driver)) throw new InvalidOperationException($"driver already registered: {scheme}"); }
public IStore Get(string url) { if (_disposed) throw new ObjectDisposedException(nameof(StoreFactory)); var uri = new Uri(url); // Check the scheme before touching the cache, so a bad URL never leaves an entry. if (!_drivers.TryGetValue(uri.Scheme, out var driver)) { var known = string.Join(", ", _drivers.Keys.OrderBy(k => k)); throw new ArgumentException($"no driver for '{uri.Scheme}' (registered: {known})"); } var lazy = _clients.GetOrAdd(url, _ => new Lazy<IStore>(() => { var store = driver(uri); _opened.Push(store); return store; })); try { return lazy.Value; } catch { // Lazy caches exceptions. Remove this entry so the next call retries the open // instead of getting the same old error forever. _clients.TryRemove(new KeyValuePair<string, Lazy<IStore>>(url, lazy)); throw; } }
public void Dispose() { _disposed = true; // Newest first. One store failing to close must not leave the others open. while (_opened.TryPop(out var store)) { try { store.Dispose(); } catch (Exception e) { Console.WriteLine($"close failed: {e.Message}"); } } _clients.Clear(); }}
public static class Program{ public static void Main() { var stores = new StoreFactory(); stores.Register("postgres", url => new PooledStore(url)); stores.Register("redis", url => new PooledStore(url));
// Eight requests ask for the same database at once, as on a cold start. var tasks = Enumerable.Range(0, 8) .Select(_ => Task.Run(() => stores.Get("postgres://db/orders"))) .ToArray(); Task.WaitAll(tasks); Console.WriteLine($"clients for 8 requests: {tasks.Select(t => t.Result).Distinct().Count()}"); // 1
stores.Get("redis://cache:6379"); try { stores.Get("mongo://reports/db"); } catch (ArgumentException e) { Console.WriteLine(e.Message); // no driver for 'mongo' (registered: postgres, redis) } stores.Dispose(); // closing redis..., then closing postgres...
// In a test, a fresh factory maps the same scheme to a fake. The code under test // still asks for "postgres://db/orders" and never knows. var test = new StoreFactory(); test.Register("postgres", url => new MemoryStore(url)); Console.WriteLine(test.Get("postgres://db/orders").Describe()); }}package main
import ( "errors" "fmt" "net/url" "sort" "strings" "sync")
// Store is a store client: one open connection pool to a database or cache.type Store interface { Describe() string Close() error}
// StoreDriver opens a store for one URL scheme. Opening is expensive: it dials the// server and fills a pool.type StoreDriver func(u *url.URL) (Store, error)
type pooledStore struct{ url string }
func openPooled(u *url.URL) (Store, error) { fmt.Println("connecting to", u) return &pooledStore{u.String()}, nil}func (p *pooledStore) Describe() string { return "pool for " + p.url }func (p *pooledStore) Close() error { fmt.Println("closing", p.url); return nil }
// memoryStore is the fake used by tests: same interface, no network.type memoryStore struct{ url string }
func openMemory(u *url.URL) (Store, error) { return &memoryStore{u.String()}, nil }func (m *memoryStore) Describe() string { return "memory store for " + m.url }func (m *memoryStore) Close() error { return nil }
// StoreFactory chooses a driver by URL scheme and owns what it creates: one shared// client per URL, opened once, closed at shutdown. Callers only ever say Get(url).type StoreFactory struct { // Check-then-create must be one step. Without the mutex, two goroutines both see // "missing" and both open a pool; one of them leaks. // Tradeoff: one mutex means a slow server blocks Gets for other URLs while it dials. // Fine for a few URLs opened at startup; use a sync.Once per URL beyond that. mu sync.Mutex drivers map[string]StoreDriver clients map[string]Store opened []Store // in open order, so Close can reverse it like defer does closed bool}
func NewStoreFactory() *StoreFactory { return &StoreFactory{drivers: map[string]StoreDriver{}, clients: map[string]Store{}}}
func (f *StoreFactory) Register(scheme string, d StoreDriver) error { f.mu.Lock() defer f.mu.Unlock() // Duplicates fail loudly: two packages silently fighting over "postgres" is a bug. if _, dup := f.drivers[scheme]; dup { return fmt.Errorf("driver already registered: %s", scheme) } f.drivers[scheme] = d return nil}
func (f *StoreFactory) Get(rawURL string) (Store, error) { f.mu.Lock() defer f.mu.Unlock() if f.closed { return nil, errors.New("factory is closed") } if s, ok := f.clients[rawURL]; ok { return s, nil } u, err := url.Parse(rawURL) if err != nil { return nil, err } driver, ok := f.drivers[u.Scheme] if !ok { // Say what to fix: the bad scheme and the valid ones. names := make([]string, 0, len(f.drivers)) for name := range f.drivers { names = append(names, name) } sort.Strings(names) return nil, fmt.Errorf("no driver for '%s' (registered: %s)", u.Scheme, strings.Join(names, ", ")) } // If the driver fails, nothing is cached and a later call can try again. s, err := driver(u) if err != nil { return nil, err } f.clients[rawURL] = s f.opened = append(f.opened, s) return s, nil}
// Close closes every store, newest first. One failure doesn't leave the others open;// all errors are returned together.func (f *StoreFactory) Close() error { f.mu.Lock() defer f.mu.Unlock() f.closed = true var errs []error for i := len(f.opened) - 1; i >= 0; i-- { errs = append(errs, f.opened[i].Close()) } f.opened, f.clients = nil, map[string]Store{} return errors.Join(errs...)}
func main() { stores := NewStoreFactory() stores.Register("postgres", openPooled) stores.Register("redis", openPooled)
// Eight request goroutines ask for the same database at once, as on a cold start. var wg sync.WaitGroup got := make([]Store, 8) for i := range got { wg.Add(1) go func() { defer wg.Done() got[i], _ = stores.Get("postgres://db/orders") }() } wg.Wait() distinct := map[Store]bool{} for _, s := range got { distinct[s] = true } fmt.Println("clients for 8 requests:", len(distinct)) // 1
stores.Get("redis://cache:6379") if _, err := stores.Get("mongo://reports/db"); err != nil { fmt.Println(err) // no driver for 'mongo' (registered: postgres, redis) } stores.Close() // closing redis..., then closing postgres...
// In a test, a fresh factory maps the same scheme to a fake. The code under test // still asks for "postgres://db/orders" and never knows. test := NewStoreFactory() test.Register("postgres", openMemory) s, _ := test.Get("postgres://db/orders") fmt.Println(s.Describe())}// A store client: one open connection pool to a database or cache.interface Store { describe(): string; close(): Promise<void>;}
// One driver per URL scheme. Opening is async and expensive: it dials the server and// fills a pool.type StoreDriver = (url: URL) => Promise<Store>;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const openPooled: StoreDriver = async (url) => { console.log(`connecting to ${url.href}`); await sleep(10); // the handshake; other requests arrive while we wait return { describe: () => `pool for ${url.href}`, close: async () => console.log(`closing ${url.href}`), };};
// Fake used by tests: same interface, no network.const openMemory: StoreDriver = async (url) => ({ describe: () => `memory store for ${url.href}`, close: async () => {},});
// The factory chooses a driver by URL scheme and owns what it creates: one shared// client per URL, opened once, closed at shutdown. Callers only ever say get(url).class StoreFactory { private readonly drivers = new Map<string, StoreDriver>(); // Cache the promise, not the store. JavaScript has one thread, but get() awaits the // handshake; if we cached only finished stores, eight requests arriving during that // wait would all see "missing" and open eight pools. The promise exists right away. private readonly clients = new Map<string, Promise<Store>>(); private readonly opened: Promise<Store>[] = []; // in open order, so close() can reverse it private closed = false;
register(scheme: string, driver: StoreDriver): void { // Duplicates fail loudly: two modules silently fighting over "postgres" is a bug. if (this.drivers.has(scheme)) throw new Error(`driver already registered: ${scheme}`); this.drivers.set(scheme, driver); }
get(url: string): Promise<Store> { if (this.closed) return Promise.reject(new Error('factory is closed')); const cached = this.clients.get(url); if (cached) return cached;
const parsed = new URL(url); const scheme = parsed.protocol.slice(0, -1); // "postgres:" -> "postgres" const driver = this.drivers.get(scheme); if (!driver) { // Say what to fix: the bad scheme and the valid ones. const known = [...this.drivers.keys()].sort().join(', '); return Promise.reject(new Error(`no driver for '${scheme}' (registered: ${known})`)); } const pending = driver(parsed); // A failed open must not be cached forever, or every later call gets the old error. pending.catch(() => this.clients.delete(url)); this.clients.set(url, pending); this.opened.push(pending); return pending; }
async close(): Promise<void> { this.closed = true; // Newest first. One failure (to open or to close) mustn't leave the others open. for (const pending of [...this.opened].reverse()) { await pending.then((s) => s.close()).catch((e: Error) => console.log(`close failed: ${e.message}`)); } this.opened.length = 0; this.clients.clear(); }}
const stores = new StoreFactory();stores.register('postgres', openPooled);stores.register('redis', openPooled);
// Eight requests ask for the same database at once, as on a cold start.const got = await Promise.all(Array.from({ length: 8 }, () => stores.get('postgres://db/orders')));console.log(`clients for 8 requests: ${new Set(got).size}`); // 1
await stores.get('redis://cache:6379');try { await stores.get('mongo://reports/db');} catch (e) { console.log((e as Error).message); // no driver for 'mongo' (registered: postgres, redis)}await stores.close(); // closing redis..., then closing postgres...
// In a test, a fresh factory maps the same scheme to a fake. The code under test// still asks for "postgres://db/orders" and never knows.const test = new StoreFactory();test.register('postgres', openMemory);console.log((await test.get('postgres://db/orders')).describe());
export {};What to notice
Section titled “What to notice”- Get-or-create must be one atomic step. Java’s
computeIfAbsentruns the creator at most once per key. C#’sGetOrAdddoes not promise that, so the value is aLazy<Store>. Two racing threads may create two cheapLazywrappers, but only the stored one ever opens. Python and Go hold a lock around the check and the create. TypeScript has one thread, butgetawaits the handshake. So it caches the promise right away. If it cached only finished stores, every request that arrives during the handshake would open its own. - Failures must not be cached. If opening fails, the next call should try again. It shouldn’t
get the same old error forever. Java’s
computeIfAbsentand the Python and Go versions store nothing on failure. C#’sLazyand the TypeScript promise do cache failures. So the code removes the entry when that happens. - Errors say how to fix them.
no driver for 'mongo' (registered: postgres, redis)names the bad input and the valid options. The person reading it at 3 a.m. doesn’t need to open the source. - Whoever creates, closes. Callers never close a store they got from
get: it’s shared, and closing it would break every other user. The factory closes all of them on shutdown, newest first, and keeps going if one fails, so one bad close doesn’t leak the rest. - Tests swap the driver, not the code. A test registers
MemoryStorefor thepostgresscheme on a fresh factory. The code under test still asks forpostgres://db/orders. - The lock has a cost. Holding a single lock while a slow server is dialled also blocks gets for
other URLs. With a few URLs opened at startup, that’s fine. With many, use a lock or
sync.Onceper key. That’s whatcomputeIfAbsentandLazyalready give you.
Our service opens a new database pool on every request and we keep hitting the connection limit. Design the fix.
Whether you cache one client per database behind a factory, make get-or-create atomic, and give the factory ownership of closing. A plain “if missing, create” check (which races on cold start) is the red flag, and so is letting callers close a client they share.
The factory picks a driver by URL scheme, and keeps one client per URL. Get-or-create is atomic, so a cold start opens each pool once. Callers never close what they get, because it’s shared. The factory closes everything on shutdown, newest first.
Tradeoffs
Section titled “Tradeoffs”Use a factory in four cases. The concrete class depends on data (a setting, a request, a URL). Creation needs settings that callers shouldn’t know. Parts must match. Or created objects need caching or cleanup. Keep calling the constructor for value objects, and for classes with one implementation that nobody needs to swap.
In each language
Section titled “In each language”| Language | Usual form | Built in |
|---|---|---|
| Java | Static factory methods (of, from, getInstance), a Map<String, Supplier<T>> registry |
List.of, Calendar.getInstance, ServiceLoader |
| Python | A plain function, a dict of classes or callables, @classmethod alternative constructors |
datetime.fromisoformat, logging.getLogger |
| C# | Static Create methods, a Dictionary<string, Func<T>>, Lazy<T> for once-only |
HttpClientFactory, DbProviderFactories, ILoggerFactory |
| Go | New... functions (Go has no constructors), a map of funcs registered in init() |
database/sql drivers registered by name, image.RegisterFormat |
| TypeScript | A function returning an interface, a Map of builders, static create |
document.createElement, Array.from |
Go’s database/sql is the Hard level in the standard library. Drivers register themselves by name
in init(). sql.Open("postgres", dsn) picks one. And the returned *sql.DB is a pool, meant to
be shared and closed once.
Commonly confused with
Section titled “Commonly confused with”| Pattern | Looks like Factory because | The difference |
|---|---|---|
| Builder | Both create objects | A factory picks which object to create in one call. A builder assembles one complex object step by step. |
| Strategy | A registry of strategies looks like a registry of products | Strategy is about swapping behavior. A factory is about creating objects; it often creates the strategy. |
| Singleton | getInstance() is a factory method |
A singleton limits a class to one instance. The Hard level’s factory caches one instance per key and is itself an ordinary object. |
| Dependency injection | Both keep new out of business code |
A DI container is a general factory configured for the whole app. A hand-written factory is the same idea for one family of objects. |
| Service locator | Both hand out objects by key | Callers of a locator reach into a global to pull dependencies, which hides them. A factory is passed in, so the dependency is visible. |
Where it shows up
Section titled “Where it shows up”On this site:
- Rate limiter: a
BucketFactorycreates the right kind of bucket for each new key, per rule. - Parking lot: a factory method builds a floor with consistent ids and distances.
- Strategy: a factory reads each merchant’s settings and builds the shipping rule tree.
In real systems, you’ll find factories in:
- JDBC and
database/sqldrivers. HttpClientFactoryin .NET.- Cloud SDKs that pick a credentials provider from the environment.
- UI toolkits that create native widgets per platform (the textbook abstract factory).
- Every dependency injection container.
Interview questions
Section titled “Interview questions”Interviewer asksWhat's the difference between factory method and abstract factory?
Factory method is one overridable method. A subclass decides which single object the base class creates. Abstract factory is an object with several creation methods, one per part of a family. So parts that must match are built together. Factory method uses inheritance. Abstract factory uses composition, and is passed in like any other dependency.
Interviewer asksWhy not just use a switch statement?
A switch inside one factory is fine, and often the right first step. Creation is already in one place. A registry goes further when new types are added often, or by other modules, because adding one doesn’t edit the factory. The switch has one advantage. The compiler can check that it covers every enum value. A string registry can’t.
Interviewer asksHow do factories help testing?
Callers depend on the factory and the product interface. So a test passes a factory that returns fakes, or registers a fake driver for the same name. Nothing in the code under test changes. And no real database, payment API or SMTP server is touched.
Interviewer asksWho should close an object a factory created?
Whoever owns it. If the factory hands out a new object per call, the caller owns it, and closes it. If the factory caches and shares it, the factory owns it. Then callers must not close it, and the factory closes everything on shutdown. Mixing the two is how shared clients get closed under other users.
| Decision | We chose | We gave up |
|---|---|---|
| Where creation lives | One factory; callers never say new |
One more indirection to read through |
| How to choose | A registry of builders by name | Compile-time checking of names |
| Parts that must match | An abstract factory per family, chosen once | Adding a part touches every family |
| Expensive products | One cached client per key | The factory now has state and a lifecycle |
| Concurrent first use | Atomic get-or-create (computeIfAbsent, Lazy, lock, cached promise) |
A slow open can block other gets if the lock is shared |
| Failures | Never cached; errors list the valid options | Nothing; it’s the fix |
| Cleanup | The factory closes what it shares, newest first | Callers can’t close shared clients early |
Further reading
Section titled “Further reading”The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.