Introduce new middleware stock coordinator#6484
Conversation
We want to support additional keyword arguments to this class and a positional argument interferes with this. Co-authored-by: Jared Norman <jared@super.gd> Co-authored-by: Adam Mueller <adam@super.gd> Co-authored-by: Noah Silvera <noah@super.gd>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6484 +/- ##
==========================================
+ Coverage 89.68% 93.46% +3.78%
==========================================
Files 991 534 -457
Lines 20832 10532 -10300
==========================================
- Hits 18683 9844 -8839
+ Misses 2149 688 -1461 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Co-authored-by: Noah Silvera <noah@super.gd> Co-authored-by: Adam Mueller <adam@super.gd>
6b44d32 to
4a86af5
Compare
Co-authored-by: Noah Silvera <noah@super.gd> Co-authored-by: Adam Mueller <adam@super.gd>
This starts the pattern of delegating low level operations to middleware objects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4a86af5 to
ca9ae0d
Compare
| context[:availability] = Spree::Stock::Availability.new( | ||
| variants: context[:desired].variants, | ||
| stock_locations: context[:stock_locations] | ||
| ) |
There was a problem hiding this comment.
This is definitely an example of how overusing this approach makes the code worse. This has seriously obfuscated the dependency chain of the availability and desired variants objects. If we have some dependent computed stuff like this, maybe we just make this methods on the context object or something? It's worth considering this further.
There was a problem hiding this comment.
Agreed! I didn't put too much thought into how things shook out entirely at this point in the draft. Will definitely revisit before opening for review.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These are pure derived values with tight dependency chains, not independent pipeline stages. Expressing them as memoized methods on the Context makes their relationships explicit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Will fix up later
|
@jarednorman ignore the tedious back + forth refactoring that is happening here. I will fixup a lot of these commits and make the history cohesive. Is this shape in the final diff more aligned with the pattern you were thinking about? |
Will fix this up
jarednorman
left a comment
There was a problem hiding this comment.
Yeah, this is much closer. I think we can do even better than this, but this is the right direction, I think.
I do think the "shape" of this problem fits less well than with the order recalculator, so I want to be careful about this change. We may find a different design that fits this part of the code better (and solves the original problem of parameterization) while refactoring this, and that's totally fine.
Noah-Silvera
left a comment
There was a problem hiding this comment.
Hey Sofia! I took a pass on this and had some ideas around how the middleware pattern might make more sense for this specific use case!
In summary, IMO the middleware pattern will only makes things easier in this case to understand and override if the following is true:
- It reduces the obfuscated relationships and tracing rabbit holes introduced by configurable classes
- It produces structured outputs at each step that are easy to locate and understand in the codebase without having to fully understand the previous middleware
- The middleware are tiny, iterative pieces that can be individually replaced & injected between to allow for maximum flexibility in the building process.
I added some comments to this end, LMK what you think of them! Curious about your thoughts and if we can come to some solution in between!
Dropping the configurable classes entirely in favour of new code/a new pattern might be a dealbreaker, but I'm not sure! cc: @jarednorman @adammathys for thoughts too
| "Spree::Stock::Middleware::Package", | ||
| "Spree::Stock::Middleware::Shipment" | ||
| ] | ||
| class_name_attribute :estimator_class, default: "::Spree::Stock::Estimator" |
There was a problem hiding this comment.
My first thought is we should let the middleware list be overridden, or use class name attributes to choose which classes exist in the middleware, but not both.
Using both just increases the possible matrix of overrides that users can configure, and makes the code path hard to trace
My gut is that this should just be a "opt in"feature, and you have to port your existing overrides to middlewares if you want to use. I think we should just get rid of configurable classes being used inside middleware or monkey patching middleware; it's better to have smaller middlewares that users can just override wholesale
| order = context[:order] | ||
|
|
||
| context[:inventory_units] = context[:inventory_units] || | ||
| Spree::Config.stock.inventory_unit_builder_class.new(order).units |
There was a problem hiding this comment.
as I mentioned in a previous comment, WDYT of getting rid of this configuration altogether in favor of putting the work in this class? Reducing obfuscation, but at the cost of a potential harder migration?
| inventory_units || Spree::Config.stock.inventory_unit_builder_class.new(order).units | ||
|
|
||
| Middleware::InventoryUnit.new.call(context) | ||
| @inventory_units = context[:inventory_units] |
There was a problem hiding this comment.
If I understand @jarednorman's point correctly, I agree we should have getters/setters for the methods on context to reduce obfuscation/add some runtime guarantees.
Maybe make context an object that extends hash? then custom attributes can be set? I'm not sure, this might be overkill and a hash like it is rn might be fine
| module Stock | ||
| class Coordinator | ||
| def initialize(order, inventory_units: nil) | ||
| context = {order:, inventory_units:} |
There was a problem hiding this comment.
Once of the goals of the initial work to make this more flexible was to allow arbitrary context to be passed into the coordinator that can be used to guide it's behavior.
Instead of turning inventory_units: into a keyword argument, what about making order and inventory_units optional, and adding a context keyword argument that can be passed instead that contains both attributes? That would allow arbitrary extension of the arguments passed into the stock coordinator
| sorted_stock_locations = Spree::Config.stock.location_sorter_class.new(@filtered_stock_locations).sort | ||
| @stock_locations = sorted_stock_locations | ||
|
|
||
| @inventory_units_by_variant = @inventory_units.group_by(&:variant) |
There was a problem hiding this comment.
absolute nit of a nit - this should be in the next commit right?
| end | ||
| end | ||
|
|
||
| def split_packages(initial_packages) |
There was a problem hiding this comment.
IMO splitter chain should be a separate middleware that splits packages after they are created
| def call(context) | ||
| context[:shipments] = context[:packages].map do |package| | ||
| shipment = package.shipment = package.to_shipment | ||
| shipment.shipping_rates = Spree::Config.stock.estimator_class.new.shipping_rates(package) |
There was a problem hiding this comment.
I'm still leaning towards inlining these classes and having users replace Spree::Stock::Middleware::BuildShipment in the middleware list if they want to change behavior
| shipment | ||
| end | ||
|
|
||
| yield context |
There was a problem hiding this comment.
Love the yield pattern!
| @@ -11,8 +11,6 @@ class StockConfiguration < Spree::Preferences::Configuration | |||
| "Spree::Stock::Middleware::InventoryUnit", | |||
There was a problem hiding this comment.
One of the things that this way of defining middleware obfuscates is the outputs of each class. Since the outputs could theoretically be anything, it be useful to define them to make the relationships easier to understand. It would also allow us to define getter and setter methods on the defined outputs
WDYT? I'm not sold on this, but it was just an idea
e.g.
middleware = [
{
class: "Spree::Stock::Middleware::InventoryUnit",
outputs: [:inventory_units]
},
{
class: "Spree::Stock::Middleware::InventoryUnitGroup",
outputs: [:inventory_units_by_variant]
},
{
class: "Spree::Stock::Middleware::LoadStockLocations",
outputs: [:stock_locations]
},
{
class: "Spree::Stock::Middleware::SortStockLocations",
outputs: [:stock_locations]
},
...
]| @@ -6,13 +6,24 @@ class Coordinator | |||
| class Context | |||
There was a problem hiding this comment.
TBH I prefer them as independent middleware pipeline stages
These are pure derived values with tight dependency chains, not
independent pipeline stages.
You are correct! But also, this is true for the whole pipeline! Almost every step is essential, and produces output required by the next. I'd rather implicitly assume that in the chain, or use a more strict configuration format (even defining inputs in addition to outputs), and have smaller middleware, easier to override, replace, or insert other middleware in between
WIP
This refactoring of the existing SimpleCoordinator leaves it in a state that retains the original functionality, and higher level shape, but encapsulates the lower level logic into middleware components.
Next steps will be to reconsider how the middleware components are encapsulated, setting up the middleware orchestration, and adding unit specs to prevent regressions.
Checklist
Check out our PR guidelines for more details.
The following are mandatory for all PRs:
The following are not always needed: