There are a number of reasons others have touched on that contribute to spaghetti code. Some of them are business centric, others are engineering centric:
- Unreasonable Deadlines. You know, the feature your company promised an external stakeholder “will be ready by noon tomorrow!” but will take a good month to build right. The feature ships on a rushed timeline (with engineers scrambling nights and weekends to get an MVP out the door), corners are cut, and QA is minimal. Yet, after all critical bugs are resolved in the coming days/weeks, it’s deemed “good enough” by the stakeholder, and never revisited again. Even if you know there’s a better, more sustainable way to build it, you already have another critical project on your plate “that has to be shipped by noon tomorrow”.
- Team Changes and Reorgs. In reality, engineering teams evolve over time. That Rockstar 10x engineer who wrote 50% of the codebase gets poached and decides to move on. Yet, they didn’t document anything, and was the only one who understood how certain parts of that stack are supposed to work. On a larger scale, your team’s product focus may change, or you may switch to a different (perhaps newly formed) engineering team as part of a re-org. This means other engineers may inherit the project you’ve worked on for the past 6-12 months, or you might inherit a part of the codebase you’ve never touched before.
- Premature Optimization and Overengineering. This is the opposite problem of the rushed project deadline, especially when building a new service from the ground up. You try too hard to get every. little. thing. right, from thorough end to end unit test coverage (even for highly obscure edge cases) and highly complex models/DB tables that try to anticipate problems (and new features) that may arise 6–12 months from now. The good news? Everything works great when the project finally ships. Of course, 6 months later, when they want to expand it, they think of complex new features that don’t fit those overly complex models at all, so much of it needs to be rewritten. And because those models are so complex, that 1-2 week feature will take a good 4–6 weeks to implement in a way that’s backwards compatible.
- Code Rot, Legacy Code and Tech Debt. Key parts of the codebase may be maintained for several years. Yet, over time, new features are tacked on (perhaps on a rushed timeline), the engineering team maintaining it changes (resulting in knowledge gaps), and the codebase itself evolves to become very monolithic. Eventually, you’re tasked with rewriting part of the codebase that was written 3–5 years ago. Some of it is still essential (and will drive the rewrite), some of it hasn’t been relevant for 3–5 years (and should be removed), and some of it is a series of hacks that are still somewhat relevant and must be maintained.
- Inexperience. This is the classic case of a Junior Engineer (or an intern/co-op) ending up on a mission critical project that will define/drive your codebase for many years. The good news is this project will help this engineer grow their career. The bad news is that they are not a Jedi yet and their code may live on long after they’ve moved on from their first big project.