Dive C2.09.2
The imperative shell
Reading, writing, and the odd mutation — all of it, kept at the edge.
A pure core cannot read a file, print a line, or take input. A real program must, so something has
to. That something is the shell: a thin outer layer that handles the side effects and wraps the
core. It parses input at the seam — converting C#'s null, throws, and
free-form strings into Option the moment they cross in — then calls the
pure functions, then renders the result. F# does not ban mutation either: where a tight loop or
a buffer wants it, a let mutable is allowed, but it is kept local to one
function so it never reaches a caller. The rule of the lab is one line: effects live in the shell, the core stays
referentially transparent.
Parse at the seam, into the core's types
The input to a discount run is a string — from a config file, a request, a C# call. A string is not a
Tier, and any string is possible, including a typo. The shell's first job is to turn
the open string into the core's closed type at the boundary, and to make the failure case explicit: a parse
that does not recognise its input returns None, not an exception (the C2.03 / C2.08
boundary). After this function, everything downstream holds a real Tier.
let parseTier (s: string) = // val parseTier : string -> Tier option match s.Trim().ToLowerInvariant() with | "bronze" -> Some Bronze | "silver" -> Some Silver | "gold" -> Some Gold | _ -> None parseTier "Gold" // val it : Tier option = Some Gold parseTier " silver " // val it : Tier option = Some Silver parseTier "platinum" // val it : Tier option = None
Loading the customers is the other effectful input. Here it is a literal list; in a real program it would
read a file or query a store, and that read is exactly the kind of side effect that stays in the shell. The
load returns the core's Customer values — once it has run, the rest of the program is
working with clean, typed data.
let load () = // val load : unit -> Customer list // a real shell reads a file or store here; the literal stands in for it [ { Id = 1; Name = "Ann"; Tier = Gold } { Id = 2; Name = "Ben"; Tier = Bronze } ]
- parseTier
string -> Tier option: the open string becomes a closedTier, orNonefor an unrecognised one.- the seam
- where C#'s
null, throws, and free-form values cross in — converted toOptionat once (C2.03.3, C2.08). - load
- the effectful input — reading from the world — kept in the shell, returning the core's types.
Mutation, where justified, stays local
F# admits in-place change for the cases where it is the right tool, and contains it. The render step below
prints a running line per customer, accumulating with a let mutable and the
<- operator. The mutable lives inside one function and never escapes
— a caller sees the same signature it would see for a pure summary, and cannot observe the assignment (the
C2.02 "mutation, contained" rule). The discipline is not "no mutation"; it is "no mutation a caller can see."
let summarise custs = // val summarise : Customer list -> float let mutable running = 0.0 // local — invisible to the caller for c in custs do running <- running + discount c.Tier // the <- reassignment, contained printfn "%s: %.2f (running %.2f)" c.Name (discount c.Tier) running running summarise (load ()) // Ann: 0.10 (running 0.10) // Ben: 0.00 (running 0.10) // val it : float = 0.1
The same total is one fold in the pure core — totalDiscount custs — so the
mutable here is a choice for the printed, line-by-line render, not a necessity. The point is that even when the
shell chooses to mutate, the change does not leak past the function that owns it.
The C# the reader knows, beside the F#
A C# program built the same way puts the validation and the writing in a method or a service class, and
calls a pure helper for the math. F# uses functions and modules where C# uses classes (C2.01), but the
architecture is identical: a thin band of effects around a referentially-transparent middle. The
Option at the seam is the one upgrade — C# typically returns a nullable or throws on a
bad parse; F# makes the failure a value the caller must read.
C# — a service method does the I/O, a helper does the math
static Tier? ParseTier(string s) => s.Trim().ToLowerInvariant() switch { "bronze" => Tier.Bronze, "silver" => Tier.Silver, "gold" => Tier.Gold, _ => (Tier?)null, // a nullable stands in for "unknown" }; // the caller must remember to check for null
F# — a shell function parses to Option, the core stays pure
let parseTier (s: string) = // val parseTier : string -> Tier option match s.Trim().ToLowerInvariant() with | "bronze" -> Some Bronze | "silver" -> Some Silver | "gold" -> Some Gold | _ -> None // the None case is in the type — the caller has to handle it parseTier "platinum" // val it : Tier option = None
C# architecture
A service class for the I/O, a pure helper for the math, a nullable or a throw at the parse — the same layering, with the failure off the type.
F# shell
Shell functions for parse · load · render, returning Option at the seam, around a pure core — mutation, if any, kept local.
Walk the pipeline · which layer is this?
A discount run flows through four stages: load the customers, parse the tier input, compute the total, render the lines. Two are pure — the core — and two are effectful — the shell. Select a stage to see which layer it belongs to and why. The shape never changes: effects bracket the core at the start and the end, the pure computation sits in the middle.
load → parse → compute → render · select a stage
| stage | layer | why |
|---|---|---|
| load () | effectful | reads the customers from the world |
| parseTier | effectful | turns input into the core's type at the seam |
| totalDiscount | pure | same input, same output, no side effect |
| render | effectful | prints the lines; any mutation stays local |
Effects bracket the core; they do not run through it. Everything between the parse and the render is a function that could be run twice and answer the same — which is what keeps the middle testable.
Where this leads
The core is built and the shell wraps it, but the two are still separate fragments. The last dive puts them
in one file in the right order — types, then core, then shell, then an [<EntryPoint>]
main — and recaps the chapter's eight translations as the plan for adopting F# in an
existing C# codebase.
References
Sources
- Microsoft — F# documentation —
let mutable, the<-operator, and side-effecting functions. - Microsoft — F# style guide — the guidance on keeping effects at the boundary and the core pure.
- FSharp.Core API reference — the
Optionmodule returned at the parse seam.
Related in this course
- C2.09.1 — The functional core — the pure middle this shell wraps.
- C2.02 — Immutability & expressions — the "mutation, contained" rule the render step follows.
- C2.09.3 — Ship it (the lab) — core and shell assembled into one program.