Death by design patterns, or On the cognitive load of abstractions in the code
alentred.medium.com
alentred.medium.com
To this end, I think Haskell, in its pursuit of the purest, can lead to incredibly-high cognitive load abstractions that make reading code daunting. It feels good when writing it, but feels awful when reading it. Sometimes applying the DRY principle can be bad if the thing you are generalizing will lead to higher cognitive load for people referencing it.
Also I think abstractions vary in quality depending on the language the abstraction is embedded in. Functional paradigms in C++ are thorny to work with; even though functional paradigms are good abstractions in general, if the language doesn't support it first-class (i.e. the cognitive load of using them is high), then they are not good abstractions.
You can also consider maps in a language like Clojure, where maps are almost "zero-class"; it's like the language was built for maps. Nested maps have incredibly low cognitive load in Clojure (not only because the syntax supports it, but also because functions are standardized). Nested maps in Common Lisp, however, are not as nice as those in Clojure; you have to import non-standard libraries in order to deal with them (and even more obscure libraries to support Clojure-like read macros).
The key question is: when someone else reads this code, how much do they need to "reconstruct" the context? Can they get away with not reconstructing the context? And if they need to, how fast can they do so, via appropriate naming, comments, and types?
Even if this leads to humourously Java-esque code, it's worth it
An abstraction that has one purpose and is named to make that purpose crystal clear is a good abstraction
Some people seem to get mad about the verbose naming common in Java - but it’s one of the biggest blessings I’ve ever experienced. If you name things after what they do, and that name is stupid, then it’s the quickest indicator of bad design I’ve ever seen. Good design is where every name is patently obvious and encompasses the entire purpose of the class / record / method / whatever.
I agree it’s a smell, but I’d caution that it’s also very situational.
Sometimes the domain has a recognizable category of like-things which don’t have a name for their likeness within the same domain, and choosing even a stupid-sounding name is a good start towards choosing a better name.
Other times, a targeted portion of an existing codebase might have a common theme which doesn’t necessarily line up with the domain, but needs to be named something to untangle what outlived its WET shelf life. You can recognize it’s a stable abstraction, you can recognize it's entirely divorced from any domain concern except by happenstance. You just have to give it a name, or let it be an increasingly unnecessary burden.
It’s easier to take such a hard line on the design quality implications of naming when you have a vocabulary to reference and/or compose, or when you have extant design attitudes relatively aligned with that principle. It’s especially difficult to apply the principle in systems with extant design problems of this nature, because whatever established or emergent abstractions do exist might not align with any principle you’d apply, and you can’t move them in that direction without some intermediate step no matter how flawed. You have to name what is before you name what should be.
As a reader of the code I think 'FooAndMaybeBar_SpecialBazSupportV2 ()' is a blessing.
Aiming for cleaness when the problem is not clean is a worse sin than messy code that could be clean.
Hard disagree. The Haskell abstractions in the standard library are (mostly) great. Classes like `Monoid` or `Functor` as as basic as they get (in the sense that they describe a very small set of behaviors) and include laws.
Of course, user defined classes can incur in high cognitive load, but that is applicable to any language that supports abstraction.
At some point, you're trying to optimize readability of code across multiple concerns, each having a good claim to being of prime importance. But you can't. You can try and invent increasingly obscure mathematical abstractions to make some concerns special cases of a more general one - but in the end, there's only so much you can cram into the same piece of plaintext. You have to trade readability for some readers (e.g. those interested in "golden path" business rules) against readability for others (e.g. those interested in error propagation).
Or, you can double down on further abstractions and make everything unreadable for everyone equally :).
The underlying problem is partially about plaintext format itself, but mostly about the fact that we always work on the same, single representation, and demand it to be everything for everyone. I feel the only way to progress is to finally give up the constraint of directly reading and writing the final "single source of truth" code. It will definitely simplify things day-to-day, as it'll allow you to plain ignore and hide things you don't care about at a particular moment, instead of trying to keep them concise with design patterns, clever syntax, advanced algebra, etc.
I like the math homework approach to programming. First you get in there with a vague idea of what you need to do, you bang your head against it until it starts to make some sense. You then move on to more complicated examples only to realize that what you thought you knew was wrong. After going through many of these iterations you might start to notice some patterns common to all examples and if you're lucky they might turn out to be true. This is rare and only happens when you have a very good understanding of the problem.
s/People use/People who have never written a line of code in it use/I see it as a trade-off between abstract thought and working memory. You can spend time up-front to reduce the amount of details you need to keep in your head as you go along, or you can continue to juggle more details to learn less up-front.
The problem, of course, is that most people don't differentiate between the two. In some sense, it's only fair: if an abstraction carries some up-front cognitive cost as you're learning it, how do you know it'll get better? Do you even have the time to learn something new right this moment? But, ultimately, it's a massive difference and avoiding abstractions that are hard up-front is self-defeating in the long term.
Haskell abstractions mostly fall in the former camp: hard to learn, but powerful once you have. I've worked with some pretty poor Haskell code and the abstractions that seemed hard when I was a beginner were exactly what made it easier to understand messy code! Turns out that expressive types and effect management mean that I don't have to carefully understand which parts of the code can affect each other indirectly and which parts can't; it's all explicit in the structure of the code. I had to learn the language and the concepts to understand it but, once I did, I could read it directly from the program rather than needing to simulate the code in my head.
I've found that, most of the time, the KISS design philosophy is heavily weighted in the opposite direction: it's all about avoiding concepts and abstractions that a reader would need to learn, but at the expense of having everybody keep more details in their head as they're writing and reading the code. The small pieces might each be easier to understand, but there are a whole bunch more pieces to track for the same amount of logic!
The problem with a blanket "how hard will somebody else find this code to read?" is that so much of it rides on familiarity rather than anything fundamental to the code. And that's what matters in any specific situation, to be sure... but it doesn't answer the broader question of how we should be writing (and reading) code. After all, even if familiarity dominates in the immediate short term, it might still be worth up-front learning to have a better experience in the medium and long term.
It’s better to have some duplication than to end up with a wrong abstraction.
I like the AHA principle much more. It suggests:
- Avoid Hasty Abstractions
- Prefer duplication over the wrong abstraction because duplication is far cheaper than the wrong abstraction
I found it here: https://kentcdodds.com/blog/aha-programming.The point about duplication being better than wrong abstraction is made here: https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction.
And the principle doesn’t suggest avoiding abstractions altogether. It’s really only about avoiding the hasty ones. Wait with the abstraction until you feel it’s necessary or when duplication itself becomes a problem. At that point, you’ll have a clearer understanding of how the abstraction should function.
This is a problem with all rule of thumbs for programmers. It is like we as a group are far more dogmatic than normal. Or our rules are worse. Or both. Dunno.
That is indeed the key question, but the answer will have a lot to do with their experience as a developer, their knowledge of the domain, and their familiarity with the code.
I'll take poor quality abstractions any day, provided they are coupled with amazing documentation, even if in the form of well written comments, that describe the reasoning behind them.
For example with dependency injection in languages where devs put Interfaces in constructors and some magic injects the Class that satisfies that Interface at runtime. When you click the type or variable, the IDE opens the Interface file which is just a bunch of function signatures, no implementation. It's infuriating when all you want is to see some concrete code. You just want to see exactly what code is being executed and then carry on with your day.
I get the benefits of dependency injection but most of the times a single class implements that interface and yet i'm forced to scavenge the code to find it. I also know some tooling is powerful enough to list the Classes that satisfy that Interface. But it's not perfect and it's still a layer of indirection adding to my cognitive load.
I find that shortcut easy/powerful enough that it's no additional burden on my cognitive load. At least, it's no more taxing than inversion of control is generally.
I don't.
I think dependency injection encourages thoughtless code. It makes it easier to "magically" make your dependencies appear wherever you want them without having to think about anything except the lifetime. Once you're project gets significantly complex, it becomes very difficult to understand what your web of dependencies is actually doing, and to comprehend how the lifetimes all interact.
Screw dependency injection. Do it the "hard way" and make the pain points ridiculously clear so that you can see where the flaws in your architecture are revealing themselves.
Another bonus point when DI is used for some deep-context "dependencies". Hey, I need a HttpClient object here, to post something somewhere. Why, instead of having a URL and instantiating my client right here, I need to dig through the sands of DI bindings to find a way to "configure" HttpClient for this particular instance? Well, I understand theoretical reasons, but in practice they never make sense and only add pain.
Another bonus point for run-time binding errors. Those sometimes slip through even when there is a "test" in the CI/CD suite for that. I think dependency injection has its place, but as used presently it resembles a cargo-cult.
What. A. Nightmare. It's like trying to build an internal combustion engine and every moving part has three extra degrees of freedom, in case you wanted to pull a piston out in the middle of the freeway and use it to wipe your windshield! Just in case you want to do that, a piston can also function as a wheel, a cigarette lighter, or a potted plant. And of course it has the, ahem, armor (?) to do all of that. In fact, every nut and bolt and rod and wire has extra armor on it. They aren't bolted together, they are held with miniature six-axis robot arms, just in case. And everything has plating. Thick metal plating. Bullet-proof plating. Everything indestructible. Getters and setters everywhere! Can't you see how much better they make things! The design is so much better with getters and setterrrrss!
Needless to say, I don't write code like that anymore. I prefer to write in Virgil, to make my fields public and immutable, initialized in the constructor or the anonymous zone, and when I need six classes to cooperate to do a job, they do, and they don't put armor on for friends. Private applies to a file, everybody's friends there, no surprises.
But I don't expect anyone to follow me or worship it. I won't claim amazing design skills. Just trying to make things work without a lot of extra work or surprises down the line.
In this vain this often posted article is fun https://grugbrain.dev/
Does this mean firing him?
If, over time, he continues to introduce more problems than he solves then he might be managed out but I'm hopeful that won't happen.
Not to get on a soapbox, but I would imagine someone with great pattern recognition would be able to realize this way of doing things is deranged; and ultimately helps them more than the team- On second though: perhaps they are genuinely the smartest people in the room -- prioritizing their image and importance by making others believe they're too stupid to understand what's going on.
I really should go study Gang of Four -- job security demands it.
A debugger needs to be smarter than the person who wrote the code being debugged.
If someone is at their smartest when writing a section of code, they are thus unable to successfully debug problems involving that code.
> Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?
I hate to go against the Wisdom of the Ancients here, but there is no point at which you're too dumb to debug your own code. With increasing code cleverness, you may lose the ability to make quick mental jumps when debugging it - but this only forces you to be methodical. With a methodical approach, you will find the bug - the thing you might be missing is not smarts, it's patience.
As for cryptography: digital locks are broken by default. It takes care and smarts to design one that isn't trivially breakable. Trying to design a lock that's too clever for you to understand will almost always yield a lock that's broken just past the boundary of your understanding, so you should be able to break it once you realize it's possible and you put a little effort.
As for physical locks: same as above, plus you can always break it literally, by applying enough physical force.
The principal-agent problem looms large.
The best developers solve the hard problems without writing the worst code.
The worst is a huge monolith that has dozens of styles and favorite patterns sprinkled all over from years of opinionated developers leaving their mark. For anything non-trivial, it might take an hour or two just to get your bearings and enter the mindset of the original developer. Then, you have to make the decision of whether your new function is going to follow that (broken) pattern or add yet another new one.
My coding heuristics now pretty much boil down to:
1) Less code is better code. The only code with no bugs is no code. After you get something working, remove as much code as you can.
2) Simple code is better code. Don't waste time making your code complex for the sake of being efficient until you've used it and proven that it's a bottleneck with a profiler. Otherwise, just do the simplest thing you can think of that will work.
3) Don't think too far ahead unless you specifically have been tasked with it. Otherwise, do what will work now. Refactor when necessary.
All teams are different, and a good rule of thumb is to write code the least skilled or experienced person can understand on its own without guidance. Since teams vary so do these limits change. I had teams where it would've been odd to even try use any abstraction, let alone algebraic data types, and teams practicing lots of abstraction and architectural patterns all on top of functional effects. The teams widely different in proficiency of the best and weakest members and so did the codebase patterns.
It's like in sports, different teams demand different approaches because the qualities and proficiencies differ, there are no universal truths but solutions that fit better different teams.
This. Also, if your team develops on framework X and people are hired based on that, stick to the damn patterns used by your framework!
I had once worked with a developer that one day unilateraly decided that the abstractions provided by the framework we used were not good enough. Instead, we should use the abstractions from a "design pattern" described in a shitty blog post they dug up, and "everyone should read". There wasn't enough pushback, so we had ended up with some seriously unmaintainable code.
std::cout << "You have " << itemCount << " items of type " << itemType << "." << std::endl;
When I could do this: printf("You have %d items of type %s.\n", itemCount, itemType.c_str());We do students a disservice teaching them how to “code” first. And not instead how to solve problems with code
But a real fucked up mental model to bake into an entire industry’s worth of developers
I also don't know any school, college or university that starts their programming courses in a webbrowser. Typical languages in Germany for a first semester algorithms/data structures class are either Java or C++.
What you explained happens in Germany is pretty par for the course in the US as well for CS degrees. But even then, why the hell are we throwing a book of data structures at students? You don’t give an apprentice framer a nail gun and say “go at it!”. They’ll destroy everything. You give them a hammer and educate them when they start to complain of smashed thumbs
printf("You have %d items of type %s.\n", itemCount, itemType.c_str());
When I could do this: std::println("You have {} items of type {}.", itemCount, itemType);
https://en.cppreference.com/w/cpp/io/printEdit: Oh, it's in C++23 standard. Only took them 38 years! ;-)
puts "You have #{itemCount} items of type #{itemType}."
print(f"You have {itemCount} items of type {itemType}.")
console.log(`You have ${itemCount} items of type ${itemType}.`)This problem is broadly solved by a combination of domain-driven design (DDD) and functional programming (FP).
Yes, I know you’re tired of hearing about FP and you think FP engineers are all white bearded monad salespeople. I don’t care.
Odds are, if you’re an engineer, you’re not doing purely abstract work: your code has some correspondence to “stuff in the real world”. FP is unencumbered by the class/object model and you can abstract over anything trivially using plain old functions: don’t “choose” your abstractions, use these, and your domain models, to figure out what the correct/real(est) abstractions are. It’s not so much about too much or too little abstractions, it’s about how suitable the abstractions are.
Pretty much everything is better with FP and walking a reasonable line when showing benefits _without_ immediately diving into all the esoteric parts wins people.
We need the MVC of FP type of trendy bandwagon. The FP rails.
I am optimistic about Roc and hope Rust also continues to help make FP style programming more commonplace even if I still dont know how to implement a monad.
For my part, the most confusing thing was that I was trying to understand monads by looking at both haskell and category theory.
However, a monad in computer code is a different beast than a monad in category theory (I believe this may be true in every programming language currently, but if you have a counter-example I'd love to hear about it). They look vaguely similar, but aren’t the same thing. Nobody told me this, but it greatly simplified comprehension once I had that epiphany.
A lot of monad explanations just confuse monads and functors too (functors are your burritos / wrapper data types; and functors should probably be understood before monads). IMO seeing monads as being primarily about function composition, kind of like the functions `compose` or `pipe` or `thread_first` or `thread_last`… but in a context that makes use of functors, is the best way to really see the core simplicity of the idea.
- >98% of engineers are mostly familiar with OOP
- about 1% of engineers “write FP code imperatively” without knowing that they’re kind of missing the point
And among the >98%:
- 20% think FP is about using map/filter/reduce (not a bad start, but misses the main ideas)
- 35% are openly hostile to FP for a whole variety of reasons, some better than others but none great
- The rest don’t really know what FP is, and if they’d have to guess they’d say it’s when you use functions.
We no longer have the same performance constraints that computer scientists had to deal with in the very early days of computing, when the theory was being written and the von neumann style of programming began to take hold. It will still take a long time (I’d wager decades) before the findings of academics are accepted in the industry.
[*] These figures are all made up and are presented only as a rough way to communicate my perspective based on my experience… But again I am not particularly qualified to speak to this.
It is interesting though, the openly hostile subset you describe I don't really consider as working in SWE as it would be the equiv. of working in a kitchen and saying you reject using knives.
But --and maybe there's a certain skew in my work experience?-- that's probably not really relevant to the vast majority of the programming that gets done. By my estimation, most professional software engineers spend most of their time working on nonrevolutionary, fairly mundane things, like basic crud apps, web apps, microservices, frontend, backend... While a minority (I think?) work on games, codecs, embedded systems, etc. (That's not to say that performance is never a concern in these areas, of course.)
But dwarfing all of the above, in numbers, are all the people who are not professional software engineers, and who are writing code as an extension of the specific work they are doing in their domain (that's all your python scripts, R scripts, ad hoc sql, lab work, etc). I'll suggest something controversial here: anything people typically think python is good for, [haskell|racket|...] is probably way better at it. That's kind of where I see the most obvious use cases.
In my view anyway, it's a pretty good bet that the step-change in complexity reduction you get with FP (big scary words notwithstanding; I am speaking here of the programs' complexity) would actually have a massive positive impact on the accessibility of programming/sw-engineering to newcomers, as well as on programming/sw-engineering's already-impressive propensity for enabling cross-talk between disciplines. There's soooooooo many low hanging fruits in programming literacy.
It's a really valuable lesson to keep most function as pure and stateless, but transformation of the existing state.
Build your entire codebase around this idea and frankly, abstraction will take care of itself.
That's something I was really missing in my earlier programming years, when all I saw were Java, Python and Ruby bloggers regurgitating OOP and Agile terms and I would try out their idea, marvel at the visible complexity of the abstraction, but then see it fail in a practical scenario. WTF.
I don't think the answer is one or the other at all like the blog post is implying. I think his previous conclusion of a threshold makes more sense. Perhaps though they have chosen the wrong abstractions and hence the confusion
That's because abstraction is a myth perpetuated by the likes of Sussman (ie academics that never wrote real code) and in reality the only thing that matters is PSPACE ⊆ EXPTIME.
With "cleverely abstracted" code, I cannot do that. Because there are no noodles any more. There are small pieces of noodles, all in different sizes, spread out over 200 plates, that reside on 140 tables in 40 different rooms, some of which are in another building. And there are now 20 types of sauce, and 14 types of parmesan, but those are not neatly separated, but spread evenly all over everything, and so it all tastes awful. And each individual plate still manages to be spaghetti.
Okay, yes, I may have strechted out the food-analogy a bit.
But that's pretty much how I feel when digging through over-engineered codebases.
And some guy banged it out over the weekend, which means he's a rock star.
One notable difference is that now the GOTOs are implicit and only in your head, so it's notably even harder to comprehend than the old style of spaghetti.
Also often adds in some code hidden in comments ('annotations'), some squirreled away in complex configuration files, and some other code where what actually gets executed depends on concatenated string variables, and maybe for good measure some places where which code gets executed (and how) implicitly depends on the filepath it is under, and now we have a mess even a debugger can't help with.
It's nice to see more people finally getting disillusioned with these trends.
A degenerate case of grep-able code is e.g. virtual methods for some class and derived types that share name with methods of another class and derived types.
I also distastes code with so much dynamicness that you have no clue where stuff happens. I really like long functions that do many things one thing after another.
I recently took over a a Django API + React front end project.
What should should have been a basic CRUD application with some calendaring and video calling added turned into a nightmare and costs of over a million dollars for my client.
I'm a Rails and iOS dev mostly, so I have a bias, but here we go:
1) Using GraphQL for a business process CRUD app is a nightmare. Building on Django's ORM with Graphene, then on the frontend with Apollo Client, Queries/Mutations, and React with some Redux mixed in is a nightmare of hard to know database and business logic paths.
2) On the Django side, having their Views and Models (mostly Models) handle business logic, and then constructing a Graphene based GraphQL layer to be consumed by another layer GraphQL Queries/Mutations via React is a mess, for what is essentially a bunch of forms on the frontend
3) Splitting out React Components into Atoms/Molecules/Organisms/Pages/Templates and more has been a nightmare to work with. Seriously - do you really need to build an Atom for a button that you pass the title text too?
The whole project is impossibly fragile.
Say what you will about Rails, but you can build a robust and functioning CRUD app that solves business problems without the need for developers to hack through a jungle of abstractions!
Sorting this all out for client has almost killed me and burned me out!
Could not agree more. GQL is such a big mistake in so many use cases.
Facebook invented React and GraphQL for good reasons. But many of those reasons don’t apply but the implementation does add development time and code complexity.
In a React and Django Graphene API situation, you have to write GraphQL queries and mutations on both sides.
The idea being you might write a GraphQL query that only retrieves exactly the data you need and nothing more.
Which is great when those optimisations improve customer experience, but for most of the SMEs I work with, they do not make any perceivable difference.
GraphQL is layering on complexity and additional libraries when a good old fashioned REST API does everything you need anyway!
I guess GraphQL is fashionable right now!
This is the story of software development, nobody uses a shotgun to get the job done quickly, because afterwards they're out of job.
It's borderline criminal but all the nonsense is about scamming your employer to pay you hefty money for many years to build the Kaiser Wilhelm Gun.
And the "geniuses" disappear right when the project gets to be delivered. Like the past few projects I worked on, the original developers that shall remain unnamed would spit out code like there was no tomorrow, always latest fashion and of course throw one or two badly broken ad-hoc domain specific languages pulled out of their ass. Get paid for two years or so and in 6 months before scheduled release, suddenly "be assigned to an uttermost important project", in fact another two years of jacking off with no responsibility. While their piece of art gets transferred to suckers like me who only need to do the "easy" finishing touches and get it released. If it succeeds, praise is in them, if it fails, it's my fault.
Examples that come to mind:
1. An internal API where you own all the clients (you own every query; optimize them.)
2. An API that deals with objects where scopes aren't going to vary (i.e. if your clients are fetching the same 6 basic queries every time, you're not getting benefits out of GQL).
3. APIs that are write/update heavy. Every mutation is the same amount of work (or more) that a REST controller would have to deal with, but you have the overhead of the GQL library on top.
Basically: everything is just JSON on the wire anyway. Stop paying the overhead penalty of GQL unless you have a zillion clients and a massive object graph that's going to be queried in unpredictable ways. Typical REST libraries make backend code management and structure MUCH cleaner than GQL, with much more obvious points for caching, query optimization, etc.
1) Fix the issues with email notification logic
2) Get them off of the very expensive & complicated AWS stack We've migrated staging and testing to a DO Droplet with DO MySQl/Redis stack. Moving to DO with plenty of headroom is a 10th of the cost of AWS!
3) After that, not sure at this stage. Most likely we will refactor low hanging fruit.
A ground up rewrite would require knowing all of the business logic. We'll just begin cleaning it up and getting it into a manageable state. We don't know what we don't know yet! :-)
Best of luck with the migration to DO :)
At the same time, though, I feel very disheartening that at my current company devs ignore what even "aggregate" means, and don't have any kind of clue about patterns and higher concepts in general. I really miss smart and educated people in IT.
People were arguing for such a thing because e.g
if you have 2 guids then you can write code which handles two completely unrelated guids
like
`student.Id == car.Id`
meanwhile if you have types like StudentId and CarId then you'd have compilation error
While I'm not a fan of this, then the concept is sound.
For value objects, surely Leibniz's philosophy applies: if a theory describes two situations as being distinct, and yet also implies that there is no conceivable way, empirically, to tell them apart, then that theory contains some superfluous and arbitrary elements that ought to be removed.
For entities, it's much more complex.
I still feel some kind of way when seeing classes (with methods!) being used for IDs, though.
Depends on the language. In many languages strings are classes in general, so it makes sense that a new type for a strong is is one as well
Modeling the business domain with types that match it is table stakes. Otherwise you end up with a "stringly-typed" codebase in which everything is just a primitive type.
This and the issues the author of the article is facing comes from the wrong mindset. Your job is not to create the purest, most perfect codebase, but to deliver value to your users. Code is a means to an end.
This. Before you wax all philosophical, try to understand first what you are arguing for and against. Make sure to read what you wrote at least two times, and that it actually makes any sense.
He may have been talented but unnecessary patterns are the hallmarks of a bad programmer
Compare to Java and with single keystrokes I can navigate up the type hierarchy and bring up a full display of every class implementing an interface. With a couple more keystrokes I can rename a function in the base class and update all the implementors, globally across hundreds of thousands of lines of code. And in Eclipse this happens nearly instantly with 100% fidelity (I assume in other IDEs too). I can move around the codebase with so much ease. As soon as I hit Python code like this I'm completely lost.
I see people complaining all the time about IDEs (if you need an IDE then your code is bad) and then also about design patterns and abstraction and I do see a connection in that yes, if you need to scale complexity there is a fundamental aspect of that whereby you need a more structured approach to how you are building things and the underlying tools that are supporting that.
Tactically speaking, if you don't abuse the global scope (class methods/singletons are abuses of the global scope), don't have circular dependencies, don't have "intialization" methods called after constructors, and most important of all, don't call non trivial functions in constructors, then your code is probably reasonably abstracted.
More importantly than being abstracted, it is probably absolutely boring and trivial to unit test.
I've been trying to tell people that for 20 years or so and Whenever I say it, I'm shouted down. Usually by someone who loves Java.
Reading understanding here not only as "intellectually grasping" but also "standing under it", as in supporting it with your actions.
I've worked with some really smart people that couldn't program themselves out of a 24hrs challenge because they went deep in lalaland with patterns up the wazoo and didn't just consider what the program needs to do.
A program is a set of instruction for a computer to do stuff. It takes data in, does stuff to it, and pushes it out. Whether you're doing a UI or an automated landing system, it's all the same. In one case, you're waiting for user input to do stuff with it, on the other you're reading sensors at a high frequency and affecting control inputs as a feedback loop.
Patterns should only be used if they actually solve a problem. E.g. the command pattern in systems that require Undos.
The simplest way to manage complexity in any program is to manage state carefully. Avoid global state, and use as much pure functions as you can. The net effects of this has been evaluated ad-nauseam and the results are clear: easier code to reason about, easier to parallelise, easier to test and less cognitive load.
Over-using patterns and abstractions is throwing cash in a pit. The harder a system becomes to reason about or change, the more it will cost to maintain.
Over-abstraction will result in
main() { application_here() }
which might look good in reviews, but the metrics won't add up.It's more like digging a ditch and convincing the company to throw money into it. It's a very lucrative proposition. That's the problem.
Going full no true scotsman, but is it really an abstraction if that property does not hold true? Isn't the author essentially arguing against leaky abstractions, which should self-evidently be a bad idea? The problem of course is that developers do not always have the prescience to predict if an abstraction will be leaky or not.
Start with Java, but don't work in Java directly. Add a layer of meta-programming on top. In this case it was an aspect-weaving system that allowed injecting methods into classes. By itself that would not have been too harmful, but this system had been used at scale, for more than a decade. The result was the emergent effect of aspect weaving on a code-base, which I have not seen before. The entire code-base had been splintered into thousands of small pieces that only existed to be injected into other contexts. It was very strange code.
The aspect-weaving system worked as an extension to the parser, so that all of the code written in it was essentially action rules for the parser. Data was constructed as synthetic or inherited attributes in the parse. The "hairy control flow" that is normally eliminated after the initial parsing phase existed in every pass in every part of the compiler. Essentially parsing never finished, as even late in the code-generation backend we were still tracing the emergent effect of grammar production rules.
Layered on-top of this was a pattern-heavy approach that tried to eliminate all remaining structure, shape and form. Days could be spent digging through scaffolding and layering code to try to find the pieces of functionality that went together to handle a particular construct or feature.
The layers of maintained- and generated- code meant that nobody actually knew how large the system was. I spent an afternoon trying to count it properly and I think my estimate of 1.5m lines was probably the closest that we had come.
I still have nightmares occasionally, but they are becoming less frequent over time.
I’ve had so many long conversations with early-mid career developers that the stupid code being written will have a shelf life about as long as a head of lettuce. And that the “best” solution is typically the simplest, shortest, and can be written the quickest.
https://mikehadlow.blogspot.com/2012/05/configuration-comple...
I prefer the old version or the pre over-refactored code better too, I can at least understand it.
I've noticed that when control flow is abstracted away at runtime for flexibility of future changes just in case we need to handle other cases in the future, the code starts to become difficult to reason about.
When people extract everything out until you have hundreds of low signal-to-noise files.
I think it's a reaction to team based software delivery, where multiple people need to work on different layers independently and separately, but when you're working solo, a dense codebase you can fit in your head is a lot more enjoyable.
Maybe the size of the codebase determines this.
Control flow is a big thing to watch for. IME an abstraction that's defined in terms of plain functions and values, and lets you use your own control flow, is often a good one; one that requires you to supply a bunch of callbacks and let it take over the control flow is often a bad one.
I started referring to it as cargo-cult programming.
Like, we could almost have our ideal UI be a dozen lines of code (in some kind of CLIish dealy) , exposed for twiddling.
It would be the ultimate in informativeness, control and flexibility.
That is enough abstraction.
However, contra OO, I submit that keeping the data orthogonal to the methods is a win.
Restated, I haven't seen much win with objectifying everything and having a huge object inheritance tree. Probably useful in some domain.
Recently looking at botocore. There are no classes for AWS services; just a "database" of JSON documents enumerating "shapes" and "operations", and then some wrapper classes that dynamically instantiate classes where needed.
Feels as though AWS may be on to something, though doubtless their server-side code is non-trivial.
And if you havent seen it already you would probably appreciate Project Cambria from Ink and Switch.
https://www.inkandswitch.com/cambria/
Editing to add: and "schema driven development" with tools like Buf
Those aren't very connected, at least in several languages. Using interfaces and composition you can have pretty shallow inheritance overall. Most of my objects are just one level "deep".
Unfortunately this is a very commonly observed difference between "software engineers" and "business people who write code". The latter want to get a job done, while the former are often looking to implement engineering doctrine that may not be relevant to the problem at hand.
Complex systems evolve by brute forcing their problem space during a boom and pruning the non-viable inroads during a bust. That means a lot of agents will be stuck building dead-ends only to get wiped at some point. That's why meta cognition is so important. We are not blind agents, we are a complex system ourselves. We are capable of internalizing giant swats of symbolic representations of system archetypes. We are capable of teleological behavior. Do it people. You can learn computer science and preempt a life of being mired by abstractions that won't lead anywhere.
Sadly greed lead us astray too often.
It's not gong to get fixed by just thinking about cognitive load. It's a very delicate balance: increasing cohesion and encapsulation, adding robust tests, also adding new features, not spending ungodly amount of time refactoring, being mindful of how easy it is to review etc. Even cognitive load is not the right way to look at it. If the abstractions give you very desirable guarantees in terms of performance and correctness, you need to consider it too.
Once you've mastered the most useful design patterns applying to your problem space, you can then focus on learning the architectural patterns.
If you're working on smaller code bases and you're not seeing design patterns emerge I would strongly question what's going on (or whether you understand patterns).
We got the simple stuff right the first time, but it's not until project #3 where we are actually building the framework properly.
A library exposes utilities that an application chooses to use.
A framework constrains the application to a precise system and offers a lot of power within that system.
Frameworks are the most risky to write.
You should always prefer libraries over frameworks until you are certain not only of the problem domain, but the future of the company.
Our first project we really just prioritized features and time to market.
When I was designing the golang based backend for our newest project, I spent almost 2 weeks iterating on the design of the framework. The set of constraints around what an API call would do. I implemented a system for syncing JSON objects between the client and server. A system I had been thinking about for 4 years. It works really well and felt good to implement.
The constraints I created offer us a lot of power. Logs are automatic and consistent. We can run analytics on the diff of every single player interaction. The constraints guide developers into writing performant code.
However there are flaws in the framework. Those flaws could become landmines. Or there are theoretical designs that cannot be represented by the framework, and I have a strong opinion on how to extend the framework to support that. Another dev might implement that new feature in a spaghetti code way. But I can't really justify spending another week iterating on those flaws.
When I was pondering where the line is of how long to iterate on the framework level, I was reminiscing fondly of how simple the first products backend is. Each API call written as a unique snowflake, with all its reads and writes and errors in a straight line, and divided into simple functions with no rules. But then I remember the flaws, the database load is high, there are reads and writes going on inside deep function calls. Reading the same record multiple times in an API call. The lack of consistency in the response structure and how the client would deal with that. The lack of completeness of the logs. The inability for us to comprehensively analyze our players data.
Abstractions are useful. It's hard to get them right. You're better off dumping all the Legos on the floor before you try to sort them.
Redefining words to mean something other than standard usage is clever.
I'd rather maintain the latter because I just need to understand the code once and figuring out abstraction layers is fun at the cost of my employer's dime.
> the older version better
Better in what aspect? Better to maintain? Better to debug? Better in solving the business needs? If the answer is all of them then congrats you have poorly done refactoring.