About a year ago I wrote a whole series on SOLID. It was fun. Some people on Reddit were less than thrilled. But I learned a lot, and it sent me down a rabbit hole of software architecture that I’m still happily stuck in.
Since then I’ve spent way more time in Elm. And looking back at those React (++; some where Kotlin and Go as well) examples with FP-tinted glasses, I keep having the same reaction: most of these problems just don’t exist in functional programming.
Not because FP developers are smarter, but because the language won’t let you make certain mistakes in the first place. SRP is the principle where this is most obvious.
Quick refresher Link to heading
Uncle Bob’s Single Responsibility Principle says a module should have only one reason to change. In my React SRP post, I showed the classic monolith component that fetches data, manages loading states, handles form submissions, and renders UI, all in one place:
// The classic anti-pattern from the original post
const UserProfile = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUser();
}, []);
const fetchUser = async () => {
try {
const response = await fetch("/api/user");
const data = await response.json();
setUser(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
// ... plus form handling, rendering, the whole kitchen sink
};
The fix in React was to split this into hooks, container components, and presentation components. Discipline and patterns to achieve what should be natural separation.
This can’t happen in Elm Link to heading
I mean that literally. You cannot write the above in Elm. Not because of some linting rule or team convention, but because the language won’t let you.
Side effects (like HTTP requests) aren’t something your view function does. They’re values your update function returns. The view is a pure function from model to HTML. It can’t fetch data. It can’t mutate state. All it can do is describe what the UI looks like for a given state.
Here’s the same user profile in Elm:
type alias Model =
{ user : RemoteData Http.Error User
}
type Msg
= GotUser (Result Http.Error User)
init : ( Model, Cmd Msg )
init =
( { user = Loading }
, fetchUser
)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotUser result ->
( { model | user = RemoteData.fromResult result }
, Cmd.none
)
view : Model -> Html Msg
view model =
case model.user of
NotAsked ->
text ""
Loading ->
text "Loading..."
Failure err ->
text "Something went wrong"
Success user ->
div []
[ h1 [] [ text user.name ]
, p [] [ text user.email ]
]
fetchUser : Cmd Msg
fetchUser =
Http.get
{ url = "/api/user"
, expect = Http.expectJson GotUser userDecoder
}
Those five responsibilities from the React anti-pattern? They got separated by the architecture itself. Data fetching is a Cmd value. Loading states are modeled in the RemoteData type (which I wrote about before in a different context). State transitions and presentation are in different functions by definition. I didn’t have to choose to separate these. TEA made that decision for me.
Pure functions and SRP Link to heading
A pure function takes input and returns output. That’s it. It can’t secretly fire off a database call or send an email. Which means it tends to do one thing by nature.
Compare:
// TypeScript: this function has hidden responsibilities
async function processOrder(order: Order) {
const validated = validateOrder(order);
await saveToDatabase(validated);
await sendConfirmationEmail(validated);
logger.info(`Order ${order.id} processed`);
}
-- Elm: each function does one thing
validateOrder : UnvalidatedOrder -> Result (List ValidationError) ValidOrder
validateOrder order =
-- just validates, returns a Result
processOrder : ValidOrder -> List (Cmd Msg)
processOrder order =
[ saveOrder order
, sendConfirmation order
]
The TypeScript version does four different things with side effects tangled together. The Elm version? validateOrder validates. processOrder describes what effects should happen. Neither function does the side effects; they’re descriptions that the runtime handles.
When your functions can’t do things, only describe things, SRP kind of takes care of itself.
“But what about big update functions?” Link to heading
You can write a 500-line update function in Elm. I have. It’s fine.
Think about what it’s actually doing: it’s a pure function that takes a message and a model and returns a new model plus some commands. One concern. One reason to change: the state transition logic for this page. Each branch of the case expression is a different input, not a different responsibility. A long update is a long pure function. A monolith React component is a tangle of concerns with runtime side effects. Those aren’t the same problem.
That said, when an update function gets big enough that scrolling becomes annoying, there’s a pattern I’ve grown fond of at work: extensible record types.
Say you’ve got a page with a form, a sidebar, search results, and user preferences. Instead of passing the full Model to every helper, you constrain each one to just the fields it needs using extensible record types:
clearFormInput : { a | formInput : String } -> { a | formInput : String }
toggleSidebar : { a | sidebarOpen : Bool } -> { a | sidebarOpen : Bool }
applySearchFilter : String -> { a | results : List Item, activeFilter : String } -> { a | results : List Item, activeFilter : String }
resetFeedback : { a | rating : Maybe Int, comment : String } -> { a | rating : Maybe Int, comment : String }
Each of these accepts your full Model (because Model has all those fields), but can only read and modify the fields in its signature. clearFormInput can’t accidentally mess with your sidebar state. toggleSidebar can’t touch the search results. The compiler enforces this.
I love this. One glance at the type signature and you know which fields a function can touch. The types are the SRP documentation.
Is any of this required? No. A big case expression in a single update function works perfectly well, and it’s what the Elm guide recommends you start with. But when things grow, partial records are a great way to keep things tidy without introducing the kind of indirection that makes code harder to follow. Richard Feldman’s “Scaling Elm Apps” talk covers this pattern and other scaling strategies really well.
Constraints that free you Link to heading
I keep coming back to this: constraints are liberating. (I know, I know, it sounds like a motivational poster. Bear with me.)
In React, your component can do anything. Fetch data, manage state, trigger side effects, render UI, all in the same function body. You need discipline and team conventions to keep things separated, and in my experience those conventions are the first thing to go when deadlines hit.
Elm doesn’t give you that option. The view can’t perform side effects. State changes go through update. Effects are return values. You can’t tangle things together even if you’re in a hurry at 11pm trying to ship something before the sprint ends. (Not that I would know anything about that.)
SRP stops being a principle you need to remember and becomes a property of the code you write. ¯\_(ツ)_/¯
What’s next Link to heading
This is the first post in what I’m calling “SOLID in FP,” revisiting each principle through a functional lens. Some principles (like SRP) become almost trivial. Others (like Liskov Substitution, which was all about inheritance, a thing Elm doesn’t even have) get really interesting when you reframe them.
I have no idea if I’ll manage to make all five compelling. But I just might.
Up next: the Open-Closed Principle, where union types and pattern matching change the game completely compared to the composition-and-props approach we used in React.