Episode 9 — System Design / 9.3 — Creational Design Patterns

9.3 Interview Questions -- Creational Design Patterns


Q1: What are creational design patterns, and why do they matter?

Model Answer:

Creational design patterns abstract the object instantiation process, decoupling what gets created from how and where it gets created. They matter for three reasons:

  1. Flexibility -- You can swap concrete classes without changing client code. If your app uses PaymentFactory.create('stripe'), switching to a new provider means changing one factory entry, not every file that creates a processor.

  2. Testability -- When creation is centralized, you can inject mocks or stubs easily. Without factories, you'd have to intercept new ConcreteClass() calls everywhere.

  3. Reduced coupling -- Client code depends on interfaces, not concrete implementations. This follows the Dependency Inversion Principle (the "D" in SOLID).

The five classic creational patterns are Singleton, Factory Method, Abstract Factory, Builder, and Prototype. In JavaScript specifically, some patterns (Singleton via module caching, Prototype via prototypal inheritance) have language-native support that simplifies their implementation.


Q2: Explain the Singleton pattern. What are its drawbacks?

Model Answer:

Singleton ensures a class has exactly one instance and provides a global access point to it. The typical implementation uses a private constructor and a static getInstance() method that creates the instance on first call and returns it on subsequent calls.

Legitimate use cases: database connection pools, configuration managers, logger instances, and hardware interface managers.

Drawbacks:

  1. Hidden dependencies -- When a function calls Config.getInstance() internally, its dependency on Config is invisible from its signature. This makes code harder to reason about and refactor.

  2. Testing difficulty -- State persists across tests unless you manually reset the instance. This causes flaky tests and order-dependent failures.

  3. Tight coupling to a global -- Code that uses a singleton becomes coupled to that specific class. Swapping it for a mock or a different implementation requires workarounds.

  4. Concurrency issues -- In async JavaScript, if getInstance() triggers async initialization (like connecting to a database), concurrent callers might each create a new instance. The fix is storing the initialization Promise.

The modern approach in JavaScript is to use module-level singletons (export an instance, not a class) and dependency injection for testability. Reserve class-based singletons for cases where you truly need to enforce "exactly one."


Q3: When would you use Factory Method over directly calling a constructor?

Model Answer:

I'd use Factory Method in three scenarios:

  1. The concrete class depends on runtime input. For example, a notification system where the type (email, SMS, push) comes from user preferences or API payloads. The factory encapsulates the decision logic so callers just say factory.create(type).

  2. You want to decouple the caller from concrete classes. If my controller imports EmailNotification directly, adding SlackNotification means editing the controller. With a factory, the controller only knows the Notification interface.

  3. Creation logic is complex. If constructing the object involves validation, defaults computation, or async work (like API key verification), a factory centralizes this instead of duplicating it at every call site.

I would NOT use a factory if there's only one concrete class with a simple constructor. Wrapping new User(name, email) in UserFactory.create(name, email) adds indirection with zero benefit.

The key design principle is Open/Closed: a factory lets you add new product types without modifying existing client code. You just register a new class in the factory.


Q4: How does Abstract Factory differ from Factory Method? Give a real example.

Model Answer:

Factory Method creates one product at a time -- you call createNotification('email') and get back a single notification object.

Abstract Factory creates families of related objects that must be used together. You call factory.createButton(), factory.createInput(), and factory.createModal(), and the factory guarantees they're all from the same family (all Material, or all Bootstrap).

Real example: cross-platform database layer.

PostgresFactory:
  createConnection() --> PostgresConnection
  createQueryBuilder() --> PostgresQueryBuilder
  createMigrator() --> PostgresMigrator

MongoFactory:
  createConnection() --> MongoConnection
  createQueryBuilder() --> MongoQueryBuilder
  createMigrator() --> MongoMigrator

The client code calls factory.createConnection() and factory.createQueryBuilder() without knowing which database it's using. Swapping from Postgres to Mongo means changing one line (which factory is injected).

The critical guarantee is family consistency -- you can't accidentally use a Postgres connection with a Mongo query builder. The factory ensures all products are compatible.

Trade-off: Abstract Factory requires more classes. Every new product type (e.g., adding a CacheAdapter) means adding a method to every factory AND a concrete class for every family.


Q5: Explain the Builder pattern with a practical example. When is it overkill?

Model Answer:

Builder separates the construction of a complex object from its representation, letting you build it step by step through a fluent interface.

Practical example: HTTP request builder.

const request = new HTTPRequestBuilder()
  .post('https://api.example.com/users')
  .json({ name: 'Alice' })
  .bearerToken('my-token')
  .timeout(5000)
  .retry(3)
  .build();

Each method configures one aspect and returns this for chaining. The build() method validates the configuration (e.g., POST should have a body), creates the final immutable object, and resets the builder for reuse.

Why it's valuable:

  • Self-documenting: .bearerToken('x') is clearer than a positional parameter.
  • Flexible: skip any optional step.
  • Validated: build() catches invalid combinations.
  • Immutable: the final object is frozen.

