Skip to content
Commitwise
Menu
Design Patterns

Abstract Factory

Create families of related objects that must be used together, such as a sandbox or live payment client, tokenizer and webhook verifier, choose the family once at startup, stop code from mixing parts of two families, and know when a DI module or a struct of functions does the same job, in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The checkout talks to a payment provider through three objects: a client that charges cards, a tokenizer that turns the card number into a token, and a verifier that checks the signature on the provider’s webhooks. Each has a sandbox version (test cards, test keys) and a live version. They only work as a set: a token created by the live tokenizer doesn’t exist in the sandbox, and a sandbox webhook fails the live signature check.

For a year, each screen built the objects it needed with an if isDebug of its own. Then a QA build shipped with a live tokenizer and a sandbox client, and every test payment failed with “token not found”.

An Abstract Factory is one object that creates the whole family. Choose the sandbox factory or the live factory once, and every part the app asks for comes from the same family.

New to some of these? Open what you need, or skip ahead.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java, TypeScript) or protocol (Swift) is a list of things a type promises it can do, without saying how. Code that only knows the interface can use any class that implements it, including a fake one in tests.

Basics:What is a factory?

A factory is a function or object whose job is to create other objects, so the code that needs them doesn’t call constructors directly. The Factory Method tutorial covers the single-product version; this one makes several related products.

Part Not started

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);
  }
}
  • PaymentKit is the abstract factory: it lists the products of the family, one make… method each.
  • SandboxPaymentKit and LivePaymentKit are the two concrete factories. Each one only creates parts that belong together, with the keys that belong together.
  • CheckoutService never 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);
Spot the bugTest payments that fail with 'token not found'

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);
  }
}

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?

  • 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

  1. What does an abstract factory create?

  2. Where should the app decide between the sandbox and the live kit?

  3. What breaks the guarantee an abstract factory gives?

An abstract factory is an interface with one creation method per product in a family. Each concrete factory creates matching products, so code that gets all its parts from one factory can't mix families. Choose the factory once, at startup, and pass it in.

Part Not started

Trade-offs and modern forms

Abstract Factory makes one kind of change easy and another kind harder:

  • A new family (a fake kit for UI tests, a kit for a second payment provider) is easy: one new class, one line at startup.
  • A new product (say, a RefundClient) is harder: the PaymentKit interface gets a new method, and every factory has to implement it.

So the pattern fits when the list of products is stable and the families change: environments, brands of a white-label app, platforms.

The asymmetry is the classic one: adding a family is open/closed, adding a product type is not (every concrete factory changes). Default implementations in the interface soften it but often hide a missing family-specific product, which is exactly the inconsistency the pattern exists to prevent; prefer a compile error. In modern codebases the factory frequently isn’t a named class: a DI module (Hilt @Module with @Provides per product, a Spring @Configuration per profile, a Swift Dependencies value) is an abstract factory, and so is a struct of closures. Two smells to watch: a factory that returns a new instance on every call when products must share state (two clients with two connection pools, two tokenizers with separate caches), and families selected by if (isSandbox) checks spread across products instead of by choosing one factory.

Production puzzles

Predict the output

LivePaymentKit.makeClient() creates a new LiveClient each time, and each LiveClient creates its own HTTP client with its own connection pool. Twelve screens each call kit.makeClient() in their initializers. What happens under load?

The factory as a DI module

// A struct of closures is an abstract factory without a protocol per product family
struct PaymentKit {
    var makeClient: () -> PaymentClient
    var makeTokenizer: () -> CardTokenizer
    var makeWebhookVerifier: () -> WebhookVerifier
}

