Practical Object-Oriented Design in Ruby
Object-Oriented Design
- Design definition: Arrange code to be easy to change later
- OOD purpose: Manage dependencies so changes have predictable, minimal consequences
- Key metrics: Make code TRUE
- Transparent: Make consequences of change obvious
- Reasonable: Keep cost of change proportional to benefits
- Usable: Write code that works in new and unexpected contexts
- Exemplary: Encourage others to maintain design quality
- Design principles: Don’t just follow rules, make pragmatic trade-offs
- Design timing: Remember “the right design” depends on current needs vs. future expectations
- Design mindset: Be practical over theoretical, adapt to changing requirements
- First rule: Resist the urge to start coding immediately – design first
Designing Classes with Single Responsibility
- Class definition: Give each class a single, well-defined responsibility
- SRP explained: Ensure each class has only “one reason to change”
- Cohesion principle: Keep everything in a class related to its responsibility
- Determining responsibility: Ask “what does this class do?” – answer should be brief
- Code smells: Watch for multiple responsibilities revealed by:
- Long method descriptions that use “and”
- Difficulty naming class concisely
- Many unrelated methods
- Techniques:
- Extract extra responsibilities to new classes
- Name classes/methods based on what, not how
- Group methods that change together
- Write instance methods that share instance variables
- Benefits: Create reusable, pluggable components with minimal entanglement
- Warning: Don’t over-apply SRP too early – wait until you understand the domain better
Managing Dependencies
- Definition: Recognize when one object knows about another
- Common dependencies:
- Knowing another class’s name
- Knowing method names on other objects
- Knowing required method arguments
- Knowing the order of multiple arguments
- Techniques for reducing dependencies:
- Inject dependencies as parameters instead of hardcoding
- Isolate instance creation to dedicated methods/factories
- Use dependency injection for flexibility
- Remove argument order dependencies with keyword arguments/hashes
- Explicitly define interfaces between objects
- Demeter Principle: Only talk to “immediate neighbors” (avoid train wrecks)
- Example of violation:
customer.bicycle.wheel.tire.pressure
- Better:
customer.tire_pressure
- Writing loosely coupled code: Let objects interact without knowing too much about each other
- Trade-offs: Balance more flexible code against more indirection and abstraction
Creating Flexible Interfaces
- Interface definition: Design a set of methods objects expose to others
- Public vs private: Make public methods form interface, keep private methods as implementation details
- Interface qualities:
- Reveal primary responsibility
- Design to remain stable
- Implement consistently across all providers
- Depend on abstractions, not concrete classes
- Kitchen sink anti-pattern: Avoid classes with bloated public interfaces that do too much
- Discovery process:
- Start with minimal public interface
- Hide implementation details
- Prefer query methods over command methods
- Designing messages before objects:
- Focus on the messages objects need to send
- Let interfaces emerge from use cases
- Trusting other objects: Ask for what you want, don’t dictate how to do it
- Law of Demeter reminder: Don’t rely on knowledge of structure of objects you don’t directly own
Reducing Costs with Duck Typing
- Duck typing definition: Follow “If it quacks like a duck, treat it as a duck”
- Benefit: Make objects of different classes substitutable if they share behavior
- Key insight: Focus on what objects do, not what they are
- Finding hidden ducks: Look for:
- Case statements that switch on class
is_a? and kind_of? checks
responds_to? calls
- Refactoring strategy:
- Extract shared behavior into a common interface
- Let each class implement that interface in its own way
- Trust objects to respond to messages appropriately
- Concrete example: Refactor trip preparation with different vehicle types
- Bad: Checking class of each vehicle
- Good: Have each vehicle implement
prepare_trip method
- Benefits: Create more flexible design, easier to extend with new classes
- Trade-offs: Balance implicit contracts against explicit type checking
Acquiring Behavior Through Inheritance
- Inheritance definition: Use automatic message delegation from subclass to superclass
- Appropriate uses:
- Specialization (“is-a” relationships)
- Code sharing between related classes
- Domain concepts with natural hierarchies
- Creating inheritance hierarchies:
- Start with concrete examples
- Extract common behavior to superclass
- Push down specific behavior to subclasses
- Antipatterns:
- Avoid inheritance for unrelated behavior
- Prevent deep inheritance trees (fragile)
- Don’t let superclass know specifics about subclasses
- Template Method Pattern: Define skeleton in superclass, implement specifics in subclasses
- Hook Methods: Provide defaults in superclass that subclasses can override
- Guidelines:
- Make subclasses fulfill superclass contract (Liskov)
- Create shallow, narrow hierarchies
- Favor composition over inheritance when appropriate
Sharing Role Behavior with Modules
- Role definition: Create sets of behaviors that are separable from classes
- Modules vs inheritance:
- Use modules for shared behavior across different class hierarchies
- Use inheritance for specialization within a type hierarchy
- Identifying roles:
- Look for objects playing multiple roles
- Find behavior shared across different types of objects
- Identify “acts like a” rather than “is a” relationships
- Module implementation:
- Include module in classes that need the behavior
- Keep module focused on a single responsibility
- Make module methods operate on a well-defined interface
- Interface considerations:
- Ensure classes including module implement required methods
- Document expectations between module and including class
- Testing roles: Test module behavior separately from classes
- Ruby specific: Understand method lookup path with included modules
- Warning: Avoid creating “hodgepodge” modules that bundle unrelated behaviors
Combining Objects with Composition
- Composition definition: Build complex objects by combining simpler ones
- “Has-a” relationship: Create objects containing other objects
- Benefits over inheritance:
- Gain more flexibility than static inheritance hierarchies
- Compose objects at runtime
- Make dependencies explicit and visible
- Common composition patterns:
- Parts: Use simple contained objects
- Component: Create objects with common interface, interchangeable
- Observer: Have objects notify others of changes
- Strategy: Implement swappable algorithms/behaviors
- Deciding between inheritance and composition:
- Choose inheritance for “is-a” with high code reuse
- Select composition for “has-a” and flexible relationships
- Rules of thumb:
- Start with composition, it’s less constraining
- Use inheritance only when specialization is clear
- Avoid deep inheritance hierarchies
- Framework considerations: Recognize when frameworks require inheritance
Designing Cost-Effective Tests
- Testing philosophy: Treat tests as part of design, not just verification
- Test benefits:
- Document code behavior
- Catch regressions
- Support refactoring
- Improve OO design
- What to test:
- Test public interfaces, not private implementation
- For incoming messages: Assert on return values
- For outgoing command messages: Verify they are sent
- For outgoing query messages: Don’t test
- Test organization:
- Arrange: Set up test conditions
- Act: Call method under test
- Assert: Verify results
- Test doubles:
- Use mocks to verify messages sent
- Create stubs to provide canned responses
- Use sparingly to avoid brittle tests
- Testing inheritance hierarchies:
- Test superclass behavior independently
- Create shared test for common behavior
- Test subclass-specific behavior separately
- Testing duck types: Test each implementation independently
- Testing modules: Test in isolation with minimum required interface
- Warning signs:
- Watch for tests that break with unrelated changes
- Reduce setup complexity
- Speed up slow tests
Key Takeaways
- Design for change: Make code easy to change without unexpected consequences
- Dependencies matter: Manage and minimize dependencies between objects
- Messages over objects: Design the messages first, then determine who should respond
- Interfaces not implementations: Make objects reveal what they do, not how they do it
- Composition flexibility: Choose composition over inheritance when appropriate
- Tests as design: Write well-designed code that is naturally testable
- Pragmatic approach: Remember no design principle is absolute; make context-appropriate trade-offs
- Incremental design: Don’t over-design upfront; let good designs evolve
- TRUE code: Create Transparent, Reasonable, Usable, and Exemplary code