Design Patterns demystified with examples
JoelBonetR 🥇
Posted on October 30, 2024
So the AI era is here, the huge leap forward that, at the moment, spits Node code with const fetch = require('node-fetch')
😳 (true for both ChatGPT and Gemini as of today) and feeds yet another spin of the cyclic machine that is Internet and its content.
In that amalgamation of content, design patterns are appearing again
From posts explaining how to apply design patterns in Node(???) to posts explaining with all detail obsolete stuff like how to apply the factory pattern in Java (Java 8 released in March 2014 added Lambdas).
Definition
Ever stumbled upon Refactoring guru?
It is a website you probably visited in your learning journey through computer science, specially in programming. It's design patterns section is quite well explained and one of the most shared through different forums over the years.
If we go to the definition of what design patterns are, we find:
Design patterns are typical solutions to common problems
in software design. Each pattern is like a blueprint
that you can customize to solve a particular
design problem in your code.
Why this post, then? I mean, there's plenty of information on the website linked above; this could be all.
The thing is, I always struggled with accepting this definition... "to solve a particular design problem in my code"... in my code? Does my code have a problem that I need to solve?
Definition, reimagined
What happens really, is that I need to code a certain "something" for which the programming language used in the project is lacking abstractions for.
Plain and simply. Just in case that doesn't resonate with you yet, lets see some examples with code.
This is a really simple implementation of the Factory Pattern in Java (primarily an Object Oriented programming language).
public class ShapeFactory {
public Shape createShape(String type) {
if (type.equalsIgnoreCase("CIRCLE")) {
return new Circle();
} else if (type.equalsIgnoreCase("SQUARE")) {
return new Square();
}
return null;
}
}
Then Java 8 (March 2014, just in case you forgot) added Lambdas (a concept from functional programming) so we can do this instead:
Map<String, Supplier<Shape>> shapeFactory = new HashMap<>();
shapeFactory.put("CIRCLE", Circle::new);
shapeFactory.put("SQUARE", Square::new);
Shape circle = shapeFactory.get("CIRCLE").get();
No need for the factory design pattern ever again (at least in Java).
Yes I know the factory pattern is the example most people use all the time, but what happens with the others? And what happens in other programming languages?
This is the visitor pattern in Typescript:
interface Shape {
draw(): void;
accept(visitor: ShapeVisitor): void;
}
class Circle implements Shape {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
draw() {
console.log("Drawing a circle");
}
accept(visitor: ShapeVisitor) {
visitor.visitCircle(this);
}
}
class Square implements Shape {
sideLength: number;
constructor(sideLength: number) {
this.sideLength = sideLength;
}
draw() {
console.log("Drawing a square");
}
accept(visitor: ShapeVisitor) {
visitor.visitSquare(this);
}
}
interface ShapeVisitor {
visitCircle(circle: Circle): void;
visitSquare(square: Square): void;
}
class AreaCalculator implements ShapeVisitor {
private area = 0;
visitCircle(circle: Circle) {
this.area = Math.PI * circle.radius * circle.radius;
console.log(`Circle area: ${this.area}`);
}
visitSquare(square: Square) {
this.area = square.sideLength * square.sideLength;
console.log(`Square area: ${this.area}`);
}
getArea(): number {
return this.area;
}
}
// Using the Visitor
const circle = new Circle(5);
const square = new Square(4);
const calculator = new AreaCalculator();
circle.accept(calculator);
square.accept(calculator);
The following code is doing exactly the same but using reflection (the ability of a language to examine and manipulate its own objects at runtime) instead of the Visitor pattern:
interface Shape {
draw(): void;
}
class Circle implements Shape {
// ... (same as before)
radius: number;
}
class Square implements Shape {
// ... (same as before)
sideLength: number;
}
function calculateArea(shape: Shape) {
if (shape instanceof Circle) {
const circle = shape as Circle; // Type assertion
const area = Math.PI * circle.radius * circle.radius;
console.log(`Circle area: ${area}`);
} else if (shape instanceof Square) {
const square = shape as Square; // Type assertion
const area = square.sideLength * square.sideLength;
console.log(`Square area: ${area}`);
}
}
const circle = new Circle(5);
const square = new Square(4);
calculateArea(circle);
calculateArea(square);
Now the observer pattern, also in TypeScript:
interface Observer {
update(data: any): void;
}
class NewsPublisher {
private observers: Observer[] = [];
subscribe(observer: Observer) {
this.observers.push(observer);
}
unsubscribe(observer: Observer) {
this.observers = this.observers.filter(o => o !== observer);
}
notify(news:
string) {
this.observers.forEach(observer => observer.update(news));
}
}
class NewsletterSubscriber implements Observer {
update(news: string) {
console.log(`Received news: ${news}`);
}
}
// Using the Observer
const publisher = new NewsPublisher();
const subscriber1 = new NewsletterSubscriber();
const subscriber2 = new NewsletterSubscriber();
publisher.subscribe(subscriber1);
publisher.subscribe(subscriber2);
publisher.notify("New product launched!");
The same but using the built-in (in the Node API) EventEmitter:
import { EventEmitter } from 'events';
class NewsPublisher extends EventEmitter {
publish(news: string) {
this.emit('news', news);
}
}
const publisher = new NewsPublisher();
publisher.on('news', (news) => {
console.log(`All subscribers received the news: ${news}`);
});
publisher.publish("New product launched!");
At that point, you might have realized that the "problem" is the OOP implementation, and you would be quite right, but not fully.
Every programming paradigm, specially when taken in its most pure form, has its quirks, difficulties or "things that can't be achieved in a straight line", if you will.
Let's get ourselves to the functional programming realm. You've probably heard of Monads.
Whether you fell for the mathematical definition mind trap or not, we -software developers- could understand Monads as design patterns as well. This is because in a world of pure functions, where nothing unexpected happens, it's difficult to conceive a side effect, but most software products need side effects, so how do we...?
This is an example of the IO Monad in Haskell:
main :: IO ()
main = do
fileContent <- readFile "myFile.txt"
putStrLn fileContent
The side effect (reading a file) is contained in the IO monad.
Let's add a monadic example using typescript;
class Maybe<T> {
private value: T | null;
constructor(value: T | null) {
this.value = value;
}
static just<T>(value: T): Maybe<T> {
return new Maybe(value);
}
static nothing<T>(): Maybe<T> {
return new Maybe<T>(null);
}
map<U>(fn: (value: T) => U): Maybe<U> {
if (this.value === null) {
return Maybe.nothing<U>();
} else {
return Maybe.just(fn(this.value));
}
}
}
// Usage
const user = Maybe.just({ name: "Alice", age: 30 });
const userName = user.map(u => u.name); // Maybe<string> with value "Alice"
const noUser = Maybe.nothing();
const noUserName = noUser.map(u => u.name);
A classic one, I've seen the maybe monad like 50 times all over the Internet, but what is it, really?
The problem it's trying to solve:
let user;
We forgot to define the properties of our object! 😩
in a real use-case this would be the input from a side-effect mostly, like reading from a database or a file
So now if we do:
const userName = user.value.name; // Uncaught TypeError: Cannot read properties of undefined (reading 'value')
the program explodes.
The solution without the Maybe monad:
const userName = user?.value?.name; // undefined
The program does not explode.
The maybe monad is not necessary in JavaScript or typescript due to the optional chaining operator but if you're using a language that does not implement it... well, you can apply the maybe monad or shall I say design pattern?
Yes I know, there's people that just learnt the Maybe thingy and eagerly applied it to 6 side-projects all at once and now I'm being the giggle at the party for telling you "you don't need it". You can still use it though, in fact I invite you to do so if you feel it's cool (at the end of the day it's your code + with that pretty face you can do whatever you want! 🤭)
But back to basics. What about other paradigms? If you're thinking outside the OOP/FP box, I like it!
All paradigms definitely have their own recurring solutions and techniques, even if they aren't always formally called "design patterns."
Here are a few examples (thanks Gemini for avoiding me thinking, thanks me for the pretty formatting and added value 😁):
Logic Programming:
- Constraint Logic Programming: This paradigm involves defining constraints and relationships between variables, and then letting the system find solutions that satisfy those constraints. Techniques like backtracking and constraint propagation are crucial for efficient problem-solving in this paradigm. (Quite useful when dealing with AI).
- Deductive Databases: These databases use logical rules and inference to derive new information from existing data. Techniques like forward/backward chaining are fundamental to how these databases operate and could be considered patterns within this paradigm.
Concurrent Programming:
- Message Passing: In concurrent systems, where multiple processes execute simultaneously, message passing is a common technique for communication and coordination. Patterns like producer-consumer and reader-writer provide established solutions for managing concurrent access to resources and ensuring data consistency.
- Synchronization Primitives: These are low-level constructs like mutexes, semaphores, and condition variables that are used to control access to shared resources in concurrent programs. While not "patterns" in the traditional sense, they represent well-defined solutions to common concurrency challenges.
Data-Oriented Programming:
- Data Transformation Pipelines: This paradigm emphasizes transforming data through a series of operations. Techniques like map, filter, and reduce (common in functional programming as well, and used A LOT in javascript since its addition) are fundamental building blocks for constructing these pipelines, and could be considered patterns within this paradigm.
- Entity-Component-System (ECS): This architectural pattern is popular in game development and other data-intensive applications. It involves breaking down entities into components (data) and systems (logic), promoting data locality and efficient processing.
There are a lot of "techniques" and "patterns", this list is just to give you threads to pull if you're curious.
Hope you find this useful, read you rather soon!
🔖 Summary, for the hurry ones!
While the term "design patterns" is most closely associated with OOP, other paradigms have their own sets of recurring solutions and techniques. These techniques address the specific challenges and constraints of those paradigms, providing established approaches to common problems. So, even if they aren't always formally labeled as "design patterns," they serve a similar purpose in guiding developers towards effective and maintainable solutions.
We can understand design patterns as well-known workarounds to patch features that the programming language we're using lacks abstractions for.
This post has been written almost entirely by me, specified examples by Gemini 1.5 Pro
Posted on October 30, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 24, 2024