andreapavoni / nova

An attempt to port/rebuild Spree, an open source e-commerce solution, with Elixir and Phoenix.
130 stars 11 forks source link

Discuss about checkout workflow and eventual solutions #9

Open andreapavoni opened 9 years ago

andreapavoni commented 9 years ago

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

andreapavoni commented 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

mvidaurre commented 9 years ago

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?

andreapavoni commented 9 years ago

@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! :-)

andreapavoni commented 9 years ago

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?

andreapavoni commented 9 years ago

here's a simple example about my idea:

Take a Cart module, such as the following one:

defmodule Cart do
  use DefaultCheckout
end

it uses 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? :-)

mvidaurre commented 9 years ago

@apeacox IMHO we should make and step back and really check what we want to build?

  1. A spree clone
  2. A ecommerce platform
  3. A ecommerce framework
  4. Any of this options

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?

mvidaurre commented 9 years ago

I think the approach that you are proposing is fine. And that could be our starting point.

andreapavoni commented 9 years ago

@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 :-)

tyler-eon commented 9 years ago

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.

andreapavoni commented 9 years ago

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?

tyler-eon commented 9 years ago

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?

andreapavoni commented 9 years ago

@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 :)

Immortalin commented 8 years ago

On the UI side, you might want to check out drupal commerce, they really managed to nail it for customising the checkout UI.