And really, it turns out most of the time that not only you don't need the complex abstractions designed early on, you actually need a set of different ones. That's why keeping the design process continuously grounded in reality is important.
My solution for not producing spaghetti code with this method? I don't release the first version that works. I don't mark the ticket as "done", and I don't even push it out of my local repo. Instead, I clean it, or even straight up rewrite it, until it reaches a sleek and acceptably elegant state. It's the responsibility of a programmer to decide when the code is ready, and it doesn't have to be at the first moment it passes all the tests.