Back to blog

Object-Oriented Design: From Requirements to Clean Code

oopsoftware-designdesign-patternstypescriptpythonjava
Object-Oriented Design: From Requirements to Clean Code

Introduction

You know the four pillars of OOP. You understand SOLID principles. You can implement design patterns. But when you sit down to design a new feature or system from scratch, you freeze: Where do I start? How many classes do I need? Who should be responsible for what?

Object-Oriented Design (OOD) is the missing bridge between knowing OOP concepts and building real systems. It's the discipline of deciding what objects you need, what each object should do, and how objects should collaborate — before you write a single line of code.

This guide teaches you the practical process of OO design — the tools, principles, and heuristics that experienced developers use to make design decisions every day.

What You'll Learn

✅ Turn requirements into objects using CRC cards
✅ Assign responsibilities using GRASP patterns
✅ Measure design quality with coupling and cohesion
✅ Apply practical design heuristics to avoid common mistakes
✅ Walk through a complete design example from requirements to code
✅ Know when to stop designing and start coding

Prerequisites

  • Familiarity with OOP concepts (classes, interfaces, inheritance, polymorphism)
  • Basic understanding of SOLID Principles
  • Experience writing code in at least one OO language

Why Design Matters

Writing code is easy. Writing code that's easy to change is hard.

Without DesignWith Design
"Let me just start coding and figure it out""Let me identify the key objects and responsibilities first"
God classes with 2000+ linesFocused classes with clear responsibilities
Changing one thing breaks five othersChanges are isolated and predictable
"I don't know where this logic should go"Every piece of logic has a natural home
Rewriting from scratch every 6 monthsEvolving the same codebase for years

Good design isn't about getting it perfect upfront — it's about making decisions that keep your options open as requirements change.


Step 1: Identify Objects with CRC Cards

CRC (Class-Responsibility-Collaborator) cards are a lightweight tool for discovering objects in your system. Invented by Kent Beck and Ward Cunningham, they've been used since the late 1980s.

How CRC Cards Work

Each card represents a potential class:

┌──────────────────────────────────────────────┐
│  Class Name: Order                           │
├──────────────────────┬───────────────────────┤
│  Responsibilities    │  Collaborators        │
│                      │                       │
│  - Calculate total   │  - OrderItem          │
│  - Apply discount    │  - DiscountPolicy     │
│  - Track status      │  - PaymentGateway     │
│  - Generate invoice  │  - InvoiceService     │
│                      │                       │
└──────────────────────┴───────────────────────┘

The CRC Process

  1. Read the requirements and underline the nouns — these are candidate classes
  2. Underline the verbs — these are candidate responsibilities
  3. Create a card for each major noun
  4. Write responsibilities on the left side
  5. Write collaborators (other classes needed) on the right side
  6. Role-play scenarios: Walk through use cases and see if the cards can handle them

Example: Online Bookstore

Requirements: "Customers can browse books, add them to a shopping cart, and place orders. Orders can have discount codes applied. Customers receive email confirmations."

Nouns (candidate classes): Customer, Book, ShoppingCart, Order, DiscountCode, EmailConfirmation

Verbs (candidate responsibilities): browse, add, place order, apply discount, send confirmation

┌──────────────────────────────────────────────┐
│  Class Name: ShoppingCart                    │
├──────────────────────┬───────────────────────┤
│  Responsibilities    │  Collaborators        │
│                      │                       │
│  - Add item          │  - Book               │
│  - Remove item       │  - CartItem           │
│  - Calculate subtotal│                       │
│  - Apply discount    │  - DiscountCode       │
│  - Create order      │  - Order              │
│                      │                       │
└──────────────────────┴───────────────────────┘
 
┌──────────────────────────────────────────────┐
│  Class Name: Order                           │
├──────────────────────┬───────────────────────┤
│  Responsibilities    │  Collaborators        │
│                      │                       │
│  - Store line items  │  - OrderItem          │
│  - Calculate total   │  - DiscountCode       │
│  - Track status      │                       │
│  - Confirm placement │  - NotificationService│
│                      │                       │
└──────────────────────┴───────────────────────┘

