OOP
02 / 10

Best Practices

OOP Best Practices

Follow these best practices to write clean, maintainable, and effective object-oriented code.

1. Single Responsibility Principle

A class should have only one reason to change - it should do one thing and do it well.

// ❌ BAD: Class doing too much
class User {
  constructor(
    public name: string,
    public email: string
  ) {}

  save(): void {
    // Database logic - NOT the user's responsibility
    console.log("Saving to database...");
  }

  sendEmail(message: string): void {
    // Email logic - NOT the user's responsibility
    console.log(`Sending email to ${this.email}: ${message}`);
  }

  generateReport(): string {
    // Reporting logic - NOT the user's responsibility
    return `User Report for ${this.name}`;
  }
}

// ✅ GOOD: Separate responsibilities
class User {
  constructor(
    public name: string,
    public email: string
  ) {}

  getFullName(): string {
    return this.name;
  }
}

class UserRepository {
  save(user: User): void {
    console.log(`Saving user: ${user.name}`);
    // Database logic here
  }

  find(email: string): User | null {
    // Find logic here
    return null;
  }
}

class EmailService {
  send(to: string, message: string): void {
    console.log(`Sending email to ${to}: ${message}`);
    // Email logic here
  }
}

class UserReportGenerator {
  generate(user: User): string {
    return `User Report for ${user.name}`;
    // Reporting logic here
  }
}

2. Favor Composition Over Inheritance

Use composition to combine behaviors rather than creating deep inheritance hierarchies.

// ❌ BAD: Deep inheritance
class Animal {}
class Bird extends Animal {}
class FlyingBird extends Bird {}
class SwimmingFlyingBird extends FlyingBird {} // Getting complex!

// ✅ GOOD: Composition
interface Flyable {
  fly(): void;
}

interface Swimmable {
  swim(): void;
}

class Duck implements Flyable, Swimmable {
  fly(): void {
    console.log("Duck flying");
  }

  swim(): void {
    console.log("Duck swimming");
  }
}

class Penguin implements Swimmable {
  swim(): void {
    console.log("Penguin swimming");
  }
}

class Eagle implements Flyable {
  fly(): void {
    console.log("Eagle soaring");
  }
}

3. Program to Interfaces, Not Implementations

Depend on abstractions rather than concrete implementations.

// ❌ BAD: Depending on concrete class
class MySQLDatabase {
  query(sql: string): any[] {
    return [];
  }
}

class UserService {
  private db: MySQLDatabase; // Tightly coupled

  constructor() {
    this.db = new MySQLDatabase();
  }

  getUsers(): any[] {
    return this.db.query("SELECT * FROM users");
  }
}

// ✅ GOOD: Depending on interface
interface Database {
  query(sql: string): any[];
}

class MySQLDatabase implements Database {
  query(sql: string): any[] {
    console.log("MySQL query:", sql);
    return [];
  }
}

class PostgreSQLDatabase implements Database {
  query(sql: string): any[] {
    console.log("PostgreSQL query:", sql);
    return [];
  }
}

class UserService {
  constructor(private db: Database) {} // Flexible!

  getUsers(): any[] {
    return this.db.query("SELECT * FROM users");
  }
}

// Easy to swap implementations
const service1 = new UserService(new MySQLDatabase());
const service2 = new UserService(new PostgreSQLDatabase());

4. Keep Classes Small and Focused

If a class has too many methods or properties, it probably needs to be split.

// ❌ BAD: God class doing everything
class OrderManager {
  createOrder() {}
  updateOrder() {}
  deleteOrder() {}
  calculateTotal() {}
  applyDiscount() {}
  processPayment() {}
  sendConfirmationEmail() {}
  updateInventory() {}
  generateInvoice() {}
  calculateShipping() {}
  // ... too many responsibilities!
}

// ✅ GOOD: Focused classes
class Order {
  constructor(
    public id: string,
    public items: OrderItem[],
    public customerId: string
  ) {}

  calculateTotal(): number {
    return this.items.reduce((sum, item) => sum + item.getTotal(), 0);
  }
}

class OrderRepository {
  save(order: Order): void {}
  find(id: string): Order | null { return null; }
  delete(id: string): void {}
}

class DiscountService {
  apply(order: Order, code: string): number { return 0; }
}

class PaymentProcessor {
  process(order: Order, method: string): boolean { return true; }
}

class OrderNotificationService {
  sendConfirmation(order: Order): void {}
}

class InventoryService {
  update(order: Order): void {}
}

class InvoiceGenerator {
  generate(order: Order): string { return ""; }
}

class ShippingCalculator {
  calculate(order: Order): number { return 0; }
}

5. Use Meaningful Names

Classes, methods, and properties should have clear, descriptive names.

// ❌ BAD: Unclear names
class Mgr {
  private d: Data[];
  
  proc(): void {}
  get(): Data[] { return this.d; }
}

// ✅ GOOD: Clear names
class UserManager {
  private users: User[];
  
  processUserRegistration(): void {}
  getAllUsers(): User[] { return this.users; }
  findUserByEmail(email: string): User | null { return null; }
}

// Class names should be nouns
class PaymentProcessor {} // Good
class ProcessPayment {}   // Bad (verb)

// Method names should be verbs
getUser()      // Good
createOrder()  // Good
user()         // Bad (noun)
order()        // Bad (noun)

6. Minimize Coupling

Classes should depend on as few other classes as possible.

// ❌ BAD: Tight coupling
class EmailService {
  send(to: string, message: string): void {
    const smtp = new SMTPClient("smtp.gmail.com", 587);
    smtp.connect();
    smtp.send(to, message);
    smtp.disconnect();
  }
}

// ✅ GOOD: Loose coupling through dependency injection
interface MailTransport {
  send(to: string, message: string): void;
}

class SMTPTransport implements MailTransport {
  constructor(
    private host: string,
    private port: number
  ) {}

  send(to: string, message: string): void {
    console.log(`SMTP: Sending to ${to}`);
  }
}

class SendGridTransport implements MailTransport {
  constructor(private apiKey: string) {}

  send(to: string, message: string): void {
    console.log(`SendGrid: Sending to ${to}`);
  }
}

class EmailService {
  constructor(private transport: MailTransport) {}

  send(to: string, message: string): void {
    this.transport.send(to, message);
  }
}

// Easy to test and swap implementations
const emailService = new EmailService(
  new SMTPTransport("smtp.gmail.com", 587)
);

7. Follow the Law of Demeter (Principle of Least Knowledge)

Don't talk to strangers - a method should only call methods on: itself, its parameters, objects it creates, or its direct fields.

// ❌ BAD: Violates Law of Demeter (train wreck)
const userName = order.getCustomer().getAddress().getCity().getName();

// ✅ GOOD: Ask, don't dig
class Order {
  getCustomerCityName(): string {
    return this.customer.getCityName();
  }
}

class Customer {
  getCityName(): string {
    return this.address.getCityName();
  }
}

const userName = order.getCustomerCityName();

Summary

  • Keep classes small and focused on a single responsibility

  • Favor composition over inheritance for flexibility

  • Program to interfaces to reduce coupling

  • Use meaningful, descriptive names

  • Minimize dependencies between classes

  • Encapsulate what varies

  • Follow the Law of Demeter

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free