// HACKER NEWS — CYBERSECURITY
Deterministic Core, Non-Deterministic Shell
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:
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.
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:
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.
Pure functional programming is a fine paradigm, but due to language or
performance considerations, it is not always practical - I would not want to
try it in C! But weakening the requirements from purely functional to
merely deterministic, we retain the testability benefits of "Functional
Core, Imperative Shell", while broadening its applicability. And so the title
of this post:
Deterministic Core, Non-Deterministic Shell.
Determinism can feel like a more abstract concept than functional purity. How
do you know it when you see it? I find it's easier to start with what is not
deterministic and work backwards. Here are some common examples of
non-repeatable behaviour:
All these belong in the non-deterministic shell. Whenever you find them in
your business logic, you have a natural target for defragmentation - either
splitting the function in two around them, or lifting them up a layer and
injecting their result as a parameter. It's illustrative to think of the
"shell" metaphor quite literally; it should surround the logic, querying the
heart of the application to get what it needs.
"This is all well and good", you might think, "but what use of it is to me,
toiling away in the legacy & vibe-code mines of industry?". A fair accusation,
imaginary reader; not everyone can be Foundation DB and make that distinction from day one
(they actually went a step further, but that's a topic for another post).
Determinism and non-determinism are highly entwined in almost every real life
codebase I have seen, and I've seen my fair share.