Warning Signs on CRC Cards

  • Too many responsibilities: The card is doing too much — split it
  • No collaborators: The class might be just a data holder — is it really needed?
  • Too many collaborators: The class is a coordinator, not a doer — redistribute responsibilities
  • Duplicate responsibilities: Two cards share the same job — consolidate

Step 2: Assign Responsibilities with GRASP

GRASP (General Responsibility Assignment Software Patterns) are nine principles that help you decide which class should be responsible for what. Created by Craig Larman, GRASP answers the most fundamental OO design question: "Who does what?"

Pattern 1: Information Expert

Assign responsibility to the class that has the information needed to fulfill it.

This is the most important GRASP pattern. If a class already has the data, it should be responsible for the behavior that uses that data.

// TypeScript — Information Expert
 
// ❌ Bad: External class calculates order total
class OrderCalculator {
  calculateTotal(order: Order): number {
    let total = 0;
    for (const item of order.items) {
      total += item.price * item.quantity;
    }
    return total - order.discount;
  }
}
 
// ✅ Good: Order calculates its own total (it has the data)
class Order {
  private items: OrderItem[] = [];
  private discount: number = 0;
 
  getTotal(): number {
    const subtotal = this.items.reduce(
      (sum, item) => sum + item.getSubtotal(), 0
    );
    return subtotal - this.discount;
  }
}
 
class OrderItem {
  constructor(
    private product: Product,
    private quantity: number
  ) {}
 
  getSubtotal(): number {
    return this.product.price * this.quantity;
  }
}
# Python — Information Expert
 
# ❌ Bad: External function calculates total
def calculate_order_total(order):
    total = sum(item.price * item.quantity for item in order.items)
    return total - order.discount
 
# ✅ Good: Order knows how to calculate its own total
class Order:
    def __init__(self):
        self._items: list[OrderItem] = []
        self._discount: float = 0
 
    def get_total(self) -> float:
        subtotal = sum(item.get_subtotal() for item in self._items)
        return subtotal - self._discount
 
class OrderItem:
    def __init__(self, product: Product, quantity: int):
        self._product = product
        self._quantity = quantity
 
    def get_subtotal(self) -> float:
        return self._product.price * self._quantity

Pattern 2: Creator

Assign class B the responsibility to create instances of class A if B contains, aggregates, records, or closely uses A.

// TypeScript — Creator
 
// ❌ Bad: External factory for tightly related objects
class OrderItemFactory {
  static create(product: Product, qty: number): OrderItem {
    return new OrderItem(product, qty);
  }
}
 
// ✅ Good: ShoppingCart creates OrderItems (it aggregates them)
class ShoppingCart {
  private items: CartItem[] = [];
 
  addItem(product: Product, quantity: number): void {
    const existing = this.items.find(
      item => item.product.id === product.id
    );
    if (existing) {
      existing.quantity += quantity;
    } else {
      this.items.push(new CartItem(product, quantity));
    }
  }
}

Pattern 3: Controller

Assign responsibility for handling system events to a class that represents the overall system or a use case scenario.

// TypeScript — Controller
 
// ❌ Bad: UI directly manipulates domain objects
class CheckoutButton {
  onClick(): void {
    const order = new Order();
    order.addItems(cart.getItems());
    order.applyDiscount(discountCode);
    paymentGateway.charge(order.getTotal());
    emailService.sendConfirmation(order);
    inventory.reduce(order.getItems());
  }
}
 
// ✅ Good: Use case controller coordinates the workflow
class CheckoutController {
  constructor(
    private orderService: OrderService,
    private paymentGateway: PaymentGateway,
    private notificationService: NotificationService
  ) {}
 
