Skip to content
This repository was archived by the owner on Oct 19, 2018. It is now read-only.

Flux Loop verses Decoupling

Mitch VanDuyn edited this page Jan 26, 2017 · 4 revisions

@catmando

The Myth of the One Way Flux Loop

After a lot of thought and research, I can find no "semantic reason" for a Store not to have mutations. The one-way flux loop seems to be a myth which is confused with a couple of other problems.

  1. Bad class protocol design
    For example we can describe how to "kill" a role playing character many ways.
Person.set_is_alive(id, boolean) # normal method call
{type: :set_is_alive, payload: {id: id, boolean: boolean}} # flux action
# BAD! what if u change "alive-ness" to be a scale instead of yes/no?
Person.set_life_level(id, integer) # normal method call
{type: :set_life_level, payload: {id: id, level: level}} # flux action
# STILL BAD! Its less brittle but it still reveals too much implemenation 
Person.kill(id) 
{type: :kill, data: {id: id}}
# This is the correct protocol!!!

The point being that it doesn't matter whether you use an "Action" or a method call, there are many ways to construct the protocol that will be brittle, BUT that in any case you are Mutating the store as a direct result of invoking the action/method. There is no "one-way data flow".

  1. Decoupling Interface from Implementation The flux Action paradigm does decouple the Action protocol from the implementation completely. An Action is a separate object from the Store receiving the action. Some event handler calls the action, and the Store registers with the action. In fact, you can have multiple Stores respond to the same Action. Cool!
    But again this has nothing to do with one-way flow of data. Sorry, an Action causes the Store to mutate, and the Action is caused (and sent the data) by an event handler. So the data flowed directly from event to store, just because we added 10 miles of piping between the two does not change the direction of the data flow.

  2. Debuggability* Running everything through the Action-Dispatcher means that you can easily trace all actions. If you are using immutable data you can have even more fun.
    Having a central place that all data flows back to the Store is helpful but it's NOT one-way flow of data.

  3. Keeping Store Concerns Clean Without some mechanism such as Actions to decouple Stores from each other you end up with Store A, knowing too much about Store B. So for example we have a cart, we want to add an item. Great. But now you also want to update a "User Interest List" with any item a user has added to a cart. So the naive implementation would probably have the Cart "add item" mechanism call some method on the UserInterestList Store. Now the Cart which seems like the more "fundamental" class, is linked to the UserInterestList, and the spagetti begins to tangle.
    This is a huge problem everywhere. The "Action" solution is a simplified version of the TrailBlazer Operation, which itself is derived from the Mutation gem. So the problem has been around for a while But lets be clear - we are not talking about 1-way data flow. We are talking about keeping the stores decoupled. Good stuff, but not 1-way data flow.

Great but So What

We can focus on what we are trying to achieve, and design our framework around that:

  1. Decouple Stores from Each Other and From External Events
  2. Provide some point in the system where we can monitor activity for debug purposes

To do this we need some obvious point in the code to insert an intermediary between Stores and between Stores and Event handlers.

That same point in the system that the flux architecture, trailblazer, and others before have all pin-pointed is where the Stores are mutated. You need to insert another entity at this point whether you call it a Dispatcher, an Operation, or a Mutation.

The point in the code where your Data Store is mutated is easy to identify and it will decouple things for improved maintainability, comprehension, and reuse.

What we place at this point are Operations taking the Trailblazer term rather than Action/Dispatcher because for one thing its

Consider a very simple example:

AddItemToCart(item: sku, qty: 1)

# vs

Cart.addItem(item: sku, qty: 1)

Both lines of code clearly do the same thing. At first glance the second seems more natural and simple. However consider the case where eventually we want to also update the UserInterest store. We can either clutter up the internals of addItem, or we move the interface out of the Cart itself, and

When do you which approach. If you change approaches midstream you have to update all code calling addItem.

So we can do this a couple of ways: (brainstorming here)

  1. Have robust recommendations, that if followed will result in most of the time logic ending up in the right place, where there is little chance of it moving from a Store to an Operation in the future.
  2. Store mutations go through Operations, reading is directly from the Store.
  3. Similar to 2, except Operations themselves may access mutators in the Store.

Robust Recommendations

Problem is I am not sure what these would look like, and if you wouldn't in the end, end up with choice (2) or (3).

Only Operations Should Mutate

For example:

class AddItemToCart < HyperOperation
  param :sku
  param qty: 1
end

class Cart < HyperStore

  state_reader items: Hash.new { |h, k| h[k] = 0 }, scope: :class

  receives AddItemToCart, scope: :class do
    state.items![params.sku] += params.qty
  end
end

(+) Nice and easy (-) Adds at least 2 lines to every mutator (+) Allows for other stores to participate in the Operation (not that i can think of many examples of that) (+) Appeals to fluxites

Only Operations can call Mutators

This is more of a "recommendation" that would allow for cases where you have an operation that needs to mutate a couple of stores. I'm actually having a hard time thinking of case where this would be big problem, especially given that Operations will by default dispatch so you can write something like this:

class AddItemToCart < HyperOperation
  param :sku
  param qty: 1
  def execute
    super
    AddToUsersInterestList(sku: sku)
  end
end

Removing the big MINUS

The only real minus to making all mutations handled by Operations is that it's just extra typing to declare the Operation class.

However, in many cases there is a "default" association between the Operation and the Store. You can see this in the names - Cart, AddItemToCart.. This is very common in the flux examples. Given this it makes sense to namespace the actions with the store:

class Cart < HyperStore
  class AddItem < HyperOperation
    param :sku
    param qty: 1
  end
  state_reader items: Hash.new { |h, k| h[k] = 0 }, scope: :class
  receives AddItem, scope: :class do
    ...
  end
  
end

We have not changed much, but to me things look much logical. you would say:

  Cart.items # works just like a scope
  Cart::AddItem(...)  # stands out!!! must be a mutator

If it's not obvious which class the Operation belongs (you can probably see it right in the name) to then it really is its own thing and should be placed in its own namespace.

So it is a little more typing but it's consistent and does some nice things for you:

  • Incoming params can have matchers, defaults, type checking. All good to have if you are going mutate something.

    class Cart < HyperStore  
      class AddItem < HyperOperation 
        param :sku, type: String, matches: SKU_PATTERN    
        param qty: 1, type: Numeric, minimum: 1
      end   
    end
  • You can move any special logic for the operation into a separate declaration

    # some where else in the code:
    class Cart::AddItem < HyperOperation
      def execute 
        ConfirmItemAvailability(sku: sku).then { super }
      end
    end
  • Because we know AddItem "belongs to the cart" we can run its receiver first, which is a common dependency ordering.

    class UserInterestList < HyperStore
      receives Cart::AddItem, scope: :class do
        # by the time we get here item is already in cart
        # because Cart 'owns' the Operation
      end
    end 
Clone this wiki locally