I'm struggling to balance encapsulation with managing side effects in a specific scenario described here.
- PriceSettings
< ActiveRecord::Base
(side-effect-based class methods, value-based instances) stores user parameters for price calculations - APIClient (adapter object) fetches statistics from an external API to use in price calculations
- PriceMatrix (value object) creates a matrix of prices for a range of days and quantities
- PriceAdjuster (value object) determines a total price adjustment for a specific day and quantity by combining several adjustments
- Adjustment::A (value object) calculates an adjustment factor from PriceSettings data only
- Adjustment::B (value object) calculates an adjustment factor from PriceSettings data, plus data from the APIClient
- GeneratePrices action (shell/integration object) Create and render a
PricingMatrix
- GeneratePrices loads a PriceSettings
- GeneratePrices creates PriceMatrix
- PriceMatrix(PriceSettings, ranges) creates a PriceAdjuster for each combination of date/quantity range values
- PriceAdjuster(PriceSettings, date, quantity) creates several Adjustment objects that require different inputs
- Adjustment::B(PriceSettings, date)
- Needs values from Price Settings
- Needs API data from APIClient for the specific
date
- Adjustment::B(PriceSettings, date)
- PriceAdjuster(PriceSettings, date, quantity) creates several Adjustment objects that require different inputs
- PriceMatrix(PriceSettings, ranges) creates a PriceAdjuster for each combination of date/quantity range values
From an encapsulation perspective, it makes sense for Adjustment::B to do its own API calls in order to fulfill its specific responsibilities. Other objects shouldn't do Adjustment::B's job for it. But this could be wildly inefficient, as the API calls would be scoped at the lowest level and therefore, might need to be repeated for each date/quantity combination.
From a value object perspective, it makes sense for Adjustment::B NOT to do its own API calls because value objects shouldn't have side effects. This could also solve potential efficiency issues because it would do a larger API call at the top level of the heirarchy and pass that data through the stack. But this would also seem to violate encapsulation since GeneratePrices would know about data that Adjustment::B needs and therefore would be passing all that data through PriceMatrix to PriceAdjuster, then through PriceAdjuster to Adjustment::B. Do you agree?
How would you structure things differently to avoid both the issue of value objects having side effects and the encapsulation problems?
This is a well-stated example of design tensions, thank you for writing it. The work that went into my 2015 RailsConf talk was trying to formalize my intuition for why I don't like the idea of Adjustment::B being "encapsulated" by having it talk to the API, that felt messy and unpredictable, and I found side effects as the underlying cause of my concern.
I think the answer is probably lurking in your description here:
This "data from the APIClient" doesn't have an explicit name and representation, there is a "primitive obsession" design smell. You probably didn't notice yet because your code grabs that raw data and processes it in one place, so it seems weird to write a whole class for a thing that only exists for a line or two. Maybe it's even harder to spot because you don't even name a variable; your code could use it directly from the response like
total = response[:prices][current_year] * adjustment_factor
.Based on that name I'd look at where it fits into your hierarchy - it is an aspect of a PriceMatrix, a PriceAdjuster, or (seems most likely) Adjustment::B that GeneratesPrices (which I think is what pulls data from APIClient?) would pass into the appropriate constructor of a value object.