  checkout(cart: ShoppingCart, discountCode?: string): Order {
    const order = this.orderService.createFromCart(cart);
 
    if (discountCode) {
      order.applyDiscount(discountCode);
    }
 
    this.paymentGateway.charge(order.getTotal());
    this.notificationService.sendConfirmation(order);
 
    return order;
  }
}

Pattern 4: Low Coupling

Assign responsibilities so that coupling remains low. Reduce the impact of change.

We'll cover this in depth in the next section.

Pattern 5: High Cohesion

Assign responsibilities so that cohesion remains high. Keep focused classes.

We'll cover this in depth in the next section.

Pattern 6: Polymorphism

When behavior varies by type, assign responsibility to the types using polymorphism.

// TypeScript — Polymorphism (GRASP)
 
// ❌ Bad: Conditional logic based on type
class ShippingCalculator {
  calculate(order: Order): number {
    if (order.type === "standard") {
      return order.weight * 5;
    } else if (order.type === "express") {
      return order.weight * 10 + 15;
    } else if (order.type === "overnight") {
      return order.weight * 20 + 30;
    }
    return 0;
  }
}
 
// ✅ Good: Each shipping type knows how to calculate itself
interface ShippingStrategy {
  calculate(weight: number): number;
}
 
class StandardShipping implements ShippingStrategy {
  calculate(weight: number): number {
    return weight * 5;
  }
}
 
class ExpressShipping implements ShippingStrategy {
  calculate(weight: number): number {
    return weight * 10 + 15;
  }
}
 
class OvernightShipping implements ShippingStrategy {
  calculate(weight: number): number {
    return weight * 20 + 30;
  }
}

Pattern 7: Indirection

Assign responsibility to an intermediate object to mediate between components to reduce direct coupling.

# Python — Indirection
 
# ❌ Bad: Direct coupling between Order and external tax API
class Order:
    def calculate_tax(self) -> float:
        response = requests.get(f"https://tax-api.com/rate?state={self.state}")
        rate = response.json()["rate"]
        return self.get_subtotal() * rate
 
# ✅ Good: TaxService mediates between Order and external API
class TaxService:
    def __init__(self, api_client: TaxApiClient):
        self._api_client = api_client
 
    def calculate_tax(self, subtotal: float, state: str) -> float:
        rate = self._api_client.get_rate(state)
        return subtotal * rate
 
class Order:
    def __init__(self, tax_service: TaxService):
        self._tax_service = tax_service
 
    def get_tax(self) -> float:
        return self._tax_service.calculate_tax(
            self.get_subtotal(), self.state
        )

Pattern 8: Pure Fabrication

When no domain object is a natural fit, create a service class to achieve low coupling and high cohesion.

// Java — Pure Fabrication
 
// There's no real-world "PersistenceManager" in an e-commerce domain,
// but it's a useful design abstraction
 
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(String id);
    List<Order> findByCustomer(String customerId);
}
 
public class PostgresOrderRepository implements OrderRepository {
    private final DataSource dataSource;
 
    @Override
    public void save(Order order) {
        // SQL insert logic — not a domain concern
    }
 
    @Override
    public Optional<Order> findById(String id) {
        // SQL select logic
    }
}

Pattern 9: Protected Variations

Identify points of predicted variation and create stable interfaces around them.

// TypeScript — Protected Variations
 
// Payment providers will change over time — protect against that
interface PaymentGateway {
  charge(amount: number, token: string): PaymentResult;
  refund(transactionId: string, amount: number): RefundResult;
}
 
class StripeGateway implements PaymentGateway {
  charge(amount: number, token: string): PaymentResult {
    // Stripe-specific implementation
  }
  refund(transactionId: string, amount: number): RefundResult {
    // Stripe-specific refund
  }
}
 
class PayPalGateway implements PaymentGateway {
  charge(amount: number, token: string): PaymentResult {
    // PayPal-specific implementation
  }
  refund(transactionId: string, amount: number): RefundResult {
    // PayPal-specific refund
  }
}
 
