Skip to main content

Mastering Object-Oriented Design

Object-Oriented Design (OOD) is one of the most fundamental paradigms in software development, providing a structured approach to building maintainable, scalable, and reusable code. While traditionally associated with languages like Java and C++, JavaScript's evolution has made it a powerful platform for implementing OOD principles. In this comprehensive guide, we'll explore the core concepts of Object-Oriented Design and demonstrate their practical implementation using modern JavaScript.

Object-Oriented Design

Understanding Object-Oriented Design

Object-Oriented Design is a programming paradigm that organizes software design around objects rather than functions and logic. An object contains both data (attributes) and code (methods) that manipulate that data. This approach mirrors how we naturally think about the world, making code more intuitive and easier to understand.

The fundamental concept behind OOD is to model real-world entities as objects that interact with each other to accomplish tasks. For example, in a banking application, you might have Account objects, Customer objects, and Transaction objects, each with their own properties and behaviors.

The Four Pillars of Object-Oriented Design

Object-Oriented Design

1. Encapsulation

Encapsulation is the practice of bundling data and the methods that operate on that data within a single unit (class or object). It also involves restricting direct access to some of an object's components, which is a means of preventing accidental interference and misuse.

class BankAccount {
#balance; // Private field
#accountNumber;

constructor(accountNumber, initialBalance = 0) {
this.#accountNumber = accountNumber;
this.#balance = initialBalance;
this.transactionHistory = [];
}

// Public method to access private balance
getBalance() {
return this.#balance;
}

// Public method to deposit money
deposit(amount) {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
this.#balance += amount;
this.#addTransaction('deposit', amount);
return this.#balance;
}

// Public method to withdraw money
withdraw(amount) {
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive');
}
if (amount > this.#balance) {
throw new Error('Insufficient funds');
}
this.#balance -= amount;
this.#addTransaction('withdrawal', amount);
return this.#balance;
}

// Private method (encapsulated)
#addTransaction(type, amount) {
this.transactionHistory.push({
type,
amount,
timestamp: new Date(),
balance: this.#balance
});
}

getAccountNumber() {
return this.#accountNumber;
}
}

// Usage example
const account = new BankAccount('12345', 1000);
console.log(account.getBalance()); // 1000
account.deposit(500);
console.log(account.getBalance()); // 1500

// This would throw an error - private field is not accessible
// console.log(account.#balance); // SyntaxError

2. Inheritance

Inheritance allows a new class to inherit properties and methods from an existing class. This promotes code reuse and establishes a relationship between classes.

// Base class
class Vehicle {
constructor(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
this.isRunning = false;
}

start() {
if (!this.isRunning) {
this.isRunning = true;
console.log(`${this.make} ${this.model} is now running`);
}
}

stop() {
if (this.isRunning) {
this.isRunning = false;
console.log(`${this.make} ${this.model} has stopped`);
}
}

getInfo() {
return `${this.year} ${this.make} ${this.model}`;
}
}

// Derived class
class Car extends Vehicle {
constructor(make, model, year, doors, fuelType) {
super(make, model, year); // Call parent constructor
this.doors = doors;
this.fuelType = fuelType;
this.gear = 'park';
}

// Method specific to Car
changeGear(newGear) {
const validGears = ['park', 'reverse', 'neutral', 'drive'];
if (validGears.includes(newGear)) {
this.gear = newGear;
console.log(`Gear changed to ${newGear}`);
} else {
console.log('Invalid gear');
}
}

// Override parent method
getInfo() {
return `${super.getInfo()} - ${this.doors} doors, ${this.fuelType}`;
}
}

// Another derived class
class Motorcycle extends Vehicle {
constructor(make, model, year, engineSize) {
super(make, model, year);
this.engineSize = engineSize;
this.hasSidecar = false;
}

wheelie() {
if (this.isRunning) {
console.log(`${this.make} ${this.model} is doing a wheelie!`);
} else {
console.log('Start the motorcycle first!');
}
}

addSidecar() {
this.hasSidecar = true;
console.log('Sidecar added');
}
}

// Usage examples
const myCar = new Car('Toyota', 'Camry', 2023, 4, 'Hybrid');
const myBike = new Motorcycle('Harley-Davidson', 'Street 750', 2022, '750cc');

