Tell HN: Technical debt isn't messy code, it's architectural compound interest
1 comments
The compound interest framing is the right one. The part people miss is that compound interest works in both directions.
Good early architectural decisions compound positively. A clean boundary between services means the team that owns service A doesn't need to coordinate with the team that owns service B when either one ships. That coordination cost you avoided on day 30 is the same cost you avoid on day 300, but by day 300 you've avoided it hundreds of times.
Bad decisions compound negatively, and the mechanism is subtle. A leaky abstraction doesn't break anything immediately. It just means every developer who touches that boundary spends an extra 15 minutes (BS, it can be an hour or two) understanding why the types don't quite match, or why that one field is sometimes null. Multiply 15 minutes by 8 developers by 250 working days and you've lost 500 engineer-hours to a decision someone made in week two.
The "Mullet Schema" idea (strict columns for business-critical data, JSONB for everything else) is a practical version of this. You're deliberately choosing where to accept compound costs (the JSONB side) and where to prevent them (the strict columns). The key is being explicit about which side a given piece of data belongs on, and reviewing that classification as the product evolves. What starts as "nice to have" metadata has a way of becoming load-bearing business logic over 18 months.
To answer your question about where to draw the line: I look at how many teams or people need to coordinate when something changes. If a schema change requires three Slack conversations or a "short" call, that's a signal the architecture is creating coordination costs that will definitely compound, what you correctly called "microservice tax" and I call "complexity tax". That's usually where the line should have been drawn earlier.
Good early architectural decisions compound positively. A clean boundary between services means the team that owns service A doesn't need to coordinate with the team that owns service B when either one ships. That coordination cost you avoided on day 30 is the same cost you avoid on day 300, but by day 300 you've avoided it hundreds of times.
Bad decisions compound negatively, and the mechanism is subtle. A leaky abstraction doesn't break anything immediately. It just means every developer who touches that boundary spends an extra 15 minutes (BS, it can be an hour or two) understanding why the types don't quite match, or why that one field is sometimes null. Multiply 15 minutes by 8 developers by 250 working days and you've lost 500 engineer-hours to a decision someone made in week two.
The "Mullet Schema" idea (strict columns for business-critical data, JSONB for everything else) is a practical version of this. You're deliberately choosing where to accept compound costs (the JSONB side) and where to prevent them (the strict columns). The key is being explicit about which side a given piece of data belongs on, and reviewing that classification as the product evolves. What starts as "nice to have" metadata has a way of becoming load-bearing business logic over 18 months.
To answer your question about where to draw the line: I look at how many teams or people need to coordinate when something changes. If a schema change requires three Slack conversations or a "short" call, that's a signal the architecture is creating coordination costs that will definitely compound, what you correctly called "microservice tax" and I call "complexity tax". That's usually where the line should have been drawn earlier.
We often conflate "messy code" (which is linear debt) with "structural coupling" (which is exponential debt). I've been looking at the trajectory of projects that hit the "10k user wall," and the pattern is always the same: early velocity was high because they coupled everything, but now every schema change requires a maintenance window and every API tweak breaks the mobile client.
Here is the specific architectural debt that actually matters (and acts like compound interest), based on my scars from migrating legacy monoliths:
First, the Integer vs. UUID debate. I used to be in the "Integers are faster/smaller" camp. But if you've ever had to merge two databases from an acquisition, or shard a database where ID collisions are mathematically guaranteed, you know the pain. Migrating from Ints to UUIDs in a live system involves locking tables and rewriting foreign keys across the entire stack. It’s a nightmare. The storage cost of UUIDs is negligible compared to the cost of that migration. Just use UUIDs (specifically v7 for sorting) from day one.
Second, the database schema rigidity. The "Monolith First" advice often leads to a strictly normalized schema that requires an `ALTER TABLE` for every feature. Once the table hits a few million rows, those migrations start timing out or locking the DB. The pattern that seems to work best is what I call the "Mullet Schema": business-critical data (auth, billing) in strict columns, but everything else (user preferences, feature configurations) in a JSONB column. Postgres JSONB is performant enough now that it effectively kills the need for a separate Mongo instance for 90% of use cases. It lets you iterate on features without database migrations.
Third, the "Velocity Cross." Everyone knows a monolith is faster for the first 6 months. You don't have the "setup tax" of distributed tracing, eventual consistency, or separate deployments. But somewhere around month 12, or the 10k user mark, the lines cross. In a monolith, I've seen dev time shift to about 70% "fighting the architecture" (preventing side effects) vs 30% shipping features. Service boundaries—even just coarse-grained ones like separating Auth/Billing from Core App—preserve that feature velocity.
The trade-offs are real, though. Microservices (or even just "services") suck at the beginning. You spend your first month writing Docker compose files and fighting with inter-service communication instead of building the product. Debugging a request that hops through three services is objectively harder than debugging a function call. If your project never grows past 1,000 users, you absolutely wasted your time with services.
But I'm starting to think "Monolith First" is dangerous advice unless you explicitly plan to throw the code away. It optimizes for a timeframe (0-6 months) that isn't the long-term reality of a successful business.
I'm curious where others draw the line in 2024. Has the tooling (K8s, managed infra) lowered the microservice tax enough to start decoupled? Or is the pain of `ALTER TABLE` on a 10GB monolith actually manageable if you're not an idiot?
Also, am I the only one who finds UUIDs annoying to debug visually, despite them being architecturally superior?