Episode 9 — System Design / 9.3 — Creational Design Patterns
9.3.c Abstract Factory Pattern
The Problem It Solves
Sometimes you need to create families of related objects that must work together. A Factory Method creates one product at a time, but what if you need a matched set?
Examples:
- A UI toolkit that produces buttons, inputs, and modals -- all in the same style (Material, Bootstrap, Fluent).
- A database layer that produces connections, query builders, and migrators -- all for the same engine (PostgreSQL, MongoDB, SQLite).
- A cross-platform app that produces file pickers, notifications, and menus -- all for the same OS (Windows, macOS, Linux).
The problem: if you mix a Material button with a Bootstrap modal, things break visually. You need a guarantee that all products in a family are compatible.
WRONG: Mix-and-match from different families
MaterialButton + BootstrapModal + FluentInput --> Inconsistent UI
RIGHT: All from the same family
MaterialButton + MaterialModal + MaterialInput --> Consistent UI
UML Diagram
+-------------------------+ +-------------------------+
| AbstractFactory | | AbstractProductA |
|-------------------------| |-------------------------|
| + createProductA() |------>| + operationA() |
| + createProductB() | +-------------------------+
+-------------------------+ ^ ^
^ ^ | |
| | ProductA1 ProductA2
| |
+-------------+ +-------------+
| Factory1 | | Factory2 | +-------------------------+
|-------------| |-------------| | AbstractProductB |
| createA() | | createA() |--->|-------------------------|
| createB() | | createB() | | + operationB() |
+-------------+ +-------------+ +-------------------------+
^ ^
| |
ProductB1 ProductB2
Factory1 always produces ProductA1 + ProductB1 (same family)
Factory2 always produces ProductA2 + ProductB2 (same family)
Cross-Platform UI Factory Example
This is the classic textbook example, adapted for a real scenario: rendering UI components that must be consistent across themes.
// =============================================================
// ABSTRACT PRODUCTS -- Define the interface each product must have
// =============================================================
class Button {
render() { throw new Error('render() must be implemented'); }
onClick(handler) { throw new Error('onClick() must be implemented'); }
}
class Input {
render() { throw new Error('render() must be implemented'); }
getValue() { throw new Error('getValue() must be implemented'); }
}
class Modal {
render() { throw new Error('render() must be implemented'); }
open() { throw new Error('open() must be implemented'); }
close() { throw new Error('close() must be implemented'); }
}
// =============================================================
// FAMILY 1: Material Design
// =============================================================
class MaterialButton extends Button {
render() {
return '<button class="mdc-button mdc-button--raised">Click Me</button>';
}
onClick(handler) {
console.log('[Material] Button click handler attached');
handler();
}
}
class MaterialInput extends Input {
constructor() {
super();
this.value = '';
}
render() {
return '<div class="mdc-text-field"><input class="mdc-text-field__input"/></div>';
}
getValue() { return this.value; }
}
class MaterialModal extends Modal {
render() {
return '<div class="mdc-dialog"><div class="mdc-dialog__surface">...</div></div>';
}
open() { console.log('[Material] Modal opened with elevation shadow'); }
close() { console.log('[Material] Modal closed with fade-out'); }
}
// =============================================================
// FAMILY 2: Bootstrap
// =============================================================
class BootstrapButton extends Button {
render() {
return '<button class="btn btn-primary">Click Me</button>';
}
onClick(handler) {
console.log('[Bootstrap] Button click handler attached');
handler();
}
}
class BootstrapInput extends Input {
constructor() {
super();
this.value = '';
}
render() {
return '<input class="form-control" />';
}
getValue() { return this.value; }
}
class BootstrapModal extends Modal {
render() {
return '<div class="modal fade"><div class="modal-dialog">...</div></div>';
}
open() { console.log('[Bootstrap] Modal opened with slide-down'); }
close() { console.log('[Bootstrap] Modal closed with fade'); }
}
// =============================================================
// ABSTRACT FACTORY
// =============================================================
class UIFactory {
createButton() { throw new Error('createButton() must be implemented'); }
createInput() { throw new Error('createInput() must be implemented'); }
createModal() { throw new Error('createModal() must be implemented'); }
}
// =============================================================
// CONCRETE FACTORIES
// =============================================================
class MaterialUIFactory extends UIFactory {
createButton() { return new MaterialButton(); }
createInput() { return new MaterialInput(); }
createModal() { return new MaterialModal(); }
}
class BootstrapUIFactory extends UIFactory {
createButton() { return new BootstrapButton(); }
createInput() { return new BootstrapInput(); }
createModal() { return new BootstrapModal(); }
}
// =============================================================
// CLIENT CODE -- Works with ANY factory (does not know concrete classes)
// =============================================================
function renderLoginForm(factory) {
const button = factory.createButton();
const input = factory.createInput();
const modal = factory.createModal();
console.log('--- Rendering Login Form ---');
console.log('Input HTML:', input.render());
console.log('Button HTML:', button.render());
console.log('Modal HTML:', modal.render());
modal.open();
button.onClick(() => console.log('Login submitted!'));
modal.close();
}
// --- Usage ---
console.log('=== Material Theme ===');
renderLoginForm(new MaterialUIFactory());
console.log('\n=== Bootstrap Theme ===');
renderLoginForm(new BootstrapUIFactory());
Output:
=== Material Theme ===
--- Rendering Login Form ---
Input HTML: <div class="mdc-text-field"><input class="mdc-text-field__input"/></div>
Button HTML: <button class="mdc-button mdc-button--raised">Click Me</button>
Modal HTML: <div class="mdc-dialog"><div class="mdc-dialog__surface">...</div></div>
[Material] Modal opened with elevation shadow
[Material] Button click handler attached
Login submitted!
[Material] Modal closed with fade-out
=== Bootstrap Theme ===
--- Rendering Login Form ---
Input HTML: <input class="form-control" />
Button HTML: <button class="btn btn-primary">Click Me</button>
Modal HTML: <div class="modal fade"><div class="modal-dialog">...</div></div>
[Bootstrap] Modal opened with slide-down
[Bootstrap] Button click handler attached
Login submitted!
[Bootstrap] Modal closed with fade
Key point: renderLoginForm works with any factory. It never imports MaterialButton or BootstrapModal directly. Swap the factory argument and the entire UI family changes.
Database Factory Example (SQL vs NoSQL)
// =============================================================
// ABSTRACT PRODUCTS
// =============================================================
class Connection {
connect() { throw new Error('Not implemented'); }
disconnect() { throw new Error('Not implemented'); }
}
class QueryBuilder {
select(fields) { throw new Error('Not implemented'); }
from(table) { throw new Error('Not implemented'); }
where(condition) { throw new Error('Not implemented'); }
build() { throw new Error('Not implemented'); }
}
class Migrator {
createTable(name, schema) { throw new Error('Not implemented'); }
dropTable(name) { throw new Error('Not implemented'); }
run() { throw new Error('Not implemented'); }
}
// =============================================================
// FAMILY 1: PostgreSQL
// =============================================================
class PostgresConnection extends Connection {
connect() {
console.log('[Postgres] Connected via pg pool');
return this;
}
disconnect() {
console.log('[Postgres] Pool drained, disconnected');
}
}
class PostgresQueryBuilder extends QueryBuilder {
constructor() {
super();
this._parts = {};
}
select(fields) { this._parts.select = fields; return this; }
from(table) { this._parts.from = table; return this; }
where(condition) { this._parts.where = condition; return this; }
build() {
return `SELECT ${this._parts.select} FROM ${this._parts.from} WHERE ${this._parts.where};`;
}
}
class PostgresMigrator extends Migrator {
constructor() {
super();
this._operations = [];
}
createTable(name, schema) {
const cols = Object.entries(schema)
.map(([col, type]) => `${col} ${type}`)
.join(', ');
this._operations.push(`CREATE TABLE ${name} (${cols});`);
return this;
}
dropTable(name) {
this._operations.push(`DROP TABLE IF EXISTS ${name};`);
return this;
}
run() {
console.log('[Postgres] Running migrations:');
this._operations.forEach((op) => console.log(` ${op}`));
}
}
// =============================================================
// FAMILY 2: MongoDB
// =============================================================
class MongoConnection extends Connection {
connect() {
console.log('[Mongo] Connected via MongoClient');
return this;
}
disconnect() {
console.log('[Mongo] Client closed');
}
}
class MongoQueryBuilder extends QueryBuilder {
constructor() {
super();
this._parts = {};
}
select(fields) {
this._parts.projection = fields.split(',').reduce((acc, f) => {
acc[f.trim()] = 1;
return acc;
}, {});
return this;
}
from(collection) { this._parts.collection = collection; return this; }
where(condition) { this._parts.filter = condition; return this; }
build() {
return `db.${this._parts.collection}.find(${JSON.stringify(this._parts.filter)}, ${JSON.stringify(this._parts.projection)})`;
}
}
class MongoMigrator extends Migrator {
constructor() {
super();
this._operations = [];
}
createTable(name, schema) {
this._operations.push(
`db.createCollection("${name}", { validator: { $jsonSchema: ${JSON.stringify(schema)} } })`
);
return this;
}
dropTable(name) {
this._operations.push(`db.${name}.drop()`);
return this;
}
run() {
console.log('[Mongo] Running migrations:');
this._operations.forEach((op) => console.log(` ${op}`));
}
}
// =============================================================
// FACTORIES
// =============================================================
class DatabaseFactory {
createConnection() { throw new Error('Not implemented'); }
createQueryBuilder() { throw new Error('Not implemented'); }
createMigrator() { throw new Error('Not implemented'); }
}
class PostgresFactory extends DatabaseFactory {
createConnection() { return new PostgresConnection(); }
createQueryBuilder() { return new PostgresQueryBuilder(); }
createMigrator() { return new PostgresMigrator(); }
}
class MongoFactory extends DatabaseFactory {
createConnection() { return new MongoConnection(); }
createQueryBuilder() { return new MongoQueryBuilder(); }
createMigrator() { return new MongoMigrator(); }
}
// =============================================================
// CLIENT CODE
// =============================================================
function setupDatabase(factory) {
// Connect
const conn = factory.createConnection();
conn.connect();
// Build a query
const query = factory.createQueryBuilder()
.select('name, email')
.from('users')
.where('active = true')
.build();
console.log('Query:', query);
// Run a migration
const migrator = factory.createMigrator();
migrator.createTable('orders', {
id: 'SERIAL PRIMARY KEY',
total: 'DECIMAL(10,2)',
status: 'VARCHAR(20)',
});
migrator.run();
conn.disconnect();
}
// --- Usage: Switch entire DB layer by changing ONE line ---
console.log('=== PostgreSQL ===');
setupDatabase(new PostgresFactory());
console.log('\n=== MongoDB ===');
setupDatabase(new MongoFactory());
Output:
=== PostgreSQL ===
[Postgres] Connected via pg pool
Query: SELECT name, email FROM users WHERE active = true;
[Postgres] Running migrations:
CREATE TABLE orders (id SERIAL PRIMARY KEY, total DECIMAL(10,2), status VARCHAR(20));
[Postgres] Pool drained, disconnected
=== MongoDB ===
[Mongo] Connected via MongoClient
Query: db.users.find("active = true", {"name":1,"email":1})
[Mongo] Running migrations:
db.createCollection("orders", { validator: { $jsonSchema: {"id":"SERIAL PRIMARY KEY","total":"DECIMAL(10,2)","status":"VARCHAR(20)"} } })
[Mongo] Client closed
Abstract Factory vs Simple Factory
+---------------------------+-------------------------------+----------------------------------+
| | Simple Factory / Factory | Abstract Factory |
| | Method | |
+---------------------------+-------------------------------+----------------------------------+
| Creates | ONE product at a time | A FAMILY of related products |
| Products related? | Not necessarily | Must be compatible with each |
| | | other |
| Number of factory methods | 1 (create) | Multiple (createA, createB, ...) |
| Adding a new product type | Add to the factory switch/map | Create a whole new factory class |
| Complexity | Low | Medium-High |
| Use when | You have variants of ONE thing | You have variants of a FAMILY |
+---------------------------+-------------------------------+----------------------------------+
Decision Flow
Do you need to create a SINGLE type of object with variants?
YES --> Factory Method (9.3.b)
Do you need to create MULTIPLE related objects that must be compatible?
YES --> Abstract Factory (this section)
Example:
"Create a notification" --> Factory Method
"Create a button + input + modal that all match a theme" --> Abstract Factory
Factory Selector Pattern
In practice, you often need a way to pick the right factory at runtime:
class FactorySelector {
static factories = {
postgres: PostgresFactory,
mongo: MongoFactory,
};
static getFactory(type) {
const Factory = FactorySelector.factories[type];
if (!Factory) {
throw new Error(`No factory registered for "${type}"`);
}
return new Factory();
}
}
// --- Usage ---
const dbType = process.env.DB_TYPE || 'postgres';
const factory = FactorySelector.getFactory(dbType);
setupDatabase(factory);
// Change DB_TYPE env var --> entire database layer switches
Adding a New Family
The power of Abstract Factory: adding SQLite support means creating ONE new factory and its products. Zero changes to client code.
// New family: SQLite
class SQLiteConnection extends Connection {
connect() { console.log('[SQLite] Opened file-based DB'); return this; }
disconnect() { console.log('[SQLite] DB file closed'); }
}
class SQLiteQueryBuilder extends QueryBuilder {
constructor() { super(); this._parts = {}; }
select(fields) { this._parts.select = fields; return this; }
from(table) { this._parts.from = table; return this; }
where(condition) { this._parts.where = condition; return this; }
build() {
return `SELECT ${this._parts.select} FROM ${this._parts.from} WHERE ${this._parts.where};`;
}
}
class SQLiteMigrator extends Migrator {
constructor() { super(); this._operations = []; }
createTable(name, schema) {
const cols = Object.entries(schema).map(([c, t]) => `${c} ${t}`).join(', ');
this._operations.push(`CREATE TABLE IF NOT EXISTS ${name} (${cols});`);
return this;
}
dropTable(name) { this._operations.push(`DROP TABLE IF EXISTS ${name};`); return this; }
run() {
console.log('[SQLite] Running migrations:');
this._operations.forEach((op) => console.log(` ${op}`));
}
}
class SQLiteFactory extends DatabaseFactory {
createConnection() { return new SQLiteConnection(); }
createQueryBuilder() { return new SQLiteQueryBuilder(); }
createMigrator() { return new SQLiteMigrator(); }
}
// Register and use -- no changes to setupDatabase()!
FactorySelector.factories.sqlite = SQLiteFactory;
setupDatabase(FactorySelector.getFactory('sqlite'));
Key Takeaways
- Abstract Factory creates families of related objects that are guaranteed to be compatible with each other.
- Use it when you need to swap entire product families at once (themes, database engines, platform-specific code).
- It enforces consistency -- you can't accidentally mix a Postgres connection with a Mongo query builder.
- The trade-off is complexity -- every new product in the family means updating every factory. Every new family means a full set of concrete products.
- Combine with a Factory Selector to choose the right factory at runtime based on config or environment.
Explain-It Challenge: Your team is building an app that must support both REST and GraphQL APIs. Each needs its own request builder, response parser, and error handler. Explain why Abstract Factory fits this scenario, in two sentences.