15 minlesson

OOP Patterns and Best Practices

OOP Patterns and Best Practices

This lesson covers common object-oriented patterns and when to use them in JavaScript.

Composition Over Inheritance

While inheritance is powerful, composition is often more flexible:

javascript
1// Inheritance approach (rigid)
2class FlyingAnimal extends Animal {
3 fly() { console.log("Flying!"); }
4}
5
6class SwimmingAnimal extends Animal {
7 swim() { console.log("Swimming!"); }
8}
9
10// What about a duck that can do both? Multiple inheritance issues!
11
12// Composition approach (flexible)
13const canFly = {
14 fly() { console.log(`${this.name} is flying!`); }
15};
16
17const canSwim = {
18 swim() { console.log(`${this.name} is swimming!`); }
19};
20
21const canWalk = {
22 walk() { console.log(`${this.name} is walking!`); }
23};
24
25class Duck {
26 constructor(name) {
27 this.name = name;
28 }
29}
30
31// Mix in behaviors
32Object.assign(Duck.prototype, canFly, canSwim, canWalk);
33
34const donald = new Duck("Donald");
35donald.fly(); // "Donald is flying!"
36donald.swim(); // "Donald is swimming!"
37donald.walk(); // "Donald is walking!"

Mixins Pattern

Reusable behavior packages:

javascript
1// Mixin factory
2const TimestampMixin = (Base) => class extends Base {
3 get createdAt() {
4 return this._createdAt || (this._createdAt = new Date());
5 }
6
7 get updatedAt() {
8 return this._updatedAt;
9 }
10
11 touch() {
12 this._updatedAt = new Date();
13 }
14};
15
16const SerializableMixin = (Base) => class extends Base {
17 toJSON() {
18 return JSON.stringify(this);
19 }
20
21 static fromJSON(json) {
22 return Object.assign(new this(), JSON.parse(json));
23 }
24};
25
26// Apply mixins
27class User extends TimestampMixin(SerializableMixin(Object)) {
28 constructor(name) {
29 super();
30 this.name = name;
31 }
32}
33
34const user = new User("Alice");
35user.touch();
36console.log(user.createdAt);
37console.log(user.toJSON());

Singleton Pattern

Ensure only one instance exists:

javascript
1class Database {
2 static #instance = null;
3
4 constructor() {
5 if (Database.#instance) {
6 return Database.#instance;
7 }
8 Database.#instance = this;
9 this.connection = null;
10 }
11
12 connect(url) {
13 this.connection = `Connected to ${url}`;
14 return this.connection;
15 }
16
17 static getInstance() {
18 if (!Database.#instance) {
19 Database.#instance = new Database();
20 }
21 return Database.#instance;
22 }
23}
24
25// All references point to same instance
26const db1 = new Database();
27const db2 = new Database();
28const db3 = Database.getInstance();
29
30console.log(db1 === db2); // true
31console.log(db2 === db3); // true

Builder Pattern

Construct complex objects step by step:

javascript
1class QueryBuilder {
2 #table = "";
3 #columns = ["*"];
4 #conditions = [];
5 #orderBy = null;
6 #limit = null;
7
8 from(table) {
9 this.#table = table;
10 return this;
11 }
12
13 select(...columns) {
14 this.#columns = columns;
15 return this;
16 }
17
18 where(condition) {
19 this.#conditions.push(condition);
20 return this;
21 }
22
23 orderBy(column, direction = "ASC") {
24 this.#orderBy = `${column} ${direction}`;
25 return this;
26 }
27
28 limit(n) {
29 this.#limit = n;
30 return this;
31 }
32
33 build() {
34 let query = `SELECT ${this.#columns.join(", ")} FROM ${this.#table}`;
35
36 if (this.#conditions.length) {
37 query += ` WHERE ${this.#conditions.join(" AND ")}`;
38 }
39 if (this.#orderBy) {
40 query += ` ORDER BY ${this.#orderBy}`;
41 }
42 if (this.#limit) {
43 query += ` LIMIT ${this.#limit}`;
44 }
45
46 return query;
47 }
48}
49
50// Usage
51const query = new QueryBuilder()
52 .from("users")
53 .select("name", "email")
54 .where("age > 18")
55 .where("active = true")
56 .orderBy("created_at", "DESC")
57 .limit(10)
58 .build();
59
60// "SELECT name, email FROM users WHERE age > 18 AND active = true ORDER BY created_at DESC LIMIT 10"

Observer Pattern

Subscribe to and react to events:

javascript
1class EventEmitter {
2 #events = new Map();
3
4 on(event, callback) {
5 if (!this.#events.has(event)) {
6 this.#events.set(event, []);
7 }
8 this.#events.get(event).push(callback);
9 return this;
10 }
11
12 off(event, callback) {
13 const callbacks = this.#events.get(event);
14 if (callbacks) {
15 const index = callbacks.indexOf(callback);
16 if (index > -1) callbacks.splice(index, 1);
17 }
18 return this;
19 }
20
21 emit(event, ...args) {
22 const callbacks = this.#events.get(event);
23 if (callbacks) {
24 callbacks.forEach(cb => cb(...args));
25 }
26 return this;
27 }
28
29 once(event, callback) {
30 const wrapper = (...args) => {
31 callback(...args);
32 this.off(event, wrapper);
33 };
34 return this.on(event, wrapper);
35 }
36}
37
38// Usage
39class User extends EventEmitter {
40 constructor(name) {
41 super();
42 this.name = name;
43 }
44
45 login() {
46 this.emit("login", this);
47 }
48}
49
50const user = new User("Alice");
51user.on("login", (u) => console.log(`${u.name} logged in`));
52user.login(); // "Alice logged in"

