Design Case Studies
Introduction
Design case studies are an invaluable way to strengthen your understanding of object-oriented design principles. While learning theory is important, seeing how these concepts apply to real-world problems provides deeper insights and practical experience. In this tutorial, we'll walk through several case studies that demonstrate effective object-oriented design in action.
Each case study will follow a structured approach:
- Problem definition
- Analysis of requirements
- Design exploration and decisions
- Implementation walkthrough
- Evaluation and lessons learned
By the end of this tutorial, you'll have a better understanding of how to approach complex design problems using object-oriented principles.
Case Study 1: Library Management System
Problem Definition
Let's design a simplified library management system that allows:
- Tracking books and their availability
- Managing member accounts
- Handling book checkouts and returns
- Maintaining a catalog system
Requirements Analysis
Before diving into code, we need to identify the key objects and their relationships:
Design Decisions
- Separation of Book and BookCopy: A book represents the abstract concept (title, author), while a BookCopy represents a physical instance of that book.
- Transaction class: To track checkout history and due dates.
- Library as a Facade: Provides a simplified interface to the complex system.
Implementation
Let's implement the core classes:
// Book.java
public class Book {
private String title;
private String author;
private String isbn;
private List<BookCopy> copies = new ArrayList<>();
public Book(String title, String author, String isbn) {
this.title = title;
this.author = author;
this.isbn = isbn;
}
public void addCopy(BookCopy copy) {
copies.add(copy);
}
public List<BookCopy> getAvailableCopies() {
return copies.stream()
.filter(BookCopy::isAvailable)
.collect(Collectors.toList());
}
// Getters and setters
public String getTitle() { return title; }
public String getAuthor() { return author; }
public String getIsbn() { return isbn; }
}
// BookCopy.java
public class BookCopy {
private String id;
private Book book;
private boolean isAvailable = true;
private String condition;
public BookCopy(String id, Book book, String condition) {
this.id = id;
this.book = book;
this.condition = condition;
book.addCopy(this);
}
public boolean checkout() {
if (isAvailable) {
isAvailable = false;
return true;
}
return false;
}
public void returnBook() {
isAvailable = true;
}
// Getters
public String getId() { return id; }
public Book getBook() { return book; }
public boolean isAvailable() { return isAvailable; }
public String getCondition() { return condition; }
}
// Member.java
public class Member {
private String id;
private String name;
private String contactInfo;
private List<BookCopy> borrowedBooks = new ArrayList<>();
private List<Transaction> transactions = new ArrayList<>();
public Member(String id, String name, String contactInfo) {
this.id = id;
this.name = name;
this.contactInfo = contactInfo;
}
public boolean checkoutBook(BookCopy copy) {
if (copy.checkout()) {
borrowedBooks.add(copy);
// Create a transaction
Transaction transaction = new Transaction(
LocalDate.now(),
"CHECKOUT",
LocalDate.now().plusDays(14),
copy
);
transactions.add(transaction);
return true;
}
return false;
}
public boolean returnBook(BookCopy copy) {
if (borrowedBooks.contains(copy)) {
copy.returnBook();
borrowedBooks.remove(copy);
// Create a transaction
Transaction transaction = new Transaction(
LocalDate.now(),
"RETURN",
null,
copy
);
transactions.add(transaction);
return true;
}
return false;
}
// Getters
public String getId() { return id; }
public String getName() { return name; }
public List<BookCopy> getBorrowedBooks() { return new ArrayList<>(borrowedBooks); }
}
// Transaction.java
public class Transaction {
private LocalDate date;
private String type; // CHECKOUT or RETURN
private LocalDate dueDate;
private BookCopy bookCopy;
public Transaction(LocalDate date, String type, LocalDate dueDate, BookCopy bookCopy) {
this.date = date;
this.type = type;
this.dueDate = dueDate;
this.bookCopy = bookCopy;
}
// Getters
public LocalDate getDate() { return date; }
public String getType() { return type; }
public LocalDate getDueDate() { return dueDate; }
public BookCopy getBookCopy() { return bookCopy; }
}
// Library.java - Facade
public class Library {
private List<Book> books = new ArrayList<>();
private List<Member> members = new ArrayList<>();
public void addBook(Book book) {
books.add(book);
}
public void addMember(Member member) {
members.add(member);
}
public List<Book> searchByTitle(String titleQuery) {
return books.stream()
.filter(book -> book.getTitle().toLowerCase().contains(titleQuery.toLowerCase()))
.collect(Collectors.toList());
}
public List<Book> searchByAuthor(String authorQuery) {
return books.stream()
.filter(book -> book.getAuthor().toLowerCase().contains(authorQuery.toLowerCase()))
.collect(Collectors.toList());
}
public Member findMemberById(String id) {
return members.stream()
.filter(member -> member.getId().equals(id))
.findFirst()
.orElse(null);
}
public Book findBookByIsbn(String isbn) {
return books.stream()
.filter(book -> book.getIsbn().equals(isbn))
.findFirst()
.orElse(null);
}
}
Example Usage
Here's how our library system could be used:
// Creating a library
Library library = new Library();
// Adding books to the library
Book book1 = new Book("Clean Code", "Robert C. Martin", "9780132350884");
Book book2 = new Book("Design Patterns", "Erich Gamma et al.", "9780201633610");
library.addBook(book1);
library.addBook(book2);
// Adding book copies
BookCopy cleanCode1 = new BookCopy("CC001", book1, "Good");
BookCopy cleanCode2 = new BookCopy("CC002", book1, "Excellent");
BookCopy designPatterns1 = new BookCopy("DP001", book2, "Fair");
// Adding members
Member alice = new Member("M001", "Alice Smith", "[email protected]");
Member bob = new Member("M002", "Bob Johnson", "[email protected]");
library.addMember(alice);
library.addMember(bob);
// Checking out books
alice.checkoutBook(cleanCode1);
bob.checkoutBook(designPatterns1);
// Searching for books
List<Book> cleanCodeBooks = library.searchByTitle("Clean Code");
System.out.println("Found " + cleanCodeBooks.size() + " book(s) with title 'Clean Code'");
System.out.println("Available copies: " + cleanCodeBooks.get(0).getAvailableCopies().size());
// Output:
// Found 1 book(s) with title 'Clean Code'
// Available copies: 1
Evaluation
This design demonstrates several OO principles:
- Encapsulation: Each class manages its own data and provides methods to interact with it.
- Single Responsibility: Each class has a clear, focused purpose.
- Open/Closed Principle: We can add new book types or member types without modifying existing code.
- Dependency Management: Objects interact through well-defined interfaces.
Case Study 2: Online Shopping Cart
Problem Definition
Design a shopping cart system that allows:
- Adding and removing products
- Calculating totals with different pricing rules
- Applying various discounts
- Processing checkout
Requirements Analysis
Let's identify the key objects:
Design Decisions
- Strategy Pattern: Using different pricing strategies (regular, bulk)
- Composite Pattern: Cart contains items, each with its own product
- Open for Extension: Different discount types implemented through an interface
Implementation
// Product.java
public class Product {
private String id;
private String name;
private String description;
private double basePrice;
private PricingStrategy pricingStrategy;
public Product(String id, String name, String description, double basePrice) {
this.id = id;
this.name = name;
this.description = description;
this.basePrice = basePrice;
this.pricingStrategy = new RegularPricing(); // Default
}
public void setPricingStrategy(PricingStrategy strategy) {
this.pricingStrategy = strategy;
}
public double calculatePrice(int quantity) {
return pricingStrategy.calculatePrice(this, quantity);
}
// Getters
public String getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
public double getBasePrice() { return basePrice; }
}
// Pricing Strategies
public interface PricingStrategy {
double calculatePrice(Product product, int quantity);
}
public class RegularPricing implements PricingStrategy {
@Override
public double calculatePrice(Product product, int quantity) {
return product.getBasePrice() * quantity;
}
}
public class BulkPricing implements PricingStrategy {
private int minimumQuantity;
private double discountPercent;
public BulkPricing(int minimumQuantity, double discountPercent) {
this.minimumQuantity = minimumQuantity;
this.discountPercent = discountPercent;
}
@Override
public double calculatePrice(Product product, int quantity) {
double baseTotal = product.getBasePrice() * quantity;
if (quantity >= minimumQuantity) {
return baseTotal * (1 - discountPercent);
}
return baseTotal;
}
}
// CartItem.java
public class CartItem {
private Product product;
private int quantity;
public CartItem(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
}
public double calculateSubtotal() {
return product.calculatePrice(quantity);
}
public void increaseQuantity(int amount) {
this.quantity += amount;
}
// Getters and setters
public Product getProduct() { return product; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
}
// Discount interface and implementations
public interface Discount {
double apply(double amount);
}
public class PercentageDiscount implements Discount {
private double percentage;
public PercentageDiscount(double percentage) {
this.percentage = percentage;
}
@Override
public double apply(double amount) {
return amount * (1 - percentage);
}
}
public class FixedAmountDiscount implements Discount {
private double amount;
public FixedAmountDiscount(double amount) {
this.amount = amount;
}
@Override
public double apply(double amount) {
return Math.max(0, amount - this.amount);
}
}
// ShoppingCart.java
public class ShoppingCart {
private List<CartItem> items = new ArrayList<>();
private List<Discount> discounts = new ArrayList<>();
public void addItem(Product product, int quantity) {
// Check if the product is already in the cart
for (CartItem item : items) {
if (item.getProduct().getId().equals(product.getId())) {
item.increaseQuantity(quantity);
return;
}
}
// If not found, add a new cart item
items.add(new CartItem(product, quantity));
}
public void removeItem(Product product) {
items.removeIf(item -> item.getProduct().getId().equals(product.getId()));
}
public void updateQuantity(Product product, int newQuantity) {
for (CartItem item : items) {
if (item.getProduct().getId().equals(product.getId())) {
if (newQuantity <= 0) {
removeItem(product);
} else {
item.setQuantity(newQuantity);
}
return;
}
}
}
public void applyDiscount(Discount discount) {
discounts.add(discount);
}
public double calculateSubtotal() {
return items.stream()
.mapToDouble(CartItem::calculateSubtotal)
.sum();
}
public double calculateTotal() {
double subtotal = calculateSubtotal();
double total = subtotal;
// Apply all discounts
for (Discount discount : discounts) {
total = discount.apply(total);
}
return total;
}
public Map<String, Object> checkout() {
Map<String, Object> receipt = new HashMap<>();
List<Map<String, Object>> itemDetails = new ArrayList<>();
for (CartItem item : items) {
Map<String, Object> itemDetail = new HashMap<>();
Product product = item.getProduct();
itemDetail.put("productId", product.getId());
itemDetail.put("productName", product.getName());
itemDetail.put("quantity", item.getQuantity());
itemDetail.put("unitPrice", product.getBasePrice());
itemDetail.put("subtotal", item.calculateSubtotal());
itemDetails.add(itemDetail);
}
receipt.put("items", itemDetails);
receipt.put("subtotal", calculateSubtotal());
receipt.put("total", calculateTotal());
receipt.put("discounts", discounts.size());
receipt.put("timestamp", LocalDateTime.now().toString());
// Clear the cart after checkout
items.clear();
discounts.clear();
return receipt;
}
}
Example Usage
// Create products
Product laptop = new Product("P001", "Laptop", "High-performance laptop", 999.99);
Product mouse = new Product("P002", "Mouse", "Wireless mouse", 29.99);
Product keyboard = new Product("P003", "Keyboard", "Mechanical keyboard", 89.99);
// Set bulk pricing strategy for keyboard
keyboard.setPricingStrategy(new BulkPricing(2, 0.15)); // 15% off when buying 2 or more
// Create shopping cart
ShoppingCart cart = new ShoppingCart();
// Add items to cart
cart.addItem(laptop, 1);
cart.addItem(mouse, 1);
cart.addItem(keyboard, 2);
// Display initial total
System.out.println("Subtotal: $" + cart.calculateSubtotal());
// Output: Subtotal: $1132.97 (999.99 + 29.99 + (89.99 * 2 * 0.85))
// Apply discount
cart.applyDiscount(new PercentageDiscount(0.10)); // 10% off entire order
// Display final total
System.out.println("Total after discount: $" + cart.calculateTotal());
// Output: Total after discount: $1019.67
// Checkout
Map<String, Object> receipt = cart.checkout();
System.out.println("Checkout complete. Order total: $" + receipt.get("total"));
Evaluation
This design showcases:
- Strategy Pattern: Different pricing strategies can be applied to products
- Open/Closed Principle: New discount types can be added without modifying existing code
- Single Responsibility: Each class has a specific purpose
- Dependency Inversion: High-level modules depend on abstractions, not concrete implementations
Case Study 3: Text Editor with Undo Functionality
Problem Definition
Design a simple text editor that allows:
- Adding and removing text
- Changing formatting (bold, italic)
- Undo/redo operations
- Saving documents
Design Approach
For this system, we'll implement the Command pattern to enable undo functionality.
Implementation
// Document.java
public class Document {
private StringBuilder text = new StringBuilder();
private Map<Integer, Map<String, Boolean>> formatting = new HashMap<>();
public void addText(int position, String textToAdd) {
text.insert(position, textToAdd);
// Shift formatting information
Map<Integer, Map<String, Boolean>> newFormatting = new HashMap<>();
for (Map.Entry<Integer, Map<String, Boolean>> entry : formatting.entrySet()) {
int pos = entry.getKey();
if (pos >= position) {
newFormatting.put(pos + textToAdd.length(), entry.getValue());
} else {
newFormatting.put(pos, entry.getValue());
}
}
formatting = newFormatting;
}
public String deleteText(int position, int length) {
String deletedText = text.substring(position, position + length);
text.delete(position, position + length);
// Update formatting
Map<Integer, Map<String, Boolean>> newFormatting = new HashMap<>();
for (Map.Entry<Integer, Map<String, Boolean>> entry : formatting.entrySet()) {
int pos = entry.getKey();
if (pos >= position + length) {
newFormatting.put(pos - length, entry.getValue());
} else if (pos < position) {
newFormatting.put(pos, entry.getValue());
}
// Formatting info within deleted text is discarded
}
formatting = newFormatting;
return deletedText;
}
public void formatText(int position, int length, String formatType, boolean value) {
for (int i = position; i < position + length; i++) {
if (!formatting.containsKey(i)) {
formatting.put(i, new HashMap<>());
}
formatting.get(i).put(formatType, value);
}
}
public String getText() {
return text.toString();
}
public Map<String, Boolean> getFormattingAt(int position) {
return formatting.getOrDefault(position, new HashMap<>());
}
public void save(String filename) {
// In a real implementation, this would save to a file
System.out.println("Saving document to " + filename);
System.out.println("Content: " + text.toString());
System.out.println("Formatting: " + formatting.toString());
}
}
// Command interface and implementations
public interface Command {
void execute();
void undo();
}
public class AddTextCommand implements Command {
private Document document;
private int position;
private String text;
public AddTextCommand(Document document, int position, String text) {
this.document = document;
this.position = position;
this.text = text;
}
@Override
public void execute() {
document.addText(position, text);
}
@Override
public void undo() {
document.deleteText(position, text.length());
}
}
public class DeleteTextCommand implements Command {
private Document document;
private int position;
private int length;
private String deletedText;
public DeleteTextCommand(Document document, int position, int length) {
this.document = document;
this.position = position;
this.length = length;
}
@Override
public void execute() {
deletedText = document.deleteText(position, length);
}
@Override
public void undo() {
document.addText(position, deletedText);
}
}
public class FormatTextCommand implements Command {
private Document document;
private int position;
private int length;
private String formatType;
private boolean value;
private Map<Integer, Boolean> previousValues = new HashMap<>();
public FormatTextCommand(Document document, int position, int length,
String formatType, boolean value) {
this.document = document;
this.position = position;
this.length = length;
this.formatType = formatType;
this.value = value;
}
@Override
public void execute() {
// Save previous state for undo
for (int i = position; i < position + length; i++) {
Map<String, Boolean> formatting = document.getFormattingAt(i);
previousValues.put(i, formatting.getOrDefault(formatType, false));
}
// Apply new formatting
document.formatText(position, length, formatType, value);
}
@Override
public void undo() {
for (Map.Entry<Integer, Boolean> entry : previousValues.entrySet()) {
document.formatText(entry.getKey(), 1, formatType, entry.getValue());
}
}
}
// CommandHistory.java
public class CommandHistory {
private Deque<Command> undoStack = new ArrayDeque<>();
private Deque<Command> redoStack = new ArrayDeque<>();
public void push(Command command) {
undoStack.push(command);
redoStack.clear(); // Clear redo stack when a new command is executed
}
public Command popUndoCommand() {
if (undoStack.isEmpty()) {
return null;
}
Command command = undoStack.pop();
redoStack.push(command);
return command;
}
public Command popRedoCommand() {
if (redoStack.isEmpty()) {
return null;
}
Command command = redoStack.pop();
undoStack.push(command);
return command;
}
public boolean canUndo() {
return !undoStack.isEmpty();
}
public boolean canRedo() {
return !redoStack.isEmpty();
}
}
// Editor.java
public class Editor {
private Document document;
private CommandHistory history;
public Editor() {
document = new Document();
history = new CommandHistory();
}
public void executeCommand(Command command) {
command.execute();
history.push(command);
}
public boolean undo() {
if (!history.canUndo()) {
return false;
}
Command command = history.popUndoCommand();
command.undo();
return true;
}
public boolean redo() {
if (!history.canRedo()) {
return false;
}
Command command = history.popRedoCommand();
command.execute();
return true;
}
public Document getDocument() {
return document;
}
public void saveDocument(String filename) {
document.save(filename);
}
}
Example Usage
// Create an editor
Editor editor = new Editor();
// Add text
editor.executeCommand(new AddTextCommand(editor.getDocument(), 0, "Hello, "));
editor.executeCommand(new AddTextCommand(editor.getDocument(), 7, "world!"));
// Print current text
System.out.println("Text: " + editor.getDocument().getText());
// Output: Text: Hello, world!
// Make text bold
editor.executeCommand(new FormatTextCommand(editor.getDocument(), 0, 7, "bold", true));
// Delete some text
editor.executeCommand(new DeleteTextCommand(editor.getDocument(), 5, 3));
// Print current text
System.out.println("Text after delete: " + editor.getDocument().getText());
// Output: Text after delete: Helloworld!
// Undo last operation
editor.undo();
System.out.println("Text after undo: " + editor.getDocument().getText());
// Output: Text after undo: Hello, world!
// Redo operation
editor.redo();
System.out.println("Text after redo: " + editor.getDocument().getText());
// Output: Text after redo: Helloworld!
// Save document
editor.saveDocument("myDocument.txt");
Evaluation
This design demonstrates:
- Command Pattern: Encapsulates operations as objects, enabling undo/redo
- Single Responsibility: Each command handles one specific change to the document
- Open for Extension: New commands can be added without modifying existing code
- History Management: Command history maintains the state for undo/redo operations
Summary and Best Practices
Through these case studies, we've seen several object-oriented design principles in action:
-
Identify the right abstractions
- Find the natural objects in the problem domain
- Abstract common behaviors into interfaces
-
Apply appropriate design patterns
- Factory, Strategy, Command, and Observer patterns solve common problems
- Don't force patterns when simpler solutions exist
-
Follow SOLID principles
- Single Responsibility: Each class should have one purpose
- Open/Closed: Open for extension, closed for modification
- Liskov Substitution: Subtypes should be substitutable for their base types
- Interface Segregation: Many specific interfaces are better than one general one
- Dependency Inversion: Depend on abstractions, not concretions
-
Design for change
- Encapsulate what varies
- Program to interfaces, not implementations
- Favor composition over inheritance
Exercises
-
Extend the Library System
- Add a fine calculation system for overdue books
- Implement a reservation system for books currently checked out
- Create a notification system for due dates
-
Enhance the Shopping Cart
- Add a coupon system that works with existing discounts
- Implement a tax calculator based on different regions
- Create a recommendation system based on cart contents
-
Improve the Text Editor
- Add paragraph styling (alignment, spacing)
- Implement a find and replace feature
- Create a document export system with multiple formats
Additional Resources
-
Books
- "Design Patterns: Elements of Reusable Object-Oriented Software" by Gamma et al.
- "Clean Code" by Robert C. Martin
- "Head First Design Patterns" by Freeman & Robson
-
Online Learning
- Object-Oriented Design courses on Coursera and edX
- Design pattern tutorials on Refactoring Guru
- Practice exercises on LeetCode and HackerRank
-
Tools
- UML modeling tools like PlantUML
- IDE plugins for design pattern templates
- Code analyzers to check for design principles adherence
Remember that good design evolves over time. Start simple, refine based on changing requirements, and always strive for readable, maintainable code.
💡 Found a typo or mistake? Click "Edit this page" to suggest a correction. Your feedback is greatly appreciated!