Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better error when submitting order with out of stock product #390

Merged

Conversation

marlena-b
Copy link
Collaborator

@marlena-b marlena-b commented Sep 1, 2024

Issue: #375

Before when a product was added to an order and subsequently went out of stock before submission, the system incorrectly showed a success message "Your order is being submitted" while leaving the order in draft status. Now it shows "Order can not be submitted! Product not available in requested quantity!".
Zrzut ekranu 2024-09-1 o 22 05 50

There are a lot of changes. I will try to explain all of them in the comments below.

The implementation for the Result object was taken from this presentation: https://www.icloud.com/keynote/0x2bfAdGek3Eg-I_2kIomD-6g#Controller_refactoring (it lacks notes but you can check slides 7 and 17 for "before" and "after")

Copy link

netlify bot commented Sep 1, 2024

Deploy Preview for ecommerce-events failed.

Name Link
🔨 Latest commit 844d110
🔍 Latest deploy log https://app.netlify.com/sites/ecommerce-events/deploys/66d4caad49c8960008bd4295


def initialize(id)
@id = id
end

def set_customer(customer_id)
raise CustomerAlreadyAssigned if @customer_id
return if @customer_id == customer_id
Copy link
Collaborator Author

@marlena-b marlena-b Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to allow users to reassign customers. If order submission fails the first time the user is still being assigned. So when user tries again this raises an exception and brakes the process.

I changed it so when customer_id is not changed it does nothing, but if it is changed then we emit the event.

@marlena-b marlena-b force-pushed the order-with-out-of-stock-product-better-error branch from 5e0c00e to 933292f Compare September 1, 2024 19:20
Comment on lines 25 to +30
state.order_lines.each do |product_id, quantity|
command_bus.(Inventory::Reserve.new(product_id: product_id, quantity: quantity))
state.product_reserved(product_id)
rescue Inventory::InventoryEntry::InventoryNotAvailable
unavailable_products << product_id
end
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReservationProcess no longer stops at the first unavailable product. Instead, it tries to reserve all of them and records which were unavailable. This way we can later display the full list of unavailable products.

end
event_store.publish(event, stream_name: stream_name(state.order_id))
Copy link
Collaborator Author

@marlena-b marlena-b Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReservationProcess now emits events. We then subscribe to these events in the controller and display proper errors to the user.
We could raise an error instead but then the transaction would be rolled back and most of the code here would make no sense.

Comment on lines +41 to +46
def order_side_effects(state)
event_store
.within { yield }
.subscribe(to: ReservationProcessFailed) { reject_order(state) }
.subscribe(to: ReservationProcessSuceeded) { accept_order(state) }
.call
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these side effects could be extracted to fully-fledged event handlers. Then the ReservationProcess could have only a single responsibility. This PR is already big so I didn't want to extract it here. Let me know what you think and I will add an issue for this refactor.

ActiveRecord::Base.transaction do
command_bus.(Ordering::SubmitOrder.new(order_id: params[:order_id]))
command_bus.(Crm::AssignCustomerToOrder.new(customer_id: cookies[:client_id], order_id: params[:order_id]))
Orders::SubmitService.new(order_id: params[:order_id], customer_id: cookies[:client_id]).call.then do |result|
Copy link
Collaborator Author

@marlena-b marlena-b Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code in this controller got more and more complex so I extracted it to a service.
I used a very simple Service object implementation combined with a simple but flexible Result object. This results in an expressive DSL like the one below :)

(the DSL was partly inspired by respond_to method)

redirect_to edit_order_path(params[:order_id]), alert: "You can't submit an empty order"
rescue Crm::Customer::NotExists
redirect_to order_path(params[:order_id]), alert: "Order can not be submitted! Customer does not exist."
Orders::SubmitService.new(order_id: params[:order_id], customer_id: params[:customer_id]).call.then do |result|
Copy link
Collaborator Author

@marlena-b marlena-b Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially wanted to have two separate services for the admin and client side but then they looked almost entirely the same. So I decided to merge them so we don't duplicate too much code and tests.

@@ -0,0 +1,17 @@
class ApplicationService
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first time service objects are used in this repo so I wanted to keep them as simple as possible. In my implementation service objects should only implement initialize and call methods and then we can call them like MyService.call(..). This makes it easier to mock them (bc we only need to mock the class method call).

Please let me know if you prefer to use some other implementation instead, this is just my proposal.

Comment on lines +20 to +28
if success
Result.new(:success)
else
Result.new(:products_out_of_stock, unavailable_products)
end
rescue Ordering::Order::IsEmpty
Result.new(:order_is_empty)
rescue Crm::Customer::NotExists
Result.new(:customer_not_exists)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call method returns a Result object which represents a logical path of execution combined with optional arguments (e.g. unavailable_products).

Comment on lines +9 to +13
def path(name, &block)
return unless @status == name.to_sym

block.call(*@args)
end
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also an alternative implementation which uses more metaprogramming but results in a nicer DSL:

def method_missing(name, *_, &block)
  return unless @status == name.to_sym

  block.call(*@args)
end

and then the DSL looks like this:

MyService.call(params).then do |on|
  on.success { puts "Awesome!" }
  on.validation_failed { |errors| puts "Oh no, there are some errors: #{errors}" }
  on.something_else { |arg1, arg2| puts arg1 * arg2 }
end

Let me know which one you prefer :)

@marlena-b marlena-b force-pushed the order-with-out-of-stock-product-better-error branch from 933292f to 844d110 Compare September 1, 2024 20:12
@marlena-b marlena-b marked this pull request as ready for review September 2, 2024 07:42
@andrzejkrzywda
Copy link
Contributor

There are many changes in this pull request which I would like to debate separately, but as a whole I think it's ready to be merged.

  1. Let's change the Result pattern to exceptions - we already use exceptions here a lot and we could save one pattern
  2. Let's separate the service objects even if they are identical
  3. No need for inheritance in service objects
  4. process publishing events - let's have it for now, we will see how it feels in the code in the longer run, might be refactored later

@andrzejkrzywda andrzejkrzywda reopened this Sep 3, 2024
@andrzejkrzywda andrzejkrzywda merged commit 690d65d into master Sep 3, 2024
43 of 47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants