A Simple Rule for Functional Architecture
Codebases tend to get messy. Different developers contribute at different times. “Temporary” hacks help meet sprint milestones. The team learns more about the domain. Requirements change. Entropy increases.
How do we keep our code clean and flexible amidst all this wear and tear?
Simple rules are best. Simple rules give rise to complex, intelligent behavior. If team members have to read a tome of nuanced design directives to contribute to a codebase, it’s going to be hard to govern and sustain that over time. Developers should be empowered to make local decisions rather than having to run everything by an architect.
Here’s a simple rule of thumb that goes a long way to keep a codebase manageable. Of all the design principles, I believe this one gives the most bang for its buck:
- Separate pure from impure code.
- Maximize pure code.
Pure code has no side effects. Given the same input, it always returns the same output. Pure code is easy to test, reason about, and reuse. You can understand a pure function in isolation. You can change it without breaking other things. No need to trace through the codebase to understand its impact on the outside world.
Impure code has side effects. It writes to disk or sends an email or makes a network request. These require mocking or stubbing to test. Since it impacts the outside world, you can’t reason locally about it. You have to understand its impact in the context of the rest of the system. It’s harder to reuse, because it’s more tightly coupled to its environment.
Overall, pure functions make for a more stable and solid building block. Impure code is more brittle and fragile. It’s harder to change without breaking something.
Some interesting things start happening to a codebase when you follow this rule of thumb. It starts to accrue layers like an onion. The core is pure. The outer layers are impure. The dependencies point inward, with the outer layers depending on the core. We may call this the “onion architecture” or “functional core, imperative shell.”
The rate of change of the layers becomes slower the deeper you go. The core remains stable while the shell around it changes. The core will be your domain logic. The shell will be your UI, database, network, and so on. And when you do need to change the domain logic, you’ll have an easier time changing pure functions than if the behavior is scattered and mixed into a bunch of side-effecting code.
So on a practical level, how do we separate pure from impure? Check out A heuristic for separating concerns for a discussion of the mechanics of extracting pure functions. In upcoming posts, I’ll further discuss this architecture and how to apply it in practice.