Single Responsibility Principle - what does it mean to you and your Elixir apps?

I think @pragdave might be importing some wisdom from his object-oriented life ;). I was thinking about it when I first opened Elixir’s own source code files, or sources of prominent Elixir libraries like Ecto, and was shocked by the large files containing hundreds of lines of code (+many more lines of documentation). In Erlang they even add a lot of unit tests at the end of the file to keep the tests close to the module they test! (moduledoc in Elixir but more popular I think)

But then, I realized what is a reason for having a short classes and it suddenly made more sense. The reason why we want to keep our classes short and clean is that we can mentally model how instances of this class will behave, and how they will modify the object’s attributes in it’s life cycle. So - we couple state to the functions that operate on this state, and we want this to be as small as possible - otherwise we easily loose track of how this works, and bugs sneak in.

In Elixir, the module has no state. The main reason for keeping classes short does not apply here anymore. Of course, it becomes tiresome to work with files that are thousands lines long, but there is no such thing as internal state of object to be concerned with.

Having said the above: again, the object-oriented wisdom of keeping classes short does have it’s place in Elixir, and again this is when you create processes (GenServer etc). I think it makes total sense to keep them short in terms of lines of code and focused on one thing.

6 Likes

I like this picture taken from Scott Wlaschin youtube video on functional design pattern.

But I also enjoyed Uncle Bob’s reply to it.

http://blog.cleancoder.com/uncle-bob/2014/11/24/FPvsOO.html

5 Likes

Personally I feel shorter modules can impact readability and therefore make it easier to ‘make sense’ of what’s going on. I also think they’re less off-putting and fatigue-inducing; when faced with having to make sense of one huge file or a few smaller ones, I think the smaller ones are more digestible and therefore might be more appealing.

There is another example in Dave’s course when working with Genservers. He says one way to go about things is to use a single file, but his preferred way used three. When comparing the two, I preferred his way of breaking things apart too.

I know it probably sounds like I’m on commission (I’m not! I just really REALLY love it!) but I highly recommend his course, even to experienced developers like yourself - if nothing more than to see how other experienced developers are going about things :slight_smile:

1 Like

I… thanks for the kind words but I’m still learning all things Erlang & Elixir :smiley:

1 Like

In that case I definitely recommend it :003: you won’t be disappointed, I promise :lol:

1 Like

I think it may have more to do with where you are (in reference to understanding the intent of SRP - as it seems to be a new concept for you). If you go by the Dreyfus model of skill acquisition a “novice” will tend towards “rigid adherence to taught rules or plans” - but in design/architecture things are rarely black and white - everything is about making tradeoffs. So the real “mental overhead” comes from the fact that there are numerous design guidelines that have to be balanced to meet the requirements for a particular situation - some will be met while others will be deliberately violated to gain some highly valued benefit.

For Example, Active Record violates SRP because it mixes the responsibilities of business logic AND persistence management. Yet it is listed as a viable Enterprise Application Pattern:
####Pros:

  • Good choice for domain logic that isn’t too complex, such as creates, reads, updates, and deletes.
  • Simplicity; easy to build, easy to understand.
  • Typically less duplication than Transaction Scripts.

####Cons:

  • Works well only if objects correspond directly to the database tables: an isomorphic schema.
  • Complex domain logic using direct relationships, collections, inheritance doesn’t easily map onto Active Record.
  • Couples the object design to the database design; makes it more difficult to refactor either (independently) as a project goes forward.

i.e. if the “cons” aren’t an issue for you (ever) and you value the “simplicity” then Active Record is a valid design choice (and investing in the higher effort alternatives is a waste).

Collectively all these design principles and guidelines are supposed to give you a sense of all the opposing forces and deliberate tradeoffs that come into play when trying to arrive at a “good design”. Ultimately no good comes from applying these principles and guidelines dogmatically.

Aside: Some people don’t like the SRP

Frankly the examples seemed to be easier to justify with “separation of concerns”:

  • Persistence
  • Domain logic
  • UI (+ reports)

Influenced by DDD “Domain Logic” is the focal point - it dictates the data types and is the dominating influence for the interface designs. Given that the domain logic only deals in domain types, SQL statements belong with persistence (provided you’re even using an RDBMS). Ideally the public API exposed by the domain logic should be able to support a whole range of UIs (web, mobile, desktop, command-line, etc. - though possibly with support BFFs) - but even when keeping it simple with a single frontend, domain logic doesn’t belong in the UI (though validation logic may be duplicated there).

