Open andreapavoni opened 9 years ago
forgot to mention there's a proof-of-concept branch with an initial state machine: https://github.com/nebulab/nova/tree/state_machine
Maybe a middle ground approach will be better. I mean the developer that is going to use nova as e-commerce framework should implement the state machine based on the checkout flow that his system needs. I think the state machine should be implemented at the orders level. What do you think @ageacox?
@mvidaurre yes! I agree in general, that was also the first idea I thought. But then I asked myself: do we really need steps? The most basic use case might be something like:
fill a form with billing info and credit card, then submit the form to complete order
Which can be achieved with a single function/command. In other words: are we sure we need steps to manage checkouts? Isn't it better to find a solution that gives the features (pay, store info, ...) while leaving a certain degree of freedom for devs? Sure we'll need to store some state that n orders, but I'd like to explore eventual alternatives to state machine.
What do you think? Let's discuss! :-)
After some pencil+paper, I've found a potential alternative. Considering that the general goals are:
I came out with this initial hint:
A Cart
module which exposes 1+ checkout
function through use
of some module (a default one is provided, devs can add their owns)
The function takes an order
, an atom that identifies the step you need, and a keyword list of eventual params.
This approach would offer more flexibility for eventual customizations.
What do you think?
here's a simple example about my idea:
Take a Cart
module, such as the following one:
defmodule Cart do
use DefaultCheckout
end
it use
s an hypothetical DefaultCheckout
module defined as follows:
defmodule DefaultCheckout do
defmacro __using__(_opts) do
quote do
def checkout(order, :address, params) do
IO.puts "I'm default checkout: address"
end
def checkout(order, :payment, params) do
IO.puts "I'm default checkout: payment"
end
end
end
end
it exposes two checkout
functions with a signature identified by their arguments (in this example, the key difference relies on the second one, which is an atom). So its usage might be this:
Cart.checkout("order", :address, []) # => I'm default checkout: address
Cart.checkout("order", :payment, []) # => I'm default checkout: payment
Now, suppose a dev wants to change something in the checkout process. He could write something like this:
defmodule CustomCheckout do
defmacro __using__(_opts) do
quote do
def checkout(order, :address, params) do
IO.puts "I'm custom checkout: address"
end
def checkout(order, :payment, params) do
IO.puts "I'm custom checkout: payment"
end
def checkout(order, :another, params) do
IO.puts "I'm custom checkout: another"
end
end
end
end
then change the Cart
module:
defmodule Cart do
use CustomCheckout
end
so that now it will call different functions:
Cart.checkout("order", :address, []) # => I'm custom checkout: address
Cart.checkout("order", :payment, []) # => I'm custom checkout: payment
Cart.checkout("order", :another, []) # => I'm custom checkout: another
@mvidaurre what do you think? :-)
@apeacox IMHO we should make and step back and really check what we want to build?
Based in our discussions I think our intention is to build an ecommerce framework and base in that framework build an ecommerce platform that will be inspired by spree. Am I right? Or do you have any other view?
I think the approach that you are proposing is fine. And that could be our starting point.
@mvidaurre yes, I want to build a minimal ecommerce platform (inspired by spree) built on a framework and easy to extend through packages/extensions.
That said, I'd like to avoid the problems encountered with Spree in several occasions, and relying on a state machine for checkout is one of these. This is why I'm exploring alternative ways to implement the same feature.
In general, I'm also trying to think forward when it comes to extensibility/customization other than familiarizing with Elixir and FP patterns. In the case of state machine, I have created a branch (https://github.com/nebulab/nova/tree/state_machine) some day ago and I've noted the following pain points:
The alternative solution I've presented, shows that it's still possible to mimic a state machine behavior, without depending on it. for example, what if we have two checkout
functions like these?
def checkout(order, :address, params) do
# do stuff here:
# - extract address info from params and store somewhere
# - do eventual calculations where/if needed
# - update order state
{:ok, order, :payment}
end
def checkout(order, :payment, params) do
# do stuff here:
# - extract payment info from params and store somewhere
# - capture/authorize payment
# - do eventual calculations where/if needed
# - update order state
{:ok, order, :complete}
end
the first one, after it finished, returns the next state, along with other meaningful data. IMHO this is a very simple and explicit approach to solve the problem. of course, the platform/framework will define one or more default modules to draw the desired checkout process.
as a side note, I'd like to assure I'm not defending this proposal as the only possible one, I've just thrown the idea on the table to discuss about it and explore if it fits better.
the state machine was my first go to solution, but I wasn't 100% convinced about it. however, I'm not arguing my approach is better, that's why I'd like to discuss it here with arguments/examples/use-cases :-)
You should be striving for "small modules that do one thing well." A friend showed me this video about a developer programming a board game using Elixir. In it the speaker shows how to simplify your code by properly organizing your logic such that each module is responsible for one thing.
Instead of saying "checkouts are where you can add payment information" you just create a Payment
module that handles storing and retrieving payment information, as well as making a charge. And instead of saying "checkouts are where you can add addresses" you just create an Address
module that handles storing and retrieving addresses. The only interesting component to storing this data would be if you allow "guests" to purchase goods, which means you would want to temporarily store guest information based on a unique session id. But in the end, the code will actually be simpler and cleaner if you separate each of the responsibilities.
Then your checkout flow looks pretty simple: checkout(order, address, payment)
. Or perhaps checkout(order, session)
if you want to extract address and payment information from a client session. Maybe even just checkout(session)
if you relate even the order to a session. If an address or payment is missing, just return an appropriate error, like {:error, :missing_payment}
. Having addresses and payments and such completely separate from the checkout flow, though, gives developers that want to integrate with your code much more freedom to decide how they want to get this information, where, and in what order.
thank you @kolorahl for the hints! :-)
I mostly agree with your approach and I was impressed by the video you linked here (it was in my to watch list and finally I found some time to check it out).
In this specific case, I had to handle some tradeoffs because I'd like to release something that works in the short term. The problem with too much separated modules relies in the effort needed to customize checkout without touching too much code. If I have a generic module where I can plug other modules (with a specific protocol/signature) to change the behavior, then I can use the generic module elsewhere (eg: controllers) transparently.
On the other hand, at least, I tried to separate modules by feature :P
In commit 3fa1bc1 I've added initial billing support through commerce_billing package, just to see how if it works ;)
what do you think?
Ok, I see what you're doing there. You're relying on commerce_billing
to have the separation between payment methods, addresses, etc, and your own code is focusing solely on parsing request data. Yeah, I like that.
I'm not sure why you're defining Nova.Checkout.Billing
as something that is included via use
. You don't have any callbacks declared and all functions used are self-contained within the module, meaning you could compile that code on its own and it would still work. Or it should; I haven't actually tried that but from what I can see it doesn't rely on any code outside of itself and the commerce_billing
package. Was there a reason for making it unusable on its own?
@kolorahl I'm glad you appreciated my proposed solution :) it's still undone, but I think it's a decent starting point. just to clarify a bit, you can see a proof-of-concept demo in this gitst
the point about use
-ing Nova.Checkout.Billing
is this: you can have (and actually there is) a Nova.Cart
module that lets you to add/remove checkout steps. I know that billing is almost present everywhere, but I preferred to keep checkout features as much as isolated, so devs can change it at their will.
The reason to use use
is the ability to expose checkout/3
functions from Nova.Checkout.Something
into the Nova.Cart
module. Using import
would let to call those functions internally, but they aren't exposed outside the module.
hope this cleared your doubts. however, if you know a better solution, feel free to suggest it here :)
On the UI side, you might want to check out drupal commerce, they really managed to nail it for customising the checkout UI.
Checkout is a tricky thing, so it would be better to discuss here eventual solutions. I'm still not sure we'd need a state_machine to manage this.
For our experience on Spree, having a rigid schema (such as a set of states managed by state machine) it's not that useful, especially when working with APIs.
One possibile solution might be to have stand-alone functions to run for each step, then it's up to developers to customize the order of steps in the workflow. After all, it's not a rule to follow a set of steps to do a checkout, right? :-)
As a reference, check this simple example of a checkout payment with Phoenix: https://github.com/joshnuss/phoenix-billing-demo
However, I'm open to suggestions, hints and similar