console.log(myCar.getInfo()); // 2023 Toyota Camry - 4 doors, Hybrid
myCar.start(); // Toyota Camry is now running
myCar.changeGear('drive'); // Gear changed to drive

myBike.start(); // Harley-Davidson Street 750 is now running
myBike.wheelie(); // Harley-Davidson Street 750 is doing a wheelie!

3. Polymorphism

Polymorphism allows objects of different types to be treated as instances of the same type through a common interface. This enables a single interface to represent different underlying forms (data types).

// Base class defining common interface
class Shape {
constructor(name) {
this.name = name;
}

// Methods to be overridden by subclasses
calculateArea() {
throw new Error('calculateArea method must be implemented');
}

calculatePerimeter() {
throw new Error('calculatePerimeter method must be implemented');
}

getInfo() {
return `${this.name}: Area = ${this.calculateArea()}, Perimeter = ${this.calculatePerimeter()}`;
}
}

class Rectangle extends Shape {
constructor(width, height) {
super('Rectangle');
this.width = width;
this.height = height;
}

calculateArea() {
return this.width * this.height;
}

calculatePerimeter() {
return 2 * (this.width + this.height);
}
}

class Circle extends Shape {
constructor(radius) {
super('Circle');
this.radius = radius;
}

calculateArea() {
return Math.PI * this.radius * this.radius;
}

calculatePerimeter() {
return 2 * Math.PI * this.radius;
}
}

class Triangle extends Shape {
constructor(side1, side2, side3) {
super('Triangle');
this.side1 = side1;
this.side2 = side2;
this.side3 = side3;
}

calculateArea() {
// Using Heron's formula
const s = this.calculatePerimeter() / 2;
return Math.sqrt(s * (s - this.side1) * (s - this.side2) * (s - this.side3));
}

calculatePerimeter() {
return this.side1 + this.side2 + this.side3;
}
}

// Polymorphic function - works with any Shape
function displayShapeInfo(shapes) {
shapes.forEach(shape => {
console.log(shape.getInfo());
});
}

// Usage - treating different objects uniformly
const shapes = [
new Rectangle(5, 10),
new Circle(7),
new Triangle(3, 4, 5)
];

displayShapeInfo(shapes);
// Output:
// Rectangle: Area = 50, Perimeter = 30
// Circle: Area = 153.938..., Perimeter = 43.982...
// Triangle: Area = 6, Perimeter = 12

4. Abstraction

Abstraction involves hiding complex implementation details while showing only the necessary features of an object. It helps in reducing programming complexity and effort.

// Abstract class (simulated in JavaScript)
class DatabaseConnection {
constructor() {
if (this.constructor === DatabaseConnection) {
throw new Error('Cannot instantiate abstract class');
}
}

// Abstract methods (must be implemented by subclasses)
connect() {
throw new Error('connect method must be implemented');
}

disconnect() {
throw new Error('disconnect method must be implemented');
}

executeQuery(query) {
throw new Error('executeQuery method must be implemented');
}

// Concrete method (shared implementation)
isValidQuery(query) {
return query && typeof query === 'string' && query.trim().length > 0;
}
}

// Concrete implementation for MySQL
class MySQLConnection extends DatabaseConnection {
constructor(host, username, password, database) {
super();
this.host = host;
this.username = username;
this.password = password;
this.database = database;
this.isConnected = false;
}

connect() {
// Simulate MySQL connection
console.log(`Connecting to MySQL database at ${this.host}...`);
this.isConnected = true;
console.log('MySQL connection established');
}

disconnect() {
if (this.isConnected) {
console.log('Disconnecting from MySQL database...');
this.isConnected = false;
console.log('MySQL connection closed');
}
}

executeQuery(query) {
if (!this.isConnected) {
throw new Error('Database not connected');
}

if (!this.isValidQuery(query)) {
throw new Error('Invalid query');
}

console.log(`Executing MySQL query: ${query}`);
// Simulate query execution
return { success: true, results: [] };
}
}

