Abstract Factory for Cross-Platform UI Component Families
Create families of related objects without specifying concrete classes, enabling platform-specific implementations that share a common interface.
Overview
Ship a button to three platforms and the problem shows up fast: Web wants a <button>, iOS wants a UIButton, Android wants a Material widget. They’re all “a button” but they’re not interchangeable — mix a web checkbox into a native form and the screen breaks. The Abstract Factory pattern creates entire families of related objects through one interface, so every product you get belongs to the same platform or theme.
This page covers applying Abstract Factory to cross-platform UI component families. For the pattern fundamentals and other applications, see the generic Abstract Factory page.
When to Use This
- A system must support multiple platforms or themes with cohesive component families
- You need to guarantee that products from the same factory are compatible — a native dialog can’t sit next to a web checkbox
- The creation process should be hidden from client code that shouldn’t know which platform it’s on
Skip it when:
- The family is small and stable — two or three products don’t justify the extra interfaces; a Factory Method is enough
- Platform differences are cosmetic (colors, spacing) — CSS variables or a theme object are simpler than separate implementations
- Only one platform exists today; add the abstraction when the second family actually arrives
Problem
A UI toolkit needs to render native buttons, checkboxes, and dialogs on Web, iOS, and Android. Using Factory Method for each widget independently risks mixing native and web components in the same view.
Solution
// factory/UIFactory.ts
interface Button {
render(): string;
onClick(handler: () => void): void;
}
interface Checkbox {
render(): string;
toggle(): void;
isChecked(): boolean;
}
interface Dialog {
show(): void;
hide(): void;
setTitle(title: string): void;
}
interface UIFactory {
createButton(label: string): Button;
createCheckbox(label: string): Checkbox;
createDialog(): Dialog;
}
// Web family
class WebButton implements Button {
constructor(private label: string) {}
render(): string { return `<button>${this.label}</button>`; }
onClick(handler: () => void): void { /* attach DOM listener */ }
}
class WebCheckbox implements Checkbox {
private checked = false;
constructor(private label: string) {}
render(): string { return `<label><input type="checkbox"> ${this.label}</label>`; }
toggle(): void { this.checked = !this.checked; }
isChecked(): boolean { return this.checked; }
}
class WebDialog implements Dialog {
private title = '';
show(): void { console.log('Show web dialog:', this.title); }
hide(): void { console.log('Hide web dialog'); }
setTitle(title: string): void { this.title = title; }
}
class WebFactory implements UIFactory {
createButton(label: string): Button { return new WebButton(label); }
createCheckbox(label: string): Checkbox { return new WebCheckbox(label); }
createDialog(): Dialog { return new WebDialog(); }
}
// Mobile family
class MobileButton implements Button {
constructor(private label: string) {}
render(): string { return `[Mobile Button: ${this.label}]`; }
onClick(): void { /* native tap handler */ }
}
class MobileCheckbox implements Checkbox {
private checked = false;
constructor(private label: string) {}
render(): string { return `[Mobile Switch: ${this.label}]`; }
toggle(): void { this.checked = !this.checked; }
isChecked(): boolean { return this.checked; }
}
class MobileDialog implements Dialog {
private title = '';
show(): void { console.log('Show mobile modal:', this.title); }
hide(): void { console.log('Dismiss mobile modal'); }
setTitle(title: string): void { this.title = title; }
}
class MobileFactory implements UIFactory {
createButton(label: string): Button { return new MobileButton(label); }
createCheckbox(label: string): Checkbox { return new MobileCheckbox(label); }
createDialog(): Dialog { return new MobileDialog(); }
}
// Client uses any factory uniformly
class SettingsScreen {
private button: Button;
private checkbox: Checkbox;
private dialog: Dialog;
constructor(factory: UIFactory) {
this.button = factory.createButton('Save');
this.checkbox = factory.createCheckbox('Dark Mode');
this.dialog = factory.createDialog();
this.dialog.setTitle('Confirm');
}
render(): string {
return `${this.button.render()} ${this.checkbox.render()}`;
}
}
// Usage
const webScreen = new SettingsScreen(new WebFactory());
const mobileScreen = new SettingsScreen(new MobileFactory());
How It Works
- Abstract Factory declares creation methods for each product type
- Concrete Factory implements creation for a specific family
- Abstract Products declare interfaces for product types
- Concrete Products implement a variant for one family
- Client uses only abstract interfaces; the concrete factory determines the family
Consequences and Trade-offs
The consistency guarantee is the whole point — and it’s also the cost. Once client code holds a factory, every object it builds comes from the same family, so mixing platforms stops being possible by construction. That’s worth real money on a design system team: nobody files a bug for a web checkbox inside a native dialog ever again. The flip side is rigidity. Adding a new product type — say, a date picker — means touching the abstract factory interface plus every concrete factory. With three platforms that’s four edits for one feature; with ten it’s a maintenance tax that never goes away.
Selection strategy matters more than the classes. Compile-time wiring (DI container, build variants) is simple and fails fast, but locks the family to a deployment. Runtime selection (detectPlatform() at startup, or per-request for web+embed contexts) costs indirection but lets one binary serve several families. Pick runtime when the same bundle ships to different platforms; pick compile-time when each platform already gets its own build artifact.
The family boundary is a design decision, not an implementation detail. “Everything on iOS” is too broad — it couples components that evolve at different speeds. “Exactly the form widgets” is narrow but stable. If a product type changes for reasons unrelated to the platform split, it probably doesn’t belong in the family. When in doubt, start with the smallest family that shows the mixing problem and grow it only when a real second product needs the guarantee.
A practical cost estimate: each product type is one interface plus one implementation per family. A 5-product, 3-platform design system is 15 concrete classes plus 5 interfaces — manageable. A 15-product, 3-platform system is 45 classes you maintain forever. Abstract Factory earns its complexity when the family is small, the platforms are real, and the mixing bug is expensive. When all three aren’t true, a Factory Method or a theme object does the same job for a fraction of the surface area.
Production Considerations
- Use dependency injection frameworks to select the factory at runtime
- Abstract Factory works well with Bridge when families also need platform-specific rendering
- Consider factory registries for plugin-based architectures
- Companion code: the abstract-factory-cross-platform companion repo packages every snippet on this page as runnable TypeScript files.
Common Mistakes
- Returning concrete product types from factory methods — if
createButtonreturnsWebButtoninstead ofButton, clients couple to the concrete class and the family guarantee evaporates. - Letting client code construct products directly — one
new WebButton()next to a factory-made dialog reintroduces exactly the mixing the pattern exists to prevent. - Detecting the platform inside the client —
if (isMobile)sprinkled through views defeats the pattern; detect once at bootstrap and inject the factory. - Testing only the products, not the factory — a
MobileFactorythat returnsWebCheckboxcompiles fine and ships the bug; assert the family per factory.
Advanced Techniques
Factory composition with dependency injection
Combine Abstract Factory with DI containers for runtime factory selection:
// factory/FactoryRegistry.ts
interface FactoryRegistry {
register(key: string, factory: UIFactory): void;
get(key: string): UIFactory;
}
class UIFactoryRegistry implements FactoryRegistry {
private factories = new Map<string, UIFactory>();
register(key: string, factory: UIFactory): void {
this.factories.set(key, factory);
}
get(key: string): UIFactory {
const factory = this.factories.get(key);
if (!factory) throw new Error(`Factory not found: ${key}`);
return factory;
}
}
// Usage with DI
const registry = new UIFactoryRegistry();
registry.register('web', new WebFactory());
registry.register('mobile', new MobileFactory());
// Runtime selection based on environment
const platform = detectPlatform(); // 'web' | 'mobile'
const factory = registry.get(platform);
const screen = new SettingsScreen(factory);
Dynamic factory loading with plugins
Load factories dynamically based on configuration or plugins:
// factory/DynamicFactoryLoader.ts
interface FactoryConfig {
type: string;
module: string;
className: string;
}
async function loadFactory(config: FactoryConfig): Promise<UIFactory> {
const module = await import(config.module);
const FactoryClass = module[config.className];
return new FactoryClass();
}
// Config-driven factory selection
const config: FactoryConfig = {
type: 'ios',
module: './factories/iOS',
className: 'IOSFactory'
};
const factory = await loadFactory(config);
Factory with parameterized product creation
Pass configuration to factories for flexible product instantiation:
interface UIFactoryConfig {
theme: 'light' | 'dark';
locale: string;
accessibility: boolean;
}
interface ConfigurableUIFactory extends UIFactory {
setConfig(config: UIFactoryConfig): void;
}
class WebFactory implements ConfigurableUIFactory {
private config: UIFactoryConfig = {
theme: 'light',
locale: 'en',
accessibility: false
};
setConfig(config: UIFactoryConfig): void {
this.config = { ...this.config, ...config };
}
createButton(label: string): Button {
return new WebButton(label, this.config);
}
}
class WebButton implements Button {
constructor(
private label: string,
private config: UIFactoryConfig
) {}
render(): string {
const themeClass = this.config.theme === 'dark' ? 'dark-mode' : 'light-mode';
return `<button class="${themeClass}">${this.label}</button>`;
}
onClick(handler: () => void): void { /* attach DOM listener */ }
}
Factory method chaining for complex product assembly
Chain factory methods to build complex products from multiple components:
interface ComplexUIFactory extends UIFactory {
createForm(): Form;
createFormField(type: 'text' | 'number' | 'email'): FormField;
createValidator(type: 'required' | 'email' | 'minLength'): Validator;
}
class WebFormFactory implements ComplexUIFactory {
createButton(label: string): Button { /* ... */ }
createCheckbox(label: string): Checkbox { /* ... */ }
createDialog(): Dialog { /* ... */ }
createForm(): Form {
return new WebForm();
}
createFormField(type: string): FormField {
return new WebFormField(type);
}
createValidator(type: string): Validator {
return new WebValidator(type);
}
}
// Client builds complex form using factory chain
const factory = new WebFormFactory();
const form = factory.createForm();
form.addField(factory.createFormField('text'));
form.addField(factory.createFormField('email'));
form.addValidator(factory.createValidator('required'));
Lazy factory initialization with proxies
Use proxies to defer factory instantiation until first use:
class LazyUIFactory implements UIFactory {
private factory: UIFactory | null = null;
private factoryFactory: () => UIFactory;
constructor(factoryFactory: () => UIFactory) {
this.factoryFactory = factoryFactory;
}
private getFactory(): UIFactory {
if (!this.factory) {
this.factory = this.factoryFactory();
}
return this.factory;
}
createButton(label: string): Button {
return this.getFactory().createButton(label);
}
createCheckbox(label: string): Checkbox {
return this.getFactory().createCheckbox(label);
}
createDialog(): Dialog {
return this.getFactory().createDialog();
}
}
// Lazy initialization
const lazyFactory = new LazyUIFactory(() => {
console.log('Initializing WebFactory...');
return new WebFactory();
});
// Factory not initialized until first method call
const button = lazyFactory.createButton('Click me');
Best Practices
- Keep the family small and stable. Every product type you add costs one interface change across all concrete factories — a date picker means four edits on a three-platform system. Families of 3-7 products age well; families of 15 become the maintenance problem they were meant to solve.
- Inject the factory, never detect the platform twice. The client takes a
UIFactoryin its constructor. Platform detection happens once, at bootstrap — sprinklingif (platform === 'ios')through the app defeats the pattern. - Test the factories, not just the products. Assert that
WebFactory.createCheckbox()returns aWebCheckbox. A factory that returns the wrong family compiles fine and ships the bug. - Let products vary freely inside the family. A
MaterialButtonand anIOSButtoncan differ wildly in markup and behavior — the contract is the interface, not the internals. The factory is what keeps the variants compatible. - Version the family, not the pieces. When the design system revs (v1 -> v2 widgets), ship a
V2UIFactoryrather than mutating each product. Old screens keep the old family, new screens opt in.
Frequently Asked Questions
How is this different from Factory Method?
Factory Method creates one product through an overridable method — each subclass picks its variant. Abstract Factory creates a whole family of related products through one injected object. Use Factory Method for one swappable type; use Abstract Factory when several types must stay compatible with each other.
When should I avoid Abstract Factory?
When the family has one or two products, when differences are cosmetic (a theme object covers colors), or when you only support one platform. The pattern pays off only if the mixing bug is real and expensive — otherwise it's interfaces nobody reads.
Can I use Abstract Factory with dependency injection frameworks?
That's the clean way to do it. Bind the concrete factory in your container config (container.bind<UIFactory>(WebFactory)), inject the interface everywhere, and swap families by rebinding — no code changes in consumers.
How do I handle adding a new product type to an existing factory family?
Expect to touch every concrete factory — that's the pattern's known cost. Add the method to the abstract interface, implement it in each concrete class, and write a compile check or test per family. If you're adding product types monthly, the family is probably too broad.
How does Abstract Factory compare to the Builder pattern?
Builder assembles a complex object step by step — one product, many parts. Abstract Factory produces ready-made families of products in one call each. They compose fine: a factory can hand you builders for the products that need assembly.
Should I use Abstract Factory for simple theme switching?
Almost never. Colors, spacing, and typography are data — a theme object or CSS variables handle them without new classes. Abstract Factory fits when behavior differs per platform, not just looks: different markup structure, different interaction semantics, different a11y requirements.
How do I test code that uses Abstract Factory?
Two levels: per-factory tests asserting each factory returns its own family's products, and a fake UIFactory in client tests so you're not dragging real widgets into unit tests. The second is the payoff — testing against the interface is exactly what the pattern buys you.
Related Resources
Abstract Factory Pattern
Create families of related objects without specifying concrete classes. A creational design pattern for consistent object families.
RecipeCreate Objects Flexibly with the Factory Pattern
How to use factory methods, abstract factories, and dependency injection containers to decouple object creation from usage and improve testability.
PatternBridge Pattern for Decoupling UI Components from Themes
Separate an abstraction from its implementation so both can vary independently using the Bridge pattern for pluggable UI themes and rendering engines
PatternDependency Injection Container in TypeScript
Build a lightweight DI container that resolves class dependencies automatically, enabling testable, loosely-coupled applications without frameworks like Angular or InversifyJS
RecipeDependency Injection: Containers in 4 Languages
Implement dependency injection to write testable, decoupled code across languages and frameworks.