Job Interview Questions - Design Patterns
- [[#What is Singleton Pattern?]]
- [[#What is the Observer Pattern?]]
- [[#What is Strategy Pattern?]]
What is Singleton Pattern?
The Singleton pattern is a creational design pattern that ensures a class has only one instance throughout the application's lifetime and provides a global point of access to that instance.
Key Characteristics
The Singleton pattern restricts instantiation of a class to a single object and typically provides a static method to access that instance. Once created, the same instance is returned on subsequent calls.
When to Use Singleton
Common use cases include:
- Database connection pools
- Logging services
- Configuration managers
- Cache managers
- Thread pools
- Application settings
Implementation Examples
Here's a basic implementation in TypeScript:
// 1. Basic Singleton Implementation
class BasicSingleton {
private static instance: BasicSingleton;
// Private constructor prevents external instantiation
private constructor() {}
public static getInstance(): BasicSingleton {
if (!BasicSingleton.instance) {
BasicSingleton.instance = new BasicSingleton();
}
return BasicSingleton.instance;
}
public doSomething(): void {
console.log('Doing something...');
}
}
// 2. Generic Singleton Base Class
abstract class Singleton {
private static instances: Map<string, Singleton> = new Map();
protected constructor() {}
public static getInstance<T extends Singleton>(this: new () => T): T {
const className = this.name;
if (!Singleton.instances.has(className)) {
Singleton.instances.set(className, new this());
}
return Singleton.instances.get(className) as T;
}
}
// 3. Logger Singleton Example
class Logger extends Singleton {
private logs: string[] = [];
private constructor() {
super();
}
public log(message: string): void {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${message}`;
this.logs.push(logEntry);
console.log(logEntry);
}
public getLogs(): readonly string[] {
return this.logs; // TypeScript readonly array
}
public clearLogs(): void {
this.logs = [];
}
}
// 4. Database Connection Singleton with Interface
interface DatabaseConfig {
host: string;
port: number;
database: string;
username: string;
password: string;
}
class DatabaseConnection {
private static instance: DatabaseConnection;
private config?: DatabaseConfig;
private isConnected: boolean = false;
private constructor() {}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
public configure(config: DatabaseConfig): void {
this.config = config;
}
public async connect(): Promise<void> {
if (!this.isConnected && this.config) {
console.log(`Connecting to ${this.config.database} at ${this.config.host}:${this.config.port}`);
// Simulate async connection
await new Promise(resolve => setTimeout(resolve, 100));
this.isConnected = true;
}
}
public disconnect(): void {
if (this.isConnected) {
console.log('Disconnecting from database');
this.isConnected = false;
}
}
public async query<T = any>(sql: string): Promise<T[]> {
if (!this.isConnected) {
throw new Error('Database not connected');
}
console.log(`Executing: ${sql}`);
// Simulate query execution
return Promise.resolve([]);
}
public getConnectionStatus(): boolean {
return this.isConnected;
}
}
// 5. Configuration Manager with Type Safety
type ConfigValue = string | number | boolean | object;
class ConfigManager {
private static instance: ConfigManager;
private config: Map<string, ConfigValue> = new Map();
private constructor() {}
public static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
return ConfigManager.instance;
}
public set<T extends ConfigValue>(key: string, value: T): void {
this.config.set(key, value);
}
public get<T extends ConfigValue>(key: string): T | undefined;
public get<T extends ConfigValue>(key: string, defaultValue: T): T;
public get<T extends ConfigValue>(key: string, defaultValue?: T): T | undefined {
const value = this.config.get(key);
return value !== undefined ? (value as T) : defaultValue;
}
public has(key: string): boolean {
return this.config.has(key);
}
public loadFromObject(obj: Record<string, ConfigValue>): void {
Object.entries(obj).forEach(([key, value]) => {
this.config.set(key, value);
});
}
public getAllSettings(): Record<string, ConfigValue> {
return Object.fromEntries(this.config.entries());
}
}
// 6. Singleton with Decorator Pattern
function singleton<T extends new (...args: any[]) => any>(constructor: T) {
let instance: InstanceType<T>;
return class extends constructor {
constructor(...args: any[]) {
if (instance) {
return instance;
}
super(...args);
instance = this as InstanceType<T>;
}
} as T;
}
@singleton
class DecoratedSingleton {
public readonly id: string;
constructor() {
this.id = Math.random().toString(36);
console.log(`DecoratedSingleton created with id: ${this.id}`);
}
public getId(): string {
return this.id;
}
}
// 7. Module-based Singleton (ES6 Modules are singletons by nature)
class ApiClient {
private baseUrl: string = '';
private headers: Record<string, string> = {};
public setBaseUrl(url: string): void {
this.baseUrl = url;
}
public setHeader(key: string, value: string): void {
this.headers[key] = value;
}
public setHeaders(headers: Record<string, string>): void {
this.headers = { ...this.headers, ...headers };
}
public async get<T = any>(endpoint: string): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
console.log(`GET ${url}`);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 100));
return { data: 'mock data' } as T;
}
public async post<T = any>(endpoint: string, data: any): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
console.log(`POST ${url}`, data);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 100));
return { success: true } as T;
}
}
// Export singleton instance (preferred approach in modern TypeScript)
export const apiClient = new ApiClient();
// 8. Lazy Singleton with getter
class LazySingleton {
private static _instance: LazySingleton;
private data: Map<string, any> = new Map();
private constructor() {
console.log('LazySingleton instance created');
}
public static get instance(): LazySingleton {
if (!LazySingleton._instance) {
LazySingleton._instance = new LazySingleton();
}
return LazySingleton._instance;
}
public setData(key: string, value: any): void {
this.data.set(key, value);
}
public getData(key: string): any {
return this.data.get(key);
}
}
// Usage Examples and Demonstrations
async function demonstrateUsage(): Promise<void> {
console.log('=== Singleton Pattern Demonstrations ===\n');
// 1. Basic Singleton
console.log('1. Basic Singleton:');
const basic1 = BasicSingleton.getInstance();
const basic2 = BasicSingleton.getInstance();
console.log('Same instance:', basic1 === basic2);
basic1.doSomething();
// 2. Logger Singleton
console.log('\n2. Logger Singleton:');
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();
console.log('Same logger instance:', logger1 === logger2);
logger1.log('Application started');
logger2.log('User logged in');
console.log('Total logs:', logger1.getLogs().length);
// 3. Database Connection
console.log('\n3. Database Connection:');
const db = DatabaseConnection.getInstance();
db.configure({
host: 'localhost',
port: 5432,
database: 'myapp',
username: 'user',
password: 'password'
});
await db.connect();
console.log('Connected:', db.getConnectionStatus());
// 4. Configuration Manager
console.log('\n4. Configuration Manager:');
const config = ConfigManager.getInstance();
config.loadFromObject({
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
debug: true
});
console.log('API URL:', config.get('apiUrl'));
console.log('Timeout:', config.get('timeout', 3000));
console.log('Debug mode:', config.get('debug'));
// 5. Decorated Singleton
console.log('\n5. Decorated Singleton:');
const decorated1 = new DecoratedSingleton();
const decorated2 = new DecoratedSingleton();
console.log('Same decorated instance:', decorated1 === decorated2);
console.log('ID:', decorated1.getId());
// 6. Module-based Singleton
console.log('\n6. API Client (Module Singleton):');
apiClient.setBaseUrl('https://api.example.com');
apiClient.setHeader('Authorization', 'Bearer token123');
const response = await apiClient.get('/users');
console.log('API Response:', response);
// 7. Lazy Singleton
console.log('\n7. Lazy Singleton:');
console.log('Accessing lazy singleton...');
const lazy = LazySingleton.instance;
lazy.setData('initialized', true);
const lazy2 = LazySingleton.instance;
console.log('Same lazy instance:', lazy === lazy2);
console.log('Data persisted:', lazy2.getData('initialized'));
}
// Run the demonstration
demonstrateUsage().catch(console.error);
Advantages
- Controlled access: Ensures only one instance exists
- Reduced memory footprint: Saves memory by reusing the same instance
- Global access point: Easy to access from anywhere in the application
- Lazy initialization: Instance created only when needed (in some implementations)
Disadvantages
- Violates Single Responsibility Principle: Class manages both its functionality and its instantiation
- Difficult to test: Hard to mock or substitute for testing
- Hidden dependencies: Makes dependencies less explicit
- Threading issues: Can be complex to implement correctly in multithreaded environments
- Global state: Can make code harder to understand and maintain
Best Practices
- Use enum implementation when possible (Java) - it's thread-safe and handles serialization automatically
- Consider dependency injection as an alternative for better testability
- Be cautious with inheritance - Singleton subclassing can be problematic
- Handle serialization carefully - implement
readResolve()
method to maintain singleton property - Consider using frameworks like Spring that manage singleton instances for you
The Singleton pattern should be used sparingly, as it can introduce tight coupling and make testing difficult. Often, dependency injection containers provide better alternatives for managing single instances while maintaining loose coupling and testability.
What is the Observer Pattern?
The Observer pattern is a behavioral design pattern that defines a one-to-many dependency between objects. When one object (the Subject or Observable) changes state, all its dependent objects (Observers) are automatically notified and updated.
Key Components:
- Subject/Observable - Maintains a list of observers and notifies them of state changes
- Observer - Defines an interface for objects that should be notified of changes
- Concrete Subject - Stores state and sends notifications to observers
- Concrete Observer - Implements the observer interface and responds to notifications
Real-World Analogy:
Think of a newspaper subscription service. The newspaper (Subject) maintains a list of subscribers (Observers). When a new edition is published, all subscribers are automatically notified and receive their copy.
Simple Example:
// Observer interface
class Observer {
update(data) {
throw new Error("Update method must be implemented");
}
}
// Subject/Observable
class NewsAgency {
constructor() {
this.observers = [];
this.news = "";
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
notify() {
this.observers.forEach(observer => observer.update(this.news));
}
setNews(news) {
this.news = news;
this.notify();
}
}
// Concrete Observers
class NewsChannel extends Observer {
constructor(name) {
super();
this.name = name;
}
update(news) {
console.log(`${this.name} broadcasting: ${news}`);
}
}
// Usage
const agency = new NewsAgency();
const cnn = new NewsChannel("CNN");
const bbc = new NewsChannel("BBC");
agency.subscribe(cnn);
agency.subscribe(bbc);
agency.setNews("Breaking: New design pattern discovered!");
When to Use Observer Pattern:
✅ Use when:
- Changes to one object require updating multiple dependent objects
- You want loose coupling between objects
- You need to notify unknown or dynamically changing sets of objects
- Implementing event handling systems
❌ Avoid when:
- There are too many observers (performance issues)
- The notification chain becomes complex
- Simple direct communication is sufficient
Common Use Cases:
- Event handling systems (DOM events, custom events)
- Model-View architectures (MVC, MVP, MVVM)
- Real-time notifications (chat apps, stock prices)
- State management (Redux, MobX)
- UI components (React state updates)
Advantages:
- Loose coupling - Subject and observers are loosely coupled
- Dynamic relationships - Observers can be added/removed at runtime
- Broadcast communication - One-to-many communication pattern
- Open/Closed principle - Easy to add new observers without modifying existing code
Disadvantages:
- Memory leaks - Observers may not be properly removed
- Performance overhead - Notifying many observers can be expensive
- Unexpected updates - Complex notification chains can be hard to debug
- Order dependency - Observer notification order might matter
Related Patterns:
- Mediator - Centralizes communication between objects
- Event Aggregator - Collects and redistributes events
- Publish-Subscribe - Similar but with message routing/filtering
Interview Follow-up Questions You Might Get:
Q: How is Observer different from Pub/Sub? A: Observer pattern typically involves direct references between subject and observers, while Pub/Sub uses an event bus/message broker for decoupled communication.
Q: How do you prevent memory leaks? A: Always unsubscribe observers when they're no longer needed, use weak references when possible, and implement proper cleanup in component lifecycles.
Q: Can you implement Observer with modern JavaScript features? A: Yes, using Proxy objects, custom events, or even RxJS observables for reactive programming.
This pattern is fundamental in modern web development, especially in frameworks like React, Vue, and Angular, where component updates are often triggered by observable state changes.
What is Strategy Pattern?
The Strategy pattern is a behavioral design pattern that allows you to define a family of algorithms, encapsulate each one, and make them interchangeable at runtime. Instead of hardcoding specific algorithms or behaviors directly into a class, the Strategy pattern lets you select which algorithm to use dynamically.
Core Components
The pattern typically involves three main parts:
- Strategy Interface - Defines a common interface for all concrete strategies
- Concrete Strategies - Different implementations of the algorithm
- Context - The class that uses a strategy and can switch between different ones
How It Works
Rather than using conditional statements (like if/else or switch) to choose between different algorithms, you create separate classes for each algorithm and let the context object delegate the work to the current strategy object.
Example
Here's a simple example using different payment methods:
// Strategy interface
class PaymentStrategy {
pay(amount) {
throw new Error("pay method must be implemented");
}
}
// Concrete strategies
class CreditCardPayment extends PaymentStrategy {
constructor(cardNumber) {
super();
this.cardNumber = cardNumber;
}
pay(amount) {
console.log(`Paid ${amount} using credit card ${this.cardNumber}`);
}
}
class PayPalPayment extends PaymentStrategy {
constructor(email) {
super();
this.email = email;
}
pay(amount) {
console.log(`Paid ${amount} using PayPal account ${this.email}`);
}
}
// Context
class ShoppingCart {
constructor(paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
setPaymentStrategy(strategy) {
this.paymentStrategy = strategy;
}
checkout(amount) {
this.paymentStrategy.pay(amount);
}
}
// Usage
const cart = new ShoppingCart(new CreditCardPayment("1234-5678"));
cart.checkout(100); // Paid $100 using credit card 1234-5678
cart.setPaymentStrategy(new PayPalPayment("user@example.com"));
cart.checkout(50); // Paid $50 using PayPal account user@example.com
Benefits
- Open/Closed Principle - Easy to add new strategies without modifying existing code
- Single Responsibility - Each strategy focuses on one specific algorithm
- Runtime flexibility - Can switch algorithms dynamically
- Eliminates conditional complexity - Reduces long if/else chains or switch statements
- Testability - Each strategy can be tested independently
When to Use
The Strategy pattern is particularly useful when you have multiple ways to perform a task and want to choose the approach at runtime, such as different sorting algorithms, validation rules, pricing calculations, or data compression methods.