// Switching payment providers requires ZERO changes to business logic
class CheckoutService {
  constructor(private paymentGateway: PaymentGateway) {}
 
  processPayment(order: Order, paymentToken: string): void {
    const result = this.paymentGateway.charge(
      order.getTotal(), paymentToken
    );
    order.markAsPaid(result.transactionId);
  }
}

GRASP Quick Reference

PatternQuestion It AnswersRule of Thumb
Information ExpertWho should do this?Whoever has the data
CreatorWho should create this?Whoever contains/aggregates it
ControllerWho handles this event?A use case or system controller
Low CouplingHow to minimize dependencies?Depend on abstractions
High CohesionHow to keep classes focused?One clear purpose per class
PolymorphismHow to handle type-based behavior?Use interfaces, not conditionals
IndirectionHow to decouple two components?Add a mediator between them
Pure FabricationWhat if no domain class fits?Create a service class
Protected VariationsHow to handle future change?Wrap variation behind an interface

Step 3: Measure Design Quality

Two metrics dominate OO design evaluation: coupling and cohesion. Every design decision you make should push toward low coupling and high cohesion.

Coupling: How Connected Are Your Classes?

Coupling measures how much one class depends on another. High coupling means changes ripple through the system.

Levels of Coupling (Best to Worst)

// TypeScript — Coupling Examples
 
// ✅ Message Coupling (Best): Only knows the method name
class OrderService {
  notify(order: Order): void {
    this.notifier.send(order.getCustomerEmail(), "Order confirmed");
  }
}
 
// ⚠️ Data Coupling: Passes primitive data
class ShippingService {
  calculateCost(weight: number, distance: number): number {
    return weight * 0.5 + distance * 0.1;
  }
}
 
// ⚠️ Stamp Coupling: Passes entire object but uses only part of it
class EmailService {
  sendConfirmation(order: Order): void {
    // Only uses email and orderId, but receives entire Order
    this.send(order.customerEmail, `Order ${order.id} confirmed`);
  }
}
 
// ❌ Control Coupling: Passes a flag that controls behavior
class ReportGenerator {
  generate(data: SalesData, format: "pdf" | "csv" | "html"): void {
    if (format === "pdf") { /* ... */ }
    else if (format === "csv") { /* ... */ }
    else if (format === "html") { /* ... */ }
  }
}
 
// ❌ Content Coupling (Worst): Directly accesses internals
class BadAuditor {
  audit(order: Order): void {
    // Reaching into internal state — breaks encapsulation
    const items = (order as any)._internalItems;
    const rawDiscount = (order as any)._discountValue;
  }
}

How to Reduce Coupling

  1. Depend on interfaces, not concrete classes
  2. Use dependency injection instead of creating dependencies internally
  3. Follow the Law of Demeter: Only talk to your immediate friends
  4. Use events/observers for cross-cutting concerns

Cohesion: How Focused Is Your Class?

Cohesion measures how related the responsibilities within a class are. High cohesion means a class does one thing well.

Levels of Cohesion (Worst to Best)

// TypeScript — Cohesion Examples
 
// ❌ Coincidental Cohesion (Worst): Unrelated methods grouped together
class Utilities {
  formatDate(date: Date): string { /* ... */ }
  calculateTax(amount: number): number { /* ... */ }
  sendEmail(to: string, body: string): void { /* ... */ }
  compressImage(path: string): Buffer { /* ... */ }
}
 
// ❌ Logical Cohesion: Related by category, not purpose
class DataParser {
  parseJSON(data: string): object { /* ... */ }
  parseXML(data: string): object { /* ... */ }
  parseCSV(data: string): object { /* ... */ }
  parseYAML(data: string): object { /* ... */ }
}
 
// ⚠️ Temporal Cohesion: Related because they happen at the same time
class ApplicationStartup {
  initialize(): void {
    this.loadConfig();
    this.connectDatabase();
    this.startServer();
    this.registerRoutes();
    this.warmCache();
  }
}
 