The example with the calculations that differed for the clerical and manager report seemed contrived. It’s valid to say that not everything that “looks the same” is automatically duplication - but the example pre-supposes the existence of distinct domain concepts that somehow had the same calculations up to this point.

I think when Scott Wlaschin jovially states (SRP => functions) he is referring to the typically highly focused nature of short, pure functions.

Modules can be used in the role of namespaces. I think the closest thing to a class is a module that defines a struct. That module should focus on the struct and the data within the struct should be focused.

Elsewhere you stated:

To me this read like you felt that Changeset conflated the notion of being a changeset with the responsibility of validation - which sounds like a violation of SRP or at least a lack of cohesion. Yet lots of people seem to appreciate the convenience afforded to them by this conflation (i.e. they are willing to accept this tradeoff).

4 Likes

You, sir, made really good observation here.

I personally believe that on some higher-level of thinking many ideas transcend paradigms. In particular, I think that a large module which deals with many things is more problematic to read and understand.

A typical example I see is a case where complex GenServer state is handled directly in the GenServer module. By moving the state manipulation to a separate module, we can separate the domain logic from the time-flow logic, and this allows us to study each aspect of our system in isolation.

For a more concrete example, see my example blackjack code from my To spawn, or not to spawn? article. The domain management is done in separate modules, which means that domain code is not polluted with GenServer and vice versa.

IMO the case for short classes (and also modules and functions) is to allow the reader to understand a single concept without worrying about non-essential details. Being able to understand a domain model without caring how it’s used from the rest of the system simplifies the code analysis, debugging, and extension.

I should also mention that I’m not a purist here. For example, if the process state is simple enough, I just bundle it inside the GenServer. If the model grows, then I split.

But tl;dr I believe that SRP and similar ideas, vague as they are, still apply in FP.

8 Likes

SRP is everything! :open_mouth: everything!
Level 1 is the application.
Level 2 are the modules. They break the application apart and their names describe the single problem every one of them is trying to solve.
Level 3 are high level functions. They break the module problem into small steps and their names describe those steps.
Level 4 are the low level functions. They do the actual work and are often one liners, at worst 3.

So when I read my code I go to the main file and look at the module names to recall what’s going on.
Then it’s just a matter of following the module names and function names down the line to add or change stuff.

I love this approach because I don’t like writing tests and I don’t like writing comments.
Why write a unit test for the + sign? It will add two numbers (in every respectable language) right?
Then come the function names which are a living documentation, no need for comments.
def add_two_numbers do end done!
Higher level functions only describe the steps taken to solve a problem, no work is done there.
If there’s a problem, you can instantly tell where it is, because all the work is done in low level functions, and if it’s a logic problem, then it’s a logic problem, and it’s easy to spot because all low level functions must work, which they always should if done right. 1+1 should always work right? (in every respectable language)

In other words, I feel like “comments”, “unit tests”, “types”, “specs”, and so on, are the crutches of the lazy programmer, who didn’t break the top problem into small modules,didn’t write functions that describe the steps taken to solve the problem, didn’t write low level functions that are so simple it’s impossible to have an error, and they just want to write code like this “oifaofiapodihfhopahpofhopasphofhogihasjuivfhus” and then go oh my unit test will help me, and look at all the types I have, the compiler will catch stuff, and check out my 5 pages of unintelligible comments describing my amazing “isdjfhpsaohgapoigaofghpg” code.

Why write comments when I can write verbose names for modules and functions, and if names become too long, then it’s obvious the module or function is trying to solve too much, break it down further, done!

Anyway, I bet I’ll get some heat for this, but it’s ok :slight_smile: I write my code, I love my code, I love, love, love, my code. Did I mention that I love my code? I want to hire a racing horse to kick me on the head when reading other people’s code <3 not your code dear reader! Your code is nice because it’s Elixir and SRP ; )

3 Likes

@Deithrian You’ve never tried type-based programming then. ^.^

2 Likes

The true test comes when you look at the code a year later :slight_smile: Do you still love your code? If so well done!

4 Likes

I just prefer “the_word_apple” and “the_adjective_red” vs “String a = ‘apple’”, “String b = ‘red’”

“the_word_apple” + 3. hmmm
the_word_apple = the_word_hamburger. hmmmm
put_item_in_object(the_word_apple, the_object_basket)

a+3. why not?
a = c. sure! What was “c” again?
g(a,n). Oh no! “g” was milk the cows, buy a newspaper, say the moon is red, 4356334, and “n” was launch the rockets? Dang it…
I’m exaggerating, but not by much. :slight_smile:
(refactoring ftw)

