Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 012 / 100

Abstract factory: a Design Patterns guide for developers

Create families of related objects that work together.

Expona Admin9 min read

Opening hook

You're building a SaaS platform that needs to support AWS, Azure, and Google Cloud. Your codebase is littered with conditional checks: if it's AWS, create an S3 bucket and SQS queue; if it's Azure, create a Storage Account and Service Bus queue instead. Each cloud provider has a different API, different configuration, and different error handling. This is where Abstract factory: a Design Patterns guide for developers becomes essential—it's the pattern that lets you create families of related objects that work together seamlessly, without scattering cloud-specific logic throughout your application.

In real-world applications, you often need to create groups of related objects that must work together. Consider these scenarios:

  • A UI framework supporting both light and dark themes—buttons, inputs, and modals need matching colors and styles.
  • A cloud abstraction layer handling AWS, Azure, and GCP—each has storage, queues, databases, and compute services with completely different APIs.
  • A payment processing system supporting Stripe, PayPal, and Square—each has different payment methods, refund logic, and webhook formats.
  • A database connector supporting PostgreSQL, MySQL, and MongoDB—query builders, connection pooling, and transaction handling differ per database.

The naive approach couples your business logic to specific implementations. You write conditional statements everywhere:

typescript

if (provider === 'aws') {
  const storage = new S3Client();
  const queue = new SqsClient();
} else if (provider === 'azure') {
  const storage = new BlobStorageClient();
  const queue = new ServiceBusClient();
} else if (provider === 'gcp') {
  const storage = new GcsClient();
  const queue = new PubSubClient();
}

This scales poorly. You might accidentally mix an AWS storage client with an Azure queue. New cloud providers require changes across multiple files. Testing becomes painful because you're testing conditional logic instead of abstractions. The abstract factory pattern solves this by letting you define a factory for each family of related objects.

A factory per family: consistent object creation

The abstract factory pattern defines an interface that creates families of related objects. Each concrete factory implements that interface and creates objects specific to one family. Here's how it works:

typescript

// Define what any storage and queue must do
interface Storage {
  put(key: string, data: Buffer): Promise;
  get(key: string): Promise;
  delete(key: string): Promise;
}

interface Queue {
  send(message: string): Promise;
  receive(): Promise;
  acknowledge(messageId: string): Promise;
}

// Define what a factory for a cloud family must do
interface CloudFactory {
  createStorage(): Storage;
  createQueue(): Queue;
}

// AWS implementation
class AwsStorage implements Storage {
  private s3Client = new S3Client();

  async put(key: string, data: Buffer): Promise {
    await this.s3Client.putObject({
      Bucket: process.env.AWS_BUCKET,
      Key: key,
      Body: data,
    });
  }

  async get(key: string): Promise {
    const response = await this.s3Client.getObject({
      Bucket: process.env.AWS_BUCKET,
      Key: key,
    });
    return response.Body as Buffer;
  }

  async delete(key: string): Promise {
    await this.s3Client.deleteObject({
      Bucket: process.env.AWS_BUCKET,
      Key: key,
    });
  }
}

class AwsQueue implements Queue {
  private sqsClient = new SqsClient();

  async send(message: string): Promise {
    await this.sqsClient.sendMessage({
      QueueUrl: process.env.AWS_QUEUE_URL,
      MessageBody: message,
    });
  }

  async receive(): Promise {
    const response = await this.sqsClient.receiveMessage({
      QueueUrl: process.env.AWS_QUEUE_URL,
      MaxNumberOfMessages: 10,
    });
    return (response.Messages || []).map(m => m.Body);
  }

  async acknowledge(messageId: string): Promise {
    // AWS SQS acknowledge logic
  }
}

class AwsFactory implements CloudFactory {
  createStorage(): Storage {
    return new AwsStorage();
  }

  createQueue(): Queue {
    return new AwsQueue();
  }
}

// Azure implementation
class AzureStorage implements Storage {
  private blobClient = new BlobServiceClient(
    `https://${process.env.AZURE_ACCOUNT}.blob.core.windows.net`
  );
  private containerClient = this.blobClient.getContainerClient(
    process.env.AZURE_CONTAINER
  );

  async put(key: string, data: Buffer): Promise {
    const blockBlobClient = this.containerClient.getBlockBlobClient(key);
    await blockBlobClient.upload(data, data.length);
  }

  async get(key: string): Promise {
    const blockBlobClient = this.containerClient.getBlockBlobClient(key);
    const downloadResponse = await blockBlobClient.download(0);
    return await downloadResponse.blobBody.arrayBuffer() as unknown as Buffer;
  }

  async delete(key: string): Promise {
    const blockBlobClient = this.containerClient.getBlockBlobClient(key);
    await blockBlobClient.delete();
  }
}

class AzureQueue implements Queue {
  private queueClient = new QueueClient(
    `https://${process.env.AZURE_ACCOUNT}.queue.core.windows.net/${process.env.AZURE_QUEUE_NAME}`
  );

  async send(message: string): Promise {
    await this.queueClient.sendMessage(message);
  }

  async receive(): Promise {
    const messages = await this.queueClient.receiveMessages({
      numberOfMessages: 10,
    });
    return messages.receivedMessages.map(m => m.messageText);
  }

