I’m in the process of refactoring a microservice that’s starting to become not-so-micro with 35 endpoints built by several developers at different times. At that size, it’s already starting to feel like a mess and it’s time to clean it up.
Below are the basic techniques I plan to use that I believe will allow our code to grow indefinitely. I realize this isn’t an example I can point you to, but in reality large codebases can’t really follow templates anyway. Templates are just a starting point. I developed a lot of these ideas while wrestling with 18-year legacy code at Tumblr, eventually realized that they’re just basic functional programming techniques, then quit to become a Clojure developer.
- Lift side effects like API calls to the entrypoints of the app. Remove all possible logic from this section of code so that writing tests around it won’t be necessary.
- Gather the data you need into a map (often called a “context” map) that can be passed down through pure functions.
- Store example data in EDN files, and use write your tests around these files. After a while you’ll find that instead of adding tests, you’ll often simply be adding more test data.
- Write tests to cover the behaviors of only the top-level primary (pure) functions.
- Use pure functions for everything except required side effects.
- Always be refactoring into a tree structure that mirrors the natural shape of nested functions.
- Break up logic into services, directories, and functions that arise naturally from your flow of logic, data, and use cases.
- Wait to destructure data until it’s really necessary, near the leaves of your code tree.
- Move shared code into utility files/directories as needed. Bubble these up the tree as needed throughout more of the codebase.
Things to avoid
- Shared state. Passing data through functions instead preserves purity, making reasoning and testing much easier. If your code is becoming too deep, think about how you can flatten it naturally with pure functions.
- Abstractions meant to reduce the number of lines of code at the expense of increased cognitive overhead. Think about the poor soul who’ll come in two years later and just needs to fix a bug. They should be able to drop into any function at any point in the app and understand everything they need to based solely on the function’s inputs and output.
-
defprotocol
. Programmers trained in object-oriented design tend to go for this, resulting in a lot of needless abstraction that breaks the natural flow of data through an app, making it hard to trace and test. - Fancy techniques like currying, chaining, partial application, and the like, which have their places, but tend to make code confusing for developers new to the application.
I hope the pattern here is clear. Pass data through a tree of pure functions, refactor as it grows, and push all side effects to the root and leaves. This is basically what I consider to be true functional programming. And even though it may not be possible to refactor a huge app into a functional structure all at once, having the overall vision in mind can give you something to work toward.