Proxy for Validation

Use Proxy for transparent validation:

javascript
1function createValidatedObject(target, validators) {
2 return new Proxy(target, {
3 set(obj, prop, value) {
4 if (validators[prop]) {
5 const isValid = validators[prop](value);
6 if (!isValid) {
7 throw new Error(`Invalid value for ${prop}: ${value}`);
8 }
9 }
10 obj[prop] = value;
11 return true;
12 }
13 });
14}
15
16const userValidators = {
17 age: (v) => typeof v === "number" && v >= 0 && v <= 150,
18 email: (v) => typeof v === "string" && v.includes("@")
19};
20
21const user = createValidatedObject({}, userValidators);
22
23user.name = "Alice"; // OK (no validator)
24user.age = 25; // OK
25user.email = "a@b.com"; // OK
26
27user.age = -5; // Error! Invalid value
28user.email = "invalid"; // Error! Invalid value

State Pattern

Manage object behavior based on state:

javascript
1class TrafficLight {
2 #states = {
3 red: {
4 color: "red",
5 next: "green",
6 duration: 5000
7 },
8 yellow: {
9 color: "yellow",
10 next: "red",
11 duration: 2000
12 },
13 green: {
14 color: "green",
15 next: "yellow",
16 duration: 5000
17 }
18 };
19
20 #currentState = "red";
21
22 get color() {
23 return this.#states[this.#currentState].color;
24 }
25
26 get duration() {
27 return this.#states[this.#currentState].duration;
28 }
29
30 change() {
31 this.#currentState = this.#states[this.#currentState].next;
32 return this.color;
33 }
34
35 start() {
36 console.log(`Light is ${this.color}`);
37 setTimeout(() => {
38 this.change();
39 this.start();
40 }, this.duration);
41 }
42}

Encapsulation Best Practices

1. Minimize Public API

javascript
1class User {
2 #data;
3 #validators;
4
5 constructor(data) {
6 this.#data = data;
7 this.#validators = { /* ... */ };
8 }
9
10 // Only expose what's needed
11 get name() { return this.#data.name; }
12 get email() { return this.#data.email; }
13
14 updateProfile(updates) {
15 // Validate and update internally
16 this.#validate(updates);
17 this.#data = { ...this.#data, ...updates };
18 }
19
20 #validate(data) {
21 // Private method for validation
22 }
23}

2. Defensive Copying

javascript
1class Config {
2 #settings;
3
4 constructor(settings) {
5 // Copy incoming data
6 this.#settings = { ...settings };
7 }
8
9 getSettings() {
10 // Return a copy, not the original
11 return { ...this.#settings };
12 }
13
14 getList() {
15 // Deep copy for arrays
16 return [...this.#list];
17 }
18}

3. Immutable Updates

javascript
1class TodoList {
2 #todos = [];
3
4 addTodo(todo) {
5 // Create new array instead of mutating
6 this.#todos = [...this.#todos, { ...todo, id: Date.now() }];
7 return this;
8 }
9
10 removeTodo(id) {
11 this.#todos = this.#todos.filter(t => t.id !== id);
12 return this;
13 }
14
15 updateTodo(id, updates) {
16 this.#todos = this.#todos.map(t =>
17 t.id === id ? { ...t, ...updates } : t
18 );
19 return this;
20 }
21}

SOLID Principles in JavaScript

Single Responsibility

javascript
1// BAD: Does too much
2class UserManager {
3 createUser() { /* ... */ }
4 validateEmail() { /* ... */ }
5 sendWelcomeEmail() { /* ... */ }
6 saveToDatabase() { /* ... */ }
7 generateReport() { /* ... */ }
8}
9
10// GOOD: Separate concerns
11class UserService {
12 createUser(data) { /* ... */ }
13}
14
15class EmailValidator {
16 validate(email) { /* ... */ }
17}
18
19class EmailService {
20 sendWelcome(user) { /* ... */ }
21}

Open/Closed Principle

javascript
1// Open for extension, closed for modification
2class PaymentProcessor {
3 #handlers = new Map();
4
5 registerHandler(type, handler) {
6 this.#handlers.set(type, handler);
7 }
8
9 process(payment) {
10 const handler = this.#handlers.get(payment.type);
11 if (!handler) throw new Error(`Unknown payment type: ${payment.type}`);
12 return handler.process(payment);
13 }
14}
15
16// Add new payment types without modifying PaymentProcessor
17processor.registerHandler("creditCard", new CreditCardHandler());
18processor.registerHandler("paypal", new PayPalHandler());
19processor.registerHandler("crypto", new CryptoHandler());

Dependency Injection

javascript
1// BAD: Hard dependency
2class UserService {
3 constructor() {
4 this.db = new PostgresDatabase(); // Tightly coupled
5 }
6}
7
8// GOOD: Inject dependencies
9class UserService {
10 constructor(database) {
11 this.db = database;
12 }
13
14 getUser(id) {
15 return this.db.find("users", id);
16 }
17}
18
19// Easy to test and swap implementations
20const service = new UserService(new PostgresDatabase());
21const testService = new UserService(new MockDatabase());

Summary

PatternUse When
CompositionNeed flexible behavior combinations
SingletonNeed exactly one instance
BuilderComplex object construction
ObserverEvent-driven communication
ProxyTransparent validation/logging
StateBehavior changes based on state

Remember:

  • Prefer composition over inheritance
  • Keep classes focused (single responsibility)
  • Use private fields for encapsulation
  • Return defensive copies of internal data
  • Inject dependencies for testability