Creation in one place
When you order a pizza, you say “a large margherita”. You don’t tell the kitchen which oven to use or how to stretch the dough. The kitchen decides how to make it; you only say what you want and get a pizza back.
A factory is that kitchen for objects. Code asks for “a sender for SMS” and gets back something it can use to send. Which class that is, and how it’s set up (API keys, settings), is decided in one place.
“Factory” covers a family of ideas: a simple factory (one function that picks a concrete type), the GoF Factory Method (a creator class defers instantiation to an overridable method), static factory methods (named constructors like List.of, URL(string:)) and Abstract Factory (a family of related products). They share one move: callers depend on an abstraction and a creation function, never on concrete constructors, so the set of products can change in one place. This part covers the simple factory; the GoF form is next.
Before: Every screen decides by itself
// In the settings screen, the checkout screen, the reminders job…
let sender: NotificationSender
if channel == "sms" {
sender = SmsSender(apiKey: Secrets.smsKey)
} else if channel == "push" {
sender = PushSender(appId: Config.pushAppId)
} else {
sender = EmailSender(host: Config.smtpHost)
}// In the settings screen, the checkout screen, the reminders job…
val sender: NotificationSender = when (channel) {
"sms" -> SmsSender(apiKey = Secrets.smsKey)
"push" -> PushSender(appId = Config.pushAppId)
else -> EmailSender(host = Config.smtpHost)
}// In the settings screen, the checkout screen, the reminders job…
NotificationSender sender;
if (channel.equals("sms")) sender = new SmsSender(Secrets.smsKey());
else if (channel.equals("push")) sender = new PushSender(Config.pushAppId());
else sender = new EmailSender(Config.smtpHost());// In the settings screen, the checkout screen, the reminders job…
const sender: NotificationSender =
channel === "sms" ? new SmsSender(secrets.smsKey)
: channel === "push" ? new PushSender(config.pushAppId)
: new EmailSender(config.smtpHost);After: One factory; callers only ask
enum Channel: String { case email, sms, push }
struct SenderFactory {
let config: Config
func sender(for channel: Channel) -> any NotificationSender {
switch channel {
case .email: EmailSender(host: config.smtpHost)
case .sms: SmsSender(apiKey: config.smsKey)
case .push: PushSender(appId: config.pushAppId)
}
}
}
let sender = factory.sender(for: .sms) // callers never name SmsSenderenum class Channel { EMAIL, SMS, PUSH }
class SenderFactory(private val config: Config) {
fun sender(channel: Channel): NotificationSender = when (channel) {
Channel.EMAIL -> EmailSender(config.smtpHost)
Channel.SMS -> SmsSender(config.smsKey)
Channel.PUSH -> PushSender(config.pushAppId)
}
}
val sender = factory.sender(Channel.SMS) // callers never name SmsSenderenum Channel { EMAIL, SMS, PUSH }
final class SenderFactory {
private final Config config;
SenderFactory(Config config) { this.config = config; }
NotificationSender sender(Channel channel) {
return switch (channel) {
case EMAIL -> new EmailSender(config.smtpHost());
case SMS -> new SmsSender(config.smsKey());
case PUSH -> new PushSender(config.pushAppId());
};
}
}
NotificationSender sender = factory.sender(Channel.SMS); // callers never name SmsSendertype Channel = "email" | "sms" | "push";
class SenderFactory {
constructor(private readonly config: Config) {}
sender(channel: Channel): NotificationSender {
switch (channel) {
case "email": return new EmailSender(this.config.smtpHost);
case "sms": return new SmsSender(this.config.smsKey);
case "push": return new PushSender(this.config.pushAppId);
}
}
}
const sender = factory.sender("sms"); // callers never name SmsSenderWhat changed:
- One place decides. Adding WhatsApp means one new case in the factory, and the compiler points at it (an
enumand aswitchwithoutdefaultmust handle every case). - Callers depend on the interface (
NotificationSender), not onSmsSender; they don’t need to know about API keys or hosts either. - No silent fallback. The old
elseturned every unknown or misspelled channel into email.
Tap the line with the bug.
func sender(for channel: String) -> any NotificationSender {
switch channel {
case "sms": return SmsSender(apiKey: config.smsKey)
case "push": return PushSender(appId: config.pushAppId)
default: return EmailSender(host: config.smtpHost)
}
}fun sender(channel: String): NotificationSender = when (channel) {
"sms" -> SmsSender(config.smsKey)
"push" -> PushSender(config.pushAppId)
else -> EmailSender(config.smtpHost)
}NotificationSender sender(String channel) {
return switch (channel) {
case "sms" -> new SmsSender(config.smsKey());
case "push" -> new PushSender(config.pushAppId());
default -> new EmailSender(config.smtpHost());
};
}function sender(channel: string): NotificationSender {
switch (channel) {
case "sms": return new SmsSender(config.smsKey);
case "push": return new PushSender(config.pushAppId);
default: return new EmailSender(config.smtpHost);
}
}Hint: The server starts sending channel: "whatsapp" for users who chose it. Nobody updated this function. What happens?
Every channel the factory doesn’t know becomes email, silently: new channels, typos ("SMS"), and empty strings. The bug shows up as user complaints, not as an error. A factory should fail loudly on something it can’t build, and take a type that limits the choices in the first place:
func sender(for channel: Channel) -> any NotificationSender { // an enum: no unknown strings
switch channel {
case .email: EmailSender(host: config.smtpHost)
case .sms: SmsSender(apiKey: config.smsKey)
case .push: PushSender(appId: config.pushAppId)
}
}
// Parse strings at the boundary: Channel(rawValue: raw) returns nil for unknown values.fun sender(channel: Channel): NotificationSender = when (channel) { // exhaustive: no else
Channel.EMAIL -> EmailSender(config.smtpHost)
Channel.SMS -> SmsSender(config.smsKey)
Channel.PUSH -> PushSender(config.pushAppId)
}
// Parse strings at the boundary: Channel.entries.find { it.name.equals(raw, ignoreCase = true) }NotificationSender sender(Channel channel) { // exhaustive: no default
return switch (channel) {
case EMAIL -> new EmailSender(config.smtpHost());
case SMS -> new SmsSender(config.smsKey());
case PUSH -> new PushSender(config.pushAppId());
};
}
// Parse strings at the boundary: Channel.valueOf(raw.toUpperCase()) throws for unknown values.function sender(channel: Channel): NotificationSender { // a union: no unknown strings
switch (channel) {
case "email": return new EmailSender(config.smtpHost);
case "sms": return new SmsSender(config.smsKey);
case "push": return new PushSender(config.pushAppId);
default: { const unknown: never = channel; throw new Error(`Unknown channel ${unknown}`); }
}
}Unknown values from the network are then handled where they arrive (skip, log, use the user’s fallback preference explicitly), not hidden inside the factory.
Practice: testing with a factory
Because the factory is the one place that creates senders, a test can give the code a factory that returns fakes:
- Describe the factory with an interface:
protocol SenderMaking { func sender(for: Channel) -> any NotificationSender }. - Pass it in to the class that sends notifications, instead of creating
SenderFactoryinside it. - In tests, pass a
FakeSenderFactorythat returns aRecordingSenderfor every channel. - Assert on what the recording sender received: “one SMS to +381…, with this text”.
Static factory methods
Static factory methods (Effective Java, item 1) are the most common factory in everyday code: List.of(…), Optional.empty(), URL(string:), Duration.ofSeconds(…). Compared with constructors they can:
- have names that say what they make (
Money.ofCents(1999)vsMoney(1999)), - return nothing or an error for invalid input (Swift failable
init?, aResult, a nullable), - return a cached or shared instance (
Boolean.valueOf,Integer.valueOffor -128…127), - return a subtype (
List.ofpicks an immutable implementation by size), - hide the class entirely (Kotlin
operator fun invokein a companion, an interface with a companion factory).
- Factories centralize “which concrete type, configured how”. Callers depend on the interface and the factory.
- Take a closed type (enum, sealed, union) and switch exhaustively; parse raw strings at the boundary; never fall back silently.
- Static factories: names, validation/failure (
init?,Result), caching, subtype selection, hidden implementations. - Inject the factory (an interface) so tests can supply fakes.
Check this part
What does a factory centralize?
A factory has
else -> EmailSender. What's the risk?Which is an advantage of a static factory method over a constructor?