When it's overkill:

  • Objects with 1-3 required params and no optional params. new Point(x, y) doesn't need a builder.
  • Simple DTOs where a plain object literal is sufficient: { name: 'Alice', age: 25 }.
  • When every field is always required -- the constructor forces completeness, which is what you want.

A good heuristic: if you find yourself passing null for parameters you don't need, consider a Builder.


Q6: What is the Prototype pattern and how does JavaScript support it natively?

Model Answer:

The Prototype pattern creates new objects by cloning existing ones instead of constructing from scratch. It's useful when object creation is expensive (e.g., loading config from a database, initializing heavy resources).

JavaScript has native support through two mechanisms:

  1. Prototypal inheritance -- Every object has a [[Prototype]] link. Object.create(proto) creates a new object that delegates property lookups to proto. This isn't cloning (it's delegation), but it's the foundation of JS's object model.

  2. structuredClone() -- The modern built-in for deep cloning. It handles Date, Map, Set, ArrayBuffer, and circular references. It does NOT handle functions or class methods.

The pattern in practice:

// Expensive template creation (done once)
const serverConfig = loadAndValidateConfig(); // 2 seconds

// Cheap clones for different services (done many times)
const apiConfig = structuredClone(serverConfig);
apiConfig.port = 8081;

const workerConfig = structuredClone(serverConfig);
workerConfig.cache.enabled = false;

Critical pitfall: Shallow copy (spread, Object.assign) shares nested objects. Mutating a nested property on the clone mutates the original. Always use deep copy for objects with nested state.

A common extension is the Prototype Registry -- a store of named templates that can be cloned on demand, like document templates, game entity types, or infrastructure presets.


Q7: You need a logging system. Walk through which creational patterns you'd consider and why.

Model Answer:

I'd evaluate each pattern against the requirements:

Singleton -- likely yes. A logger typically should be one instance per process. Multiple loggers could interleave output, duplicate file handles, or fragment log streams. A module-level singleton (module.exports = new Logger()) is sufficient in Node.js.

Factory Method -- depends on output targets. If the logger needs to write to different destinations (console, file, cloud service), I'd use a factory to create the appropriate transport. LogTransportFactory.create('cloudwatch') returns a transport that implements a common write(entry) interface. This way, adding Datadog support means registering one new class.

Builder -- maybe for configuration. If the logger has many options (level, format, destinations, timestamp format, max file size, rotation policy), a builder makes configuration readable:

const logger = new LoggerBuilder()
  .setLevel('info')
  .setFormat('json')
  .addDestination('stdout')
  .addDestination('file', { path: '/var/log/app.log', rotate: '1d' })
  .enableTimestamps('ISO')
  .build();

Abstract Factory -- unlikely. I'd only use this if I needed families of related logging components (logger + formatter + transport) that must be consistent across environments. Usually, a single factory suffices.

Prototype -- unlikely. Logger creation isn't expensive enough to warrant cloning.

My recommendation: Singleton for the logger instance, Factory Method for transports, Builder for configuration if the options are complex.


Q8: How do you handle the testing challenges that creational patterns introduce?

Model Answer:

Each pattern has specific testing challenges:

Singleton -- state leaks between tests. Solution: Add a _resetInstance() method for test use. Better yet, use dependency injection -- pass the logger as a parameter instead of calling Logger.getInstance() inside functions. In Jest, jest.mock('./logger') replaces the module-level singleton entirely.

Factory Method -- hard to test creation logic separately from product behavior. Solution: Test the factory and the products independently. Test that the factory returns the correct type for each input. Test each product class in isolation. For integration, test through the factory.

Abstract Factory -- complex class hierarchy makes test setup verbose. Solution: Create a TestFactory that produces mock/stub products. Inject this factory in tests instead of a real one. This validates that your client code depends on interfaces, not implementations.

Builder -- validation logic in build(). Solution: Test the builder's validation thoroughly: required fields missing, invalid combinations, boundary values. Test that build() produces immutable objects. Test the fluent interface returns the correct builder instance.

Prototype -- shallow vs deep copy bugs. Solution: Write tests that mutate the clone's nested properties and verify the original is unchanged. This is the single most important Prototype test.

General principle: Creational patterns centralize creation, which is good for testing -- you have ONE place to mock instead of many. The trade-off is that singletons and factories become implicit dependencies, so prefer explicit injection.


Q9: Design a payment processing system using creational patterns. Which patterns would you use and why?

Model Answer:

Here's my design:

