Design Patterns for Humans: Making Complex Software Simple with TypeScript Snippets (Part 2)
Producer–Consumer
"One side makes work, the other side processes it."
The Producer-Consumer pattern is a classic concurrency design pattern that decouples the production of data from its consumption by introducing a shared buffer (queue) between producers and consumers. Example: A video app buffering and displaying frames.
Key Characteristics
Potential Pitfalls
When to Use
When to Avoid
class BoundedBuffer<T> {
private buffer: T[];
private capacity: number;
private count: number;
private putIndex: number;
private takeIndex: number;
private notEmpty: Promise<void>;
private notFull: Promise<void>;
private resolveNotEmpty: () => void;
private resolveNotFull: () => void;
constructor(capacity: number) {
this.capacity = capacity;
this.buffer = new Array(capacity);
this.count = 0;
this.putIndex = 0;
this.takeIndex = 0;
// Create resolvable promises for flow control
this.notEmpty = new Promise(resolve => this.resolveNotEmpty = resolve);
this.notFull = new Promise(resolve => this.resolveNotFull = resolve);
}
async put(item: T): Promise<void> {
while (this.count === this.capacity) {
await this.notFull;
}
this.buffer[this.putIndex] = item;
this.putIndex = (this.putIndex + 1) % this.capacity;
this.count++;
// Notify waiting consumers
if (this.count === 1) {
this.resolveNotEmpty();
this.notEmpty = new Promise(resolve => this.resolveNotEmpty = resolve);
}
}
async take(): Promise<T> {
while (this.count === 0) {
await this.notEmpty;
}
const item = this.buffer[this.takeIndex];
this.takeIndex = (this.takeIndex + 1) % this.capacity;
this.count--;
// Notify waiting producers
if (this.count === this.capacity - 1) {
this.resolveNotFull();
this.notFull = new Promise(resolve => this.resolveNotFull = resolve);
}
return item;
}
}
// Example usage
async function runExample() {
const buffer = new BoundedBuffer<number>(5);
// Producer
const producer = async () => {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, Math.random() * 500));
await buffer.put(i);
console.log(`Produced: ${i}`);
}
};
// Consumer
const consumer = async () => {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
const item = await buffer.take();
console.log(`Consumed: ${item}`);
}
};
await Promise.all([producer(), consumer()]);
}
runExample().catch(console.error);
Actor Model
“Each actor is like a tiny service that handles one job at a time.”
The Actor Model is a conceptual model for concurrent computation that treats "actors" as the universal primitives of computation. In response to a message it receives, an actor can make local decisions, create more actors, send more messages, and determine how to respond to the next message received. Example: Used in frameworks like Akka or languages like Erlang.
Key Characteristics
Potential Pitfalls
When to Use
When to Avoid
interface Message {
type: string;
payload?: any;
sender?: ActorRef;
}
type ActorRef = {
send: (message: Message) => void;
};
type Behavior = (message: Message, context: ActorContext) => Behavior;
interface ActorContext {
self: ActorRef;
spawn: (behavior: Behavior) => ActorRef;
}
class ActorSystem {
private actors: Map<ActorRef, Behavior> = new Map();
spawn(initialBehavior: Behavior): ActorRef {
const self: ActorRef = {
send: (message: Message) => this.handleMessage(self, message),
};
this.actors.set(self, initialBehavior);
return self;
}
private handleMessage(actor: ActorRef, message: Message): void {
const behavior = this.actors.get(actor);
if (!behavior) return;
const context: ActorContext = {
self: actor,
spawn: this.spawn.bind(this),
};
const nextBehavior = behavior({ ...message, sender: actor }, context);
this.actors.set(actor, nextBehavior);
}
}
Reactor
“React to events as they arrive. Handle I/O events asynchronously."
The Reactor pattern is a event handling architecture that efficiently manages service requests delivered concurrently to an application by one or more clients. It demultiplexes incoming requests and dispatches them synchronously to the associated request handlers.
Key Characteristics
Potential Pitfalls
Real-World Use Cases
When to Use
When to Avoid
interface EventHandler {
handleEvent(event: string, data: any): void;
}
class Reactor {
private handlers: Map<string, EventHandler[]> = new Map();
registerHandler(eventType: string, handler: EventHandler): void {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
}
this.handlers.get(eventType)?.push(handler);
}
removeHandler(eventType: string, handler: EventHandler): void {
const handlers = this.handlers.get(eventType);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}
dispatchEvent(eventType: string, data: any): void {
const handlers = this.handlers.get(eventType);
if (handlers) {
for (const handler of handlers) {
handler.handleEvent(eventType, data);
}
}
}
run(): void {
// Simulated event loop
setInterval(() => {
// In a real implementation, this would check for I/O events
const now = new Date().toISOString();
this.dispatchEvent('timer', { time: now });
}, 1000);
}
}
class LoggerHandler implements EventHandler {
handleEvent(event: string, data: any): void {
console.log(`[${event}] ${JSON.stringify(data)}`);
}
}
// Usage
const reactor = new Reactor();
const logger = new LoggerHandler();
reactor.registerHandler('timer', logger);
reactor.run();
Double-Checked Locking
"A pattern to reduce overhead of acquiring a lock multiple times."
Double-Checked Locking is a software design pattern that reduces the overhead of acquiring a lock by first testing the locking criterion without actually acquiring the lock. Only if the check indicates that locking is required does the actual locking logic proceed.
Key Characteristics
Potential Pitfalls
Real-World Use Cases
When to Use
When to Avoid
class Singleton {
private static instance: Singleton;
private static initialized: boolean = false;
private constructor() {
// Private constructor to prevent direct instantiation
}
public static getInstance(): Singleton {
if (!Singleton.initialized) {
synchronized(Singleton) {
if (!Singleton.initialized) {
Singleton.instance = new Singleton();
Singleton.initialized = true;
}
}
}
return Singleton.instance;
}
}
// Note: TypeScript/JavaScript doesn't have built-in synchronized blocks like Java.
// In a real implementation, you would use a mutex or similar synchronization primitive.
class ThreadSafeSingleton {
private static instance: ThreadSafeSingleton;
private static lock: boolean = false;
private constructor() {
// Initialization code
}
public static getInstance(): Promise<ThreadSafeSingleton> {
if (!ThreadSafeSingleton.instance) {
return new Promise((resolve) => {
if (!ThreadSafeSingleton.lock) {
ThreadSafeSingleton.lock = true;
// Simulate async initialization
setTimeout(() => {
ThreadSafeSingleton.instance = new ThreadSafeSingleton();
resolve(ThreadSafeSingleton.instance);
}, 0);
} else {
// Wait for initialization to complete
const check = setInterval(() => {
if (ThreadSafeSingleton.instance) {
clearInterval(check);
resolve(ThreadSafeSingleton.instance);
}
}, 10);
}
});
}
return Promise.resolve(ThreadSafeSingleton.instance);
}
}
// Usage
ThreadSafeSingleton.getInstance().then(instance => {
console.log('Singleton instance created');
});
Read–Write Lock
“Multiple readers, one writer.” (Not native to JS; simulated with logic or libraries.)
The Read-Write Lock (also known as Shared-Exclusive Lock) is a synchronization primitive that allows concurrent access for read-only operations while maintaining exclusive access for write operations. This pattern is particularly useful in scenarios where data is read more frequently than it is modified. Example: A shared data store that’s read often but written rarely.
Key Characteristics
Potential Pitfalls
Real-World Use Cases
When to Use
When to Avoid
interface ReadWriteLock {
readLock(): Promise<() => void>;
writeLock(): Promise<() => void>;
}
class SimpleReadWriteLock implements ReadWriteLock {
private readers = 0;
private writer = false;
private queue: (() => void)[] = [];
async readLock(): Promise<() => void> {
return new Promise((resolve) => {
const tryAcquire = () => {
if (!this.writer) {
this.readers++;
resolve(() => {
this.readers--;
this.processQueue();
});
} else {
this.queue.push(tryAcquire);
}
};
tryAcquire();
});
}
async writeLock(): Promise<() => void> {
return new Promise((resolve) => {
const tryAcquire = () => {
if (this.readers === 0 && !this.writer) {
this.writer = true;
resolve(() => {
this.writer = false;
this.processQueue();
});
} else {
this.queue.push(tryAcquire);
}
};
tryAcquire();
});
}
private processQueue() {
while (this.queue.length > 0) {
const next = this.queue[0];
if (this.writer) break;
if (next === this.queue[0]) {
this.queue.shift();
next();
}
}
}
}
class UpgradeableReadWriteLock implements ReadWriteLock {
private state: { readers: number; writer: boolean; writeRequests: number } = {
readers: 0,
writer: false,
writeRequests: 0
};
private queue: (() => void)[] = [];
async readLock(): Promise<() => void> {
return new Promise((resolve) => {
const tryAcquire = () => {
if (!this.state.writer && this.state.writeRequests === 0) {
this.state.readers++;
resolve(() => {
this.state.readers--;
this.processQueue();
});
} else {
this.queue.push(tryAcquire);
}
};
tryAcquire();
});
}
async writeLock(): Promise<() => void> {
return new Promise((resolve) => {
this.state.writeRequests++;
const tryAcquire = () => {
if (this.state.readers === 0 && !this.state.writer) {
this.state.writer = true;
this.state.writeRequests--;
resolve(() => {
this.state.writer = false;
this.processQueue();
});
} else {
this.queue.push(tryAcquire);
}
};
tryAcquire();
});
}
async upgradeToWriteLock(currentReadUnlock: () => void): Promise<() => void> {
currentReadUnlock(); // Release the read lock first
return this.writeLock(); // Acquire write lock
}
private processQueue() {
while (this.queue.length > 0) {
const next = this.queue[0];
if (this.state.writer) break;
if (next === this.queue[0]) {
this.queue.shift();
next();
}
}
}
}
Applying Patterns in Real Life
Design patterns aren't just academic tools or interview buzzwords—they appear frequently in day-to-day development. Whether writing front-end applications, backend services, or full-stack projects, developers often use patterns without explicit recognition.
How to Recognize Pattern-Shaped Problems
Consider patterns as familiar tools in a toolbox. When facing coding challenges, ask these diagnostic questions:
As pattern recognition improves, identification within your own codebase becomes more intuitive.
Framework Implementations of Patterns
Modern JavaScript frameworks incorporate patterns extensively:
React
Redux
Express.js
Understanding these implementations aids in framework mastery.
Refactoring Example: Notification System
Initial Implementation
function notifyUser(user: string, type: string) {
if (type === 'email') {
// Email implementation
} else if (type === 'sms') {
// SMS implementation
} else if (type === 'push') {
// Push notification
}
}
Strategy Pattern Refactor
interface NotificationStrategy {
send(user: string): void;
}
class EmailStrategy implements NotificationStrategy {
send(user: string) {
console.log(`Email sent to ${user}`);
}
}
class Notifier {
constructor(private strategy: NotificationStrategy) {}
notify(user: string) {
this.strategy.send(user);
}
}
// Implementation
const notifier = new Notifier(new EmailStrategy());
notifier.notify("Abdulmoiz");
This approach improves extensibility and testability while reducing conditional complexity.
Pattern Application Guidelines
Anti-Patterns of Pattern Usage
Overengineering Risks
Applying complex patterns to simple problems creates unnecessary abstraction. For example, using Abstract Factory for minimal component variation adds complexity without benefit.
Misapplication Consequences
Forcing patterns where they don't fit produces confusing code. Singleton misuse can create unnecessary global state, while excessive Decorator use may obfuscate program flow.
Core Objectives
Prioritize these qualities over pattern adherence:
Learning Resources
Recommended Platforms
Essential Literature
Practical Exercises
Conclusion: Human-Centric Pattern Use
Design patterns serve as problem-solving tools rather than rigid requirements. Their value emerges when they:
Developers should cultivate:
The ultimate goal remains creating software that balances technical excellence with human comprehension—patterns serve this purpose when applied judiciously.
Software Engineer | Next, Nest, React, Typescript
2moIt was definitely worth a read!