{"slug": "the-functional-refactoring-pass", "title": "The Functional Refactoring Pass", "summary": "A developer who advocates functional programming has found that large language models (LLMs) reason about functional code more easily than imperative code, and has developed a multi-step refactoring prompt to guide LLMs in converting codebases to a functional style. The approach involves prompting the LLM to create a plan, then executing it in phases to move side effects to the edges and make the core stateless. The developer has applied this to the jrm-code-project.com web site, with the LLM generating a detailed refactoring plan.", "body_md": "This is an anecdote, not a data point, yet.\n\nI'm a firm believer in functional programming and I consider myself a `mostly functional` programmer. I use functional programming when I can, but when a side effect is required, I'll use it. I'm not a purist.\n\nFunctional programming is supposed to have a number of advantages over imperative, procedural programming. The primary advantage is that there are provably no race conditions. You can trivially scale functional code to multiple threads and multiple processors. In theory, functional code is easier to reason about and easier to test because each function can be reasoned about and tested in isolation. In practice, functional code can become a maze of thunks and lambdas that many find difficult to read. Nonetheless, I prefer to reason about tangled lambdas than to reason about tangled state.\n\nI believe that functional code is easier for an LLM to reason about as well. I haven't proven this, but anecdotally it seems to be the case. In theory, the LLM would gain all the usual benefits of functional programming. It would find the code easier to reason about and easier to test.\n\nUnfortunately, the vast majority of code that the LLM has been trained on is imperative, procedural code. The LLM can write functional code when specifically prompted, but it will default to writing imperative, procedural code.\n\nIf you start a vibe coding project *ab initio*, you'll get a\nlot of imperative, procedural code, and the LLM will have more and\nmore difficulty reasoning about it as the project grows. To stop\nthis from happening, I prompt the LLM to refactor the code to be\nmore functional. I do this fairly early on in the project, once the\nproject starts to show signs of life.\n\nThe functional refactoring is a multi-step process and the best way to do it is to prompt the LLM to first create a plan for the refactoring. I use a prompt something like this:\n\nMake a plan. We need the code to be refactored to be more functional and to adhere to functional programming principles. Take several steps to refactor the code so that the main interaction path is functional and stateless. Move the side effects to the edges of the codebase. Use functional programming techniques such as monads and reactive programming to keep the core of the codebase functional. Make sure that utility functions are pure and stateless. Write the plan to a file FUNCTIONAL_REFACTORING.md\n\nThe LLM will cogitate for a while and will write a multi-step plan for the refactoring. Here is the plan that the LLM generated for the jrm-code-project.com web site.\n\n```\n# Functional Refactor Plan for `jrm-code-project`\n\n**Author's lens:** Senior Functional Programming Architect\n**Scope:** `package.lisp`, `csrf.lisp`, `server.lisp`, `auth.lisp`, `billing.lisp`,\n`admin.lisp`, `chef.lisp`, `db-auth.lisp`, `stripe.lisp`, `jwt.lisp`, `totp.lisp`, `ses.lisp`\n**Status:** Complete. Phases 1-8 below have all landed as separate,\nindividually-tested commits; the codebase now reflects this plan. The\nphase write-ups are retained as historical design-rationale documentation\n-- comments elsewhere in the codebase that cite \"FUNCTIONAL_REFACTOR.md\nPhase N\" are pointing at finished work, not an in-progress migration.\n\n---\n\n## 0. Framing\n\nThis codebase is a working, well-organized Hunchentoot application (the recent\nfile split into `csrf`/` server`/` auth`/` billing`/` admin`/` chef` was a good move\nalong the *separation-of-concerns* axis). But every one of those modules is\nwritten in a straight-line, **imperative-shell-with-no-functional-core** style:\nHTTP handling, session mutation, SQL, third-party HTTP calls, HTML rendering,\nand business rules are all fused into single `DEFUN` s that read the world,\nmutate the world, and print strings, in one undifferentiated breath.\n\nThe project already imports `SERIES`, `FOLD`, `FUNCTION` (compose/inverse), and\n`NAMED-LET` — real functional-programming firepower — via shadowing imports in\n`package.lisp`. Almost none of it is actually used in the handler code; the\nshadowed `LET`/` DEFUN`/` LET*`/` MULTIPLE-VALUE-BIND` forms are used as drop-in\nreplacements for their vanilla CL counterparts, not as a foundation for a\ndifferent *style* of programming. That's the central irony this plan\naddresses: the tools for a functional architecture are already a dependency of\nthe system; they're just not driving any design decisions yet.\n\nThe plan below does **not** propose rewriting Hunchentoot, Postmodern, or\nStripe's HTTP API into something pure — those are unavoidably effectful\nboundaries. It proposes pushing effects to the *edges* (a thin imperative\nshell) and pulling everything else — validation, view-model construction,\ntier/authorization logic, Stripe payload shaping, HTML rendering — into a\n**pure, immutable, composable core** that can be unit-tested without a\ndatabase, without Hunchentoot, and without live Stripe credentials.\n\n---\n\n## 1. Anti-Pattern Catalog (current state)\n\n### 1.1 Global mutable state used as an implicit parameter-passing channel\n\n- `*acceptor*` (`server.lisp`) — mutated by `start-server`/` stop-server`.\n- `*stripe-tier-price-ids*`, `*stripe-tier-product-ids*`, `*stripe-price-id-tiers*`,\n  `*stripe-billing-portal-configuration-id*` (`stripe.lisp`) — four separate\n  `DEFVAR` s, populated by side-effecting `PUSH` inside `ensure-tier-product`\n  and `ensure-billing-portal-configuration`, and read by unrelated functions\n  (`tier-price-id`, `tier-from-price-id`, `create-billing-portal-session`)\n  scattered throughout the file. This is really *one* piece of \"Stripe\n  catalog\" data, represented as four uncoordinated globals that must be\n  mutated in lock-step (see `init-stripe-product`, which zeroes all four by\n  hand before repopulating them) — a classic sign that a single immutable\n  value is trying to escape.\n- Every handler reaches into `hunchentoot:session-value`/` hunchentoot:cookie-in`\n  as ambient dynamic state rather than being handed an explicit `Request`\n  value. E.g. `dashboard-page` (`auth.lisp`) pulls `:authenticated-user` from\n  the session, `challenge-2fa-page` reads/writes `:limbo-email` and\n  `:post-login-redirect` via `setf` in the middle of a rendering branch.\n\n### 1.2 God-functions that fuse I/O, business logic, and presentation\n\nNearly every `hunchentoot:define-easy-handler` in `auth.lisp`, `billing.lisp`,\nand `admin.lisp` does all of the following in one function body:\n\n1. Read ambient state (session, cookies, POST params).\n2. Validate/branch on it.\n3. Call the database or an external HTTP API (side effect #1).\n4. Mutate session/cookie state (side effect #2).\n5. Build and return an HTML string via nested `FORMAT` calls (presentation).\n\n`dashboard-page` (`auth.lisp`) is the extreme case: ~250 lines mixing tier\nmath, JWT issuance (a side effect), a conditional redirect, and a giant\n`FORMAT` template with 20+ interpolation arguments computed inline. There is\nno way to unit-test \"what should the dashboard tier grid look like for a\nLAMBDA-tier user with a Stripe customer ID\" without spinning up Hunchentoot,\na session, and a database row.\n\n`stripe-webhook-handler` (`billing.lisp`) mixes signature verification,\nJSON parsing, event-type dispatch, and five different DB-mutation call sites\nin one `COND`, with logging `FORMAT` calls interleaved — untestable without a\nlive (or heavily mocked) Postgres connection and a hand-built JSON fixture.\n\n### 1.3 Stringly-typed, un-composable HTML rendering\n\nEvery page is a hand-written `FORMAT nil \"<html>...~A...</html>\"` template.\nConsequences:\n\n- No composition: the \"vault\" card, the \"tier grid\", and the notification\n  banner in `dashboard-page` cannot be reused or tested independently — they\n  are inline slices of one giant format string.\n- No enforced escaping discipline: some interpolations go through\n  `hunchentoot:escape-for-html` (e.g. `(hunchentoot:escape-for-html user)`),\n  others don't (e.g. tier-derived CSS class strings, which happen to be safe\n  today only because they come from a fixed internal vocabulary) — the\n  safety property is not structurally guaranteed, only true by convention and\n  developer discipline.\n- Every handler re-embeds the same `<style>` block or repeats layout\n  boilerplate (`signup-page` and `setup-2fa-page` both hand-roll near-identical\n  `<html><head><style>...` wrappers).\n\n### 1.4 Alist-of-keywords as a poor man's record type\n\n`db-auth.lisp`'s `get-user`/` list-users`/` get-user-by-customer` all return\n`postmodern:query ... :alists` rows, and every caller repeats\n`(cdr (assoc :membership-tier user-data))`, `(cdr (assoc :wheel user-data))`,\netc. — by grep, this exact shape appears **20+ times** across `auth.lisp`,\n`billing.lisp`, and `admin.lisp`. There is no `USER` type: the \"schema\" is an\nimplicit contract enforced only by every call site independently getting the\nkeyword spelling right (`:stripe-subscription-id` vs. a typo would fail\nsilently, returning `NIL`, not a compile- or run-time error).\n\n### 1.5 Side-effecting, non-monadic error/control flow\n\n- `csrf.lisp`'s `WITH-CSRF-PROTECTION` macro is a control-flow combinator\n  wearing a syntactic disguise: it's really \"if failure, mutate the HTTP\n  return code and short-circuit\" — imperative branching hidden inside a\n  `DEFMACRO`, not a composable value.\n- `jwt.lisp`'s `require-membership-tier`/` require-wheel`/` require-membership-jwt`\n  each *either* return a value *or* perform a side-effecting `REDIRECT` and\n  return `NIL` — callers are contractually obligated to check for `NIL` and\n  \"immediately stop processing\" (a convention documented in a comment,\n  not enforced by the type/control-flow system). This is exactly the shape\n  `Either`/` Result`/` Maybe` monadic short-circuiting exists to replace.\n  Compare with e.g. `require-session-wheel` in `admin.lisp`, which duplicates\n  the same \"return value or redirect-and-return-nil\" shape independently for\n  session-based (not JWT-based) authorization — the same *pattern* implemented\n  twice, un-abstracted.\n- `stripe-webhook-handler` and `roast-code-with-gemini`/` chef-handler` use\n  `HANDLER-CASE` around large blocks and communicate failure by mutating\n  `hunchentoot:return-code*` and returning an ad hoc string — errors are\n  effectively `(values nil side-effect)`, not typed outcomes.\n\n### 1.6 Duplicated imperative HTTP-client boilerplate\n\n`stripe.lisp` rebuilds `(stripe-auth-headers secret-key)` and re-checks\n`(and secret-key (not (string= secret-key \"\")))` in nearly every function\n(`find-existing-tier-product`, `create-tier-product`,\n`ensure-billing-portal-configuration`, `create-stripe-checkout-session`,\n`create-billing-portal-session`, `get-stripe-subscription-tier`,\n`cancel-stripe-subscription-with-prorated-refund`) — eight independent,\nhand-written guard clauses for what is structurally one precondition\n(\"do we have Stripe configured\") and one authenticated-GET/POST helper.\nRequest payloads are built as raw `(cons \"key[bracket][path]\" \"value\")` lists\nby hand at each call site (see the billing-portal-configuration content-list\nconstruction) rather than through a small combinator/DSL that could be unit\ntested for correct shape independent of the network call.\n\n### 1.7 Unused functional idioms already in scope\n\n`package.lisp` imports `SERIES` (lazy, compiler-fused sequence pipelines) and\n`FOLD`, yet the codebase's list processing — `list-users` pagination,\n`mapcar #'render-member-row members`, `dolist` loops in `db-auth.lisp` and\n`stripe.lisp`, the `LOOP ... COLLECT` in `generate-recovery-codes` — is all\nplain `CL:LOOP`/` DOLIST`/` MAPCAR` with `SETF`-based accumulation\n(`random-string`'s `(setf (char res i) ...)` loop, `admin-members-page`'s\nimperative pagination math). None of it is wrong CL, but it means the\nproject's own stated architectural direction (series/fold-based composition)\nisn't actually load-bearing anywhere yet.\n\n### 1.8 Testing is coupled to live, mutable external state\n\n`recovery-code-verification`, `stripe-database-and-routes`, and\n`user-membership-tiers` (per `tests/tests.lisp` and this repo's own\ndocumented conventions) require a live Postgres instance and mutate real\nrows. This is a direct consequence of §1.2/§1.4: because business logic is\nnever separated from the DB/HTTP shell, there is no way to test \"does\n`tier-meets-minimum-p` correctly rank CADR above CONS\" or \"does the webhook\nhandler correctly map a `customer.subscription.deleted` event to a\ncancellation\" without a database in the loop.\n\n---\n\n## 2. Target Architecture\n\n**Functional core, imperative shell**, applied consistently:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Imperative shell (thin, at the edges only)                   │\n│  - Hunchentoot handlers: parse Request, call pure core,      │\n│    interpret its pure Response/Effect value, perform I/O.    │\n│  - Postmodern calls: translate SQL rows <-> immutable domain │\n│    records at the boundary only.                             │\n│  - Stripe/Gemini HTTP calls: translate typed request records │\n│    <-> typed response records at the boundary only.          │\n│  - *ACCEPTOR*, *STRIPE-CATALOG*, cookie/session get/set.      │\n└───────────────────────────┬───────────────────────────────────┘\n                            │ immutable values only cross this line\n┌───────────────────────────▼───────────────────────────────────┐\n│ Pure functional core (the bulk of new/moved code)             │\n│  - Domain records: USER, MEMBERSHIP-CLAIMS, STRIPE-CATALOG,   │\n│    CHECKOUT-REQUEST, WEBHOOK-EVENT, VIEW-MODEL, RESULT.       │\n│  - Pure decision functions: tier-meets-minimum-p,              │\n│    dashboard-view-model, webhook-event->db-commands,          │\n│    checkout-request->stripe-params, csrf-check, auth-check.   │\n│  - Pure rendering functions: view-model -> HTML string.       │\n│  - Composable middleware combinators over a Request->Result   │\n│    handler shape.                                              │\n└─────────────────────────────────────────────────────────────────┘\n```\n\nKey design commitments:\n\n1. **Immutable domain records, not alists-of-keywords.** Every \"row\" that\n   crosses the DB boundary becomes a `defstruct` (or `defclass` with\n   `:read-only` when the CLOS overhead-per-instance is not a concern) with\n   named, typed accessors — `user-membership-tier`, `user-wheel-p`, etc. —\n   constructed once at the DB boundary via a single `row->user` converter,\n   never re-derived by ad hoc `(cdr (assoc :x row))` at call sites.\n\n2. **Explicit `Result`/` Either`-style outcomes instead of \"return NIL and\n   trust the caller to have already redirected.\"** A tiny `defstruct result`\n   (or reuse of `(values status payload)`, or a proper condition-based\n   approach — see Phase 6) makes success/failure a first-class value that\n   the *shell* interprets (issue a redirect, render an error page), rather\n   than a side effect the *core* performs mid-computation.\n\n3. **Middleware as composable functions, not macros with inline control\n   flow.** `WITH-CSRF-PROTECTION`, `require-membership-tier`,\n   `require-session-wheel` all collapse into one combinator shape:\n   `(defun wrap-with-csrf (handler) ...)`, `(defun wrap-with-tier (min-tier handler) ...)`,\n   composed via `FUNCTION:COMPOSE` (already a dependency!) at route-definition\n   time, e.g. `(compose (require-tier \"CADR\") require-login csrf-protected) #'chef-page-core)`.\n\n4. **Pure view-model construction, separated from HTML string rendering,\n   separated from the HTTP handler.** `dashboard-page` becomes: (a) a pure\n   `dashboard-view-model` function (user record + query params -> an\n   immutable `DASHBOARD-VIEW-MODEL` struct), (b) a pure `render-dashboard`\n   function (view-model -> HTML string, independently unit-testable with\n   hand-built view-models and no session/DB at all), and (c) a thin handler\n   that wires the two together and performs the one real side effect\n   (issuing the JWT cookie).\n\n5. **One immutable `Stripe` catalog value, not four mutable globals.**\n   `ensure-tier-product`/` ensure-billing-portal-configuration` become pure\n   functions that *return* an updated `STRIPE-CATALOG` record; `init-stripe-product`\n   becomes the one place that takes the pure result and stores it in a single\n   `*stripe-catalog*` global (still a necessary impurity — Stripe's actual\n   product IDs are genuinely mutable external state fetched once at startup —\n   but now it's *one* clearly-labeled impurity instead of four unsynchronized\n   ones).\n\n6. **Lean on `SERIES`/` FOLD` where they fit naturally** (pagination,\n   filtering, tier-ranking, recovery-code generation) so the project's own\n   declared functional dependencies start pulling their weight, without\n   forcing awkward `SERIES` usage onto genuinely imperative I/O loops (the\n   SMTP hand-rolled protocol in `ses.lisp`, for instance, is legitimately\n   sequential/stateful and is *not* a refactor target for series-ification).\n\n---\n\n## 3. Non-Goals\n\n- **Not** rewriting Hunchentoot request handling, Postmodern's connection\n  model, or the raw SMTP-over-TLS code in `ses.lisp` — these are genuine\n  imperative shells (sockets, connections, OS processes) and should stay\n  imperative, just kept as thin and as clearly bounded as possible.\n- **Not** introducing a heavyweight external templating engine or ORM as a\n  prerequisite — the plan below builds small in-house combinators sized to\n  this codebase, consistent with its existing dependency footprint\n  (`alexandria`, `fold`, `function`, `series`).\n- **Not** a big-bang rewrite. Every phase below ships independently, keeps\n  `(asdf:test-system :jrm-code-project)` green throughout, and preserves\n  every documented behavior (CSRF exemptions, the `next` breadcrumb, JWT\n  redirect-to-`/` semantics, wheel bootstrap, etc.) verbatim.\n\n---\n\n## 4. Incremental Migration Plan\n\nEach phase is scoped to be its own PR/commit, independently testable, and\nreversible. Phases are ordered so that later phases can build on the domain\ntypes and combinators introduced earlier ones.\n\n### Phase 1 — Immutable domain records at the database boundary\n**Files touched:** `db-auth.lisp`, call sites in `auth.lisp`, `billing.lisp`,\n`admin.lisp`.\n\n- Introduce `defstruct (user (:copier nil))` (email, password-hash,\n  totp-secret, auth-state, stripe-customer-id, stripe-subscription-id,\n  subscription-status, membership-tier, wheel-p) plus a single\n  `row->user` converter used by `get-user`, `get-user-by-customer`, and\n  `list-users`.\n- `get-user`, `list-users`, etc. keep their existing names/call signatures\n  (no handler changes yet) but return `USER` structs instead of alists.\n- Replace every `(cdr (assoc :membership-tier user-data))`-style call site\n  with `(user-membership-tier user-data)`.\n- **Payoff:** typos become compile-time `SLOT-UNBOUND`/undefined-function\n  errors instead of silent `NIL`; this is the least risky phase (pure\n  mechanical substitution) and unblocks everything else.\n- **Tests:** existing FiveAM DB tests continue to pass unchanged (they\n  already exercise these accessors indirectly); add direct unit tests for\n  `row->user` using a hand-built alist fixture, no DB required.\n\n### Phase 2 — Extract pure decision logic out of handlers\n**Files touched:** new `tier.lisp` (or fold into `jwt.lisp`), `auth.lisp`,\n`billing.lisp`.\n\n- Move `tier-rank`/` tier-meets-minimum-p` (already pure!) into a dedicated,\n  independently-tested module — they're the easiest possible first win.\n- Extract the *decision* half of `dashboard-page` into a pure\n  `dashboard-view-model` function: given a `USER`, a `checkout-status`, and a\n  `next` param, return an immutable `DASHBOARD-VIEW-MODEL` struct (tier\n  flags, badge/button HTML fragments *as data*, e.g.\n  `(:active-p t :badge :current :button :manage-subscription)` rather than\n  pre-rendered HTML — defer string rendering to Phase 5).\n- Extract the *decision* half of `stripe-webhook-handler`'s event dispatch\n  into a pure `webhook-event->db-commands` function: given the decoded JSON\n  alist, return a list of *data* describing what should happen (e.g.\n  `(:update-subscription :email ... :tier ...)`), with a thin imperative\n  loop in the handler that executes each command against `jrm-auth:*`.\n- **Payoff:** these pure functions get direct FiveAM unit tests with\n  hand-built fixtures — no Postgres, no Hunchentoot, no live Stripe webhook\n  payloads needed to verify \"a `customer.subscription.deleted` event\n  produces a cancel command for the right user.\"\n\n### Phase 3 — Composable middleware combinators\n**Files touched:** `csrf.lisp`, `jwt.lisp`, `admin.lisp`.\n\n- Replace `WITH-CSRF-PROTECTION` (macro) with a higher-order function\n  `wrap-csrf-protected` that takes a zero-argument thunk (or, once Phase 4\n  handler shape lands, a `Request -> Result` handler) and returns a value\n  representing either \"proceed\" or \"403 forbidden\" — usable both as today's\n  macro (thin `defmacro with-csrf-protection (&body body) `(funcall\n  (wrap-csrf-protected (lambda () ,@body)))`, preserving all call sites) *and*\n  directly composable with `FUNCTION:COMPOSE` for new code.\n- Unify `require-membership-tier`, `require-wheel`, and `admin.lisp`'s\n  hand-rolled `require-session-wheel` behind one combinator shape:\n  `(defun require (predicate on-failure) ...)`, parameterized by *what* to\n  check (JWT tier, session wheel bit) and *what to do on failure*\n  (redirect-to-login vs. redirect-to-dashboard vs. redirect-to-upgrade),\n  eliminating the duplicated \"return value or side-effecting-redirect-and-nil\"\n  pattern called out in §1.5.\n- **Payoff:** one audited implementation of \"check X, else redirect Y\" instead\n  of three ad hoc ones; new protected routes become one line of composition\n  instead of copy-pasted boilerplate.\n\n### Phase 4 — Consolidate Stripe catalog state into one immutable value\n**Files touched:** `stripe.lisp`.\n\n- Introduce `(defstruct stripe-catalog tier-price-ids tier-product-ids\n  price-id-tiers billing-portal-configuration-id)`.\n- Rewrite `ensure-tier-product`, `ensure-billing-portal-configuration`, and\n  `init-stripe-product` as pure functions of `(catalog, ...) -> new-catalog`\n  (the actual Stripe HTTP calls remain side effects, but the *bookkeeping*\n  that today happens via four `PUSH` es across two functions becomes one\n  `(defun catalog-with-tier (catalog tier price-id product-id) ...)`\n  returning a fresh struct).\n- `*stripe-tier-price-ids*` etc. collapse into a single `*stripe-catalog*`\n  global, set once by `init-stripe-product`, read via small accessor\n  functions (`tier-price-id`, `tier-from-price-id`) that close over it —\n  same call-site API, one source of truth underneath.\n- Extract the repeated `(and secret-key (not (string= secret-key \"\")))`\n  guard and `stripe-auth-headers` construction into a single\n  `with-stripe-credentials (headers) ...` macro/combinator so the eight\n  duplicated guard clauses in §1.6 collapse to one.\n- **Payoff:** `init-stripe-product`'s \"zero all four, then repopulate\" dance\n  disappears; the catalog can never be observed half-updated.\n\n### Phase 5 — Pure, composable HTML rendering\n**Files touched:** new `views.lisp`, `auth.lisp`, `billing.lisp`, `admin.lisp`.\n\n- Introduce small rendering combinators: `(html-page title body-html)`,\n  `(html-form action fields &key csrf-token)`, `(html-notification kind text)`\n  — pure string -> string functions, each independently testable.\n- Rewrite the Phase-2 `DASHBOARD-VIEW-MODEL` -> HTML as a pure\n  `render-dashboard` function built from the above combinators; the\n  `dashboard-page` handler shrinks to \"build view-model, issue JWT cookie,\n  call `render-dashboard`.\"\n- Apply the same pattern to `admin-members-page`/` render-member-row` (already\n  half-decomposed — `render-member-row` is already a pure function of a\n  `USER`; formalize it as `(user -> html)` operating on the Phase-1 struct)\n  and to the repeated signup/2FA/login page chrome.\n- Standardize escaping: every interpolated *user-controlled* value flows\n  through one `(html-escape value)` combinator used *inside* the rendering\n  combinators themselves, so escaping is structurally guaranteed rather than\n  convention-dependent (closes the gap in §1.3).\n- **Payoff:** view logic becomes unit-testable (\"does a LAMBDA-tier user\n  with no Stripe customer ID render a disabled CONS button and an active\n  LAMBDA badge?\") without any I/O; duplicated page chrome collapses to one\n  `html-page` call per handler.\n\n### Phase 6 — Explicit outcome values for error handling\n**Files touched:** `billing.lisp` (webhook + checkout), `chef.lisp` (Gemini\ncall), `stripe.lisp`.\n\n- Introduce a minimal `(defstruct (result (:constructor ok (value)))\n  value)` / `(defstruct (failure (:constructor err (reason))) reason)` pair\n  (or a tagged `(cons :ok value)` / `(cons :error reason)` if a full struct\n  is overkill) used by `roast-code-with-gemini`, `create-stripe-checkout-session`,\n  and the webhook command interpreter from Phase 2.\n- Handlers interpret the `RESULT`/` FAILURE` value at the shell boundary\n  (mutate `return-code*`, pick the right error string) — the pure/impure\n  split becomes: *pure code computes an outcome value; only the handler\n  performs the HTTP-visible side effect of reporting it.*\n- **Payoff:** `stripe-webhook-handler`'s `HANDLER-CASE`-wrapped cascade of\n  five DB mutations becomes: compute a list of typed commands (Phase 2),\n  execute them, collect any resulting `FAILURE` s, report once — testable end\n  to end by mocking the command-execution step.\n\n### Phase 7 — Lean on `SERIES`/` FOLD` for sequence-shaped logic\n**Files touched:** `db-auth.lisp`, `admin.lisp`, `stripe.lisp`.\n\n- `admin-members-page`'s pagination math (` offset`, `total-pages`,\n  `has-prev`/` has-next`) and `random-string`'s character-by-character\n  `SETF` loop are natural, low-risk candidates for `SERIES`-based rewrites\n  once the surrounding data is already immutable (Phases 1 and 5).\n- `generate-recovery-codes`'s `LOOP REPEAT 10 COLLECT ...` and the\n  `dolist`-based Stripe tier-plan initialization in `init-stripe-product`\n  are good `FOLD`/` SERIES` candidates once Phase 4 makes the underlying\n  state immutable.\n- Treat this phase as *opportunistic polish*, not a hard requirement — the\n  goal is internal consistency with the project's declared dependencies, not\n  a mandate to force every loop into `SERIES` syntax.\n\n### Phase 8 — Test suite rebalancing\n**Files touched:** `tests/tests.lisp`.\n\n- Once Phases 1–6 land, add a large batch of **pure unit tests** requiring no\n  Postgres/Stripe/Hunchentoot: `row->user`, `tier-meets-minimum-p`,\n  `dashboard-view-model`, `webhook-event->db-commands`, `render-dashboard`,\n  `catalog-with-tier`, the CSRF/tier middleware combinators.\n- Keep the existing live-Postgres tests (`recovery-code-verification`,\n  `stripe-database-and-routes`, `user-membership-tiers`) as the *thin*\n  integration-test layer that only needs to verify the imperative shell\n  correctly wires pure functions to real I/O — their scope should shrink\n  over time as more logic moves into directly-tested pure functions.\n- **Payoff:** CI/local runs that don't have Postgres available can still\n  exercise the majority of the codebase's actual logic; the live-DB tests\n  become a smaller, more focused confirmation layer instead of the primary\n  way anything gets tested.\n\n---\n\n## 5. Sequencing & Risk Notes\n\n- Phases are ordered by **increasing dependency on prior phases**, not by\n  file. Do not skip Phase 1 — every later phase assumes `USER` (and later\n  `STRIPE-CATALOG`) structs exist, so alist-accessor call sites should be\n  fully migrated before Phase 2 work begins on the same files.\n- Each phase should land as its own commit/PR with `(asdf:test-system\n  :jrm-code-project)` green before and after — this plan is explicitly\n  incremental so the app is deployable after every single phase.\n- No phase changes an HTTP-visible behavior (routes, redirects, cookie\n  names/lifetimes, CSRF exemption list, the `next` breadcrumb contract, or\n  JWT-missing-redirects-to-`/` semantics) — those are refactors of\n  *implementation*, not of *behavior*. Any phase whose diff would change\n  observable behavior should be split so the behavior change is its own,\n  separately-reviewed commit.\n- `ses.lisp`'s hand-rolled SMTP client is explicitly out of scope (§3) —\n  it's a sequential protocol state machine talking to a raw socket, not a\n  data-transformation pipeline, and forcing it into this plan's shape would\n  fight the grain of what it actually is.\n\n---\n\n## 6. Definition of Done\n\nThe refactor is \"complete\" (per phase, and overall) when:\n\n1. No handler function directly calls Postmodern, Stripe's HTTP API, or\n   builds a final HTML response string in the same function body that also\n   makes the authorization/business decision — each of those three concerns\n   is a separately named, separately testable function.\n2. No `(cdr (assoc :keyword row))` pattern remains outside the Phase-1\n   `row->*` converter functions.\n3. Every cross-cutting concern (CSRF, session auth, JWT tier-gating,\n   wheel-gating) is expressed as a composable function over a handler, with\n   exactly one implementation per concern (no duplicated\n   `require-session-wheel`-style reimplementations).\n4. Stripe's in-memory catalog is one immutable value with one owning\n   global, not four independently-mutated globals.\n5. A newly-added contributor can run the pure-function unit tests (Phase 8)\n   with zero external services configured and still exercise the majority of\n   the application's actual decision logic.\n```\n\nAs you can see, this is a very detailed and serious plan. Come to think about it, I should have done the functional refactor sooner so that it would not have needed such an extensive plan.\n\nOnce the plan is written, I prompt the LLM to implement each phase\nof the plan in turn. The prompt is straightforward: *Read\nFUNCTIONAL_REFACTORING.md and implement the next phase of the\nIncremental Migration plan.* I use this prompt over and over until\nall the phases have been implemented. I monitor the progress of the\nLLM to make sure it is not getting lost in the weeds.\n\nFunctional refactoring is expensive. It chews through a ton of tokens, and it may seem like a waste because if it is done correctly, the code will behave exactly the same as it did before the refactoring. I have done a functional refactoring on most of my vibe coding projects and I have been pleased with the results. The generated code is surprisingly good, and subsequent `vibing` seems to be quite easy for the LLM.\n\nOnce the functional refactoring is complete, the LLM will tend to write future code in a more functional style. It is a pattern matcher, so if it sees functional patterns, it will tend to mimic them. But imperative code will creep back in over time because the LLM is so heavily trained on imperative code. I have found that occasionally prompting the LLM to refactor the code to be more functional is useful. Subsequent functional refactorings are much easier than the first functional refactoring because the core code is already functional and large refactorings are not needed.\n\nIf you are not a functional programmer, I expect that you will find this to be a massive waste of time with a lot of code churn. But if you are a functional programmer, I bet you'll be pleased with the results - I have been.", "url": "https://wpnews.pro/news/the-functional-refactoring-pass", "canonical_source": "https://funcall.blogspot.com/2026/08/the-functional-refactoring-pass.html", "published_at": "2026-08-25 07:00:00+00:00", "updated_at": "2026-08-25 07:13:10.656172+00:00", "lang": "en", "topics": ["large-language-models", "developer-tools", "artificial-intelligence"], "entities": ["jrm-code-project.com", "Hunchentoot"], "alternates": {"html": "https://wpnews.pro/news/the-functional-refactoring-pass", "markdown": "https://wpnews.pro/news/the-functional-refactoring-pass.md", "text": "https://wpnews.pro/news/the-functional-refactoring-pass.txt", "jsonld": "https://wpnews.pro/news/the-functional-refactoring-pass.jsonld"}}