The Functional Refactoring Pass 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. This is an anecdote, not a data point, yet. I'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. Functional 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. I 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. Unfortunately, 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. If you start a vibe coding project ab initio , you'll get a lot of imperative, procedural code, and the LLM will have more and more difficulty reasoning about it as the project grows. To stop this from happening, I prompt the LLM to refactor the code to be more functional. I do this fairly early on in the project, once the project starts to show signs of life. The 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: Make 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 The 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. Functional Refactor Plan for jrm-code-project Author's lens: Senior Functional Programming Architect Scope: package.lisp , csrf.lisp , server.lisp , auth.lisp , billing.lisp , admin.lisp , chef.lisp , db-auth.lisp , stripe.lisp , jwt.lisp , totp.lisp , ses.lisp Status: Complete. Phases 1-8 below have all landed as separate, individually-tested commits; the codebase now reflects this plan. The phase write-ups are retained as historical design-rationale documentation -- comments elsewhere in the codebase that cite "FUNCTIONAL REFACTOR.md Phase N" are pointing at finished work, not an in-progress migration. --- 0. Framing This codebase is a working, well-organized Hunchentoot application the recent file split into csrf / server / auth / billing / admin / chef was a good move along the separation-of-concerns axis . But every one of those modules is written in a straight-line, imperative-shell-with-no-functional-core style: HTTP handling, session mutation, SQL, third-party HTTP calls, HTML rendering, and business rules are all fused into single DEFUN s that read the world, mutate the world, and print strings, in one undifferentiated breath. The project already imports SERIES , FOLD , FUNCTION compose/inverse , and NAMED-LET — real functional-programming firepower — via shadowing imports in package.lisp . Almost none of it is actually used in the handler code; the shadowed LET / DEFUN / LET / MULTIPLE-VALUE-BIND forms are used as drop-in replacements for their vanilla CL counterparts, not as a foundation for a different style of programming. That's the central irony this plan addresses: the tools for a functional architecture are already a dependency of the system; they're just not driving any design decisions yet. The plan below does not propose rewriting Hunchentoot, Postmodern, or Stripe's HTTP API into something pure — those are unavoidably effectful boundaries. It proposes pushing effects to the edges a thin imperative shell and pulling everything else — validation, view-model construction, tier/authorization logic, Stripe payload shaping, HTML rendering — into a pure, immutable, composable core that can be unit-tested without a database, without Hunchentoot, and without live Stripe credentials. --- 1. Anti-Pattern Catalog current state 1.1 Global mutable state used as an implicit parameter-passing channel - acceptor server.lisp — mutated by start-server / stop-server . - stripe-tier-price-ids , stripe-tier-product-ids , stripe-price-id-tiers , stripe-billing-portal-configuration-id stripe.lisp — four separate DEFVAR s, populated by side-effecting PUSH inside ensure-tier-product and ensure-billing-portal-configuration , and read by unrelated functions tier-price-id , tier-from-price-id , create-billing-portal-session scattered throughout the file. This is really one piece of "Stripe catalog" data, represented as four uncoordinated globals that must be mutated in lock-step see init-stripe-product , which zeroes all four by hand before repopulating them — a classic sign that a single immutable value is trying to escape. - Every handler reaches into hunchentoot:session-value / hunchentoot:cookie-in as ambient dynamic state rather than being handed an explicit Request value. E.g. dashboard-page auth.lisp pulls :authenticated-user from the session, challenge-2fa-page reads/writes :limbo-email and :post-login-redirect via setf in the middle of a rendering branch. 1.2 God-functions that fuse I/O, business logic, and presentation Nearly every hunchentoot:define-easy-handler in auth.lisp , billing.lisp , and admin.lisp does all of the following in one function body: 1. Read ambient state session, cookies, POST params . 2. Validate/branch on it. 3. Call the database or an external HTTP API side effect 1 . 4. Mutate session/cookie state side effect 2 . 5. Build and return an HTML string via nested FORMAT calls presentation . dashboard-page auth.lisp is the extreme case: ~250 lines mixing tier math, JWT issuance a side effect , a conditional redirect, and a giant FORMAT template with 20+ interpolation arguments computed inline. There is no way to unit-test "what should the dashboard tier grid look like for a LAMBDA-tier user with a Stripe customer ID" without spinning up Hunchentoot, a session, and a database row. stripe-webhook-handler billing.lisp mixes signature verification, JSON parsing, event-type dispatch, and five different DB-mutation call sites in one COND , with logging FORMAT calls interleaved — untestable without a live or heavily mocked Postgres connection and a hand-built JSON fixture. 1.3 Stringly-typed, un-composable HTML rendering Every page is a hand-written FORMAT nil "