Simplify your code: Functional core, imperative shell

(testing.googleblog.com)

Comments

socketcluster 23 hours ago
Even large companies are still grasping at straws when it comes to good code. Meanwhile there are articles I wrote years ago which explain clearly from first principles why the correct philosophy is "Generic core, specific shell."

I actually remember early in my career working for a small engineering/manufacturing prototyping firm which did its own software, there was a senior developer there who didn't speak very good English but he kept insisting that the "Business layer" should be on top. How right he was. I couldn't imagine how much wisdom and experience was packed in such simple, malformed sentences. Nothing else matters really. Functional vs imperative is a very minor point IMO, mostly a distraction.

novoreorx 10 hours ago
While I largely agree with the philosophy, the example provided is not very practical. The code snippet `getExpiredUsers(db.getUsers(), Date.now())` is unlikely to occur in real-life scenarios. No one would retrieve all users and then filter them within the program. Instead, it should be `db.getExpiredUsers(Date.now())`.

We should never be too extreme on anything, otherwise it would turn good into bad.

hinkley 27 October 2025
Bertrand Meyer suggested another way to consider this that ends up in a similar place.

For concerns of code complexity and verification, code that asks a question and code that acts on the answers should be separated. Asking can be done as pure code, and if done as such, only ever needs unit tests. The doing is the imperative part, and it requires much slower tests that are much more expensive to evolve with your changing requirements and system design.

The one place this advice falls down is security - having functions that do things without verifying preconditions are exploitable, and they are easy to accidentally expose to third party code through the addition of subsequent features, even if initially they are unreachable. Sun biffed this way a couple of times with Java.

But for non crosscutting concerns this advice can also be a step toward FC/IS, both in structuring the code and acclimating devs to the paradigm. Because you can start extracting pure code sections in place.

hackthemack 27 October 2025
I never liked encountering code that chains functions calls together like this

email.bulkSend(generateExpiryEmails(getExpiredUsers(db.getUsers(), Date.now())));

Many times, it has confused my co-workers when an error creeps in in regards to where is the error happening and why? Of course, this could just be because I have always worked with low effort co-workers, hard to say.

I have to wonder if programming should have kept pascals distinction between functions that only return one thing and procedures that go off and manipulate other things and do not give a return value.

https://docs.pascal65.org/en/latest/langref/funcproc/

procaryote 13 hours ago
I like the general idea, but unless you're assuming some very clever language or even more clever ORM that fixes things implicitly, wouldn't

    email.bulkSend(generateReminderEmails(getExpiredUsers(db.getUsers(), fiveDaysFromNow)));
get all users and then filter out the few that will expire in 5 days, on a code level? That doesn't sound like it would scale
metalrain 17 hours ago
I like the idea but the example doesn't make much sense.

In what application would you load all users into memory from database and then filter them with TypeScript functions? And that is the problem with the otherwise sound idea "Functional core, imperative shell". The shell penetrates the core.

Maybe some filters don't match the way database is laid out, what if you have a lot of users, how do you deal with email batching and error handing?

So you have to write the functional core with the side effect context in mind, for example using query builder or DSL that matches the database conventions. Then weave it with the intricacies of your email sender logic, maybe you want iterator over the right size batches of emails to send at once, can it send multiple batches in parallel?

sherinjosephroy 16 hours ago
I like the idea of separating your “business logic” (the functional core) from the glue code that interacts with the outside world (the imperative shell). It makes the core easier to test and reason about.

But also: the challenge is knowing where to draw the line. In real systems you’ll still have messy side-effects, transactions, performance constraints — so you might end up in a mixed bag anyway. The principle is solid, but the practical trade-offs matter.

CharlieDigital 19 hours ago
I wrote our AI agents code with a functional core + imperative shell and I have to agree: this approach yields much faster cycle times because you can run pure unit tests and it makes testing a lot easier.

We have tens of thousands of lines of code for the platform and millions of workflow runs through them with no production errors coming from the core agent runtime which manages workflow state, variables, rehydration (suspend + resume). All of the errors and fragility are at the imperative shell (usually integrations).