// ✅ Functional Cohesion (Best): Everything serves one purpose
class PasswordHasher {
  private readonly saltRounds = 12;
 
  async hash(password: string): Promise<string> {
    return bcrypt.hash(password, this.saltRounds);
  }
 
  async verify(password: string, hash: string): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }
}

The Coupling-Cohesion Balance

Rule of thumb: If increasing cohesion causes coupling to increase, you've gone too far. Find the balance.


Step 4: Apply Design Heuristics

Design heuristics are rules of thumb that help you make quick decisions. They aren't absolute laws — they're guardrails.

Heuristic 1: Keep It Shy, Keep It Dry, Tell the Other Guy

  • Shy (Encapsulation): Minimize what each class exposes
  • DRY (Don't Repeat Yourself): Extract shared logic into a single place
  • Tell, Don't Ask: Tell objects what to do, don't ask for their data and act on it
// TypeScript — Tell, Don't Ask
 
// ❌ Ask: Pull data out, then decide externally
function processOrder(order: Order): void {
  const total = order.getTotal();
  const status = order.getStatus();
  if (status === "pending" && total > 0) {
    order.setStatus("processing");
    paymentService.charge(total, order.getPaymentToken());
    order.setStatus("paid");
  }
}
 
// ✅ Tell: Let the order handle its own state transitions
class Order {
  process(paymentService: PaymentService): void {
    if (!this.canBeProcessed()) {
      throw new Error("Order cannot be processed");
    }
    this.status = "processing";
    paymentService.charge(this.total, this.paymentToken);
    this.status = "paid";
  }
 
  private canBeProcessed(): boolean {
    return this.status === "pending" && this.total > 0;
  }
}

Heuristic 2: The Law of Demeter

Only talk to your immediate friends. Don't talk to strangers.

A method should only call methods on:

  • Its own object (this)
  • Its parameters
  • Objects it creates
  • Its direct components (fields)
# Python — Law of Demeter
 
# ❌ Violation: Reaching through objects (train wreck)
def get_city(order):
    return order.get_customer().get_address().get_city()
 
# ✅ Following Demeter: Ask the direct collaborator
class Order:
    def get_shipping_city(self) -> str:
        return self._customer.get_shipping_city()
 
class Customer:
    def get_shipping_city(self) -> str:
        return self._address.city

Heuristic 3: Favor Composition Over Inheritance

Inheritance creates tight coupling between parent and child classes. Composition gives you the same code reuse with more flexibility.

// Java — Composition over Inheritance
 
// ❌ Inheritance: Tight coupling, fragile hierarchy
public class LoggingOrderService extends OrderService {
    @Override
    public Order createOrder(Cart cart) {
        logger.info("Creating order...");
        Order order = super.createOrder(cart);
        logger.info("Order created: " + order.getId());
        return order;
    }
}
 
// ✅ Composition: Flexible, swappable, testable
public class LoggingOrderService implements OrderService {
    private final OrderService delegate;
    private final Logger logger;
 
    public LoggingOrderService(OrderService delegate, Logger logger) {
        this.delegate = delegate;
        this.logger = logger;
    }
 
    @Override
    public Order createOrder(Cart cart) {
        logger.info("Creating order...");
        Order order = delegate.createOrder(cart);
        logger.info("Order created: " + order.getId());
        return order;
    }
}

Heuristic 4: Design for the Interface, Not the Implementation

// TypeScript — Interface-based design
 
// ❌ Depends on implementation details
class OrderProcessor {
  private db: PostgresDatabase;
  private mailer: SmtpMailer;
 
  process(order: Order): void {
    this.db.insertRow("orders", order.toRow());
    this.mailer.sendSmtpMessage(order.email, "Order confirmed");
  }
}
 
// ✅ Depends on interfaces — implementation can change freely
class OrderProcessor {
  constructor(
    private repository: OrderRepository,
    private notifier: Notifier
  ) {}
 
  process(order: Order): void {
    this.repository.save(order);
    this.notifier.notify(order.email, "Order confirmed");
  }
}

Heuristic 5: Don't Design What You Don't Need (YAGNI)

You Aren't Gonna Need It.

// ❌ Over-designed: Supports 5 export formats "just in case"
interface ExportStrategy { /* ... */ }
class JSONExporter implements ExportStrategy { /* ... */ }
class XMLExporter implements ExportStrategy { /* ... */ }
class CSVExporter implements ExportStrategy { /* ... */ }
class PDFExporter implements ExportStrategy { /* ... */ }
class ExcelExporter implements ExportStrategy { /* ... */ }
 
// ✅ Right-sized: Only build what you need right now
class ReportExporter {
  exportToCSV(report: Report): string {
    // The only format the client actually asked for
    return report.rows.map(row => row.join(",")).join("\n");
  }
}
// When they ask for PDF, THEN add a strategy pattern

Design Heuristics Cheat Sheet

HeuristicWhen to ApplyWarning Sign
Tell, Don't AskWhen you're getting data just to make a decisionGetter calls followed by conditional logic
Law of DemeterWhen you see chains of method callsa.getB().getC().doSomething()
Composition > InheritanceWhen you're tempted to extend a classDeep inheritance hierarchies (>2 levels)
Interface-based DesignWhen you depend on external systemsDirect imports of concrete classes
YAGNIWhen you're adding "just in case" featuresUnused interfaces, abstract classes with one impl

Putting It All Together: A Design Walkthrough

Let's design a library management system from requirements to code.

Requirements

"Librarians can add books to the catalog. Members can search for books, borrow them (max 3 at a time), and return them. Overdue books incur a daily fine. Members receive email notifications when books are due soon."

Step 1: Identify Objects (CRC)

Nouns: Librarian, Book, Catalog, Member, Loan, Fine, EmailNotification

Verbs: add, search, borrow, return, calculate fine, send notification

After creating CRC cards and role-playing scenarios:

Step 2: Assign Responsibilities (GRASP)

DecisionGRASP PatternReasoning
Who calculates fines?Information ExpertLoan has the due date and return date
Who creates Loans?CreatorMember aggregates loans
Who handles the borrow workflow?ControllerLibraryService coordinates the use case
Who sends notifications?Pure FabricationNotificationService — no domain counterpart
How to support email/SMS/push?Protected VariationsNotifier interface wraps the variation

Step 3: Write the Code

// TypeScript — Library Management System
 
// Domain Objects
class Book {
  constructor(
    public readonly id: string,
    public readonly title: string,
    public readonly author: string,
    public readonly isbn: string,
    private available: boolean = true
  ) {}
 
  isAvailable(): boolean {
    return this.available;
  }
 
  markBorrowed(): void {
    if (!this.available) {
      throw new Error(`Book "${this.title}" is not available`);
    }
    this.available = false;
  }
 
  markReturned(): void {
    this.available = true;
  }
}
 
class Loan {
  private static readonly FINE_PER_DAY = 0.50;
  private static readonly LOAN_PERIOD_DAYS = 14;
  private returnedAt?: Date;
 
  public readonly dueDate: Date;
 
  constructor(
    public readonly book: Book,
    public readonly borrowedAt: Date = new Date()
  ) {
    this.dueDate = new Date(borrowedAt);
    this.dueDate.setDate(this.dueDate.getDate() + Loan.LOAN_PERIOD_DAYS);
  }
 
  isOverdue(now: Date = new Date()): boolean {
    if (this.returnedAt) return false;
    return now > this.dueDate;
  }
 
  // Information Expert: Loan has due date and return date
  calculateFine(now: Date = new Date()): number {
    if (!this.isOverdue(now)) return 0;
    const overdueDays = Math.ceil(
      (now.getTime() - this.dueDate.getTime()) / (1000 * 60 * 60 * 24)
    );
    return overdueDays * Loan.FINE_PER_DAY;
  }
 
  markReturned(): void {
    this.returnedAt = new Date();
    this.book.markReturned();
  }
 
  isActive(): boolean {
    return !this.returnedAt;
  }
}
 
class Member {
  private static readonly MAX_LOANS = 3;
  private loans: Loan[] = [];
 
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string
  ) {}
 
  // Creator: Member aggregates Loans
  borrow(book: Book): Loan {
    if (this.getActiveLoans().length >= Member.MAX_LOANS) {
      throw new Error(`Member "${this.name}" has reached the borrowing limit`);
    }
    book.markBorrowed();
    const loan = new Loan(book);
    this.loans.push(loan);
    return loan;
  }
 
  returnBook(book: Book): number {
    const loan = this.loans.find(
      l => l.book.id === book.id && l.isActive()
    );
    if (!loan) {
      throw new Error(`No active loan found for "${book.title}"`);
    }
    const fine = loan.calculateFine();
    loan.markReturned();
    return fine;
  }
 
  getActiveLoans(): Loan[] {
    return this.loans.filter(loan => loan.isActive());
  }
 
  getOverdueLoans(): Loan[] {
    return this.getActiveLoans().filter(loan => loan.isOverdue());
  }
}
 
