XRAPH/Writing/A microservice framework in Rust, first attempt

A microservice framework in Rust, first attempt

I wanted the ergonomics of a batteries-included framework without giving up the compiler. Ultimate got about eighteen months before I admitted the type signatures had won.

Published
Oct 2020
Length
2 min read
Systems
1

#What I was after

By 2020 I had written enough TypeScript services to know exactly which parts of a framework I valued, and enough Rust to want those parts with a compiler that would catch the mistakes I kept making. Ultimate was an attempt to have both.

The target was a service where dependency wiring, configuration, health and shutdown ordering were handled, and where getting any of those wrong was a compile error rather than a runtime surprise.

#Where it went well

Ownership forced precision that I would not have applied voluntarily. Writing a service registry in Rust makes you answer who owns each service, how long it lives relative to the container, and what happens if two things want it at once. In TypeScript I had never answered those questions explicitly, and the answers had been wrong in ways that only appeared under concurrency.

Error handling improved for the same reason. Rust culture pushes toward errors that describe the operation being attempted, and once I had that habit I could not go back to a bare cause string.

#Where it went wrong

Framework code sits at the boundary between generic and concrete. In Rust in 2020 that boundary was expressed entirely in the type signature.

A handler that took a request, resolved two services from a container and returned a response ended up with four generic parameters, two lifetimes and a trait bound that did not fit on a line. I could read it because I had written it. Nobody else was going to, and a framework whose signatures only its author can read has failed at the job frameworks exist to do.

Async made it worse. Async functions in traits were not available, so anything asynchronous behind an abstraction meant boxed futures, and the ergonomic cost compounded with the generic cost.

#The general lesson

Parnas argued that a module should be defined by the decision it hides . The failure in Ultimate was that the abstraction did not hide its decisions. It re-exported them as type parameters, so every user had to understand the internals in order to satisfy the signature.

Brooks separates the essential difficulty of a problem from the accidental difficulty of the tools . Distributed service lifecycle is essential difficulty. Four generic parameters to express a request handler is accidental, and I had spent a year and a half on the accidental part.

#What happened next

I stopped, and started again as North with better instincts about lifecycle. That one lasted longer and hit the same wall in a slightly different place. The ideas from both went into a Go framework years later, where the ergonomics were never in question and the lifecycle model was the thing worth keeping.

Some of the language constraints have since eased. A version of this written today would be a better experience. I am not going to write it, and the reason is not the language. It is that the people I write frameworks for are writing services, and the calculus for them has not changed.

References

  1. [1]D. L. Parnas, On the Criteria To Be Used in Decomposing Systems into Modules, Communications of the ACM, vol. 15, no. 12, pp. 1053-1058, 1972doi:10.1145/361598.361623
  2. [2]Frederick P. Brooks Jr., No Silver Bullet: Essence and Accidents of Software Engineering, Computer, vol. 20, no. 4, pp. 10-19, 1987doi:10.1109/MC.1987.1663532