Skip to content

Abstraction: The Key to Simplifying Complexity

What is Abstraction

Every morning when you turn on the water tap, clean water flows out. You don't need to know how the city's water supply system works—from which reservoir the water is drawn, how many levels of filtration it goes through, or through what pipes it's delivered. You only need to know "turn the tap, water comes out"—this simple interface. This is the power of abstraction.

Abstraction is one of the core principles of object-oriented programming, and its essence is:

  1. Hide Complexity: Conceal complex implementation details
  2. Expose Essentials: Only reveal the most necessary and important information
  3. Define Interfaces: Provide simple, clear ways to use from the outside

Abstraction allows us to think about problems at a higher level without getting bogged down in low-level details.

Levels of Abstraction

Let's understand different levels of abstraction through a car example:

javascript
// Lowest level - concrete implementation (usually hidden)
class Engine {
  #cylinders;
  #fuelType;
  #rpm;

  constructor(cylinders, fuelType) {
    this.#cylinders = cylinders;
    this.#fuelType = fuelType;
    this.#rpm = 0;
  }

  #ignite() {
    console.log("Ignition system starting...");
  }

  #injectFuel() {
    console.log(`Injecting ${this.#fuelType}...`);
  }

  #combustion() {
    console.log(`${this.#cylinders} cylinders combusting...`);
  }

  start() {
    this.#ignite();
    this.#injectFuel();
    this.#combustion();
    this.#rpm = 800;
    console.log("Engine started\n");
  }

  accelerate(amount) {
    this.#rpm += amount;
    console.log(`RPM: ${this.#rpm}`);
  }

  stop() {
    this.#rpm = 0;
    console.log("Engine stopped");
  }
}

// Middle level - partial abstraction
class Vehicle {
  #engine;
  #speed;
  #isRunning;

  constructor(engine) {
    this.#engine = engine;
    this.#speed = 0;
    this.#isRunning = false;
  }

  start() {
    if (this.#isRunning) {
      console.log("Vehicle already running");
      return;
    }

    console.log("Starting vehicle...");
    this.#engine.start();
    this.#isRunning = true;
    console.log("Vehicle started\n");
  }

  accelerate(speedIncrease) {
    if (!this.#isRunning) {
      throw new Error("Please start the vehicle first");
    }

    this.#speed += speedIncrease;
    // Calculate required engine RPM based on speed
    const rpmIncrease = speedIncrease * 50;
    this.#engine.accelerate(rpmIncrease);

    console.log(`Current speed: ${this.#speed} km/h\n`);
  }

  brake(speedDecrease) {
    this.#speed = Math.max(0, this.#speed - speedDecrease);
    console.log(`Slowing to: ${this.#speed} km/h\n`);
  }

  stop() {
    console.log("Stopping vehicle...");
    this.#speed = 0;
    this.#engine.stop();
    this.#isRunning = false;
    console.log("Vehicle stopped\n");
  }

  getSpeed() {
    return this.#speed;
  }
}

// High-level abstraction - simple and easy-to-use interface
class Car {
  #vehicle;
  #make;
  #model;

  constructor(make, model) {
    this.#make = make;
    this.#model = model;
    // User doesn't need to know engine details
    this.#vehicle = new Vehicle(new Engine(4, "gasoline"));
  }

  // Simplest interface
  turnOn() {
    console.log(`${this.#make} ${this.#model} ready to go`);
    this.#vehicle.start();
  }

  drive(targetSpeed) {
    const currentSpeed = this.#vehicle.getSpeed();
    const speedDiff = targetSpeed - currentSpeed;

    if (speedDiff > 0) {
      console.log(`Accelerating to ${targetSpeed} km/h`);
      this.#vehicle.accelerate(speedDiff);
    } else if (speedDiff < 0) {
      console.log(`Decelerating to ${targetSpeed} km/h`);
      this.#vehicle.brake(-speedDiff);
    }
  }

  park() {
    console.log("Parking");
    this.#vehicle.stop();
  }
}

// Usage - highest level of abstraction, very simple
const myCar = new Car("Toyota", "Camry");

myCar.turnOn(); // User doesn't need to know how engine starts
myCar.drive(60); // Doesn't need to know how to control throttle and engine RPM
myCar.drive(100);
myCar.drive(40);
myCar.park(); // Doesn't need to know how to stop engine

Abstract Classes and Interfaces

Although JavaScript doesn't have built-in abstract class and interface concepts, we can simulate them:

Simulating Abstract Classes