// Pure Fabrication: No domain counterpart
class Catalog {
  private books: Map<string, Book> = new Map();
 
  addBook(book: Book): void {
    this.books.set(book.id, book);
  }
 
  search(query: string): Book[] {
    const lower = query.toLowerCase();
    return Array.from(this.books.values()).filter(
      book =>
        book.title.toLowerCase().includes(lower) ||
        book.author.toLowerCase().includes(lower) ||
        book.isbn.includes(lower)
    );
  }
 
  getAvailableBooks(): Book[] {
    return Array.from(this.books.values()).filter(b => b.isAvailable());
  }
}
 
// Protected Variations: Notification method can change
interface Notifier {
  send(to: string, subject: string, message: string): Promise<void>;
}
 
// Controller: Coordinates the borrow use case
class LibraryService {
  constructor(
    private catalog: Catalog,
    private notifier: Notifier
  ) {}
 
  async borrowBook(member: Member, bookId: string): Promise<Loan> {
    const book = this.catalog.search(bookId)[0];
    if (!book) throw new Error("Book not found");
 
    const loan = member.borrow(book);
 
    await this.notifier.send(
      member.email,
      `Book Borrowed: ${book.title}`,
      `Due date: ${loan.dueDate.toLocaleDateString()}`
    );
 
    return loan;
  }
 
