Fancy or Fragile? How to Hit the Sweet Spot
The Dangers of Over and Under-Engineering in < 5 minutes
Have you ever launched a feature you spent way too much time perfecting?
Or maybe released something that later was difficult to extend and had performance issues?
Guilty. š
Building software is all about finding the perfect balance.
But where is that sweet spot, and how do you avoid the pitfalls of over and under-engineering?
āļø Main Takeaways
Start with a clear MVP: Focus only on essential features.
Iterate based on user feedback: Continuously improve and expand features.
Prioritize maintainability: Write clean, well-documented code.
Avoid unnecessary complexity: Keep designs simple and practical.
Letās look at the arguments for under and over-engineering and my tips on finding the sweet spot.
In last weekās issue, I used the example of a Wizard UI component to demonstrate my point of AI-driven coding:
Well, guess what!
Iāve built the entire component, and despite starting from the template offered by the AI, after a few iterations, we came to a completely different solution.
š The Simple Solution
This example perfectly demonstrates the problem of under-engineering.
// logic for back, next finish
<>
<Stepper activeStep={currentStep} orientation="horizontal">
{steps.map((child, index) => (
<Step key={index}>
<StepLabel>{child.props.label}</StepLabel>
</Step>
))}
</Stepper>
<div>
{renderStepContent()}
<Button variant="contained" disabled={currentStep === 0} onClick={back}>
Back
</Button>
<Button variant="contained" onClick={currentStep === steps.length - 1 ? finish : next}>
{currentStep === steps.length - 1 ? 'Finish' : 'Next'}
</Button>
</div>
</>
And in the code, Iād use this component like this:
<Wizard>
<Step1Component label="Connect account" />
<Step2Component label="Trigger Events" />
<Step3Component label="Actions" />
<Step4Component label="Verification" />
</Wizard>
Pretty simple, right?
Yes, but what if:
I donāt want a Back and Finish/Next button on all of my wizards.
I want to be able to control the wizard steps programmatically
I want to skip steps
I want to render the wizard controls in a different place
These are all possible use cases for a wizard component that should be reusable. But despite taking minutes to create, our simple solution doesnāt allow any extension.
So, to recap:
šŖ Arguments for Under-Engineering
Pros:
Speed of Development
Quick to Market: Less time spent on unnecessary features means faster delivery.
Reduced Costs: Less development time reduces initial project costs. AI makes this trivial.
Simplicity
Easier Maintenance: Simpler code is often easier to understand, debug, and maintain.
Flexibility: Leaner codebases are more adaptable to changing requirements or pivots.
Cons:
Technical Debt
Accumulating Issues: The next team using your reusable component will need new features for their use cases. The more teams try to adapt your component, the more issues youāll have to address.
Hidden Bugs: Patching a design that wasnāt robust is a recipe for disaster. Such patches will result in undetected bugs that cause bigger problems or inflexibilities in the future.
Scalability and Performance
Limited Scalability: Under-engineered solutions might not handle increased load or complexity well.
Potential Rewrites: As demands grow, the initial simplistic design may need a complete overhaul.
š¤ The Robust Solution
So we took a step back and arrived at a solution that:
extracted the wizardās āstepperā logic from the wizardās display
wizard controls are added per use-case, with no hardcoded buttons
const steps = [{
label: "Connect account",
component: <Step1Component />
isDisabled: false,
...
}, { step2...
}, { step3...
}];
const {
back, next, finish, isFirstStep, isLastStep, setCurrentStep
} = useWizard(steps);
<>
<Wizard steps={steps} />
{!isFirstStep && <button onClick={back}>Back</button>}
{isLastStep ?
<button onClick={finish}>Finish</button> :
<button onClick={next}>Next</button>}
}
</>
This solution takes the case of all of our previous problems:
you can add Back, Next, Finish as you please
you can jump to a specific stepāmeaning you can also skip steps
how you render
<button>
is up to you because itās not part of the wizard
But it also brings up a couple of other problems, for example:
repeating the Back/Next/Finish logic every time
needing extra logic to find the next/previous step if some steps are disabled
you have two functions instead of one (
useWizard
andWizard
) to maintain, as well as all the other components that will useWizard
and the Back/Next/Finish logic
š°ļø Arguments for Over-Engineering
Pros:
Robustness and Reliability
Fewer Bugs: More rigorous design and testing can result in more stable software. In a separation like this, you can test the business logic of the wizard and the wizard separately and together.
Future-Proofing: Extracting the logic made a reusable component more adaptable to growth and new features.
Scalability
Handles Growth Well: Unlike The Simple Solution, a little over-engineering handles future complexity better.
Built-In Flexibility: Revrites become nonissues because the current design is flexible enough to allow for easy feature additions.
Cons:
Increased Complexity
Complex Maintenance: This is much more difficult to understand than The Simple Solution. It has more moving parts, leading to longer debugging and development times.
Steep Learning Curve: New team members might struggle to get up to speed.
Cost and Time
Higher Costs: More resources are needed upfront, increasing the projectās costāWe worked more on this than we initially estimated. š
Delayed Launch: Thankfully, no launch depended on this particular component, but in a different scenario, we might have to settle for something less robust. Spending too much time on details can delay the productās time to market, missing opportunities.
So, what can we do?
āÆļø Finding the Balance
Define Clear Requirements:
Focus on MVP (Minimum Viable Product): Prioritize features essential for the initial release.
Iterative Development: Based on user feedback and real-world use cases, plan to enhance and add features in future iterations. Donāt do a customizable Wizard layout if no team needs it.
Set Realistic Goals:
Time and Budget Constraints: Itās difficult to justify spending time and money on features you donāt need, but also consider:
Risk Assessment: Evaluate the risks of not implementing certain features now versus the potential costs of implementing them prematurely. This is where potential rewrites (and their cost š¬) come into play.
User Feedback and Testing:
Beta Testing: Release beta versions to gather user feedback and identify necessary features.
User-Centric Design: Focus on building features that directly address user needs and pain points.
Simplify Designs:
Avoid Perfection: Resist the urge to add extra features or overly complex solutions that donāt provide significant value.
Refactor Code Regularly: Periodic code reviews and refactoring can help keep the codebase clean and maintainable without unnecessary complexity.
How do you avoid perfecting a feature but not making it too dumb that it needs to be rewritten every time someone touches it?
š° Weekly shoutout
š£ Share
Thereās no easier way to help this newsletter grow than by sharing it with the world. If you liked it, found something helpful, or you know someone who knows someone to whom this could be helpful, share it:
š Subscribe
Actually, thereās one easier thing you can do to grow and help grow: subscribe to this newsletter. Iāll keep putting in the work and distilling what I learn/learned as a software engineer/consultant. Simply sign up here:
Looks like we were thinking about this topic at the same time, the same week I released this article: https://www.leadership-letters.com/p/the-sweet-spot-between-too-much-and, consider checking it out!
Loved your post, btw.
A great mental model Akos! It can help decide between over-engineering and under-engineering, though I won't call your second solution over-engineering :) It was a perfectly reasonable thing to do as far as I can see. Sure it took more time but the wizard seems like a pretty basic building block that can come in handy (depending on the system context, of course).
Most of the time I've seen the decision between making something flexible enough for the future boils down to how much time you have. It's always a trade-off.
Also, thanks for the mention!