javascript
class AbstractDataStore {
  constructor() {
    // Prevent direct instantiation of abstract class
    if (new.target === AbstractDataStore) {
      throw new Error("AbstractDataStore is an abstract class and cannot be directly instantiated");
    }
  }

  // Abstract methods - must be implemented by subclasses
  async connect() {
    throw new Error("connect() must be implemented by subclass");
  }

  async disconnect() {
    throw new Error("disconnect() must be implemented by subclass");
  }

  async save(key, value) {
    throw new Error("save() must be implemented by subclass");
  }

  async load(key) {
    throw new Error("load() must be implemented by subclass");
  }

  async delete(key) {
    throw new Error("delete() must be implemented by subclass");
  }

  async exists(key) {
    throw new Error("exists() must be implemented by subclass");
  }

  // Common methods - can be used by subclasses
  async saveMultiple(items) {
    const results = [];
    for (const [key, value] of Object.entries(items)) {
      await this.save(key, value);
      results.push(key);
    }
    return results;
  }

  async loadMultiple(keys) {
    const results = {};
    for (const key of keys) {
      results[key] = await this.load(key);
    }
    return results;
  }
}

// Concrete implementation - LocalStorage
class LocalStorageStore extends AbstractDataStore {
  async connect() {
    // LocalStorage doesn't need connection
    console.log("LocalStorage: Ready");
    return true;
  }

  async disconnect() {
    console.log("LocalStorage: No need to disconnect");
    return true;
  }

  async save(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
    console.log(`LocalStorage: Saved ${key}`);
    return true;
  }

  async load(key) {
    const value = localStorage.getItem(key);
    console.log(`LocalStorage: Loaded ${key}`);
    return value ? JSON.parse(value) : null;
  }

  async delete(key) {
    localStorage.removeItem(key);
    console.log(`LocalStorage: Deleted ${key}`);
    return true;
  }

  async exists(key) {
    return localStorage.getItem(key) !== null;
  }
}

// Concrete implementation - Memory
class MemoryStore extends AbstractDataStore {
  #data = new Map();
  #connected = false;

  async connect() {
    this.#connected = true;
    console.log("MemoryStore: Connected");
    return true;
  }

  async disconnect() {
    this.#connected = false;
    this.#data.clear();
    console.log("MemoryStore: Disconnected");
    return true;
  }

  async save(key, value) {
    if (!this.#connected) {
      throw new Error("Not connected to storage");
    }
    this.#data.set(key, value);
    console.log(`MemoryStore: Saved ${key}`);
    return true;
  }

  async load(key) {
    if (!this.#connected) {
      throw new Error("Not connected to storage");
    }
    console.log(`MemoryStore: Loaded ${key}`);
    return this.#data.get(key) || null;
  }

  async delete(key) {
    if (!this.#connected) {
      throw new Error("Not connected to storage");
    }
    const result = this.#data.delete(key);
    console.log(`MemoryStore: Deleted ${key}`);
    return result;
  }

  async exists(key) {
    return this.#data.has(key);
  }
}

// Concrete implementation - API
class APIStore extends AbstractDataStore {
  #baseUrl;
  #apiKey;
  #connected = false;

  constructor(baseUrl, apiKey) {
    super();
    this.#baseUrl = baseUrl;
    this.#apiKey = apiKey;
  }

  async connect() {
    console.log(`APIStore: Connecting to ${this.#baseUrl}`);
    // Verify API connection
    this.#connected = true;
    return true;
  }

  async disconnect() {
    this.#connected = false;
    console.log("APIStore: Disconnected");
    return true;
  }

  async save(key, value) {
    if (!this.#connected) {
      throw new Error("Not connected to API");
    }

    console.log(`APIStore: POST ${this.#baseUrl}/data/${key}`);
    // Simulate API call
    // await fetch(`${this.#baseUrl}/data/${key}`, { ... })
    return true;
  }

  async load(key) {
    if (!this.#connected) {
      throw new Error("Not connected to API");
    }

    console.log(`APIStore: GET ${this.#baseUrl}/data/${key}`);
    // Simulate API call
    return { data: "Simulated data" };
  }

  async delete(key) {
    if (!this.#connected) {
      throw new Error("Not connected to API");
    }

    console.log(`APIStore: DELETE ${this.#baseUrl}/data/${key}`);
    return true;
  }

  async exists(key) {
    if (!this.#connected) {
      throw new Error("Not connected to API");
    }

    console.log(`APIStore: HEAD ${this.#baseUrl}/data/${key}`);
    return true;
  }
}

// Application layer code using abstract interface
class DataManager {
  #store;