  async returnBook(member: Member, bookId: string): Promise<number> {
    const book = this.catalog.search(bookId)[0];
    if (!book) throw new Error("Book not found");
 
    const fine = member.returnBook(book);
 
    if (fine > 0) {
      await this.notifier.send(
        member.email,
        "Overdue Fine",
        `You owe $${fine.toFixed(2)} for late return of "${book.title}"`
      );
    }
 
    return fine;
  }
}

Design Analysis

Let's verify our design against the principles:

PrincipleAssessment
Information ExpertLoan calculates fines (has due date), Member checks limits (has loans)
CreatorMember creates Loan objects (aggregates them)
ControllerLibraryService coordinates workflows
Low CouplingNotifier interface decouples from email implementation
High Cohesion✅ Each class has a focused purpose
Protected Variations✅ Notification method wrapped behind interface
Tell, Don't Askmember.borrow(book) instead of checking limits externally
Law of Demeter✅ No long method chains

Common Design Mistakes

Mistake 1: Anemic Domain Model

Objects are just data holders with getters and setters, while all logic lives in service classes.

// ❌ Anemic: Domain object is just a data bag
class Order {
  id: string;
  items: OrderItem[];
  status: string;
  discount: number;
}
 
// All logic lives outside the object
class OrderService {
  calculateTotal(order: Order): number { /* ... */ }
  applyDiscount(order: Order, code: string): void { /* ... */ }
  canBeCancelled(order: Order): boolean { /* ... */ }
  cancel(order: Order): void { /* ... */ }
}
 