There’s a fine line between good names and Hungarian notation (in whatever language) because you don’t have types.

1 Like

None of that has anything whatsoever to do with type driven programming. ^.^

Type driven programming would be something like, oh if we were to emulate Phoenix’s :safe html renderer:


type safe = SafeHTML of string

let make_input_safe input =
  SafeHTML (escape_html input)

let raw input = SafeHTML raw

let render_html (SafeHTML html) = render_to_whatever html

Notice that I do define a wrapper type, it is documentation but otherwise no types are listed anywhere, not in the input arguments, no where else, it is all properly inferred. With the above you cannot even accidentally pass a non-safe chunk of html text to the renderer, you have to either mark is as raw or or escape the input. Also notice that the type drove the interface, not vice-versa.

Your examples are much more, hmm, Java-like, and Java is NOT a good language to do type-driven programming.

I’m not even sure what your example is demonstrating, do you have actual code to show?

And your things about the_word_apple and so forth seems very hungarian notation, but significantly more verbose, and verbosity can make code much harder to read as well.

Like say you have a unit of measurement, let’s say a meter:

let width_of_house = 12

Well that is not descriptive at all, we could do:

let meters_width_of_house = 12

That just makes it longer, and does not cause an error when we doing something like:

centimeters_is_expected_in_this_function meters_width_of_house

Wow that becomes unreadable very fast, and sure you may catch it with your eyes, but it is still easily missable. Compare this to a type driven approach to measurements:

type meter = Meter of float

let cm i = Meter (i /. 100.0)
let dm i = Meter (i /. 10.0)
let m i = Meter i
let km i = Meter (i *. 1000.0)

Or you could do something like:

type length =
| Meters of float
| Centimeters of float
| Kilometers of float
| Feet of float
...

let centimeters_of_length = function
| Meters m -> m
| Centimeters cm -> cm /. 100.0
| Kilometers km -> km *. 1000.0
| Feet f -> f *. 30.48
...

Or via Witnesses or a variety of other ways, whatever type style fits the pattern best. And you can keep names short, descriptive, readable, and never worry about the system compiling when you try to pass something to somewhere where it does not belong. ^.^

1 Like

Yes, it will not cause an error to call “calc_in_centimeters(house_width_gallons)”
But why would you ever type it?

And is this Hungarian notation? :slight_smile:

defmodule Main do
def handle_user_input(user_input_post) do
with
{:ok, user_input_post} <- Sanitize_user_input.escape_html_chars(user_input_post),
{:ok, user_input_post} <- Sanitize_user_input.step_2(user_input_post),
{:ok, user_input_post} <- Sanitize_user_input.step_3(user_input_post)
do
continue(user_input_post)
else
{:error, user_input_post} -> game_over_man_game_over()
end
end

defmodule Sanitize_user_input do
def escape_html_chars(user_input_post){
}
def step_2(user_input_post){
}
def step_3(user_input_post){
}
end

Because things happen, could be an accident, a macro, any number of other things. :wink:

Uh, what was that about descriptive names? What do those even mean?! o.O
And what if you accidentally commented out, say, step_2, will step_3 break? Does step_3 even make sense then? Etc…

Step 2 and Step 3 are to illustrate the point for SRP, not actual names :slight_smile:
And I don’t accept the provided answer for why would you ever type that :stuck_out_tongue:

So what happens if they get, say, swapped? With proper types that becomes impossible:

let handle_user_input user_input_post =
  try
    let Some user_input_post = Sanitize_user_input.escape_html user_input_post in
    let Some user_input_post = Sanitize_user_input.step_2 user_input_post in
    let Some user_input_post = Sanitize_user_input.step_3 user_input_post in
    continue user_input_post
  with _ -> game_over_man_game_over ()

...
module Sanitize_user_input = struct
  let escape_html user_input = ...

  let step_2 (EscapedHtml user_input) = ...

  let step_3 (Step2Completed user_input) = ...
end

As an example, this now makes it that you cannot mix up the order either, at compile time instead of at some random time at runtime later.

Really? Have you not been programming long? o.O
Innumerable times have I done something like:

set_dimensions(height, width); // wrong swapped arguments!

And the arguments ended up being backwards or something trivial like that, hence why I started typing out about everything since my early C++ days, at least in the code I control. Among a whole host of other instances and styles.

1 Like