  constructor(store) {
    // Accept any storage that implements AbstractDataStore interface
    if (!(store instanceof AbstractDataStore)) {
      throw new Error("store must inherit from AbstractDataStore");
    }
    this.#store = store;
  }

  async init() {
    await this.#store.connect();
  }

  async saveUser(user) {
    const key = `user:${user.id}`;
    await this.#store.save(key, user);
    console.log(`✓ User ${user.name} saved\n`);
  }

  async getUser(userId) {
    const key = `user:${userId}`;
    return await this.#store.load(key);
  }

  async cleanup() {
    await this.#store.disconnect();
  }
}

// Can easily switch between different storage implementations
async function demo() {
  console.log("=== Using MemoryStore ===\n");
  const memoryManager = new DataManager(new MemoryStore());
  await memoryManager.init();
  await memoryManager.saveUser({
    id: 1,
    name: "Alice",
    email: "[email protected]",
  });

  console.log("\n=== Using APIStore ===\n");
  const apiManager = new DataManager(
    new APIStore("https://api.example.com", "key123")
  );
  await apiManager.init();
  await apiManager.saveUser({ id: 2, name: "Bob", email: "[email protected]" });

  // Trying to instantiate abstract class will fail
  try {
    new AbstractDataStore();
  } catch (error) {
    console.log(`\n❌ ${error.message}`);
  }
}

// demo();

Abstraction Level Design

Good abstractions should have clear hierarchical structures:

javascript
// Lowest level: Hardware abstraction
class NetworkAdapter {
  #isConnected = false;

  connect(ssid, password) {
    console.log(`Connecting to WiFi: ${ssid}`);
    // Low-level hardware operations
    this.#isConnected = true;
    return true;
  }

  disconnect() {
    this.#isConnected = false;
    return true;
  }

  sendBytes(bytes) {
    if (!this.#isConnected) {
      throw new Error("Network not connected");
    }
    console.log(`Sending ${bytes.length} bytes`);
    return bytes.length;
  }

  receiveBytes(maxBytes) {
    if (!this.#isConnected) {
      throw new Error("Network not connected");
    }
    // Simulate receiving data
    return new Uint8Array(maxBytes);
  }
}

// Second level: Protocol abstraction
class HTTPClient {
  #adapter;

  constructor(adapter) {
    this.#adapter = adapter;
  }

  request(method, url, headers = {}, body = null) {
    console.log(`\n${method} ${url}`);

    // Build HTTP request
    const request = this.#buildRequest(method, url, headers, body);

    // Send request
    const requestBytes = new TextEncoder().encode(request);
    this.#adapter.sendBytes(requestBytes);

    // Receive response
    const responseBytes = this.#adapter.receiveBytes(4096);
    return this.#parseResponse(responseBytes);
  }

  #buildRequest(method, url, headers, body) {
    let request = `${method} ${url} HTTP/1.1\r\n`;

    for (const [key, value] of Object.entries(headers)) {
      request += `${key}: ${value}\r\n`;
    }

    if (body) {
      request += `Content-Length: ${body.length}\r\n`;
    }

    request += "\r\n";

    if (body) {
      request += body;
    }

    return request;
  }

  #parseResponse(bytes) {
    // Simplified response parsing
    return {
      status: 200,
      headers: {},
      body: "Response content",
    };
  }
}

// Third level: REST API abstraction
class APIClient {
  #http;
  #baseUrl;
  #token;

  constructor(baseUrl, token) {
    const adapter = new NetworkAdapter();
    adapter.connect("MyWiFi", "password");

    this.#http = new HTTPClient(adapter);
    this.#baseUrl = baseUrl;
    this.#token = token;
  }

  #getHeaders() {
    return {
      Authorization: `Bearer ${this.#token}`,
      "Content-Type": "application/json",
    };
  }

  async get(endpoint) {
    return this.#http.request(
      "GET",
      `${this.#baseUrl}${endpoint}`,
      this.#getHeaders()
    );
  }

  async post(endpoint, data) {
    return this.#http.request(
      "POST",
      `${this.#baseUrl}${endpoint}`,
      this.#getHeaders(),
      JSON.stringify(data)
    );
  }

  async put(endpoint, data) {
    return this.#http.request(
      "PUT",
      `${this.#baseUrl}${endpoint}`,
      this.#getHeaders(),
      JSON.stringify(data)
    );
  }

  async delete(endpoint) {
    return this.#http.request(
      "DELETE",
      `${this.#baseUrl}${endpoint}`,
      this.#getHeaders()
    );
  }
}

// Fourth level: Business abstraction
class UserService {
  #api;

