In object-oriented programming, the design and architecture of your code can significantly impact its maintainability, flexibility, and readability. As you build complex systems in Ruby, it's crucial to understand and avoid common design pitfalls that can lead to convoluted codebases. In this lesson, we will explore two common pitfalls: the use of fluent interfaces and the excessive use of inheritance over composition. By adopting recommended best practices, you will be equipped to write cleaner, more efficient code with distinct and manageable class responsibilities.
A fluent interface is an object-oriented API that aims to improve the readability of the source code through method chaining. While there are some contexts, frequently builder objects, where this pattern reduces verbosity (such as query building in databases), it often comes with several drawbacks:
- Breaks encapsulation
- Complicates the use of decorators
- Is harder to mock in a test suite
- Makes diffs of commits harder to read
Here is an example to illustrate the downsides of fluent interfaces:
Ruby1class Car 2 def initialize(make, model, color) 3 @make = make 4 @model = model 5 @color = color 6 # Returning self for method chaining 7 self 8 end 9 10 def set_make(make) 11 @make = make 12 self 13 end 14 15 def set_model(model) 16 @model = model 17 self 18 end 19 20 def set_color(color) 21 @color = color 22 self 23 end 24 25 def save 26 puts "Saving" 27 self 28 end 29end 30 31car = Car.new('Ford', 'F-150', 'red') 32 .set_color('pink') 33 .save
Ruby1class Car 2 attr_accessor :make, :model, :color 3 4 def initialize(make, model, color) 5 @make = make 6 @model = model 7 @color = color 8 end 9 10 def save 11 puts "Saving" 12 end 13end 14 15car = Car.new('Ford', 'F-150', 'red') 16car.color = 'pink' 17car.save
By avoiding fluent interfaces, the code becomes simpler, and the individual responsibilities of each method and class are clearer, thus improving code maintainability.
As stated in well-established design principles, favor composition over inheritance when possible. There are good reasons to choose either inheritance or composition, but the essential idea is to consider whether composition can model your problem effectively rather than defaulting to inheritance. Here's when inheritance might be more appropriate than composition:
- Your inheritance represents an "is-a" relationship rather than a "has-a" relationship (e.g., Human is an Animal vs. User has UserDetails).
- You can reuse code from base classes (e.g., Humans can move like all animals).
- You want to make global changes to derived classes by altering a base class (e.g., change the caloric expenditure of all animals when they move).
Ruby1class Employee 2 def initialize(name, email) 3 @name = name 4 @email = email 5 end 6 7end 8 9# Problematic because Employees "have" tax data; EmployeeTaxData is not a type of Employee 10class EmployeeTaxData < Employee 11 def initialize(ssn, salary) 12 @ssn = ssn 13 @salary = salary 14 end 15end
Ruby1class EmployeeTaxData 2 def initialize(ssn, salary) 3 @ssn = ssn 4 @salary = salary 5 end 6end 7 8class Employee 9 def initialize(name, email) 10 @name = name 11 @email = email 12 end 13 14 def set_tax_data(ssn, salary) 15 @tax_data = EmployeeTaxData.new(ssn, salary) 16 end 17 # ... 18end
By employing composition, the Employee
class delegates the responsibility of handling tax data to a separate class, EmployeeTaxData
, making the design more flexible and easier to manage.
In this lesson, we explored common design pitfalls in object-oriented programming, focusing on the drawbacks of fluent interfaces and the misuse of inheritance. We emphasized the importance of avoiding fluent interfaces to maintain clarity, simplicity, and testability in your code. Additionally, we highlighted the advantages of preferring composition over inheritance, using clear examples to demonstrate how composition can lead to more flexible and maintainable designs. By applying these principles, you can enhance the maintainability and scalability of your Ruby applications.