A few weeks back I was wiring up an ops probe against one of my services. The README said every lithair server comes with /health, /ready, and /info registered out of the box. I had built the service the way the newer examples build them — the LithairServer builder, the one I’d been steering people towards. I curled /health. 404.
I sat there for a minute. The README wasn’t lying — there was a server in lithair-core that registered those endpoints automatically. It just wasn’t the one I was using. The other one. The older one. The one called DeclarativeServer, which I’d been quietly trying to retire for months without ever finishing the job.
That was the moment the cost stopped being abstract.
The standard pattern
When a framework ships a v2 of one of its primitives, the gentle thing to do is to keep the v1 around. Mark it deprecated. Give users a migration window. Maintain both for a few releases. Eventually, when the ecosystem catches up, the v1 quietly disappears.
This is good craft. It respects users who pinned to v1 in production and don’t want to be dragged into a refactor on someone else’s schedule. It respects the maintenance graph — the v1 was the thing people learned first, the thing examples link to, the thing tutorials reference.
I’d been trying to do that. lithair shipped DeclarativeServer first — the macro-driven path, declarative top to bottom. Then I shipped LithairServer, the builder — same goal, a more flexible API, room to grow. The plan was for LithairServer to gradually become the recommended path while DeclarativeServer stayed as a stable, simpler entrypoint for the macro use case.
For a year or so, that plan looked fine.
The realization
What broke the plan wasn’t the maintenance burden. It was the asymmetry.
DeclarativeServer had /health, /ready, /info registered automatically. LithairServer did not. The README claimed both did, because at some point I’d written the README assuming the two would converge. They never had. So the more I steered people toward LithairServer, the more the README quietly drifted from the truth, and the more likely a developer was to hit the same 404 I hit.
I could have closed the gap by porting the ops endpoints to LithairServer and leaving DeclarativeServer in place. That was the plan I’d been carrying around in my head. Two server types, both correct, both documented, parity restored.
I sat down to scope it on a Saturday morning. Open a fresh issue, list every callsite of DeclarativeServer in the workspace, see what the migration footprint actually looked like. The grep returned six matches.
Five of them were doc-comments.
The sixth was inside a macro — #[derive(DeclarativeModel)] — that I owned, in the same repo, that I could rewrite in an afternoon.
The “two server types, both maintained” plan I’d been protecting for a year was protecting almost nothing. There was no install base of DeclarativeServer outside the documentation referring to itself. The deprecation window I was preserving for users was being preserved for users who didn’t exist.
What I did instead
I closed the original parity ticket and opened a different one: retire DeclarativeServer entirely.
The work split into five small phases, each its own pull request, each reviewable on its own:
- Walk every callsite. Confirm the inventory.
- Rewrite the doc-comments to point at
LithairServer. - Migrate the macro to generate a
LithairServer-basedmain(). - Mark the type
#[deprecated]. - Delete the file.
I filed the plan Saturday morning. The last commit landed Sunday evening. The crate published to crates.io the same day. Two mental models collapsed into one. The README’s promise about ops endpoints became true at the same moment the type that satisfied it became the only type.
It wasn’t a refactor. It was a rewrite of one macro and a five-line search-and-replace through the docs. I’d been carrying it as a yearlong design decision, and the actual work fit in a weekend.
What I tried
What LithairServer looks like in v0.2.0, with the ops endpoints now registered by default:
LithairServer::new()
.with_port(8080)
.with_route(Method::GET, "/".to_string(), index_handler)
.with_route(Method::POST, "/api/widgets".to_string(), create_widget)
.serve()
.await?;
/health, /ready, and /info answer without anything in the builder asking for them. If you want to override one, you register your own route at the same path and yours wins. The default is there because the README said it would be.
That is the entire user-visible API change in v0.2.0. The interesting work was in what got removed, not what got added.
Why I prefer this for my projects
I’m a solo developer maintaining several Rust crates as a hobby and a portfolio. Every type, every module, every doc page is a thing I personally have to keep accurate. The cost of a duplicate path isn’t just the code — it’s that every change to one path raises a quiet question about whether the other path needs the same change, and the answer is rarely a confident yes.
For a framework with paying customers, retiring a documented type after a year would be aggressive. There would be users running DeclarativeServer in production, depending on its exact shape, and breaking them on a weekend would be a betrayal of the contract. The five-phase plan would be a five-release plan, the deprecation window would be measured in months, and the maintenance overhead during that window would be the price of not breaking anyone.
For a project where the install base is the documentation and a handful of examples I wrote myself, the calculus inverts. Carrying both types costs me real attention every week. Retiring one costs me a Sunday. The tradeoff isn’t even close.
That’s a context-specific answer, not a universal one. The standard pattern exists because the standard pattern is correct for the standard situation. Mine wasn’t that.
What this is not
It’s not “DeclarativeServer was bad code.” It was the original implementation. It worked. Most of the lithair examples in the wild use it, and they still work — they just won’t with v0.3 unless they migrate, which the macro now does for them automatically.
It’s not “you should always delete the older path.” If users are pinned to it in production, the right move is parity, not retirement. Coexistence is a tax some projects must pay; I just discovered I wasn’t really paying it, only worrying about it.
It’s not “v0.2.0 means v1.0 is around the corner.” The version reflects one breaking change to one type. The framework is still pre-1.0 because the open questions about the broader API surface haven’t been answered. SemVer is honest accounting, not a marketing schedule.
It’s: when the maintenance cost of a deprecated path is being paid in real time, and the install base it protects is mostly the docs that mention it, the deprecation window is a story you’ve been telling yourself. v0.2.0 is what stopping that story looks like.