// Concrete implementation for MongoDB
class MongoDBConnection extends DatabaseConnection {
constructor(connectionString) {
super();
this.connectionString = connectionString;
this.isConnected = false;
}

connect() {
console.log(`Connecting to MongoDB at ${this.connectionString}...`);
this.isConnected = true;
console.log('MongoDB connection established');
}

disconnect() {
if (this.isConnected) {
console.log('Disconnecting from MongoDB...');
this.isConnected = false;
console.log('MongoDB connection closed');
}
}

executeQuery(query) {
if (!this.isConnected) {
throw new Error('Database not connected');
}

if (!this.isValidQuery(query)) {
throw new Error('Invalid query');
}

console.log(`Executing MongoDB query: ${query}`);
// Simulate query execution
return { success: true, documents: [] };
}
}

// Database manager that works with any database connection
class DatabaseManager {
constructor(connection) {
this.connection = connection;
}

performDatabaseOperation(query) {
try {
this.connection.connect();
const result = this.connection.executeQuery(query);
console.log('Operation completed successfully');
return result;
} catch (error) {
console.error('Database operation failed:', error.message);
} finally {
this.connection.disconnect();
}
}
}

// Usage - abstraction in action
const mysqlDB = new MySQLConnection('localhost', 'user', 'pass', 'mydb');
const mongoDB = new MongoDBConnection('mongodb://localhost:27017/mydb');

const mysqlManager = new DatabaseManager(mysqlDB);
const mongoManager = new DatabaseManager(mongoDB);

// Both work the same way despite different implementations
mysqlManager.performDatabaseOperation('SELECT * FROM users');
mongoManager.performDatabaseOperation('db.users.find()');

Best Practices for Object-Oriented Design in JavaScript

1. Use Composition Over Inheritance

While inheritance is powerful, composition often provides more flexibility:

// Instead of deep inheritance hierarchies
class FlyingCar extends Car {
// Complex inheritance chain
}

// Use composition
class Vehicle {
constructor() {
this.capabilities = [];
}

addCapability(capability) {
this.capabilities.push(capability);
}

hasCapability(capabilityName) {
return this.capabilities.some(cap => cap.name === capabilityName);
}
}

class FlyingCapability {
constructor() {
this.name = 'flying';
}

fly() {
console.log('Taking off!');
}
}

class DrivingCapability {
constructor() {
this.name = 'driving';
}

drive() {
console.log('Driving on road');
}
}

// Usage
const flyingCar = new Vehicle();
flyingCar.addCapability(new FlyingCapability());
flyingCar.addCapability(new DrivingCapability());

2. Favor Immutability When Possible

class ImmutablePoint {
constructor(x, y) {
this._x = x;
this._y = y;
Object.freeze(this);
}

get x() { return this._x; }
get y() { return this._y; }

// Return new instance instead of modifying existing
move(deltaX, deltaY) {
return new ImmutablePoint(this._x + deltaX, this._y + deltaY);
}

toString() {
return `Point(${this._x}, ${this._y})`;
}
}

const point1 = new ImmutablePoint(1, 2);
const point2 = point1.move(3, 4);
console.log(point1.toString()); // Point(1, 2) - unchanged
console.log(point2.toString()); // Point(4, 6) - new instance

Conclusion

info

Object-Oriented Design in JavaScript provides a robust foundation for building maintainable and scalable applications. By understanding and applying the four pillars of OOP—encapsulation, inheritance, polymorphism, and abstraction—along with SOLID principles and common design patterns, developers can create code that is not only functional but also elegant and sustainable.

The key to successful OOD is finding the right balance between abstraction and simplicity. While these concepts provide powerful tools for organizing code, they should be applied judiciously. Over-engineering can be just as problematic as under-engineering.

As JavaScript continues to evolve with new features like private fields, static blocks, and enhanced class syntax, the language becomes increasingly capable of expressing sophisticated object-oriented designs. By mastering these concepts and staying current with JavaScript's evolution, developers can build applications that stand the test of time and changing requirements.

Remember that Object-Oriented Design is not just about using classes and objects—it's about thinking in terms of responsibilities, relationships, and interactions between different parts of your system. This mindset, combined with the practical techniques demonstrated in this guide, will serve you well in your journey toward writing better, more maintainable code.