  constructor(apiClient) {
    this.#api = apiClient;
  }

  async getUser(userId) {
    console.log(`\nGetting user ${userId}`);
    return await this.#api.get(`/users/${userId}`);
  }

  async createUser(userData) {
    console.log(`\nCreating user: ${userData.name}`);
    return await this.#api.post("/users", userData);
  }

  async updateUser(userId, updates) {
    console.log(`\nUpdating user ${userId}`);
    return await this.#api.put(`/users/${userId}`, updates);
  }

  async deleteUser(userId) {
    console.log(`\nDeleting user ${userId}`);
    return await this.#api.delete(`/users/${userId}`);
  }

  async searchUsers(query) {
    console.log(`\nSearching users: ${query}`);
    return await this.#api.get(`/users/search?q=${encodeURIComponent(query)}`);
  }
}

// Highest level: Application layer usage
const apiClient = new APIClient("https://api.example.com", "token123");
const userService = new UserService(apiClient);

// Application layer code is very concise, no need to know any low-level details
// userService.getUser(123);
// userService.createUser({ name: "John", email: "[email protected]" });

Implementing Dependency Injection through Abstraction

Abstraction makes dependency injection simple and flexible:

javascript
// Define abstract logging interface
class Logger {
  log(level, message, meta = {}) {
    throw new Error("log() must be implemented");
  }

  info(message, meta) {
    this.log("INFO", message, meta);
  }

  warn(message, meta) {
    this.log("WARN", message, meta);
  }

  error(message, meta) {
    this.log("ERROR", message, meta);
  }
}

// Concrete implementation: Console logging
class ConsoleLogger extends Logger {
  log(level, message, meta) {
    const timestamp = new Date().toISOString();
    const metaStr = Object.keys(meta).length > 0 ? JSON.stringify(meta) : "";
    console.log(`[${timestamp}] [${level}] ${message} ${metaStr}`);
  }
}

// Concrete implementation: File logging
class FileLogger extends Logger {
  #filename;

  constructor(filename) {
    super();
    this.#filename = filename;
  }

  log(level, message, meta) {
    const timestamp = new Date().toISOString();
    const logEntry = `[${timestamp}] [${level}] ${message} ${JSON.stringify(
      meta
    )}\n`;
    console.log(`Writing to file ${this.#filename}: ${logEntry.trim()}`);
    // In real applications, would write to file
  }
}

// Concrete implementation: Remote logging
class RemoteLogger extends Logger {
  #endpoint;

  constructor(endpoint) {
    super();
    this.#endpoint = endpoint;
  }

  log(level, message, meta) {
    const logData = {
      timestamp: new Date().toISOString(),
      level,
      message,
      meta,
    };
    console.log(`Sending log to ${this.#endpoint}:`, logData);
    // In real applications, would send to remote server
  }
}

// Concrete implementation: Multi-logger
class MultiLogger extends Logger {
  #loggers;

  constructor(loggers) {
    super();
    this.#loggers = loggers;
  }

  log(level, message, meta) {
    for (const logger of this.#loggers) {
      logger.log(level, message, meta);
    }
  }
}

// Business service - dependency injection
class OrderService {
  #logger;
  #database;

  constructor(logger, database) {
    this.#logger = logger; // Inject abstract logger
    this.#database = database;
  }

  createOrder(orderData) {
    this.#logger.info("Creating order", { orderId: orderData.id });

    try {
      // Business logic
      this.#database.save("order", orderData);

      this.#logger.info("Order created successfully", {
        orderId: orderData.id,
        amount: orderData.total,
      });

      return { success: true, orderId: orderData.id };
    } catch (error) {
      this.#logger.error("Order creation failed", {
        orderId: orderData.id,
        error: error.message,
      });

      throw error;
    }
  }

  cancelOrder(orderId) {
    this.#logger.warn("Cancelling order", { orderId });

    // Business logic
    this.#database.delete("order", orderId);

    this.#logger.info("Order cancelled", { orderId });
  }
}

// Usage - can easily switch between different logging implementations
console.log("\n=== Using Console Logger ===");
const consoleService = new OrderService(new ConsoleLogger(), {
  save: () => {},
  delete: () => {},
});
consoleService.createOrder({ id: "ORD-001", total: 299.99 });

console.log("\n=== Using File Logger ===");
const fileService = new OrderService(new FileLogger("/var/log/orders.log"), {
  save: () => {},
  delete: () => {},
});
fileService.createOrder({ id: "ORD-002", total: 499.99 });