Idk, some of the proposed scenarios sound like “what if my cat walked on the keyboard” :slight_smile:
The swamped arguments case is also strange, because why would you call the function without being sure of the order of the arguments?
If I want to use a function I must go and check to see what it expects?
If a function has arity 1, 2 or 3, I must go and see what’s on offer?
When you “type”, I guess you can say “first argument is of type width”, “second is of type height” and get an error when you try to pass swamped arguments. But this means that to get swamped arguments, you must call the function without being sure what the arguments are. In one case you get an error, in the other you don’t, but in both cases you must call the function without being sure what it expects, and that’s something I don’t know how it happens.

Yes, I’m new, but for example, after not having used a basic “web component” for a while and need to import it and extend it, I go and check what it does, what it already offers, because I can’t be sure if I remember everything about everything?

I think that’s the main point for SRP, not to have magic, not to be able to put anything into a single function and have a result + 30 other things happening? At least that’s how I understand it. :slight_smile:
Push complexity down and break it apart until it’s impossible to get it wrong.

While I 100% agree with the general approaches you’ve outlined (small, modular components with limited scopes; descriptive variable names; self-documenting functions, etc.), I don’t think the conclusions you’ve drawn from them are tenable for non-trivial applications that are worked on by teams over some period of time.

Comments, tests (!!), types, and specs are all very important tools for dealing with complexity. These aren’t things that are pursued simply to adhere to some strict formalities of ‘good software’, but rather because they are effective ways of identifying, describing, and reasoning about the complexity and correctness of systems. Over the lifecycle of any given project you may have new features, new bugs (same difference), plenty of human error, shifting requirements, employee turnover, etc. The software and the people writing it will grow throughout the project’s lifetime and I think it’s important to treat this growing complexity as a first class citizen. Comments, tests, types, specs, etc. are all ways of acknowledging this. You have to recognize from the start that a project will be too complex for a single person (or multiple) to hold in their mind all at once, and respond by putting in place the appropriate safeguards and ‘crutches’ to accommodate for this.

This only works when there is a well defined ‘main’ file, but often you will have multiple ‘main’ entry points, each to different parts of the system that are reached under different circumstances. You might also be dealing with code that was written by another member of your team, in which case there is nothing for you to recall since you have no memories of it. Good module and function names are tremendously helpful here for explaining the ‘how’, but they can’t adequately explain the ‘why’, and understanding the ‘why’ is crucial before you start making changes that are going to have an effect on the whole system.

The main purpose of writing tests is to avoid regressions and ensure that changes to one part of the system don’t unknowingly effect another. As systems grow it becomes increasingly more difficult to instantly tell where a problem is. Sometimes it’s even difficult to tell where a problem is a week later, when an an unrelated part of the system is suddenly crashing or returning incorrect results. Some regressions don’t reveal themselves instantly, nor do they reveal themselves in expected ways. “Why is the FooService crashing? We’ve only been working on BarService this week?”. Integration tests help alleviate this by acting as a kind of formal contract for the interactions that happen between different subsystems.

Even if you have a bunch of low level functions that perform simple tasks, these functions still have to be composed together at different layers. And the more compositions you have the more dependencies you create between different parts of the system. It may be easy to reason about how changing a function will effect a single specific composition, but what about the other 30? and what about the 15 or so higher layer constructs that those 30 functions compose into? and the 7 above that? You don’t get the higher layer functions risk free just because their lower layers are 1 + 1.

Hey, what’s wrong with being lazy. I usually get called lazy for not writing these things. I guess it’s ‘damned if you do, damned if you don’t’ :wink:. I don’t agree with the implication that these these are used by less abled programmers in lieu of following good development principles (in fact, I think these things are a critical part of those principles), but on the other hand I say “what’s wrong with a crutch?”. Things get hard and complicated sometimes and being able to lean on these fundamentals enables you to deal more confidently with complexity. Though I would say these things are more like snow shoes than crutches, and building a complex system is like climbing a mountain. And code is like snow… (or something?).

I’m throwing a little heat (:fire:), but in general I agree with the underlying principles you’ve outlined: small pieces are much easier to reason about; things should be grouped together logically by their purpose in the system; design things as layers of composable interfaces; descriptive naming is an absolute necessity. But in my experience no matter how good your names are, or how small your functions are, or how impeccably contextualized your boundaries are, complexity will rear its ugly head at some point and you have to be prepared to take it on. And I don’t think relying merely on your ability to write a bunch of aptly-named small things that are “impossible to have any errors” is sufficient when that complexity grows large. Sometimes neither are specs, comments, and tests, but they certainly make it more manageable than the alternative.

5 Likes