extension PaymentKit {
    static func live(_ keys: LiveKeys) -> PaymentKit {
        let client = LiveClient(secretKey: keys.secret)       // shared: one instance
        return PaymentKit(
            makeClient: { client },
            makeTokenizer: { LiveTokenizer(publishableKey: keys.publishable) },
            makeWebhookVerifier: { HmacVerifier(secret: keys.webhookSecret) }
        )
    }
}
// Hilt: one module per family, installed by build flavor (src/sandbox and src/live)
@Module
@InstallIn(SingletonComponent::class)
object LivePaymentModule {
    @Provides @Singleton fun client(keys: LiveKeys): PaymentClient = LiveClient(keys.secret)
    @Provides fun tokenizer(keys: LiveKeys): CardTokenizer = LiveTokenizer(keys.publishable)
    @Provides fun verifier(keys: LiveKeys): WebhookVerifier = HmacVerifier(keys.webhookSecret)
}
// Spring: one configuration per family, selected by profile
@Configuration
@Profile("live")
class LivePaymentConfig {
    @Bean PaymentClient paymentClient(LiveKeys keys) { return new LiveClient(keys.secret()); }
    @Bean CardTokenizer cardTokenizer(LiveKeys keys) { return new LiveTokenizer(keys.publishable()); }
    @Bean WebhookVerifier webhookVerifier(LiveKeys keys) { return new HmacVerifier(keys.webhookSecret()); }
}
// An object of functions, chosen once
type PaymentKit = {
  makeClient: () => PaymentClient;
  makeTokenizer: () => CardTokenizer;
  makeWebhookVerifier: () => WebhookVerifier;
};

function liveKit(keys: LiveKeys): PaymentKit {
  const client = new LiveClient(keys.secret);   // shared: one instance
  return {
    makeClient: () => client,
    makeTokenizer: () => new LiveTokenizer(keys.publishable),
    makeWebhookVerifier: () => new HmacVerifier(keys.webhookSecret),
  };
}

Build flavors (Android), Spring profiles and Swift build configurations then choose the family at build or startup time, which also keeps the live keys out of debug builds when the live module isn’t compiled into them.

Dev note: Where the name comes from

The Gang of Four book (1994) illustrated Abstract Factory with a GUI toolkit that had to create matching widgets (windows, scroll bars, buttons) for several look-and-feel standards. Today the platform does that for you, which is why modern examples are environments, brands and providers.

Exerciseeasy

A white-label app

The same shopping app is sold to two store chains, each with its own analytics service, support chat SDK and loyalty card API. A build for chain A must never send events to chain B’s analytics. Sketch the abstract factory: the interface, the concrete factories, and where the choice is made.

›

List what each chain has its own version of. Those are the products; each chain is a family.

SolutionTry it yourself first
  • Interface BrandServices with makeAnalytics(), makeSupportChat() and makeLoyaltyApi().
  • Factories ChainAServices and ChainBServices, each creating only its own chain’s SDKs with its own keys.
  • Choice made once, at startup, from the build (a build flavor or scheme per chain), and the factory is passed to the screens that need it.

Bonus: if the chain is chosen by the build, chain B’s keys and SDK don’t even need to be in chain A’s app.

  • Adding a family is easy; adding a product type changes the interface and every factory. Prefer a compile error over default implementations that hide missing products.
  • DI modules (Hilt modules per flavor, Spring @Configuration per profile) and structs of closures are abstract factories.
  • Decide which products are shared (one instance) vs per use; a make… that silently creates pools or caches per call is a leak.
  • Avoid families selected by if (isSandbox) checks scattered across products.

Check this part

  1. Which change is easy with an abstract factory?

  2. A Spring @Configuration per profile that declares the beans of one family is…

  3. Why can a factory that creates a new client on every call hurt?

Adding a new kind of product means changing the interface and every factory, so abstract factories suit stable families. In modern code the factory is often a DI module or a struct of functions. Watch for factories that hand out shared state, and for families chosen by checks spread through the code.

Key takeaways

  • An abstract factory creates a family of objects that must work together.
  • Each concrete factory is one family: sandbox or live, brand A or brand B.
  • Choose the factory once, at startup, and pass it in.
  • Never build a product of the family directly; ask the factory.
  • The value is the consistency constraint across a family.
  • New families are cheap; new product types touch every factory.
  • DI modules, profiles, build flavors and structs of closures are the modern forms.
  • Be explicit about which products are shared and which are created per use.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.