I recently wrote about over-engineering and striking a good balance between making your code “too” future-proof and not making it future-proof at all. Some time later, I realized it was missing a critical perspective. I hadn’t addressed over-engineering from an architectural point of view, so this post is dedicated precisely to that.
Let’s talk about a decision I made for Collecto, my side project. Collecto is still in its early stages, and like most early-stage projects, its future is uncertain. It could grow into something big—or not. That’s where architectural decisions get tricky. You don’t want to overengineer and waste time, but you also don’t want to under-engineer and regret not laying a solid foundation.
Collecto is a forms-backend service, meaning it handles the creation, management, and processing of forms data for applications. I wanted to add the ability to send emails on certain events.
The simplest solution? I could write a new service responsible for sending emails and call it directly wherever needed— for example, right after a user signup is saved to the database. This approach works, is easy to set up, and introduces no additional overhead. However, it results in tight coupling, making future changes more challenging. If tomorrow I want to also send a notification to the form owner when they receive a new subscription, I would have to keep adding more responsibilities to the form service code. This bloats the core service, which should ideally focus solely on CRUD operations for forms.