Post

Functional Core - Imperative Shell (FCIS) — When to Use It

Functional core/imperative shell is a way of structuring code so that the heart of your program is pure, predictable, and easy to test (the functional core), while the messy realities of the outside world—databases, networks, user input—are handled around the edges (the imperative shell).


The Basic Idea (in Plain Language)

  1. Functional core
    Write code that looks like math:

    • It always returns the same result for the same input.

    • It doesn’t touch the outside world.

    • It has no hidden state.

  2. Imperative shell
    Write code that talks to the world:

    • Read files, hit APIs, show UIs.

    • Gather the raw data your core needs and pass it in.

    • Take the core’s results and push them back out.

Put differently:

Pure functions decide what should happen; the shell decides how and where it actually happens.


When You Should Use It

SituationWhy it helps
Business rules / calculationsBugs hide in logic, not in I/O; a functional core lets you test that logic without spinning up databases or mocking HTTP.
Code that needs unit testsPure functions are trivial to test → fast feedback loops.
Repeated or concurrent workflowsDeterministic code is safer to call many times or run in parallel.
Long-lived back-end servicesEasy-to-reason-about cores age well and survive refactors.

When You Shouldn’t Bother

SituationWhy not
Tiny scripts or one-offsThe ceremony outweighs the gain; just write and ship.
Heavily stateful UIsFrameworks already manage state; forcing a pure core can fight the grain.
Hard-real-time systemsExtra indirection may introduce latency you can’t afford.
Teams unfamiliar with functional ideasA pattern nobody understands becomes tech debt, not a blessing.

How to Start in Three Small Steps

  1. Draw a line—decide which functions must touch the outside world. Everything else belongs in the core.

  2. Return data, not side effects—have the core describe changes (e.g., “send this email”) instead of sending the email itself.

  3. Wrap the core with an adapter—the shell takes those descriptions and executes them.


Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# -------- functional core --------
def decide(action, state):
    """
    Pure: given the current state and an intent,
    return a *description* of what should happen next.
    """
    if action == "deposit":
        amount = state["amount"]
        return {"new_balance": state["balance"] + amount}
    if action == "withdraw":
        amount = state["amount"]
        return {"new_balance": state["balance"] - amount}
    raise ValueError("unknown action")

# -------- imperative shell --------
if __name__ == "__main__":
    import json, sys

    # I/O: read a single JSON blob from stdin, e.g.
    # {"action":"deposit","balance":100,"amount":25}
    state = json.loads(sys.stdin.read())

    # pure decision
    result = decide(state["action"], state)

    # I/O: persist and report
    state["balance"] = result["new_balance"]
    print(json.dumps(state))

  • Functional core (decide) – deterministic, stateless, no printing or file work.

  • Imperative shell (__main__) – handles JSON, stdin/stdout, persistence.

Swap the shell (CLI, web route, queue worker) or test against decide directly—no other changes required.

A Closing Analogy

Think of your functional core as a chef’s recipe: precise, repeatable, pure instructions.
The imperative shell is the kitchen staff: they buy ingredients, heat the stove, and serve the dish.
Keep the recipe immaculate and the cooking chaotic bits on the periphery, and you’ll ship meals—er, software—that are both reliable and easy to improve.

This post is licensed under CC BY 4.0 by the author.