One factory per family
Think of furniture sets in a store. A “Nordic” set has a Nordic chair, a Nordic table and a Nordic lamp; a “Vintage” set has vintage ones. If you order a set, everything matches. If you pick each piece from a different aisle, you might end up with a vintage chair at a Nordic table.
In code, the abstract factory is the order form: “give me a chair, a table, a lamp”. Each concrete factory is one set. The rest of the app only ever asks the form, so it gets matching pieces without knowing which set it is.
For payments, the form says “give me a client, a tokenizer, a webhook verifier”, and there are two sets: sandbox and live.
Abstract Factory provides an interface for creating families of related or dependent objects without specifying their concrete classes. Its value is the consistency constraint: products from one factory are designed to work together, and clients that depend only on the factory can’t combine products from different families. It’s typically chosen once, at the composition root, from configuration (environment, brand, platform, region), then injected. Compared to Factory Method, which varies one product through subclassing, Abstract Factory varies a whole family through composition: the client holds a factory object.
protocol PaymentClient { func charge(token: String, cents: Int) async throws -> ChargeId }
protocol CardTokenizer { func tokenize(_ card: CardDetails) async throws -> String }
protocol WebhookVerifier { func isValid(payload: Data, signature: String) -> Bool }
// The abstract factory: one method per product in the family
protocol PaymentKit {
func makeClient() -> PaymentClient
func makeTokenizer() -> CardTokenizer
func makeWebhookVerifier() -> WebhookVerifier
}
struct SandboxPaymentKit: PaymentKit {
let keys: SandboxKeys
func makeClient() -> PaymentClient { SandboxClient(secretKey: keys.secret) }
func makeTokenizer() -> CardTokenizer { SandboxTokenizer(publishableKey: keys.publishable) }
func makeWebhookVerifier() -> WebhookVerifier { HmacVerifier(secret: keys.webhookSecret) }
}
struct LivePaymentKit: PaymentKit {
let keys: LiveKeys
func makeClient() -> PaymentClient { LiveClient(secretKey: keys.secret) }
func makeTokenizer() -> CardTokenizer { LiveTokenizer(publishableKey: keys.publishable) }
func makeWebhookVerifier() -> WebhookVerifier { HmacVerifier(secret: keys.webhookSecret) }
}
// Callers only know the abstract factory
final class CheckoutService {
private let client: PaymentClient
private let tokenizer: CardTokenizer
init(kit: PaymentKit) {
client = kit.makeClient()
tokenizer = kit.makeTokenizer()
}
func pay(_ card: CardDetails, cents: Int) async throws -> ChargeId {
let token = try await tokenizer.tokenize(card)
return try await client.charge(token: token, cents: cents)
}
}interface PaymentClient { suspend fun charge(token: String, cents: Int): ChargeId }
interface CardTokenizer { suspend fun tokenize(card: CardDetails): String }
interface WebhookVerifier { fun isValid(payload: ByteArray, signature: String): Boolean }
// The abstract factory: one method per product in the family
interface PaymentKit {
fun makeClient(): PaymentClient
fun makeTokenizer(): CardTokenizer
fun makeWebhookVerifier(): WebhookVerifier
}
class SandboxPaymentKit(private val keys: SandboxKeys) : PaymentKit {
override fun makeClient(): PaymentClient = SandboxClient(keys.secret)
override fun makeTokenizer(): CardTokenizer = SandboxTokenizer(keys.publishable)
override fun makeWebhookVerifier(): WebhookVerifier = HmacVerifier(keys.webhookSecret)
}
class LivePaymentKit(private val keys: LiveKeys) : PaymentKit {
override fun makeClient(): PaymentClient = LiveClient(keys.secret)
override fun makeTokenizer(): CardTokenizer = LiveTokenizer(keys.publishable)
override fun makeWebhookVerifier(): WebhookVerifier = HmacVerifier(keys.webhookSecret)
}
// Callers only know the abstract factory
class CheckoutService(kit: PaymentKit) {
private val client = kit.makeClient()
private val tokenizer = kit.makeTokenizer()
suspend fun pay(card: CardDetails, cents: Int): ChargeId {
val token = tokenizer.tokenize(card)
return client.charge(token, cents)
}
}public interface PaymentClient { ChargeId charge(String token, int cents); }
public interface CardTokenizer { String tokenize(CardDetails card); }
public interface WebhookVerifier { boolean isValid(byte[] payload, String signature); }
// The abstract factory: one method per product in the family
public interface PaymentKit {
PaymentClient makeClient();
CardTokenizer makeTokenizer();
WebhookVerifier makeWebhookVerifier();
}
public record SandboxPaymentKit(SandboxKeys keys) implements PaymentKit {
public PaymentClient makeClient() { return new SandboxClient(keys.secret()); }
public CardTokenizer makeTokenizer() { return new SandboxTokenizer(keys.publishable()); }
public WebhookVerifier makeWebhookVerifier() { return new HmacVerifier(keys.webhookSecret()); }
}
public record LivePaymentKit(LiveKeys keys) implements PaymentKit {
public PaymentClient makeClient() { return new LiveClient(keys.secret()); }
public CardTokenizer makeTokenizer() { return new LiveTokenizer(keys.publishable()); }
public WebhookVerifier makeWebhookVerifier() { return new HmacVerifier(keys.webhookSecret()); }
}
// Callers only know the abstract factory
public final class CheckoutService {
private final PaymentClient client;
private final CardTokenizer tokenizer;
public CheckoutService(PaymentKit kit) {
this.client = kit.makeClient();
this.tokenizer = kit.makeTokenizer();
}
public ChargeId pay(CardDetails card, int cents) {
String token = tokenizer.tokenize(card);
return client.charge(token, cents);
}
}interface PaymentClient { charge(token: string, cents: number): Promise<ChargeId> }
interface CardTokenizer { tokenize(card: CardDetails): Promise<string> }
interface WebhookVerifier { isValid(payload: string, signature: string): boolean }
// The abstract factory: one method per product in the family
interface PaymentKit {
makeClient(): PaymentClient;
makeTokenizer(): CardTokenizer;
makeWebhookVerifier(): WebhookVerifier;
}
class SandboxPaymentKit implements PaymentKit {
constructor(private readonly keys: SandboxKeys) {}
makeClient() { return new SandboxClient(this.keys.secret); }
makeTokenizer() { return new SandboxTokenizer(this.keys.publishable); }
makeWebhookVerifier() { return new HmacVerifier(this.keys.webhookSecret); }
}
class LivePaymentKit implements PaymentKit {
constructor(private readonly keys: LiveKeys) {}
makeClient() { return new LiveClient(this.keys.secret); }
makeTokenizer() { return new LiveTokenizer(this.keys.publishable); }
makeWebhookVerifier() { return new HmacVerifier(this.keys.webhookSecret); }
}
// Callers only know the abstract factory
class CheckoutService {
private readonly client: PaymentClient;
private readonly tokenizer: CardTokenizer;
constructor(kit: PaymentKit) {
this.client = kit.makeClient();
this.tokenizer = kit.makeTokenizer();
}
async pay(card: CardDetails, cents: number): Promise<ChargeId> {
const token = await this.tokenizer.tokenize(card);
return this.client.charge(token, cents);
}
}PaymentKitis the abstract factory: it lists the products of the family, onemake…method each.SandboxPaymentKitandLivePaymentKitare the two concrete factories. Each one only creates parts that belong together, with the keys that belong together.CheckoutServicenever says “sandbox” or “live”. It asks the kit for a client and a tokenizer, so they always match.- The choice of kit happens once, when the app starts:
let paymentKit: PaymentKit = config.environment == .production
? LivePaymentKit(keys: config.liveKeys)
: SandboxPaymentKit(keys: config.sandboxKeys)
let checkout = CheckoutService(kit: paymentKit)val paymentKit: PaymentKit = when (config.environment) {
Environment.PRODUCTION -> LivePaymentKit(config.liveKeys)
else -> SandboxPaymentKit(config.sandboxKeys)
}
val checkout = CheckoutService(paymentKit)PaymentKit paymentKit = config.environment() == Environment.PRODUCTION
? new LivePaymentKit(config.liveKeys())
: new SandboxPaymentKit(config.sandboxKeys());
var checkout = new CheckoutService(paymentKit);const paymentKit: PaymentKit = config.environment === "production"
? new LivePaymentKit(config.liveKeys)
: new SandboxPaymentKit(config.sandboxKeys);
const checkout = new CheckoutService(paymentKit);Tap the line with the bug.
final class SavedCardsViewModel {
private let client: PaymentClient
private let tokenizer: CardTokenizer
init(kit: PaymentKit, keys: LiveKeys) {
client = kit.makeClient()
tokenizer = LiveTokenizer(publishableKey: keys.publishable)
}
func addCard(_ card: CardDetails) async throws {
let token = try await tokenizer.tokenize(card)
try await client.attach(token: token)
}
}class SavedCardsViewModel(
kit: PaymentKit,
keys: LiveKeys,
) : ViewModel() {
private val client = kit.makeClient()
private val tokenizer = LiveTokenizer(keys.publishable)
suspend fun addCard(card: CardDetails) {
val token = tokenizer.tokenize(card)
client.attach(token)
}
}public final class SavedCardsService {
private final PaymentClient client;
private final CardTokenizer tokenizer;
public SavedCardsService(PaymentKit kit, LiveKeys keys) {
this.client = kit.makeClient();
this.tokenizer = new LiveTokenizer(keys.publishable());
}
public void addCard(CardDetails card) {
String token = tokenizer.tokenize(card);
client.attach(token);
}
}class SavedCardsViewModel {
private readonly client: PaymentClient;
private readonly tokenizer: CardTokenizer;
constructor(kit: PaymentKit, keys: LiveKeys) {
this.client = kit.makeClient();
this.tokenizer = new LiveTokenizer(keys.publishable);
}
async addCard(card: CardDetails) {
const token = await this.tokenizer.tokenize(card);
await this.client.attach(token);
}
}Hint: Where does the tokenizer come from, and where does the client come from?
The client comes from the kit, but the tokenizer is built directly as a live one. In a QA build the kit is the sandbox kit, so the screen creates a live token and asks the sandbox client to attach it: “token not found”. In production it happens to work, which is why nobody noticed.
The pattern only protects you if every product of the family comes from the same factory. Ask the kit for both, and drop the LiveKeys parameter, which no screen should need:
init(kit: PaymentKit) {
client = kit.makeClient()
tokenizer = kit.makeTokenizer()
}class SavedCardsViewModel(kit: PaymentKit) : ViewModel() {
private val client = kit.makeClient()
private val tokenizer = kit.makeTokenizer()
}public SavedCardsService(PaymentKit kit) {
this.client = kit.makeClient();
this.tokenizer = kit.makeTokenizer();
}constructor(kit: PaymentKit) {
this.client = kit.makeClient();
this.tokenizer = kit.makeTokenizer();
}A lint rule or an architecture test that forbids LiveTokenizer( outside the payment module keeps it from coming back.
Predict the output
The app adds a third family, a FakePaymentKit for UI tests that returns canned tokens and charges instantly. How many files outside the payment module change?
Screens depend on the PaymentKit interface, so they don't change. You write FakePaymentKit (inside the payment or test module) and change one line at startup, where the kit is chosen, for example when the app is launched with a --ui-testing argument. That's the payoff of choosing the family in one place.
- Interface with one creation method per product; each concrete factory produces a consistent family.
- The point is the consistency constraint: clients that only use the factory can’t mix families.
- Choose the factory once at the composition root from configuration (environment, brand, platform), then inject it.
- Factory Method varies one product by subclassing; Abstract Factory varies a family by composition.
- Guard the boundary: constructing a concrete product outside the factory silently breaks the guarantee.
Check this part
What does an abstract factory create?
Where should the app decide between the sandbox and the live kit?
What breaks the guarantee an abstract factory gives?