+-------------------------------------------------------------------+
|                    Payment System Architecture                     |
+-------------------------------------------------------------------+
|                                                                   |
|  [Factory Method] PaymentProcessorFactory                         |
|    - create('stripe') --> StripeProcessor                          |
|    - create('paypal') --> PayPalProcessor                          |
|    - create('crypto') --> CryptoProcessor                          |
|                                                                   |
|  [Abstract Factory] CheckoutFactory (Domestic / International)     |
|    - createProcessor()      --> matches region                     |
|    - createTaxCalculator()  --> matches region                     |
|    - createShippingCalc()   --> matches region                     |
|                                                                   |
|  [Builder] OrderBuilder                                            |
|    - .addItem(item)                                                |
|    - .setShipping(address)                                         |
|    - .setPayment(method)                                           |
|    - .applyCoupon(code)                                            |
|    - .build() --> validates and returns immutable Order             |
|                                                                   |
|  [Singleton] PaymentGatewayManager                                 |
|    - Single instance managing API keys, connection pools            |
|    - Rate limiting, circuit breaking                                |
|                                                                   |
|  [Prototype] (Not used -- creation isn't expensive enough)          |
+-------------------------------------------------------------------+

Reasoning:

  • Factory Method for processors: New payment methods are added quarterly. The factory's registry lets us add them without modifying existing code. Each processor implements charge(), refund(), validate().

  • Abstract Factory for regional checkout: Domestic orders need USD tax calculation and USPS shipping. International orders need VAT calculation and DHL shipping. The Abstract Factory ensures all three components (processor, tax, shipping) are compatible for a region.

  • Builder for orders: Orders have required fields (items, address) and many optional ones (coupon, gift wrap, special instructions, insurance). Builder makes this readable and validated.

  • Singleton for the gateway manager: One connection pool per payment API, one rate limiter, one circuit breaker. Multiple instances would bypass rate limits.


Q10: Compare using creational patterns in JavaScript vs a statically typed language like Java or TypeScript. What changes?

Model Answer:

JavaScript:

  • Singleton is simpler -- module caching gives you singletons for free. No private constructor hack needed.
  • Factory functions replace factory classes -- JavaScript's first-class functions mean you often don't need a class hierarchy. A simple function with a map lookup suffices.
  • Prototype is native -- JavaScript is a prototypal language. Object.create() and structuredClone() are built-in. In Java, you'd implement Cloneable.
  • No interface enforcement -- JavaScript relies on duck typing. There's no compiler error if a factory product is missing a method. You discover this at runtime.
  • Builder is still very useful -- The fluent interface pattern works identically. JavaScript's lack of method overloading makes Builder even more valuable than named parameters.

TypeScript/Java:

  • Interfaces enforce contracts -- interface Notification { send(msg: string): void } guarantees every factory product has send. The compiler catches mistakes.
  • Abstract classes provide partial implementations -- You can share common logic through abstract base classes, which JavaScript approximates with base classes + manual throw new Error('not implemented').
  • Generics make factories type-safe -- Factory<T extends Product> ensures the factory's create() method returns the correct type.
  • Private constructors -- Java/TypeScript can truly prevent new Singleton() from outside. JavaScript has #private fields (ES2022) but they're newer and less common.

Practical impact: In JavaScript, use simpler implementations (factory functions, module singletons). In TypeScript/Java, lean into the type system (interfaces, abstract classes, generics) for compile-time safety.


Q11: Your interviewer asks you to design a document generation system. Walk through your design.

Model Answer:

Requirements: Generate documents in multiple formats (PDF, HTML, DOCX) from the same data. Documents have sections: header, body, table of contents, appendix. Some sections are optional.

Design:

Builder for document construction:

const doc = new DocumentBuilder()
  .setTitle('Q4 Report')
  .addAuthor('Alice')
  .addTableOfContents()
  .addSection('Introduction', introContent)
  .addSection('Revenue', revenueContent)
  .addChart('revenue-chart', chartData)
  .addAppendix('Raw Data', rawData)
  .setPageSize('A4')
  .build();

Builder because: documents have many optional sections, order matters, and the final build() can validate (e.g., table of contents requires at least one section).

Abstract Factory for format-specific rendering:

PDFFactory:
  createRenderer() --> PDFRenderer
  createStyler()   --> PDFStyler
  createExporter() --> PDFExporter

HTMLFactory:
  createRenderer() --> HTMLRenderer
  createStyler()   --> HTMLStyler (CSS)
  createExporter() --> HTMLExporter

Abstract Factory because: each format needs a family of compatible components. A PDF renderer needs a PDF styler, not an HTML styler.

Prototype for document templates:

const templates = new TemplateRegistry();
templates.register('quarterly-report', baseQuarterlyTemplate);
templates.register('invoice', baseInvoiceTemplate);

const q4Report = templates.create('quarterly-report', {
  title: 'Q4 2025 Report',
});

Prototype because: templates are expensive to construct (loading styles, computing layouts) and many documents share the same base structure.

Factory Method for individual components:

const chart = ChartFactory.create('bar', chartData);
const table = TableFactory.create('sortable', tableData);

Factory Method because: the specific chart or table type depends on the data and user configuration.

Singleton is not needed -- document generation is stateless. Multiple generators running concurrently is fine.

How they interact:

  1. Clone a template (Prototype) to get the base structure.
  2. Use the Builder to customize sections and add content.
  3. Pass the built document to an Abstract Factory to render it in the chosen format.
  4. The factory's renderer uses Factory Method internally to create charts and tables.