  async acknowledge(messageId: string): Promise {
    // Azure Service Bus acknowledge logic
  }
}

class AzureFactory implements CloudFactory {
  createStorage(): Storage {
    return new AzureStorage();
  }

  createQueue(): Queue {
    return new AzureQueue();
  }
}

// GCP implementation follows the same pattern
class GcpStorage implements Storage { }
class GcpQueue implements Queue { }
class GcpFactory implements CloudFactory {
  createStorage(): Storage { return new GcpStorage(); }
  createQueue(): Queue { return new GcpQueue(); }
}

Now your business logic doesn't care which cloud provider you're using:

typescript

class FileService {
  constructor(private factory: CloudFactory) {}

  async uploadAndNotify(fileData: Buffer, fileName: string): Promise {
    const storage = this.factory.createStorage();
    const queue = this.factory.createQueue();

    await storage.put(fileName, fileData);
    await queue.send(`File uploaded: ${fileName}`);
  }
}

// Usage
const provider = process.env.CLOUD_PROVIDER || 'aws';
let factory: CloudFactory;

switch (provider) {
  case 'azure':
    factory = new AzureFactory();
    break;
  case 'gcp':
    factory = new GcpFactory();
    break;
  default:
    factory = new AwsFactory();
}

const fileService = new FileService(factory);
await fileService.uploadAndNotify(buffer, 'my-file.txt');

Consistency guaranteed: preventing mismatched components

The strongest benefit of the abstract factory pattern is enforcing consistency across object families. Once you've instantiated a factory, all objects it creates work together by design:

  • Type safety: If your AwsFactory creates an AwsQueue, you can't accidentally call an Azure-specific method on it. The interface defines the contract.
  • Configuration alignment: The AwsFactory can ensure all its products are configured to use the same AWS region, account, or credentials without your business logic thinking about it.
  • No runtime surprises: You avoid the bug where you create AWS storage but Azure queues, which would fail only in production when edge cases trigger the mismatch.
  • Easy testing: Create a MockFactory that returns stub implementations. Your tests never touch real cloud services and run in milliseconds.
typescript

// Mock factory for testing
class MockFactory implements CloudFactory {
  createStorage(): Storage {
    return {
      put: jest.fn().mockResolvedValue(undefined),
      get: jest.fn().mockResolvedValue(Buffer.from('test')),
      delete: jest.fn().mockResolvedValue(undefined),
    };
  }

  createQueue(): Queue {
    return {
      send: jest.fn().mockResolvedValue(undefined),
      receive: jest.fn().mockResolvedValue(['msg1', 'msg2']),
      acknowledge: jest.fn().mockResolvedValue(undefined),
    };
  }
}

// Tests run fast and deterministically
const fileService = new FileService(new MockFactory());

Often overkill: knowing when to apply the pattern

Abstract factory is powerful but adds complexity. Use it when:

  • You have distinct families of objects: Not just random unrelated objects, but groups that belong together semantically (cloud providers, UI themes, database systems).
  • You need to swap entire families: If you're only ever using one provider, you don't need the pattern. If you might deploy to AWS today and Azure tomorrow, abstract factory shines.
  • Consistency across families matters: If mixing products from different families would break your application, the pattern prevents bugs.
  • You're tired of conditional logic: If your codebase has provider-specific branches scattered across files, the pattern consolidates that logic in one place.

Skip it if:

  • You're building for a single, fixed provider and see no reason to change.
  • The families don't have enough in common—you'd end up with empty or trivial methods in concrete factories.
  • Your team is unfamiliar with the pattern and you're just starting a project. Start with simpler patterns first.

Intermediate complexity requires intermediate solutions. A startup building on AWS alone probably doesn't need abstract factory. A platform supporting multiple clouds absolutely does.

Common mistakes

  • Leaking provider-specific details into the interface: If your Storage interface includes AWS-only methods like `enableTransferAcceleration()`, you've already failed. The interface must stay agnostic.
  • Mixing factory selection with business logic: Don't pass the provider string to your service class. Instantiate the correct factory outside, then inject it. Keep your service class clean.
  • Creating factories per object instead of per family: A StorageFactory and a QueueFactory is overkill. Create one CloudFactory that produces all related objects together. That's the point.
  • Forgetting to add new products to all concrete factories: If you add a new DatabaseConnection interface, every factory must implement it. Use TypeScript's strict mode and abstract base classes to catch this at compile time, not in production.
  • Over-engineering for extensibility that never comes: Support AWS and Azure? Great. Don't design for 47 hypothetical providers. Add support when the requirement actually arrives.

Key takeaways

  • Abstract factory lets you define an interface that creates families of related objects, ensuring consistency and preventing accidental mixing of incompatible components.
  • Each concrete factory creates all products for its family using the shared interface, letting you swap entire families by changing one factory instance.
  • Use it when you have distinct families (cloud providers, UI themes, database systems) and need to swap between them without scattered conditional logic.
  • Keep interfaces agnostic of implementation details. Storage should not expose AWS-specific methods. This preserves the pattern's abstraction.
  • Avoid the pattern if you're locked into a single provider or if your "families" are artificial. The complexity should be justified by real requirements.
  • Use mock factories in tests to run fast, deterministic tests without touching real cloud services.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles