Fourteen years ago, Gary Bernhardt coined the term Functional Core, Imperative Shell . Like most good ideas in computing it was not entirely new, but his conception had great clarity, and it forms an excellent basis for talking about testing and determinism in existing systems.
Briefly, Functional Core/Imperative Shell architecture divides the code into two parts. The Functional Core is purely functional - that is no IO, and no destructive state updates. It is concerned with the business logic of the application. The Imperative Shell has comparatively little pathing, but maintains state, coordinates external dependencies, and deals with the outside world - that is to say IO. Its job is to query the core with values, receive values back as the result of some blackbox decision, and use that to interact with the outside world; whether that's writing to a database, sending a request, or updating a GUI.
The Shell and the Core in this model have distinct characteristics:
Core Shell Makes decisions Coordinates dependencies Many branching execution paths More linear execution Isolated from the world Integrates with the world
This makes the core very amenable to testing. Since it's purely functional, the same inputs will always get the same results. Since it's isolated, there is nothing to mock or stub. And since it handles complex business logic, the tests can tell us a lot about how the system behaves.
Functional Purity and Determinism
A shorter way of describing the properties that make pure functions amenable to testing is that they are deterministic. That is - given a stream of inputs, a pure function always returns the same stream of outputs; their behaviour is repeatable. But pure functional programming is not the only way to get there. If we tilt our heads a little we can see that a stream of values and a sequence of assignments are different ways of expressing the same thing, and State Machines can bring us the same benefits. Consider the following code:
function add ( ns ) { return ns . reduce ( ( a , b ) => a + b , 0 ) } class AddMachine { # state = 0 transition ( input ) { this .# state += input } get state ( ) { return this .# state } }
The function add is easy to reason about; it's pure and thus deterministic. But the AddMachine is also deterministic - given the same sequence of calls to the transition function, AddMachine will return the same state. It being imperative does not change that.
const output = add ( [ 1 , 2 , 3 ] )
... continue reading