console.log("\n=== Using Multiple Loggers ===");
const multiService = new OrderService(
  new MultiLogger([
    new ConsoleLogger(),
    new FileLogger("/var/log/orders.log"),
    new RemoteLogger("https://logs.example.com/api"),
  ]),
  { save: () => {}, delete: () => {} }
);
multiService.createOrder({ id: "ORD-003", total: 999.99 });

Design Principles of Abstraction

1. Interface Segregation Principle

Interfaces should be small and focused, not forcing classes to implement methods they don't need:

javascript
// ❌ Bad design - bloated interface
class Worker {
  work() {}
  eat() {}
  sleep() {}
  getMaintenance() {} // Robot Worker doesn't need this
  chargeBattery() {} // Human Worker doesn't need this
}

// ✅ Good design - separated interfaces
class Workable {
  work() {
    throw new Error("work() must be implemented");
  }
}

class Eatable {
  eat() {
    throw new Error("eat() must be implemented");
  }
}

class Sleepable {
  sleep() {
    throw new Error("sleep() must be implemented");
  }
}

class Rechargeable {
  chargeBattery() {
    throw new Error("chargeBattery() must be implemented");
  }
}

// Human worker only implements needed interfaces
class HumanWorker extends Workable {
  constructor(name) {
    super();
    this.name = name;
  }

  work() {
    console.log(`${this.name} is working`);
  }

  eat() {
    console.log(`${this.name} is eating`);
  }

  sleep() {
    console.log(`${this.name} is sleeping`);
  }
}

// Robot worker implements different interfaces
class RobotWorker extends Workable {
  constructor(id) {
    super();
    this.id = id;
  }

  work() {
    console.log(`Robot ${this.id} is working`);
  }

  chargeBattery() {
    console.log(`Robot ${this.id} is charging`);
  }

  getMaintenance() {
    console.log(`Robot ${this.id} is under maintenance`);
  }
}

2. Dependency Inversion Principle

High-level modules should not depend on low-level modules; both should depend on abstractions:

javascript
// Abstract payment interface
class PaymentProcessor {
  process(amount) {
    throw new Error("process() must be implemented");
  }
}

// Concrete implementations
class StripePayment extends PaymentProcessor {
  process(amount) {
    console.log(`Processing $${amount} via Stripe`);
    return { success: true, provider: "Stripe" };
  }
}

class PayPalPayment extends PaymentProcessor {
  process(amount) {
    console.log(`Processing $${amount} via PayPal`);
    return { success: true, provider: "PayPal" };
  }
}

// High-level module depends on abstraction
class CheckoutService {
  #paymentProcessor;

  constructor(paymentProcessor) {
    // Depend on abstraction, not concrete implementation
    if (!(paymentProcessor instanceof PaymentProcessor)) {
      throw new Error("Must provide PaymentProcessor instance");
    }
    this.#paymentProcessor = paymentProcessor;
  }

  checkout(cart) {
    const total = cart.getTotal();
    console.log(`Checkout total: $${total}`);

    // Use injected payment processor
    const result = this.#paymentProcessor.process(total);

    if (result.success) {
      console.log(`Payment successful (${result.provider})\n`);
    }

    return result;
  }
}

// Usage - can easily switch payment methods
const cart = { getTotal: () => 99.99 };

const stripeCheckout = new CheckoutService(new StripePayment());
stripeCheckout.checkout(cart);

const paypalCheckout = new CheckoutService(new PayPalPayment());
paypalCheckout.checkout(cart);

Hierarchy of Abstraction

Good abstractions should have clear layers, with each level concerned only with details at its level:

javascript
// Low-level abstraction - Data access
class Repository {
  findById(id) {}
  save(entity) {}
  delete(id) {}
}

// Mid-level abstraction - Business logic
class Service {
  #repository;

  constructor(repository) {
    this.#repository = repository;
  }

  // Business methods use repository abstraction
}

// High-level abstraction - Application flow
class Controller {
  #service;

  constructor(service) {
    this.#service = service;
  }

  // Controller methods use service abstraction
}

Summary

Abstraction is a key tool for managing complexity:

  • Hide Details: Hide complex implementations behind simple interfaces
  • Enhance Reusability: Through abstract interfaces, code can apply to multiple implementations
  • Reduce Coupling: Depend on abstractions rather than concrete implementations, making systems more flexible
  • Facilitate Testing: Can easily replace implementations for testing

Good abstractions should be:

  • Simple and intuitive, easy to understand
  • Stable and reliable, not frequently changing
  • Single responsibility, focused and clear
  • Clear hierarchy, not over-abstracted