All books

Practical Object-Oriented Design in Ruby

Bookshelf

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

  1. Design for change: Make code easy to change without unexpected consequences
  2. Dependencies matter: Manage and minimize dependencies between objects
  3. Messages over objects: Design the messages first, then determine who should respond
  4. Interfaces not implementations: Make objects reveal what they do, not how they do it
  5. Composition flexibility: Choose composition over inheritance when appropriate
  6. Tests as design: Write well-designed code that is naturally testable
  7. Pragmatic approach: Remember no design principle is absolute; make context-appropriate trade-offs
  8. Incremental design: Don’t over-design upfront; let good designs evolve
  9. TRUE code: Create Transparent, Reasonable, Usable, and Exemplary code