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 Design | With Design |
|---|---|
| "Let me just start coding and figure it out" | "Let me identify the key objects and responsibilities first" |
| God classes with 2000+ lines | Focused classes with clear responsibilities |
| Changing one thing breaks five others | Changes 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 months | Evolving 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
- Read the requirements and underline the nouns — these are candidate classes
- Underline the verbs — these are candidate responsibilities
- Create a card for each major noun
- Write responsibilities on the left side
- Write collaborators (other classes needed) on the right side
- 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._quantityPattern 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
| Pattern | Question It Answers | Rule of Thumb |
|---|---|---|
| Information Expert | Who should do this? | Whoever has the data |
| Creator | Who should create this? | Whoever contains/aggregates it |
| Controller | Who handles this event? | A use case or system controller |
| Low Coupling | How to minimize dependencies? | Depend on abstractions |
| High Cohesion | How to keep classes focused? | One clear purpose per class |
| Polymorphism | How to handle type-based behavior? | Use interfaces, not conditionals |
| Indirection | How to decouple two components? | Add a mediator between them |
| Pure Fabrication | What if no domain class fits? | Create a service class |
| Protected Variations | How 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
- Depend on interfaces, not concrete classes
- Use dependency injection instead of creating dependencies internally
- Follow the Law of Demeter: Only talk to your immediate friends
- 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.cityHeuristic 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 patternDesign Heuristics Cheat Sheet
| Heuristic | When to Apply | Warning Sign |
|---|---|---|
| Tell, Don't Ask | When you're getting data just to make a decision | Getter calls followed by conditional logic |
| Law of Demeter | When you see chains of method calls | a.getB().getC().doSomething() |
| Composition > Inheritance | When you're tempted to extend a class | Deep inheritance hierarchies (>2 levels) |
| Interface-based Design | When you depend on external systems | Direct imports of concrete classes |
| YAGNI | When you're adding "just in case" features | Unused 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)
| Decision | GRASP Pattern | Reasoning |
|---|---|---|
| Who calculates fines? | Information Expert | Loan has the due date and return date |
| Who creates Loans? | Creator | Member aggregates loans |
| Who handles the borrow workflow? | Controller | LibraryService coordinates the use case |
| Who sends notifications? | Pure Fabrication | NotificationService — no domain counterpart |
| How to support email/SMS/push? | Protected Variations | Notifier 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:
| Principle | Assessment |
|---|---|
| Information Expert | ✅ Loan calculates fines (has due date), Member checks limits (has loans) |
| Creator | ✅ Member creates Loan objects (aggregates them) |
| Controller | ✅ LibraryService coordinates workflows |
| Low Coupling | ✅ Notifier interface decouples from email implementation |
| High Cohesion | ✅ Each class has a focused purpose |
| Protected Variations | ✅ Notification method wrapped behind interface |
| Tell, Don't Ask | ✅ member.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:
- You can explain each class's purpose in one sentence
- You know which class handles each major use case
- No class has more than 5-7 responsibilities
- You've role-played the main scenarios through your objects
- 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
- The Four Pillars of OOP — Foundation of OO concepts
- SOLID Principles Explained — Five design principles for clean code
- Inheritance and Composition — When to use each approach
- Strategy and Template Method Patterns — Common behavioral patterns
- Software Architecture Patterns — Taking design to the system level
📬 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.