It's funny how often I've made this argument with even fairly experienced programmers and they seem to have a visceral reaction to the code being "less dry" than it could possibly be.
Similarly, once a codebase uses several very wrong abstractions, it becomes significantly more confusing to work on, exponentially increasing cost.
The temptation to use a mature library as a dependency is very strong since time is initially saved. When that library introduces the wrong abstraction, the consequences can be severe.
I typically argue for (at least) creating the correct abstraction and having it wrap the mature library to constrain or properly name its behavior, and to make it less strongly coupled to the rest of the system.
It doesn't just take experience to realize these sorts of things, it takes a willingness to question one's own code and imagine how it might have been done better, or how it might appear to someone who didn't write it.
Abstraction is primarily about separation of concerns, not about avoiding repetition. Drying out code that's repeated all over isn't the same as creating a formal abstraction for some element of the overall logic.
Which is why
>once a codebase uses several very wrong abstractions, it becomes significantly more confusing to work on, exponentially increasing cost.
And drying out code makes it easier to maintain, but it doesn't guarantee that the architecture isn't a mess.
The problem is perhaps that CS teaches algos, and sometimes it teaches design patterns. But there's almost no useful theory of abstraction design.
Design patterns are more or less as good as it gets, and all they do is give give you a cookbook of stock formulas to try.
Beyond that, there's no useful way to reason about abstractions, test them for domain fit, or rate them for elegance and efficiency.
If anything, some developers use design patterns as a grab bag when solving a problem... a better approach is to model the solution and then be ready to "back in" to a design pattern upon noticing strong similarity or observing that the design pattern is a bit more abstract way of doing the same thing.
Because of the tendency to pick a pattern first and design for the domain later, many instances of design pattern use in the wild are subtly (or not so subtly) incorrect.
> Beyond that, there's no useful way to reason about abstractions, test them for domain fit, or rate them for elegance and efficiency.
Very true indeed. Looking at a design through the lens of coupling and testability and modularity is a good start and can reveal many problems, but I think the real gotcha has to do with naming: Once we name a concept/abstraction we are likely to reason within that abstraction, and we rarely consider whether we are stretching it too far, or if it is even that thing anymore.
There's a lot of other good stuff there too, when you start digging through the archives.
Generally, I find that I've got to build a prototype first, which is quick and dirty and ugly, but works. Then iterate as the structure emerges. Some things stay in flux, and it's okay to leave them messier, but eventually the code and functionality settles out and commonality arises. Quite often, I don't necessarily know what I'm doing going in; I've got a general goal, but until I experiment a bit, the best way to get there is unclear.
One of the joys and pitfalls of a multi-paradigm language like C# is that there are so many different ways to skin a cat. You can pick and choose procedural, object-oriented, functional, or some bastard mishmash of all of the above and more.
I would argue that 'Refactoring' gives you a bunch of tools for discovering your good abstractions. Whereas GoF puts a bunch of ideas in your head without giving you any preparation for using them responsibly.
Similarly, once a codebase uses several very wrong abstractions, it becomes significantly more confusing to work on, exponentially increasing cost.
The temptation to use a mature library as a dependency is very strong since time is initially saved. When that library introduces the wrong abstraction, the consequences can be severe.
I typically argue for (at least) creating the correct abstraction and having it wrap the mature library to constrain or properly name its behavior, and to make it less strongly coupled to the rest of the system.
It doesn't just take experience to realize these sorts of things, it takes a willingness to question one's own code and imagine how it might have been done better, or how it might appear to someone who didn't write it.