As a consultant at Atomic, I evaluate or contribute to a lot of codebases. Over the last few years, as Redux and TypeScript have superseded the use of more object-oriented paradigms, I’ve noticed a common issue — tight coupling of all layers of the application state.
Redux stores a single reference to an immutable global state value. Pure functions (selectors) map the state value to information needed by the front end. User and system actions are passed to your reducer function with the current state value so that you can produce an updated state incorporating the event. Aside from a bit of memoization and asynchrony, your application is the functional core within Redux’s imperative shell.
While Redux has one global state value that’s updated with pure functions, that doesn’t mean there’s no place for (or value to) building abstractions for dealing with that state. How you structure those selectors and reducers matters, and I often see these things implemented in terms of deep access to nested substructure, perhaps with some token extraction of common access patterns into “utility” helper functions. Redux is not object-oriented programming, but that does not mean one cannot abstract.
A common misconception is that TypeScript and the use of types to make invalid states unrepresentable solves this problem. It does not. TypeScript makes change easier by pointing out all code that is inconsistent with a data structure, but that will only make it easier to find every one of the hundreds of places you’ve tightly coupled yourself to a specific data structure! Writing your reducers and selectors in terms of a high-level abstraction allows you to redefine the format of the underlying data types while preserving the abstraction’s semantics. This enriches the capabilities of a domain concept without invalidating hundreds of existing references to other aspects of that concept.
We can learn valuable lessons from object-oriented design here. Sacriledge! do I hear? Get over yourself. You have a duty to your self, your team, and your employer (or customer) to learn from history even though that history used a programming paradigm that’s fallen out of fashion in your niche of the software world. Lots of terrible object-oriented code has been written, but a lot of excellent thinking came out of that paradigm, and some of it can help us here.
Object orientation ties data together with operations on that data, encouraging a view of software as individual entities sending semantical messages between each other. Within object-oriented programming, the Law of Demeter is a common guideline for building decoupled systems. Each object should only talk to immediate “friends,” not to strangers who just happen to be friends of friends. This can be framed as the “only use one dot” rule. If object A
knows about B
and B
knows about C
, A
should not send messages to C
by using multiple dots. B.getC().doSomething()
is disallowed in favor of enriching B
to provide the operation A
needs and implementing it in terms of C
.
At first glance, this advice may not seem relevant to Redux applications. The State
is just data, not object-oriented with built-in operations. There’s no mechanism for raising an operation up to B
from C
because they’re all just data! That’s only true if you only think about your State
as being composed of data types and not of type algebras.
I think Debasish Ghosh does an excellent job explaining this in Functional and Reactive Domain Modeling. He points out that the analog to the object-oriented interface
is not a type
but a module. A type defines a set of values. A module defines a type or collection of related types and a set of functions between those types and other types that preserve the invariants of the domain concept. This set of types and invariant-preserving functions comprise the algebra of the module. (It’s worth reiterating here that the types should be immutable and the functions all side effect free.)
Want to bring the flexibility, modularity, and loose coupling of well-designed object-oriented software to functional architectures like Redux? Use a good module pattern for TypeScript, design algebras for each of your types, and compose new algebras from the constituent element modules. Each domain concept should get a type (or types), together with a set of operations that present other modules a semantic API for manipulating and deriving information from those types. This API should be made up of functions and functional structures, such as lenses, isomorphisms, etc. When defining a new type, implement its own algebra by combining the algebras of its constituent elements.
In my experience, the modularity achieved from building abstractions in this way in functional paradigms far exceeds what I’ve usually been able to accomplish in object-oriented architectures. One big problem I’ve had with the Law of Demeter as it has been applied in object-oriented software is that it can lead to broken-comb APIs where the operations exposed by B
are a handful of hyper-specific cases A
happened to need of B
‘s neighbors, C
and D
. B
becomes a haphazard subset of the cartesian product of C
and D
‘s capabilities, in part because the message-passing paradigm of object-orientation can get a little limiting.
Modularity in functional architectures is way more powerful because you’re free to project to any other type that provides an appropriate algebra for expressing your problems — a freedom that comes from the inherent decoupling of side-effect free, pure functions between types. There are a whole host of techniques that can be used for introducing more fine-grained, flexible abstractions into your application:
- Need to express a lot of operations on a complex constituent element but don’t want to muddy your higher-level type’s API with a cartesian product of capabilities? Provide an
updateFoo
function of typeState -> (Foo -> Foo) -> State
that you can use for defining any operation in terms of logical updates toFoo
. - Better yet, define a
foo
lens that enables a lot of flexible ways of getting or settingFoo
, includingupdate
as a derivative operation. - Is there another potential representation of a value that’s more convenient or semantical in some contexts? Implement an
Isomorphism
for easy bi-directional conversion between those types so you can always use the most convenient algebra for your specific problem. - Combine these and other techniques however you want, such as mapping a lens with an
Ismorphism
to provide an equivalent logical lens of another type.
And more.
Modularity isn’t less important in functional architectures, even with a type system. On the contrary, functional programming and powerful type systems provide much more powerful tools for achieving modularity, but you have to learn them and learn how to use them. If you do, you’ll be richly rewarded with better results than you’ve ever achieved in classical object-oriented software. But if you leave them aside and just dot down into your nested types, you might as well go back to writing bad Java code. You’ve doomed yourself to repeating the failures of history instead of amplifying its successes.