IMHO: if you stop focusing on the code being KISS/DRY, and start focusing on
the requirements driving the code, you will find the better answer you are looking for.
I believe:
We need to encourage each other to remain pragmatic (as you're doing)
We must never stop promoting the importance of testing
Focusing on the requirements more will resolve your questions.
TLDR
If your requirement is to have parts change independently, then keep functions
independent by not having a helper functions. If your requirement (and any
future changes to it) are the same for all functions, move that logic into a
helper function.
I think all of our answers so far make a Venn diagram: we all kind of say the
same thing, but we give details to different parts.
Also, no one else mentioned testing, which is partially why I wrote this
answer. I think that if someone mentions programmers being afraid of making
changes, then it's very unwise to not talk about testing! Even if we "think"
the problem is about the code, it might be the real problem is the lack of
testing. Objectively superior decisions become more realistic when people have
invested into automated testing first.
First, avoiding fear is wisdom -- Good Job!
Here is a sentence you said: the programmers will be very afraid to make any
changes to such [helper] functions or they will cause bugs in other use cases
of the function
I agree that this fear is the enemy, and you must never cling to principles if
they are only causing fear of cascading bugs/work/changes. If copy/pasting
between multiple functions is the only way to remove this fear (which I don't
believe it is -- see below), then that is what you should do.
The fact that you sense this fear of making changes, and that you are trying to
do something about it, makes you a better professional than many others who
don't care enough about improving the code -- they just do what they're told
and make the bare minimum changes to get their ticket closed.
Also (and I can tell I'm repeating what you already know): people skills
trump design skills. If the real life people in your company are outright
bad, then it doesn't matter if your "theory" is better. You might have to make
decisions that are objectively worse, but you know that the people who will
maintain it are capable of understanding and working with. Also, many of us
also understand management who (IMO) micromanage us and find ways to always
deny needed refactoring.
As someone who is a vendor that writes code for customers, I have to think of
this all the time. I might want to use currying and meta-programming because
there is an argument that it's objectively better, but in real life, I see
people being confused by that code because it's not visually obvious what's
happening.
Second, Better Testing Solves Multiple Problems at Once
If (and only if) you have effective, stable, time-proven automated tests (unit
and/or integration), then I bet you will see fear fade away. For newcomers to
automated tests, it may feel very scary to trust the automated tests;
newcomers may see all those green dots and have very little confidence those
green dots reflects real life production working. However, if you, personally,
have confidence in the automated tests, then you can start
emotionally/relationally encouraging others to trust it too.
For you, (if you haven't already) the first step is to research test practices
if you haven't. I honestly assume you already know this stuff, but since I
didn't see this mentioned in your original post, I have to talk about it. Because
automated tests are this important and relevant to your situation you posed.
I am not going to try to single-handedly boil down all testing practices in a
single post here, but I would challenge you to focus on the idea of
"refactor-proof" tests. Before you commit a unit/integration test to code, ask
yourself if there are any valid ways to refactor the CUT (code under test) that
would break the test you just wrote. If that's true, then (IMO) delete that
test. It's better to have fewer automated tests that don't needlessly break
when you refactor, than it is to have a thing tell you you have high test
coverage (quality over quantity). After all, making refactoring easier is
(IMO) the prime purpose of automated tests.
As I have adopted this "refactor-proof" philosophy across time, I have come to
the following conclusions:
- Automated integration tests are better than unit tests
- For integration tests, if you need to, write "simulators/fakes" with "contract tests"
- Never test a private API -- be that private class methods or unexported functions from a file.
References:
While you are researching test practices, you may have to make extra time to
write those tests yourself. Sometimes the only best approach is to not tell
anyone you're doing that, because they'll micromanage you. Obviously this is
not always possible because the amount of need for testing may be bigger than
the need for a good work/life balance. But, sometimes there are things small
enough that you can get away with secretly delaying a task by a day or two in
order to just write the tests/code needed. This, I know, can be a controversial
statement, but I think it's reality.
In addition, you obviously can be as politically prudent as possible to help
encourage others to take steps towards understanding/writing tests themselves.
Or maybe you're the tech lead that can impose a new rule for code reviews.
As you talk about testing with your colleagues, hopefully point #1 above (be
pragmatic) reminds us all to keep listening first and not become pushy.
Third, Focus on the Requirements, Not the Code
Too many times we focus on our code, and not deeply understand the bigger
picture our code is supposed to be solving! Sometimes you have got to stop arguing about if the code is clean, and start making sure you have a good understanding of the requirements that are supposed to be driving the code.
It's more important that you do the right thing than it is that you feel that
your code is "pretty" according to ideas like KISS/DRY. That's why I'm
hesitant to care about those catch phrases, because (in practice) they
accidentally make you focus on your code without thinking about the fact that
the requirements are what provide a good judgment of good code quality.
If the requirements of two functions are interdependent/same, then put that requirement's implementation logic into a helper function. The inputs to that helper function will be the inputs to the business logic for that requirement.
If the requirements of the functions are different, then copy/paste between
them. If they both happen to have the same code this moment, but could
rightfully change independently, then a helper function is bad because it's
affecting another function whose requirement is to change independently.
Example 1: you have a function called "getReportForCustomerX" and
"getReportForCustomerY", and they both query the database the same way. Let's
also pretend there's a business requirement where each customer can customize
their report literally any way they want to. In this case, by design, the
customers want different numbers in their report. So if you have a new customer
Z who needs a report, it may be best to copy/paste the query from another
customer, and then commit the code and move one. Even if the queries are
exactly the same, the definitional point of those functions is to separate
changes from one customer impacting another. In the cases where you provide a
new feature that all customers will want in their report, then yes: you will
possibly be typing the same changes between all the functions.
However, let's say that we decide to go ahead and make a helper function called
queryData
. The reason that's bad is because there will be more cascading
changes by introducing a helper function. If there is a "where" clause in your
query that is the same for all customers, then as soon as one customer wants a
field to be different for them, then instead of 1) changing the query inside
function X, you have to 1) change the query to do what customer X wants 2) add
conditionals into the query to not do that for others. Adding more
conditionals into a query is logically different. I might know how to add a
subclause into a query, but that doesn't mean I know how to make that subclause
conditional without affecting performance for those not using it.
So you notice that using a helper function requires two changes instead of one.
I know this is a contrived example, but the Boolean complexity to maintain
grows more than linearly, in my experience. Therefore, the act of adding
conditionals counts as "one more thing" people have to care about and "one more
thing" to update each time.
This example, it sounds to me, might be like the situation you are running
into. Some people emotionally cringe at the idea of copy/pasting between these
functions, and such an emotional reaction is OK. But the principle of
"minimizing cascading changes" will objectively discern the exceptions for when
copy/pasting is OK.
Example 2: You have three different customers, but the only thing you allow to
be different between their reports are the titles of the columns. Notice that
this situation is very different. Our business requirement is no longer
"provide value to the customer by allowing compete flexibility in the report".
Instead, the business requirement is "avoid excess work by not allowing the
customers to customize the report much". In this situation, the only time you
would ever change the query logic is when you will also have to make sure every
other customer gets the same change. In this case, you definitely want to
create a helper function with one array as input -- what the "titles" are for
the columns.
In the future, if product owners decide that they want to allow customers to
customize something about the query, then you will add more flags to the helper
function.
Conclusion
The more you focus on the requirements instead of the code, the more the code
will be isomorphic to the literal requirements. You naturally write better
code.
functions like these has a tendency to become more and more complex with more and more parameters over time
This seems like a false assumption. Your functions don't need to become more and more complex if you are continually refactoring.The DRY principle sometimes forces the programmers to write complex, hard to maintain functions/classes. Code like this has a tendency to become more complex and harder to maintain over time.
Maybe if you follow it dogmatically, but the idea of principles like KISS and DRY is not to take them as gospel rules but to treat them as guidelines to help you write more maintainable code. If your attempt at working with DRY conflicts with the goal of trying to write more maintainable code, then perhaps it's better not to, or to look for an alternative.GetAllProductsList
function, where each variation simply providing a filtered list. Filters can be user provided and potentially combined. This is why SQL exists.