I've never been bitten by an interface that is too simple.
I've never had a bug in code that I didn't need to write.
Don't try to solve future problems. You'll have the problem that the problem you solved isn't the problem that needs solving, and then the problem that needs solving.
If anything, overly simplistic solutions sometimes make me feel like I'm repeating myself, but really since the code is so easy it always feels possible to refactor it. That's a good place to be, much better than afraid to change something because it's too generic to know if you'll break something.
Everyone has an opinion on an interface that is easy to understand, and not over-engineered (bikeshedding).
I've definitely been bitten by designs that were too simple in critical areas. Interfaces can always be added to and code can always be deduplicated later. But an accretion of kludges around an insufficient set of core functionality is absolutely worse than having a little extra functionality that you end up deleting later.
Don't try to solve speculative future problems. But if you are pretty sure you're going to need something in the future, you should probably take it into consideration in your design.
Yes, during a rewrite of a product I had a (new) team member scream YAGNI at me with supercilious delight when I pointed out all functions needed to account for the full primary key of (id, territory) rather than just (id) because we will definitely be needing it imminently although the current build only worked with data for one territory.
We then had to spend a long time cleaning up the mess when lo and behold, we needed to add more territories to the dataset.
I think we've swung too far in the wrong direction when it comes to "premature abstraction" and yagni.
The only acceptable reason I've seen for writing bad code is "we might not be here tomorrow" but that is literally only valid for extremely young startups and even then those decisions could ripple and indirectly kill the company in the future when they're no longer able to pay off the tech debt in time
For me database structure should be built with decent flexibility, everything else is easier to refactor in the future when needed and best to keep simple unless it is a pattern you have definite experience with in the past and it is basically no cost and high certainty of working out.
I hear YAGNIs when I know a pattern from experience with past projects even though deep down I know it is not that high effort due to past experience and we will need it.
I hate hearing YAGNIs when it is something I specifically have had experience with in the past and they think it is too complex because they haven't done it.
And they won't get it because they haven't gone through it.
As someone who has been operating in hacky mode for years there's another situation where
> we might not be here tomorrow
Is true and increasingly common which is the recurring rounds of layoffs we all seem to be experiencing. It's in the individual developer's interest to bodge something in if it makes them appear productive in time for the next layoff. Certainly not a healthy incentive for building good software but it is what it is.
You know, I plainly disallow composed PKs on my workplace just so nobody ever makes that specific argument. Nobody can work with partial keys if your keys all have a single field.
> But an accretion of kludges around an insufficient set of core functionality is absolutely worse than having a little extra functionality that you end up deleting later.
I agree that an accretion of kludges is bad. The problem here comes down to usually the skill of the programmer when the new work comes up. To people who don't understand the code, they are more likely to make what they think is a simple kludge than actually refactor to solve the problem "correctly", which usually involves more thought and creativity.
Whereas extra functionality usually increases the code surface area, and once published as a public interface to a library, is very costly and painful to delete. Then it festers as complications to new code that is added have to assume this extra functionality is used rather than not.
The skill of the programmer is subordinate to getting stuff done at a reasonable velocity.
Given unlimited time, a sufficiently skilled programmer can always refactor as needed, when needed, and not a moment before. Data formats can be migrated, domain primitives can be reconsidered, names can be changed, interface boundaries can be moved.
But there is never unlimited time, Unless you are simply programming for the joy of programming, and not particularly concerned about finishing some thing, or being able to make changes without taking on a large project each time.
YAGNI is a mantra that we use to keep ourselves away from designing too much too early, introducing inappropriate abstractions and complexity that we will come to regret. But we are not religious fundamentalists either. YAGNI is a reminder, not a commandment. Just like DRY has its opposing WET, YAGNI has its opposing principle, even if we don't have a pithy name for it.
> Don't try to solve speculative future problems. But if you are pretty sure you're going to need something in the future, you should probably take it into consideration in your design.
This is the real balance being struck. YAGNI is a pushback against the "you never know, one day we might want to expand this thing. I can't think of why right now, but let's make sure it's easy to do when if it happens!"
That's very different from the case of "There's a use case that we plan on getting to in 3 weeks. Why don't we save ourselves some future work and do this now"
Actually, under engineering is a common thing as well. It leads to systems that once they are implemented are hard to modify. Typical symptoms are people trying to hack around that. Or designers sacrificing necessary complexity in favor of an elegant look.
If you've ever had the discussion whether the name field is 1 text field or 2 text fields, that's what's going on. "I don't care about first name last name, we just need a single name field, much more elegant". Fast forward a few months ... "hey can you auto-magically split that name field in first name, last name".
No kidding, I've had to sit a PM down on at least two projects where that happened and explain to them why that was a silly idea. In case you are wondering, there's a good reason why most apps and sites use two fields: splitting automatically reliably is not possible and as soon as you want to address somebody by their first (or last) name in e.g. an email, invoice, etc. you need both fields. Another classic mistake here is that the first name and last name are two words. They can be multiple words. Both. And two fields is actually not enough for some names if you really want to cover everything. There's also salutations, titles, middle name, maiden name, etc. It's a mess. But you can usually get away with using just first & last name. Just one example.
It's the famous Einstein paraphrasing of making things as simple as you can; but not simpler.
The issue with over engineering is engineers getting carried away with edge cases that in practice aren't relevant. Like trying to build a super scalable web platform for a thing that has maybe a handful of active users. Optimizing a thing that only takes a millisecond in a request that takes half a second. Nice but your problem was somewhere in the 499 other milliseconds. Obsessing over the one in ten thousand users that might want feature x used in some weird way, etc.
You've correctly identified a problem, but you've come to the wrong conclusion.
If you ask someone their name, a single field copes with a range of different name shapes.
If you want to address them in an email with "just first name", what you're really asking for is a preferred name to be greeted by, and that should be an entirely different single field.
By splitting or having "firstname/lastname" fields you're making the classic mistake about names.
Ask in the sign-up or user preferences their preferred way to be greeted in an email. If the user wants their marking emails to start "Dear shithead" then free them up to do that. You could try splitting the "full name" field to pre-fill a suggested name by which to be greeted.
Just going with the principle of the least amount of surprise here. This is simply what the vast majority of websites do because it's good enough. It doesn't offend any one too much. I'm well aware of the full range of complexity offered in e.g. the vcard standard. But dumping that on the user might not work out the way you want either. There are also some cultural differences of course with E.g. Asian people using their family name first instead of last.
But going with the number of fields: 1 is probably under-engineering it, 2 is probably enough, 3 would be a stretch. 4 or more is probably over engineering the problem (unless you specifically have a requirement for this).
I can throw one more curveball here that currently isn't really solved anywhere. Nameless people.
This situation arises when you know you want to travel after giving birth, and you want to book the tickets in advanced to take advantage of lower prices. Most airlines won't let you book a ticket without a name, nor do they let you edit names.
The situation is also relevant in hospitals where babies are born, in Finland they literally put girl <surname> / boy <surname> into the system as the first / lastname. It's quite funny that a place literally designed to handle new humans, does not have a way to handle that.
I don’t remember who said it but there’s a quote about communications protocols that goes “it’s not finished when there’s nothing left to add, it’s finished when there’s nothing left to take away”. I use that a lot in discussions with my teams.
I've seen so many times the same situation - brilliant junior comes, after reading it all about design patterns and taking inspiration in ie internals of Spring framework, builds a small (or big) cathedral, optimizing for designs that normally will never come in future.
Layers and layers of abstractions, matching various situations that happen during initial implementation, everybody seems happy, business doesn't care about internals. Then brilliant junior inevitably leaves for greener pastures since it becomes boring, or is reassigned elsewhere. Every single other, even more experienced dev, struggles with maintaining, changing and fixing that part. But it became so embedded in overall architecture thus nobody has guts to rip it out, and business rarely wants to finance internal refactorings.
So this burden than stays around, mothballing problems, making projects take longer, unexpected issues keep happening since everybody else has hard time keeping good mental model of that part with all its implications. Also nobody wants to admit that design is just overengineered crap, people have ego issues and feeling inferior which is pretty toxic and immature behavior, but I've seen it also in older folks as well.
I had discussions around this exact topic with various very senior devs over my 20 years in business. Its always the same story - they see the code, are in awe of how such complex and on first sight elegant code can be done so quickly and respect the author uncritically. But every single time, down the line, it becomes a nightmare and source of massive pain.
Thats why I call them brilliant juniors, nothing more, seniority also comes in form of 'should we do it like that' instead of 'can we do it like that'. Focusing on 'having fun', 'being a code ninja' or whatever little ego game they need to play with themselves to not get bored. Guess what - business who pays your salary loves boring, predictable, measurable, estimable, and couldn't care less about some internal issues of its employees. One of the reasons C suites look down on IT is exactly these geeks who should know better, or be coached/managed better.
Or yeah, just don't optimize for problems you are not experiencing, in 99% of the cases, for 99% of the apps.
I get your idea but I disagree - I’ve had plenty of bugs where people don’t fully implement an “interface” or a design or a contract. I’ve had many issues where people don’t handle failure cases and leave things in a bad state.
On the interfaces one, it’s less likely but a bad interface is a symptom of a bad design. An interface that doesn’t actually expose what it needs to means that the implementation ends up coupled to other areas. Just right now, I’m untangling an interface for storing “stuff” which has a save method. The implementation has third party auth in it, and now that something else is using the same service we need to share the credentials between the two services. It’s turning out to be a mountain of work!
I think the problem with simple is that people that don't know what they are doing are too tempted to just copy the simple everywhere, not knowing when to stop.
Then when you reach the point where you need to change the simple to something more complex, you realize that there's 249 places where you need to change that simple thing, and management is unwilling to take that risk just to prevent potential problems down the road, so the simple thing slowly grows to 984 places, when a massive, massive incident happens, and suddenly it becomes the highest priority to fix. Joy.
In my experience, the _only_ time to make things complex is right from the start.
> If anything, overly simplistic solutions sometimes make me feel like I'm repeating myself, but really since the code is so easy it always feels possible to refactor it.
This is way underrated. I've been on a project that was in one hand quite over-engineered, quick and dirty on the other hand. The thing is tech leads insisted on factoring absolutely everything. So you had a component (not compliant with solid's O) that dealt with 2 cases, needed two more cases added. And this is where it all breaks. I wish we could have dumbly copy pasted the thing two more times instead of having to deal with the weird and unforcasted interactions.
This. The problem is people stay at a job for 2 years. You can't properly see how the software evolves in 2 years to know that the things you're imagining might be needed have never been needed. Software tends to change in fairly predictable ways. If it suddenly changes in a new direction, your market has changed and the old solution isn't going to work well anyway.
> You can't properly see how the software evolves in 2 years
I'd counter that this is subjective based on experience.
If I've built a similar system before, it's in the same domain, and the customer/userbase has a similar profile, then it makes sense that an engineer that's "been there, done that" can foresee the needs of the system, domain, and customer and can more accurately extrapolate how the system will need to evolve.
I assume that this is one of the reasons teams hire for both technical and domain experience.
As an example, I spent more than a decade building regulated document management systems and it's clear to me what needs to be done to scale the system to support millions of documents. Is it premature optimization or over-engineering if I join a team that's building a new document management system and I design it to scale from day 1?
Similarly, if you're building a product that interacts with calendars, perhaps you start with only supporting GCal, but you can already extrapolate that the market has 2 other major calendar providers to account for: Outlook 365 and Apple iCal. Wouldn't it make sense to write an interface here knowing that a year down the line, you'll add support for O365 and then another year after, add support for Apple?
To an engineer or product manager new to the space, any extra effort looks like "over-engineering". For someone that's built similar systems for over a decade, some decisions seem inevitable because the usage patterns of such systems are not that different and will eventually converge so one might as well start with that endpoint.
I suspect that some decisions made in the development of Threads or BlueSky might seem like "over-engineering". But those teams are not working in a vacuum; they already have a sense of how such systems will grow over time based on experience.
Thus I would make the case that the cost of perceived "over-engineering" is also going to differ. For someone doing it for the first time, that perceived cost is higher than for someone doing it the 2nd or 3rd time.
My take is that what is "over-engineered" has a subjective aspect based on the experience level of the parties involved.
> > You can't properly see how the software evolves in 2 years
> I'd counter that this is subjective based on experience.
> If I've built a similar system before, it's in the same domain, and the customer/userbase has a similar profile, then it makes sense that an engineer that's "been there, done that" can foresee the needs of the system, domain, and customer and can more accurately extrapolate how the system will need to evolve.
Don't just ignore the first part of their comment:
> > The problem is people stay at a job for 2 years.
You can't "been there, done that" if you haven't "been there" in the first place.
It doesn't really matter if the domain is the same.
If you're building a regulated document management system for life sciences, the big picture aspects of what it has to do, how it has to scale, and how it has to be packaged (process, testing, documentation) doesn't have much variance.
> that an engineering that's "been there, done that" can foresee the needs ...
Agreed.
That said, where I've encountered the most issues have been when engineers who are relatively new hires *believe* they've "been there, done that", and haven't yet internalized the different nuances of the situation vs their prior experience. This sets up a dangerous scenario where they have enough experience to be quite believable, but unfortunately may be missing the fact that their experience isn't as applicable as they believe.
> I've never been bitten by an interface that is too simple.
I've been bitten by strcpy. And errno. And malloc/free. And many others.
In general, simple interfaces are good interfaces, but it takes quite a bit of experience to learn what is a good simple interface.
For example not having to resort to global variables or a lot of state or a tightly coupled mess is not self evidient, but something that comes with practice, and usually the hard way. It doesn't help that many "best practices" aren't very good practices.
The key superpower is a culture of refactoring when needed.
I firmly believe you should design for the problem you've got now, and not worry too much about the future. You're usually wrong about it anyway! This is fine, as long as you do refactor when more requirements come along.
In order to do that you need a few things though: people who notice and care "oh we do the same in 5 places now, I should probably pull that out to a new module", a development culture that encourages and accepts that thinking, and a QA/delivery approach that doesn't say "oooh, that's a bit risky, do you have to refactor that?".
I think you probably understated the importance of a culture of refactoring.
In one of my roles, the focus was solely on velocity. That meant no technical releases and an incessant rush to get the features out while still blaming people for issues. All that did was to instill a fear of breaking things and encouraged blind copy/paste and doing the bare minimum. One repo had scores of scripts that were several hundreds of LOC each while the difference between them was maybe 10-100 lines. People copied with misalignment/typos intact. When I attempted to refactor some stuff out to a library, the tech lead gave me a scolding citing the discomfort to Support team due to "lack of familiarity" (even though there wasn't any objection from Support team themselves) i.e. better propagation of the familiar ball of mud rather than the unfamiliar cleanliness (the unfamiliarity is usually only temporarily, until people catch up, and eventually results in eminently more readable code and actually speeds up dev velocity). This was just one example of out of a much larger ecosystem that had all sorts of issues built up over many years due to complete disinterest in refactoring.
One of the senior managers told us to focus on speed and that it was ok to break some things and we could fix it iteratively while ignoring and/or being unaware of the ground reality that doing so requires:
- avoiding blame game (not the case in this example)
- having sufficient manpower (again not the case in this example)
- having a culture of fearless refactoring based on technical judgement (definitely not the case in this example).
Apparently it is easy to think you can copy Facebook's move-fast-and-break-things and reap the benefits without fulfilling any of the prerequisites.
Even today, the technically undisciplined management still keeps chasing velocity and keeps being more frustrated that deliveries are progressively getting slower.
This is something a lot of people don’t get. Refactors and even complete rewrites are a natural phase of the SDLC. They keep codebases up to date on the latest technological advances, and allow devs to maintain a complete mental model of the system.
Yep, fully agree here. Some companies seem to consider the word 'refactor' to be profane language, and I think that's a real shame. In my experience it's those companies that end up with mountains of technical debt and regressions each cycle.
Allowing your engineers the freedom to truly own and maintain the code they're responsible for will necessarily result in refactors that sometimes even include rewrites and data migrations. On the surface and in the short term this seems risky. In the medium-to-longer term I think it results in happier employees, more confidence in the solutions and faster longer-term velocity.
The right balance will look very different in different parts of a system. In general the lower levels of a system need to be more generic, and higher levels more specific. One often overlooked guiding principle is that when you’re building large systems it’s important to be able to “finish” some parts of the system and be “done” with them, otherwise your mental “context window” will just grow forever until not even the brightest among us can make any progress.
The thing missing from this analysis is how hard things will be to change later.
If it's an internal function called in just one or two other files maintained by you our your team, then no need to spend a lot of time thinking too hard about it -- just write it as simply as possible, and then rewrite it when you find it no longer suits your needs.
If it's going to go into an storage schema (database, json, whatever) which will require data migration if we need to refactor it -- or if it's an library that will be used by large amounts of code maintained by other teams or other organizations -- then it's worth spending a bit more time designing it. You're balancing the risk of wasting effort over-engineering it against the risk of the effort invovled in having to modify it later.
If it's going to be a public interface used by customers and supported indefinitely, then you'd better be very careful about getting it as close to perfect as you can, since now you're balancing the risk of wasting effort over-engineering it against the risk of having to support a terrible API for years.
I constantly try to improve how I write my code. The last couple of years I’ve been involved in a couple of projects that’s required rebuilding a few times over because the project managers kept changing directions and was always in a hurry.
For one project I eventually managed to convince them to let our team write a more general purpose library to conserve time in the future. And this has paid itself of several times over.
When we write code it’s important we do consider how it may be utilised in the future. Since we cannot make exact predictions it’s better to make methods as small as possible instead to reduce the amount of time spent on refactoring (since this is unavoidable). This also helps us to create solid, more future proof, tests for our business logic.
You don’t need to follow a strict set of design rules, but general guidelines is a good idea. Like trying to follow SRP, avoiding more than x nubmer of lines for your method bodies and trying to avoid nestled code.
> Since we cannot make exact predictions it’s better to make methods as small as possible instead
No, please don't. Separate your functions based on what they're doing, not an arbitrary line count. If it takes 50 lines to do what's necessary, a 50 line function is absolutely fine.
You know what isn't fine? One operation split into 50 different subroutines that are all useless on their own unless composed together in order.
I agree with you, I didn’t say that quite right. In my mind this is actually pretty much what I meant, though I completely see how it can be misinterpreted. Line counts, like most other design rules should really be more like guidelines. Thank you for helping me clarify myself mate!
Experience and domain knowledge also matters when designing. For some parts, futuristic thinking is not at all needed while other parts may need it. If you are building a ticketing software, you should expect to add different kind of printers (dot matrix, thermal, inkjet, etc.) so you add an abstraction. The ticket text format may change, so some abstraction will help. The API service hander might not change at all, so an abstraction is unnecessary in that case.
> public void RemoveItems(Func<Item, bool> condition)
Tangential, but remember that you can't generally push this condition over a database connection. You've just forced your code to be best-case O(n).
Using structured queries as far as possible is useful for performance. But consider what happens if you create a Predicate structure, and someone adds a field without updating the Remove function to match... Perhaps a recursive discriminated union is the way to go, for languages that fail compilation when not all branches are covered? Positional arguments in other languages?
Why does introducing interfaces and vtables have to be the right abstraction? Given the example, the simplest solution is best. If you don’t need runtime semantics, just write simple code.
The Cons of a "simple" solution:
> As you add features, the class becomes more cluttered.
But if it's a feature, it shouldn't be considered clutter. Clutter usually comes from over-engineering, not necessary additions.
> Each method does one specific thing. That seems fine until you realize your interface is full of shallow, one-off methods. That makes it hard to maintain and extend.
However, each method is efficient and tailored to solve a specific feature, which boosts performance. Plus, if there's a bug, it's easy to find and fix since each method has a clear path.
Also, to maintain a clear designed API, I suggest following some data-oriented recommendations, like designing functions to take lists of elements instead of single elements.
For the Vehicle example the conclusion wasn't that the Vehicle abstraction was premature, it was to prefer Composition and Interfaces over Inheritance which is a well discussed idea around OO design.
The Vehicle example is relatable to day to day work but for me the Vehicle abstraction seems reasonable. The mistake is not that a Bike is or isn't a vehicle but rather that the abstractions should align with the business usage: a bicycle doesn't share much in common with a car, the company just wants to rent them out.
So most the stuff that they will share in common will be around the sales/rental part of the business logic. You wouldn't expect them to really be displayed together anywhere either. So I wouldn't be trying to force a Bike into a Vehicle, I'd be looking for the seams related to the truly common operations on Bikes vs Cars and look to extract interfaces or extend polymorphism there
I think this is the common understanding of what 'abstraction' and 'generality' mean in the over-engineering sense, and I think it's wrong.
You make things more general/abstract/future-proof by removing things, not adding things.
ShoppingCart is a collection. We have libraries and libraries of code which work over collections. So List<Item> is better.
Once you start hacking on the various ShoppingCart-related methods a bit more, you'll probably realise you don't depend on Item. Many of those methods should start operating on List<A> instead. And then chances are they'll be pass-through methods that just forward the behaviour to some List implementation, at which point you can delete the passthrough methods, and directly call the List methods directly.
The vehicle example doesn't make sense (start engine? At most, a notification from a rented vehicle to the server that the engine has been started, which is simply not going to arrive from a bicycle).
The shopping cart example is dangerous because it looks reasonable. Actually, the removal methods define atomic operations, so they are a big deal from an architectural point of view: can the cart change while we are testing elements for removal? Can the predicates look at other elements of the cart? Self-contained special case operations might be a more correct choice after all.
If anyone with more than six months experience submitted that vehicle PR with a half dozen interfaces we'd be having a chat outside of the review because that's pretty bad. That might be the worst code in the entire article, and it's supposed to be one of the good examples? This is even ignoring the fact that there are languages that don't support multiple interface inheritance which isn't that big of a deal but hints that the author may not have a ton of experience outside the couple languages he works in every day.
A structural engineer has to get it right the first time, or people may get hurt. Nobody is going to lose their life if a SWE has some inefficient code duplication in their POC that is tested by a maximum of 5 people. There is a time for improving your work and making it more robust, reliable, and efficient — but that can often be done iteratively. Code is malleable, just don’t let it fester and rot forever if you continue to build on top of it, or you will be drowning in tech debt.
I always try to find the unspoken assumptions in these kind of articles. In this case, the need to use classes for everything, as the only hammer.
Pure functions with clearly typed inputs and outputs: you list all the available types and add when you need more supported, like data. They could be in a static class for convenience, or simply in a module.
or some finite state machine or a data-driven approach would make most of the examples much clearer.
It's interesting you call out a year as a time span. I have no idea what the goals will be in a year, let alone how I'll code it. A year seems like a really long time, but also just short enough to justify adding extra work in early. My timeframe is more like a week to a month. Obviously different orgs will have different time windows for real reasons.
It's quite a different thing when you think about just plain code that would need refactoring and data systems that need migrating. Probably an easier decision to keep code more focused on the present than data architecture, because rewriting code, while annoying, is a lot simpler than switching out data systems and migrating data in production.
> This approach is good for now. But, it will limit you later. Your code will quickly get out of control.
Will it? If we need to be able to “delete via X”, it sure feels more maintainable to have this feature supported via an explicit, named method vs. inlining it as an anonymous closure (as recommended by the “balanced” solution).
I wish there were a big list of example situations like these to pour over. One could write a whole book on this topic (maybe someone already has). I spend so much time thinking about design decisions like these, but rarely get to hear someone else's thought process.
problems created by being stuck in too much of an OOP / Clean Code mindset.
if your initial entity is Map like - some call them records, data classes, objects etc - you can simply add / remove properties willy nilly. and have functions that filter on those properties.
Holy strawmans, Batman! This article sets up a false dichotomy (virtually nobody argues for designing for "future needs", it's more like varying degrees and definitions of "cleanliness"), then gives examples of terrible code at both ends of this dichotomy, and settles it with very mediocre code in the middle. IExternalRuleService? Is that supposed to resemble anything anyone sane would ever do? Do people literally write "Car : Vehicle" out of their 1st year of college anymore?
Let's assume we're all past those strawman examples and talk about the proposed "Balanced Approaches" instead.
ShoppingCart looks to be re-writing an in-memory collection. Is it actually adding anything beyond "Collection<Item>"? (Substitute with whatever collection type you like in your language). Do you actually need a class at all? There simply isn't enough specified in the article to tell. Therefore there's no lesson here: the code as-is shouldn't exist, and we can't tell what ShoppingCart is actually supposed to be doing. If it's making database calls, you have multiple major problems. The best advice I can give in that situation is: don't do, just plan. RemoveItems should take something that can be interpreted into SQL (or whatever's appropriate for the persistence you're using) and return a plan that can be composed with other plans.
IRefuelable, IParkable, IEngineOperable ... these seem to be missing the point that interfaces are defined by their consumers, not by their implementations. You write an interface when you have a need for its methods, not when you happen to have two implementations of them. If you find you're writing classes like RealLifeObject : IFoo, IBar, IBaz, IBing, IBong, then your class is capturing the wrong thing. Most likely, those Foos and Bars are the real domain objects you care about, and Car should never have been a class. Go read Eric Lippert's excellent Wizards and Warriors (all five posts): https://ericlippert.com/2015/04/27/wizards-and-warriors-part...
It's like this any time I see an argument about over-engineering, abstraction, etc. It just feels like everyone is missing the whole point. We argue about abstraction without even agreeing on what abstraction actually is. (Spoiler: it's many different things.) We get bogged down in silly underspecified toy examples. So many articles are "stop smearing shit all over your face! Smear it on your LEGS instead! Much better!" Why are we smearing all this shit in the first place? It's very hard to have an internet conversation about this.
Don't try to solve future problems. You'll have the problem that the problem you solved isn't the problem that needs solving, and then the problem that needs solving.
If anything, overly simplistic solutions sometimes make me feel like I'm repeating myself, but really since the code is so easy it always feels possible to refactor it. That's a good place to be, much better than afraid to change something because it's too generic to know if you'll break something.
Everyone has an opinion on an interface that is easy to understand, and not over-engineered (bikeshedding).
Don't try to solve speculative future problems. But if you are pretty sure you're going to need something in the future, you should probably take it into consideration in your design.
We then had to spend a long time cleaning up the mess when lo and behold, we needed to add more territories to the dataset.
I think we've swung too far in the wrong direction when it comes to "premature abstraction" and yagni.
The only acceptable reason I've seen for writing bad code is "we might not be here tomorrow" but that is literally only valid for extremely young startups and even then those decisions could ripple and indirectly kill the company in the future when they're no longer able to pay off the tech debt in time
I hear YAGNIs when I know a pattern from experience with past projects even though deep down I know it is not that high effort due to past experience and we will need it.
I hate hearing YAGNIs when it is something I specifically have had experience with in the past and they think it is too complex because they haven't done it.
And they won't get it because they haven't gone through it.
> we might not be here tomorrow
Is true and increasingly common which is the recurring rounds of layoffs we all seem to be experiencing. It's in the individual developer's interest to bodge something in if it makes them appear productive in time for the next layoff. Certainly not a healthy incentive for building good software but it is what it is.
I agree that an accretion of kludges is bad. The problem here comes down to usually the skill of the programmer when the new work comes up. To people who don't understand the code, they are more likely to make what they think is a simple kludge than actually refactor to solve the problem "correctly", which usually involves more thought and creativity.
Whereas extra functionality usually increases the code surface area, and once published as a public interface to a library, is very costly and painful to delete. Then it festers as complications to new code that is added have to assume this extra functionality is used rather than not.
Given unlimited time, a sufficiently skilled programmer can always refactor as needed, when needed, and not a moment before. Data formats can be migrated, domain primitives can be reconsidered, names can be changed, interface boundaries can be moved.
But there is never unlimited time, Unless you are simply programming for the joy of programming, and not particularly concerned about finishing some thing, or being able to make changes without taking on a large project each time.
YAGNI is a mantra that we use to keep ourselves away from designing too much too early, introducing inappropriate abstractions and complexity that we will come to regret. But we are not religious fundamentalists either. YAGNI is a reminder, not a commandment. Just like DRY has its opposing WET, YAGNI has its opposing principle, even if we don't have a pithy name for it.
This is the real balance being struck. YAGNI is a pushback against the "you never know, one day we might want to expand this thing. I can't think of why right now, but let's make sure it's easy to do when if it happens!"
That's very different from the case of "There's a use case that we plan on getting to in 3 weeks. Why don't we save ourselves some future work and do this now"
If you've ever had the discussion whether the name field is 1 text field or 2 text fields, that's what's going on. "I don't care about first name last name, we just need a single name field, much more elegant". Fast forward a few months ... "hey can you auto-magically split that name field in first name, last name".
No kidding, I've had to sit a PM down on at least two projects where that happened and explain to them why that was a silly idea. In case you are wondering, there's a good reason why most apps and sites use two fields: splitting automatically reliably is not possible and as soon as you want to address somebody by their first (or last) name in e.g. an email, invoice, etc. you need both fields. Another classic mistake here is that the first name and last name are two words. They can be multiple words. Both. And two fields is actually not enough for some names if you really want to cover everything. There's also salutations, titles, middle name, maiden name, etc. It's a mess. But you can usually get away with using just first & last name. Just one example.
It's the famous Einstein paraphrasing of making things as simple as you can; but not simpler.
The issue with over engineering is engineers getting carried away with edge cases that in practice aren't relevant. Like trying to build a super scalable web platform for a thing that has maybe a handful of active users. Optimizing a thing that only takes a millisecond in a request that takes half a second. Nice but your problem was somewhere in the 499 other milliseconds. Obsessing over the one in ten thousand users that might want feature x used in some weird way, etc.
If you ask someone their name, a single field copes with a range of different name shapes.
If you want to address them in an email with "just first name", what you're really asking for is a preferred name to be greeted by, and that should be an entirely different single field.
By splitting or having "firstname/lastname" fields you're making the classic mistake about names.
Ask in the sign-up or user preferences their preferred way to be greeted in an email. If the user wants their marking emails to start "Dear shithead" then free them up to do that. You could try splitting the "full name" field to pre-fill a suggested name by which to be greeted.
But going with the number of fields: 1 is probably under-engineering it, 2 is probably enough, 3 would be a stretch. 4 or more is probably over engineering the problem (unless you specifically have a requirement for this).
This situation arises when you know you want to travel after giving birth, and you want to book the tickets in advanced to take advantage of lower prices. Most airlines won't let you book a ticket without a name, nor do they let you edit names.
The situation is also relevant in hospitals where babies are born, in Finland they literally put girl <surname> / boy <surname> into the system as the first / lastname. It's quite funny that a place literally designed to handle new humans, does not have a way to handle that.
https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-...
My decider question on whether something is really needed: "Am I willing to write a test case for this?"
This typically helps strip down functionality to the essentials, and may even prompt some minor API redesigns.
Layers and layers of abstractions, matching various situations that happen during initial implementation, everybody seems happy, business doesn't care about internals. Then brilliant junior inevitably leaves for greener pastures since it becomes boring, or is reassigned elsewhere. Every single other, even more experienced dev, struggles with maintaining, changing and fixing that part. But it became so embedded in overall architecture thus nobody has guts to rip it out, and business rarely wants to finance internal refactorings.
So this burden than stays around, mothballing problems, making projects take longer, unexpected issues keep happening since everybody else has hard time keeping good mental model of that part with all its implications. Also nobody wants to admit that design is just overengineered crap, people have ego issues and feeling inferior which is pretty toxic and immature behavior, but I've seen it also in older folks as well.
I had discussions around this exact topic with various very senior devs over my 20 years in business. Its always the same story - they see the code, are in awe of how such complex and on first sight elegant code can be done so quickly and respect the author uncritically. But every single time, down the line, it becomes a nightmare and source of massive pain.
Thats why I call them brilliant juniors, nothing more, seniority also comes in form of 'should we do it like that' instead of 'can we do it like that'. Focusing on 'having fun', 'being a code ninja' or whatever little ego game they need to play with themselves to not get bored. Guess what - business who pays your salary loves boring, predictable, measurable, estimable, and couldn't care less about some internal issues of its employees. One of the reasons C suites look down on IT is exactly these geeks who should know better, or be coached/managed better.
Or yeah, just don't optimize for problems you are not experiencing, in 99% of the cases, for 99% of the apps.
On the interfaces one, it’s less likely but a bad interface is a symptom of a bad design. An interface that doesn’t actually expose what it needs to means that the implementation ends up coupled to other areas. Just right now, I’m untangling an interface for storing “stuff” which has a save method. The implementation has third party auth in it, and now that something else is using the same service we need to share the credentials between the two services. It’s turning out to be a mountain of work!
Then when you reach the point where you need to change the simple to something more complex, you realize that there's 249 places where you need to change that simple thing, and management is unwilling to take that risk just to prevent potential problems down the road, so the simple thing slowly grows to 984 places, when a massive, massive incident happens, and suddenly it becomes the highest priority to fix. Joy.
In my experience, the _only_ time to make things complex is right from the start.
This is way underrated. I've been on a project that was in one hand quite over-engineered, quick and dirty on the other hand. The thing is tech leads insisted on factoring absolutely everything. So you had a component (not compliant with solid's O) that dealt with 2 cases, needed two more cases added. And this is where it all breaks. I wish we could have dumbly copy pasted the thing two more times instead of having to deal with the weird and unforcasted interactions.
This. The problem is people stay at a job for 2 years. You can't properly see how the software evolves in 2 years to know that the things you're imagining might be needed have never been needed. Software tends to change in fairly predictable ways. If it suddenly changes in a new direction, your market has changed and the old solution isn't going to work well anyway.
If I've built a similar system before, it's in the same domain, and the customer/userbase has a similar profile, then it makes sense that an engineer that's "been there, done that" can foresee the needs of the system, domain, and customer and can more accurately extrapolate how the system will need to evolve.
I assume that this is one of the reasons teams hire for both technical and domain experience.
As an example, I spent more than a decade building regulated document management systems and it's clear to me what needs to be done to scale the system to support millions of documents. Is it premature optimization or over-engineering if I join a team that's building a new document management system and I design it to scale from day 1?
Similarly, if you're building a product that interacts with calendars, perhaps you start with only supporting GCal, but you can already extrapolate that the market has 2 other major calendar providers to account for: Outlook 365 and Apple iCal. Wouldn't it make sense to write an interface here knowing that a year down the line, you'll add support for O365 and then another year after, add support for Apple?
To an engineer or product manager new to the space, any extra effort looks like "over-engineering". For someone that's built similar systems for over a decade, some decisions seem inevitable because the usage patterns of such systems are not that different and will eventually converge so one might as well start with that endpoint.
I suspect that some decisions made in the development of Threads or BlueSky might seem like "over-engineering". But those teams are not working in a vacuum; they already have a sense of how such systems will grow over time based on experience.
Thus I would make the case that the cost of perceived "over-engineering" is also going to differ. For someone doing it for the first time, that perceived cost is higher than for someone doing it the 2nd or 3rd time.
My take is that what is "over-engineered" has a subjective aspect based on the experience level of the parties involved.
> I'd counter that this is subjective based on experience.
> If I've built a similar system before, it's in the same domain, and the customer/userbase has a similar profile, then it makes sense that an engineer that's "been there, done that" can foresee the needs of the system, domain, and customer and can more accurately extrapolate how the system will need to evolve.
Don't just ignore the first part of their comment:
> > The problem is people stay at a job for 2 years.
You can't "been there, done that" if you haven't "been there" in the first place.
If you're building a regulated document management system for life sciences, the big picture aspects of what it has to do, how it has to scale, and how it has to be packaged (process, testing, documentation) doesn't have much variance.
Agreed.
That said, where I've encountered the most issues have been when engineers who are relatively new hires *believe* they've "been there, done that", and haven't yet internalized the different nuances of the situation vs their prior experience. This sets up a dangerous scenario where they have enough experience to be quite believable, but unfortunately may be missing the fact that their experience isn't as applicable as they believe.
I've been bitten by strcpy. And errno. And malloc/free. And many others.
In general, simple interfaces are good interfaces, but it takes quite a bit of experience to learn what is a good simple interface.
For example not having to resort to global variables or a lot of state or a tightly coupled mess is not self evidient, but something that comes with practice, and usually the hard way. It doesn't help that many "best practices" aren't very good practices.
- Matthew 6:34, KJV
I firmly believe you should design for the problem you've got now, and not worry too much about the future. You're usually wrong about it anyway! This is fine, as long as you do refactor when more requirements come along.
In order to do that you need a few things though: people who notice and care "oh we do the same in 5 places now, I should probably pull that out to a new module", a development culture that encourages and accepts that thinking, and a QA/delivery approach that doesn't say "oooh, that's a bit risky, do you have to refactor that?".
In one of my roles, the focus was solely on velocity. That meant no technical releases and an incessant rush to get the features out while still blaming people for issues. All that did was to instill a fear of breaking things and encouraged blind copy/paste and doing the bare minimum. One repo had scores of scripts that were several hundreds of LOC each while the difference between them was maybe 10-100 lines. People copied with misalignment/typos intact. When I attempted to refactor some stuff out to a library, the tech lead gave me a scolding citing the discomfort to Support team due to "lack of familiarity" (even though there wasn't any objection from Support team themselves) i.e. better propagation of the familiar ball of mud rather than the unfamiliar cleanliness (the unfamiliarity is usually only temporarily, until people catch up, and eventually results in eminently more readable code and actually speeds up dev velocity). This was just one example of out of a much larger ecosystem that had all sorts of issues built up over many years due to complete disinterest in refactoring.
One of the senior managers told us to focus on speed and that it was ok to break some things and we could fix it iteratively while ignoring and/or being unaware of the ground reality that doing so requires:
- avoiding blame game (not the case in this example)
- having sufficient manpower (again not the case in this example)
- having a culture of fearless refactoring based on technical judgement (definitely not the case in this example).
Apparently it is easy to think you can copy Facebook's move-fast-and-break-things and reap the benefits without fulfilling any of the prerequisites.
Even today, the technically undisciplined management still keeps chasing velocity and keeps being more frustrated that deliveries are progressively getting slower.
Allowing your engineers the freedom to truly own and maintain the code they're responsible for will necessarily result in refactors that sometimes even include rewrites and data migrations. On the surface and in the short term this seems risky. In the medium-to-longer term I think it results in happier employees, more confidence in the solutions and faster longer-term velocity.
If it's an internal function called in just one or two other files maintained by you our your team, then no need to spend a lot of time thinking too hard about it -- just write it as simply as possible, and then rewrite it when you find it no longer suits your needs.
If it's going to go into an storage schema (database, json, whatever) which will require data migration if we need to refactor it -- or if it's an library that will be used by large amounts of code maintained by other teams or other organizations -- then it's worth spending a bit more time designing it. You're balancing the risk of wasting effort over-engineering it against the risk of the effort invovled in having to modify it later.
If it's going to be a public interface used by customers and supported indefinitely, then you'd better be very careful about getting it as close to perfect as you can, since now you're balancing the risk of wasting effort over-engineering it against the risk of having to support a terrible API for years.
https://www.informit.com/articles/article.aspx?p=2995357&seq...
For one project I eventually managed to convince them to let our team write a more general purpose library to conserve time in the future. And this has paid itself of several times over.
When we write code it’s important we do consider how it may be utilised in the future. Since we cannot make exact predictions it’s better to make methods as small as possible instead to reduce the amount of time spent on refactoring (since this is unavoidable). This also helps us to create solid, more future proof, tests for our business logic.
You don’t need to follow a strict set of design rules, but general guidelines is a good idea. Like trying to follow SRP, avoiding more than x nubmer of lines for your method bodies and trying to avoid nestled code.
No, please don't. Separate your functions based on what they're doing, not an arbitrary line count. If it takes 50 lines to do what's necessary, a 50 line function is absolutely fine.
You know what isn't fine? One operation split into 50 different subroutines that are all useless on their own unless composed together in order.
Make your interfaces small, not your functions.
0: https://caseymuratori.com/blog_0015
Learn, practice, and teach to understand what good enough-engineering is.
Tangential, but remember that you can't generally push this condition over a database connection. You've just forced your code to be best-case O(n).
Using structured queries as far as possible is useful for performance. But consider what happens if you create a Predicate structure, and someone adds a field without updating the Remove function to match... Perhaps a recursive discriminated union is the way to go, for languages that fail compilation when not all branches are covered? Positional arguments in other languages?
The Cons of a "simple" solution: > As you add features, the class becomes more cluttered. But if it's a feature, it shouldn't be considered clutter. Clutter usually comes from over-engineering, not necessary additions.
> Each method does one specific thing. That seems fine until you realize your interface is full of shallow, one-off methods. That makes it hard to maintain and extend. However, each method is efficient and tailored to solve a specific feature, which boosts performance. Plus, if there's a bug, it's easy to find and fix since each method has a clear path.
Also, to maintain a clear designed API, I suggest following some data-oriented recommendations, like designing functions to take lists of elements instead of single elements.
The Vehicle example is relatable to day to day work but for me the Vehicle abstraction seems reasonable. The mistake is not that a Bike is or isn't a vehicle but rather that the abstractions should align with the business usage: a bicycle doesn't share much in common with a car, the company just wants to rent them out.
So most the stuff that they will share in common will be around the sales/rental part of the business logic. You wouldn't expect them to really be displayed together anywhere either. So I wouldn't be trying to force a Bike into a Vehicle, I'd be looking for the seams related to the truly common operations on Bikes vs Cars and look to extract interfaces or extend polymorphism there
You make things more general/abstract/future-proof by removing things, not adding things.
ShoppingCart is a collection. We have libraries and libraries of code which work over collections. So List<Item> is better.
Once you start hacking on the various ShoppingCart-related methods a bit more, you'll probably realise you don't depend on Item. Many of those methods should start operating on List<A> instead. And then chances are they'll be pass-through methods that just forward the behaviour to some List implementation, at which point you can delete the passthrough methods, and directly call the List methods directly.
The shopping cart example is dangerous because it looks reasonable. Actually, the removal methods define atomic operations, so they are a big deal from an architectural point of view: can the cart change while we are testing elements for removal? Can the predicates look at other elements of the cart? Self-contained special case operations might be a more correct choice after all.
Pure functions with clearly typed inputs and outputs: you list all the available types and add when you need more supported, like data. They could be in a static class for convenience, or simply in a module.
or some finite state machine or a data-driven approach would make most of the examples much clearer.
> This approach is good for now. But, it will limit you later. Your code will quickly get out of control.
Will it? If we need to be able to “delete via X”, it sure feels more maintainable to have this feature supported via an explicit, named method vs. inlining it as an anonymous closure (as recommended by the “balanced” solution).
if your initial entity is Map like - some call them records, data classes, objects etc - you can simply add / remove properties willy nilly. and have functions that filter on those properties.
Let's assume we're all past those strawman examples and talk about the proposed "Balanced Approaches" instead.
ShoppingCart looks to be re-writing an in-memory collection. Is it actually adding anything beyond "Collection<Item>"? (Substitute with whatever collection type you like in your language). Do you actually need a class at all? There simply isn't enough specified in the article to tell. Therefore there's no lesson here: the code as-is shouldn't exist, and we can't tell what ShoppingCart is actually supposed to be doing. If it's making database calls, you have multiple major problems. The best advice I can give in that situation is: don't do, just plan. RemoveItems should take something that can be interpreted into SQL (or whatever's appropriate for the persistence you're using) and return a plan that can be composed with other plans.
IRefuelable, IParkable, IEngineOperable ... these seem to be missing the point that interfaces are defined by their consumers, not by their implementations. You write an interface when you have a need for its methods, not when you happen to have two implementations of them. If you find you're writing classes like RealLifeObject : IFoo, IBar, IBaz, IBing, IBong, then your class is capturing the wrong thing. Most likely, those Foos and Bars are the real domain objects you care about, and Car should never have been a class. Go read Eric Lippert's excellent Wizards and Warriors (all five posts): https://ericlippert.com/2015/04/27/wizards-and-warriors-part...
It's like this any time I see an argument about over-engineering, abstraction, etc. It just feels like everyone is missing the whole point. We argue about abstraction without even agreeing on what abstraction actually is. (Spoiler: it's many different things.) We get bogged down in silly underspecified toy examples. So many articles are "stop smearing shit all over your face! Smear it on your LEGS instead! Much better!" Why are we smearing all this shit in the first place? It's very hard to have an internet conversation about this.