On over-engineering; finding the right balance
The article highlights the importance of balancing specific-purpose and generic solutions in software design, advocating for flexible approaches while avoiding over-engineering that complicates code and maintenance.
Read original articleThe article discusses the challenges of over-engineering in software design, emphasizing the need for a balanced approach between specific-purpose and overly generic solutions. It uses two examples: a shopping cart system and a vehicle rental system. In the shopping cart example, a specific-purpose code can lead to clutter and maintenance issues, while an overly generic solution introduces unnecessary complexity. The balanced approach suggests using a single method that accepts a condition for item removal, simplifying the code while maintaining flexibility. The vehicle rental system illustrates the pitfalls of poor abstraction when trying to future-proof designs. An overly abstract Vehicle class can lead to confusion and rigidity when new vehicle types are introduced. The article concludes that while it is important to create flexible code that can adapt to future needs, overgeneralization can complicate simple tasks and lead to significant refactoring. Developers are encouraged to focus on current problems while keeping future adaptability in mind, ensuring that their designs remain manageable and clear.
- Finding a balance between specific-purpose and overly generic code is crucial in software design.
- Overly specific code can lead to clutter, while overly generic code can introduce unnecessary complexity.
- A flexible approach, such as using a single method for various conditions, can simplify code.
- Poor abstractions can result in confusion and rigidity when accommodating new requirements.
- Developers should focus on current needs while allowing for future adaptability without over-engineering.
Related
Why We Build Simple Software
Simplicity in software development, likened to a Toyota Corolla's reliability, is crucial. Emphasizing straightforward tools and reducing complexity enhances reliability. Prioritizing simplicity over unnecessary features offers better value and reliability.
We Build Simple Software
Simplicity in software development, likened to a Toyota Corolla's reliability, is crucial. Emphasizing straightforward tools, Pickcode aims for user-friendly experiences. Beware of complex software's pitfalls; prioritize simplicity for better value and reliability.
Beyond Clean Code
The article explores software optimization and "clean code," emphasizing readability versus performance. It critiques the belief that clean code equals bad code, highlighting the balance needed in software development.
The human typewriter, or why optimizing for typing is short-sighted
The article highlights the drawbacks of optimizing code for typing speed, advocating for readability and clarity over efficiency, as obfuscated code increases mental strain and confusion among developers.
Design Patterns Are Temporary, Language Features Are Forever
The article examines programming paradigms, emphasizing that modern features in languages like Java 21 can render certain design patterns obsolete, enhancing code readability and maintainability while simplifying problem-solving.
- Many commenters emphasize the importance of designing for current needs rather than over-engineering for future problems, advocating for a culture of refactoring as requirements evolve.
- There is a consensus that simplicity in code is often preferable, with several commenters arguing that overly complex abstractions can lead to maintenance challenges.
- Some highlight the significance of context in design decisions, suggesting that the level of abstraction should vary depending on the component's role within the system.
- Several comments discuss the risks associated with data architecture changes compared to code refactoring, indicating that data systems often require more careful planning.
- Critiques of the article's examples suggest that they oversimplify the complexities of real-world software design, calling for more nuanced discussions around abstraction and interface design.
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 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?".
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.
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.
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.
Related
Why We Build Simple Software
Simplicity in software development, likened to a Toyota Corolla's reliability, is crucial. Emphasizing straightforward tools and reducing complexity enhances reliability. Prioritizing simplicity over unnecessary features offers better value and reliability.
We Build Simple Software
Simplicity in software development, likened to a Toyota Corolla's reliability, is crucial. Emphasizing straightforward tools, Pickcode aims for user-friendly experiences. Beware of complex software's pitfalls; prioritize simplicity for better value and reliability.
Beyond Clean Code
The article explores software optimization and "clean code," emphasizing readability versus performance. It critiques the belief that clean code equals bad code, highlighting the balance needed in software development.
The human typewriter, or why optimizing for typing is short-sighted
The article highlights the drawbacks of optimizing code for typing speed, advocating for readability and clarity over efficiency, as obfuscated code increases mental strain and confusion among developers.
Design Patterns Are Temporary, Language Features Are Forever
The article examines programming paradigms, emphasizing that modern features in languages like Java 21 can render certain design patterns obsolete, enhancing code readability and maintainability while simplifying problem-solving.