20. 11. 2023
5 min read
5 tips to beat overengineering
Balancing simplicity with effectiveness can be tough. If you've ever felt the pressure of overcomplicating your projects, this blog is your go-to source for practical advice. We share invaluable tips, and everyday programming insights, complete with code examples that you can use to help you navigate rapid development without losing yourself in over-engineering.
Peter Papp
Product Manager
In my last article, I discussed the two challenges that engineers often face and talked about how to make your work more meaningful while saving valuable time. So if you've opted for the rapid, minimal-effort approach to product building, here are a few pieces of advice that you can follow in your rapid development and not lose your head with overengineering.
1. Keep it stupid simple
Along with DRY and other rules, this might be less used, but what does it mean? Imagine you have a to-do list where you need to add a task, and also be able to remove it. You have two simple methods available. A bad example of this can be if you have one function that tries to control the entire spaceship. So really, do things as simply as possible.
Simple
let todoList = [];
function addTodo(item) { todoList.push(item);}
function deleteTodo(item) { const index = todoList.index0f(item); if (index > -1) { todoList.splice(index, 1); }}
addTodo("Buy groceries");deleteTodo("Buy groceries");
Not simple
let todoList = []
function manipulateTodo(action, item) { if (action === "add") { if (!item) throw new Error('An item is required to add.')
const existingItem = todolist.find((todo) => todo === item)
if (existingItem) throw new Error('The item already exists.')
todoList.push(item) }
if (action === "delete") { const index = todoList.indexOf(item)
if (index === -1) throw new Error('The item does not exist.')
todoList = [...todolist.slice(0, index), ...todolist.slice(index + 1)] }}
manipulateTodo("add", "Buy groceries")manipulateTodo("delete", "Buy groceries")
2. You ain't gonna need it
Let’s say you have a to-do list of tasks and you want to implement searching in it, start with a very simple search. Don't complicate it with additional functions, like sorting results by date or marking. Probably, when you create a new product, you won't have many users yet. So you can add these functions later. But this is an example of where, every time you create a function, you should think about how you create it, what it will actually do, and which functions you can add later, depending on what the user needs and what they might not even need in the foreseeable future after launching the application.
Simple
let todoList = [ { task: 'Buy groceries', complete: false }, { task: 'Walk the dog', complete: false }, { task: 'Read a book', complete: true },]
function searchTodo(taskName) { return todolist.filter( task => task.task.includes(taskName) )}
searchTodo('Buy')
Not simple
let todolist = [ { task: 'Buy groceries', complete: false, date: '2022-01-01', label: 'shopping' }, { task: 'Walk the dog', complete: false, date: '2022-01-05', label: 'personal' }, { task: 'Read a book', complete: true, date: '2022-02-01', label: 'personal' },]function searchTodo(taskName, isComplete, date, label) { let results = todoList
if (taskName) { results = results.filter(task => task.name.includes(taskName)) }
if (isComplete !== undefined) { results = results.filter(task => task.complete === isComplete) }
if (date) { results = results.filter(task => task.date === date) }
if (label) { results = results.filter(task => task.label === label) }
return results}
console.log(searchTodo('Buy'))
3. Stop premature abstraction
Abstraction is always more expensive than duplicated code. This applies both to the front end and back end, where premature abstraction of functions that should be simple and straightforward is a common issue. We can have two functions: adding a subscription and removing a subscription. This is completely sufficient, it's simple and easily readable. Looking at this code, you might find it hard to understand what's happening. However, engineers often tend to say that they will definitely do it because it might be needed in the future. The problem is that this situation may never arise. So it's best to do things simply first and not live in the future. If you need to create some abstraction, you'll do it when you really need it.
Simple
let userSubscriptions = []
function addSubscription(userId, subscription) { userSubscriptions.push({ userId, subscription })}
function removeSubscription(userId, subscription) { userSubscriptions = userSubscriptions.filter( sub => sub.userId !== userId || sub.subscription !== subscription )}
Not simple
class SubscriptionManager { constructor() { this.subscriptions = [] }
addSubscription(user, subscription) { this.subscriptions.push(new Subscription(user, subscription)) }
removeSubscription(user, subscription) { this.subscriptions = this.subscriptions.filter( (sub) => sub.user !== user || sub.subscription !== subscription ) }}
class Subscription { constructor(user, subscription) { this.user = user this.subscription = subscription }}
const subscriptionManager = new SubscriptionManager()
4. Say no to component atomization
I advocate the opposite approach, do not atomize components. This can be clearly seen in the design picture of the front end. Generally, during the development of an MVP, you work quickly and iterate quickly. From my experience, I know that the design is often created along with the project, as well as the design system, so you still don't know how it will finally work and look. You don't know what you will add in the end, so you should maintain a larger component that won't be in your way. And when something changes, you can react and adjust it very quickly.
Let me show this in the example of the product that I created when the ChatGPT API was launched. I created my own application that offers me features that I was missing before. An engineer might suggest atomizing everything. For example, create a component from a button, a component from navigation, etc. But what do I suggest? I think we should create one component for the entire navigation bar, and one component for pageviews. When I was creating this application, it changed about 4 to 5 times. If I had atomized it, it wouldn't have been so easy to iterate.
So it's perfectly fine when you have a 200-300 line component. Over time, the code also needs to mature like wine, and if later there won't be interference in it, you can start to atomize those components, so that long-term development is sustainable. Therefore, at the start of the project, I do not atomize components because the benefits are often not so obvious.
5. Triple Ts: Tests, tests, and more tests!
And who would have said you need to test when doing rapid development or an MVP? If you don't, it will catch you off guard at some point and then you can't move forward.
However, when you add tests, you can maintain a good trend curve of speed and add new features. But what I'm saying - there is one difference and what I recommend is to familiarize yourself with three basic types of testing.
The first is unit testing, the second is integration testing, and the third is acceptance testing. Start with those acceptance tests, and you can get a lot for a little money. They look like they're trying to copy the entire user process in their test. They're not completely end-to-end tests, but they will be very helpful. Depending on whether you're doing backend or frontend, you can easily test it. And then, when something doesn't work for you, you know you have a problem. It looks classic - you log in, you create a task via API, check if it works, fix it, and you can continue. So you should at least start with those acceptance tests.
it('Acceptance Test: Login, Create Task, Fetch Task, Logout', async () => { // Login const user = await User.login('testUser', 'testPassword') assert.ok(user.isLoggedIn()) // Create task via API const createdTask = await API.Tasks.create({ title: 'New ToDo Task', description: 'Some description' }) assert.strictEqual(createdTask.title, 'New ToDo Task') // Fetch task from API const fetchedTask = await API.Tasks.get(createdTask.id) assert.strictEqual(fetchedTask.title, 'New ToDo Task') // Logout await user.logout() assert.ok(!user.isLoggedIn())})
Ready to set your projects up for success?
By embracing simplicity, rejecting premature abstraction, and preserving component unity, you're ready to make your projects soar. Remember, code matures over time like fine wine, and component atomization can wait for the right moment. Lastly, the triple T mantra—Test, Test, and more tests—guarantees your progress remains steady and swift. With these tools, you're primed to create efficient, dynamic solutions that stand out.
You might
also like