loading...

Let's talk about Orchestration vs Separation of Concerns: React/Redux Edition: Part 1

rosschapman profile image Ross Chapman Updated on ・6 min read

There's an architectural pattern that Ian Horrocks describes in his 1999 book Constructing the User Interface with Statecharts as the "user interface-control-model." UCM will exhibit a familiar symmetry for anyone that has worked with React/Redux for any substantial period of time. From Horrocks:

The event handlers associated with user interface objects are simply used to forward events supplied by the user to the appropriate control object....

The control object maintains the state of the user interface as a whole. When a control object receives a message from a user interface object, the message and the current state of the control object are used to determine the actions that will be executed and possibly update the state information maintained by the control object....

The control layer provides a user interface with an explicit state that can be used to determine the different contexts in which events occur.

Constructing the User Interface with Statecharts, p 27-28

The indirection provided by the "control object" described above is analogous to the store-like object we've come to see in most JavaScript data libraries. Just like our hate-to-love, love-to-hate breadwinner Redux.

A colleague of mine enshrines this patterning of event and state system as "Simple Flow." The Three Principles of Redux represent another incantation of this flow. It's everywhere. It's certainly nothing new, but there are many flavors with subtle differences.

What if I try and take a stab a heuristic that describes at least one characteristic of this pattern:

Centralized orchestration of actions

By using "orchestration" here I'm invoking a recent tweet by David Kourshid where he condemns the overuse of "separation of concerns."

Been thinking about this a lot. The common principle of "separation of concerns" is often blindly applied and leads to fragile architecture. Orchestration is the missing part.

https://twitter.com/DavidKPiano/status/1243938073009889281, [emphasis mine]

Kourshid is leaning on the accomplishment of xState which executes a finite state automaton and state chart as an actor -- in the heritage of the Actor model -- resulting in an exemplary example of an orchestration "machine" where events drive drive deterministic results.

Leaving the technicalities of xState aside for the moment, I had to let this critique sit with me a bit -- but I think I like it. Separation of concerns oversimplifies the idea that clarity and reasonability emerge solely from separation. Even if we keep our models -- a group of functions and data -- small and distinct, we have to make sure they are not only bounded by relative assumptions about their context, but composed in a way that makes them adaptable to change and portable for reuse: two cornerstones of software for practical world building. The tendency in separation alone is risking a mathematical reductionism. I think that's the spirit of Kourshid's distinction.

I'm finding myself persuaded that mathematically reductive code -- code that follows deductive reasoning as Zachary Tellman would say -- is how we end up with embarrassing bugs despite complete unit test coverage.

Many early computer scientists were trained as physicists, and it shows.... Since then, practical use of software has exploded, and deductive models have given way to inductive ones.

Elements of Clojure, p74

An example that might seem familiar out in the wild is the lack of orchestration when coordinating the sub-routines in client code after a form submission. I've seen a perfectly reasonable sequence of behaviors encoded in a submit event callback like the following:

// This is oversimplified. The real code for this callback would be a complicated graph  
// of nested asynchronous and synchronous calls. Imagine at the edge of thes thunks each 
// dispatched action mutates state.
let startPostUpdateStoreThenResetForm = (e, data) => {
    await dispatch(saveEntity(data));
    let entities = await dispatch(fetchEntities());
    let taxPolicy = await dispatch(maybeFetchEntityTaxPolicy());
    await dispatch(maybeUpdateEntityPriceSuggestions(taxPolicy, entities));
    let isEditing = dispatch(getIsEditingFromState());

    if (isEditing) {
        dispatch(prePopulateForm(data));
    } else {
        dispatch(resetForm());
    }
}

let MyFormComponent = () => {
    return {
        <Form>
            <Button type={'submit'} onClick={startPostUpdateStoreThenResetForm}/>
        </Form>
    }
}

This design attempts to create a meaningful abstraction by lifting a group of associated action creators/thunks to startPostUpdateStoreThenResetForm. There are immediate developer benefits, like liberating the sequence from render to decouple the callback logic from the presentational component; which in turn simplifies unit testing. But something is irksome.

We can use Leo Brodie's application of "Structured Design" principles to interrogate this function's "strength":

THINKING FORTH, p18

Basically all four apply in some dimension (to my somewhat exaggerated name). Therefore the function might be further described as exhibiting types of "weaker" binding, which Brodie goes on discuss.

Ibid. p19

The most salient of these for our function above would be "temporal" and "sequential," and to a lesser extent "logical" and "communicational."

I think Brodie’s intent in the book is to wield the idea of “weak” in order to signify a less successful realization of the software. In other words, weak doesn't necessarily mean broken or bad, but it is a classification to help programmers de-correlate the simple act of grouping related things as good design.

What else do we observe? startPostUpdateStoreThenResetForm's weak bindings encode a fixed outcome for a fixed set of operations, which is very hierarchical; we're really dealing with an array-like structure. The encapsulation semantics merely create a thin veil between stateless renderers and the store. Meanwhile, the store can only respond the best it can to the sequence of effects, enacting a kind of merciless mutation bombardment on the renderers. There's not a sense that anything in the code is really in full control except; the last recourse is the notional machine in the programmers' heads. Which means developers will end up with a superstitious mental model of the software, as well as an inability to safely re-sequence this code without a heavy amount of documentation or in-person discussion with the last dev to blame.

Confidence requires understanding. If we cannot understand our software, it becomes oracular.

Elements of Clojure, p74, [emphasis mine]

Which means a dangerous increase in risk and liability and a (non-trivial) consolidation of power for certain engineers who will (often subconsciously) lord this expertise in non-cooperative ways. This sounds kind of dramatic because we are used to frustrating encounters with code -- and people. We know we signed up for this (or learned quickly after we started), but it shouldn't have to be this way. We can eliminate the frustration that results from overly exercised dogma with better abstractions; we can dethrone genius programmers; we can spend more energy on the tough problems of our evolutionary domain logic.

And just to pull on the earlier thread a bit more about testing. What, pray tell, does separation really achieve for testing?

describe('when editing an entity', () => {
    it('posts the entity form and does all the right stuff afterward', () => {
        stub(myModule, 'prePopulateForm');
        dispatch = jest.fn();

        startPostUpdateStoreThenResetForm();

        expect(dispatch).toHaveBeenCalledTimes(6);
        expect(prePopulateForm).toHaveBeenCalledTimes(1)
    });
});

What kind of assurance does the 👆🏻 provide other than introducing a kind of needless check of implementation details. I'll hand wave a bit here but I believe this is what Kent Dodds calls a Test User.

For simple applications and toy examples this level of existentialism is overkill. But:

Doing the easy thing over and over again leads to the thing that's not simple.

Sandi Metz, Maintainable Podcast, 28:18

We only need to introduce the conductor when things get too big for one person's head. At that juncture, for example when we achieve market validation for some feature, it's time for the business logic to be liberated, lifted, from within callbacks to achieve an abstraction that sits above the store and our dispatch sequence. xState is an option. But I'd like to offer a simplistic version of our conductor built entirely in React in the next post.

Posted on by:

rosschapman profile

Ross Chapman

@rosschapman

Software engineer interested in building useful things, collaboration in large systems, and the semiotics of code. he/him

Discussion

pic
Editor guide