// ✅ Rich: Domain object owns its behavior
class Order {
  private items: OrderItem[] = [];
  private status: OrderStatus = "pending";
  private discount: number = 0;
 
  getTotal(): number { /* ... */ }
  applyDiscount(policy: DiscountPolicy): void { /* ... */ }
  cancel(): void {
    if (this.status !== "pending") {
      throw new Error("Only pending orders can be cancelled");
    }
    this.status = "cancelled";
  }
}

Mistake 2: God Class

One class does everything. It has dozens of methods, hundreds of lines, and is imported everywhere.

Symptoms: File has 500+ lines, 10+ dependencies, methods that don't use each other.

Fix: Split by responsibility. If a class has methods that can be grouped into themes, each theme is a candidate for its own class.

Mistake 3: Premature Abstraction

Creating interfaces, abstract classes, and factories before you have two concrete implementations.

// ❌ Premature: Only one implementation exists
interface IUserRepository { /* ... */ }
interface IUserService { /* ... */ }
interface IUserValidator { /* ... */ }
abstract class BaseUserHandler { /* ... */ }
class UserRepositoryFactory { /* ... */ }
class UserServiceImpl implements IUserService { /* ... */ }
 
// ✅ Right-sized: Concrete class, extract interface WHEN you need polymorphism
class UserRepository {
  save(user: User): void { /* ... */ }
  findById(id: string): User | null { /* ... */ }
}

Mistake 4: Feature Envy

A method uses more data from another class than from its own.

# ❌ Feature Envy: This method belongs in Order, not ReportGenerator
class ReportGenerator:
    def generate_order_summary(self, order: Order) -> str:
        total = sum(item.price * item.qty for item in order.items)
        tax = total * order.tax_rate
        shipping = order.weight * order.shipping_rate
        return f"Total: {total + tax + shipping}"
 
# ✅ Move the logic to where the data lives
class Order:
    def get_summary(self) -> str:
        return f"Total: {self.get_total()}"
 
    def get_total(self) -> float:
        return self._subtotal() + self._tax() + self._shipping()

When to Stop Designing

Design is a means to an end, not the end itself. Here are signs you're ready to code:

  1. You can explain each class's purpose in one sentence
  2. You know which class handles each major use case
  3. No class has more than 5-7 responsibilities
  4. You've role-played the main scenarios through your objects
  5. You have a clear picture of the interfaces between components

And here are signs you're over-designing:

  • You have more interfaces than concrete classes
  • You're designing for requirements no one has asked for
  • You've spent more time on diagrams than code
  • Every simple operation requires 5 classes and 3 interfaces

"The best design is the simplest one that works and is easy to change."


Summary and Key Takeaways

Object-Oriented Design is a skill that improves with practice. Here's your toolkit:

The OO Design Process:
CRC Cards — Discover objects and responsibilities from requirements
GRASP Patterns — Assign responsibilities systematically
Coupling & Cohesion — Measure and improve design quality
Design Heuristics — Apply practical rules of thumb

Key Principles to Remember:
✅ Assign behavior to the class that has the data (Information Expert)
✅ Tell objects what to do, don't ask for their state (Tell, Don't Ask)
✅ Depend on abstractions, not implementations (Protected Variations)
✅ Only talk to your immediate friends (Law of Demeter)
✅ Build what you need now, not what you might need later (YAGNI)

Practice Exercise: Take a feature you recently built and draw CRC cards for it. Are responsibilities well-distributed? Would GRASP suggest moving any behavior? This retrospective analysis is one of the fastest ways to sharpen your design skills.


Further Reading

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.