My API SaaS company chose Javalin for our API backend to start with, for several reasons:

• it is "just enough" web framework for our needs, without all the bloat that a framework targeting browsers/serving HTML would have;

• it has an annotation-based OpenAPI auto-summarization plugin, to allow us to immediately get some docs and autogenerated SDKs up;

• and it explicitly owns the lifecycle for its components — using e.g. an embedded Jetty instance that gets started + configured in code by the framework — where you can inject config into that code, or replace it wholesale. (This is what we were used to in other web frameworks, and is unusual in Java land, where web servers seem to be separate "background services" that happen to start up independently in the same JVM, and then auto-discover and runtime-bind to your web service's "background service" through XML files the framework generates and the web server probes for. Which is a bit much when you're not running an enterprise-y "app server" with tons of different servlets mounted to it that can be hot-upgraded without restarting the server, but instead a single-purpose Java app in a Docker container as a k8s Deployment.)

That being said, as our company grew, we eventually ran into the disadvantages:

1. Scanty documentation.

The https://javalin.io/documentation page is all you get. Want, say, a list of the HTTPResponse exceptions Javalin offers? Or to know the types — rather than just the behaviors — of the getters and setters on Context? Well, too bad — go read the (Kotlin) source code!

(Why no per-module JavaDoc docs? Even the hollowed-out docs that just give constants + methods, that you get by generating docs from a module with no docstrings, would be better than no JavaDoc! IDEs do give you some of the same info as a trivial Javadoc, but JavaDoc docs are a lot more browsable as a reference when you don't know the name of the thing you want, just the type.)

2. Static routes!

Javalin routes must be bound to static methods of Java classes. You can't† build an object, pass the object to your Javalin app constructor, and have it bind routes to that object's methods. Which makes constructing any kind of DDD service contexts — and having them be testable, with separate e.g. DB providers in a test context — near-impossible. Javalin and dynamic Dependency Injection — the kind you do in tests — just don't get along.

† Or, well, you can do that — in that you can bind each route to a closure which calls the method on the object. But those closures need to be able to bubble arbitrary checked exceptions back to the Javalin exception handler (which the trivial examples in the Javalin docs carefully avoid the need for by only calling pure code.) Which means so you can't use the syntax-sugar'ed kind of functional closure. So each route balloons into 10 lines of instance-subclass boilerplate, and your routes module is no longer a readable + greppable DSL definition of "what your app reacts to", but instead a big mess.

3. No support for writing Java Servlet middleware.

Javalin uses servlets under the covers, but doesn't expose the servlet "stack" for injection. Instead, you have to write Javalin plugins (Which haven't been good for much for most of Javalin's lifetime — though they seem to be pretty flexible now, so this might not be as much of a concern if you're doing a greenfield Javalin project today.)

I'm guessing that most devs still just end up just dumping all their middleware-ish concerns (auth, rate-limiting, etc) in app.before + app.after. Which thereby accidentally leaks resources — because some exceptions can cause app.after to not run! (You have to do a little dance with app.after and app.exception calling into common code — essentially "rewriting the framework to do the thing it should have done" — to resolve that.)

4. The Javalin devs are slow to add very critical features that tons of people ask for, sometimes leaving outstanding requests on the backburner for years.

Until Javalin 6, there was no app.beforeMatched, and therefore, no request-lifecycle callback point for "after routing has been resolved, but before actually calling the route."

So, before recently, you couldn't do rate-limiting or auth or etc generically in middleware, while also taking the static route-name of the resolved endpoint — the string that looks like "/account/:id/binding" that the Javalin context gives you access to inside your controller code — into account as an input. Instead, to do this, you had to let the route trigger, and then call generic code from inside the controller (see e.g. https://javalin.io/plugins/how-to#creating-a-plugin-with-con..., which seems to show that the Javalin devs still think of this as the idiomatic way to do this.)

Even with this particular feature request finally resolved, there are still several outstanding ones. And given the shape of Javalin as a framework, the way to work around these missing features, always ends up involving writing redundant generic code inside your MVC controllers. If you do this enough times — and you follow the natural desire to factor them out — then you end up basically building a framework on top of your framework! (And then, when these new features do finally come along, you're already added all this now-unneeded architecture that's very hard to get rid of.)

---

A while ago, we got fed up with the disadvantages — so we're very likely going to be moving to Spring Boot.

At some point. (Given the particular shape Javalin forces your code into, though, such a move is a lot of work!)