Some of the examples in this thread I think get it wrong.

    db.getUsers() |> filter(User.isExpired(Date.now()) |> map(generateExpiryEmail) |> email.bulkSend
This is already wrong because the call already starts with I/O; flip it and it makes a lot more sense.

What you really want is (in TS, as an example):

    bulkSend(
      userFn: () => user[],
      filterFn: (user: User) => bool,
      expiryEmailProducerFn: (user: User) => Email,
      senderFn: (email: Email) => string
    ) 
The effect of this is that the inner logic of `bulkSend` is completely decoupled from I/O and external logic. Now there's no need for mocking or integration tests because it is possible to use pure unit tests by simply swapping out the functions. I can easily unit test `bulkSend` because I don't need to mock anything or know about the inner behavior.

I chose this approach because writing integration tests with LLM calls would make the testing run too slowly (and costly!) so most of the interaction with the LLM is simply a function passed into our core where there's a lot of logic of parsing and moving variables and state around. You can see here that you no longer need mocks and no longer need to spy on calls because in the unit test, you can pass in whatever function you need and you can simply observe if the function was called correctly without a spy.

It is easier than most folks think to adopt -- even in imperative languages -- by simply getting comfortable working with functions at the interfaces of your core API. Wherever you have I/O or a parameter that would be obtained from I/O (database call), replace it with a function that returns the data instead. Now you can write a pure unit test by just passing in a function in the test.

I am very surprised how many of the devs on the team never write code that passes a function down.

QuadmasterXLII 19 hours ago
I would argue that the real key is to have a distinct core and shell, and to hold the core to a much higher standard of quality than the shell. In this article, being "functional" is just serving as a proxy for code quality.
ryangibb 16 hours ago
The MirageOS project [0] is a great collection of functionality pure OCaml libraries that are useful outside of unikernels. I've used the DNS library with an effectful layer for various nameserver experiments [1].

[0] https://mirage.io/

[1] https://ryan.freumh.org/eon.html

johnrob 22 hours ago
Functions can have complexity or side effects, but not both.
fsmv 6 hours ago
Google writes articles like these every week and hangs them in the bathroom. It's meant to be a quick one page tip thing. That's why the example isn't super realistic, it has to be short.

There's a link with more info at the top. I'm not sure why this one in particular made it to the front page of HN.

rockyj 12 hours ago
Interestingly, I have been harping on this for a while. Recently wrote a blog on how to separate business logic from infrastructure code and tie them together by composing functions together - https://rockyj-blogs.web.app/2025/10/25/result-monad.html

I also see that lately "code quality" is the least concern of most (even software product) companies, just ask AI to write code in a single file / module / class - then launch feature and fix if you have to. I could see that in a few years things will be extremely messy (but who can say).

jackbravo 22 hours ago
Reminds me of this clean architecture talk with Python explains this very well: https://www.youtube.com/watch?v=DJtef410XaM
smusamashah 6 hours ago
How does it fit with Tell Don't Ask https://martinfowler.com/bliki/TellDontAsk.html

Or is it that the example in the article is a bit poor?

kitd 12 hours ago
I never really got into Haskell in a big way, but one of the things I liked about the Haskell Wikibook [1] was how they presented Haskell code as being either in pure form or "do" form, and how the latter orchestrates the former, much as presented here. To a beginner like me not interested in monads etc, this was a very simple and explicit way of approaching coding in Haskell.

[1] https://en.wikibooks.org/wiki/Haskell

foobarian 7 hours ago
This sounds to me like the old hexagonal architecture [1]

[1] https://en.wikipedia.org/wiki/Hexagonal_architecture_(softwa...

rcleveng 27 October 2025
If your language supports generators, this works a lot better than making copies of the entire dataset too.
zkmon 27 October 2025
I think it's just your way of looking at things.

What if a FCF (functional core function) calls another FCF which calls another FCF? Or do we do we rule out such calls?

Object Orientation is only a skin-deep thing and it boils down to functions with call stack. The functions, in turn, boil down to a sequenced list of statements with IF and GOTO here and there. All that boils boils down to machine instructions.

So, at function level, it's all a tree of calls all the way down. Not just two layers of crust and core.

urxvtcd 14 hours ago
I have written a small system in Elixir adhering to FCIS. Not used to the approach, I was pretty slow and sometimes it felt like jumping through hoops set by myself, lol, but I loved it, the code was very clean, testable, and refactorable. Highly recommend it as an exercise, it was surprising just how much state and IO can be pushed out.
pjmlp 15 hours ago
All nice ideas, that unfortunately don't get appreciated on the age of offshoring and vibe coding.

Have to ship it non matter what.

lucifer153 18 hours ago
This is same idea with onion architect in "Grokking Simplicity: Taming Complex Software with Functional Thinking Book by Eric Normand"
droningparrot 18 hours ago
Haskell practically encourages this style of programming. Any function that touches IO needs to wrap outputs with an appropriate monad. It becomes easier to push all IO out to the edges of your program and keep your core purely functional with no monads
semiinfinitely 23 hours ago
this looks like a post from 2007 im shocked at the date
postepowanieadm 22 hours ago
Something like that was popular in perl world: functional core, oop external interface.
svat 14 hours ago
Another good blog post that is IMO in the same vein: https://lambdaisland.com/blog/2022-03-10-mechanism-vs-policy (“Improve your code by separating mechanism from policy”). This blends harmoniously with “functional core, imperative shell”—the "mechanism" code is the "functional core", and the "policy" code is the "imperative shell"—and also a little bit with John Ousterhout's idea in A Philosophy of Software Design of "deep modules" (in this context, don't put policy stuff, i.e. arbitrary decisions, inside the module).
taeric 27 October 2025
This works right up to the point where you try to make the code to support opening transactions functional. :D

Some things are flat out imperative in nature. Open/close/acquire/release all come to mind. Yes, the RAI pattern is nice. But it seems to imply the opposite? Functional shell over an imperative core. Indeed, the general idea of imperative assembly comes to mind as the ultimate "core" for most software.

Edit: I certainly think having some sort of affordance in place to indicate if you are in different sections is nice.

bitwize 23 hours ago
I invented this pattern when I was working on a small ecommerce system (written in Scheme, yay!) in the early 2000s. It just became much easier to do all the pricing calculations, which were subject to market conditions and customer choices, if I broke it up into steps and verified each step as a side-effect-free, data-in-data-out function.

Of course by "invented" I mean that far smarter people than me probably invented it far earlier, kinda like how I "invented" intrusive linked lists in my mid-teens to manage the set of sprites for a game. The idea came from my head as the most natural solution to the problem. But it did happen well before the programming blogosphere started making the pattern popular.

wslh 20 hours ago
I don't really like the example (and it's from Google) because, beyond the general concept, it seems like the trigger for sending emails is calling bulkSend with Date.now() instead of the user actually triggering an email when it's really expired: user.subscriptionEndDate change to < Date.now().
chairhairair 21 hours ago
The for-loop is just better.
diamondtin 22 hours ago
destory all software
itsthecourier 20 hours ago
that's nice, so should I get all the db users and then filter them in app?
vivzkestrel 17 hours ago
db.getUsers() I am sorry what? Who in their right mind loads all users from the database and then filters out the expired subscription ones. Shouldn't the database query do this?
SafeDusk 22 hours ago
One of the core design principles at https://github.com/aperoc/toolkami