Announcing Litestar 3
Janek Nouvertné
A long long time ago, we released Litestar 2.0, and with it came the promise of another major version. A version that would not be in development for too long, and would focus mostly on stabilising existing features, improved developer experience, and removing leftovers from the 1.x -> 2.x transition.
Now, over 3 years later, we gotta admit that this did not go according to plan! This is mostly due to unforseen happenstances, and the core maintainer team not being able to spend as much time on the project as they would have liked. However, things have and are still brewing, the project is growing, and significant progress as been made, so let's take a look at some of the things Litestar 3 will bring!
What's coming
Overwhelmingly overhauled and extended dependency injection
Yes, Litestar 3 will finally bring the most requested (and debated) feature of the past years: A new dependency injection system.
What's new about it?
In a nutshell:
- Type-based injection alongside the existing named-based injection
- Native DI in guards, hooks and other places
- Ad-hoc DI everywhere with access to a request / application context (e.g. arbitrary ASGI middlewares)
- Explicit registration of DI providers / values
- Nested resolution scopes
What's it look like?
While it's currently being developed in secrecy, we can take a little peek behind the curtain, and see what its API looks like:
Type-based DI
Litestar 2.23.0 introduced NamedDependency, as part of its move towards more
explicit declarations (read more here).
This also laid the groundwork for the new type-based DI system: Litestar 3 will feature
TypeDependency alongside NameDependency; The semantics remain the same, with the
exception that the provider key won't be inferred from the parameter name, but will be
the type itself:
@get("/")
async def handler(some_service: TypeDependency[SomeService]) -> None:
pass
router = Router(
"/",
[handler],
dependencies={SomeService: provide_some_service},
)
Extended DI availability
DI in guards
async def provide_user(request: TypeDependency[Request[User]]) -> User:
return request.user
async def require_admin_user(active_user: TypeDependency[User]) -> None:
if not active_user.is_admin:
raise NotAuthorized()
@get("/", guards=[require_admin_user])
async def handler() -> None:
pass
router = Router(
"/",
[handler],
dependencies={User: provide_user},
)
Ad-hoc DI
The Litestar and ASGIConnection instances will expose a di_context attribute,
which can be used to dynamically resolve dependencies:
class SomeMiddleware(ASGIMiddleware):
async def handle(self, scope: Scope, send: Send, receive: Receive, next_app: ASGIApp) -> None:
litestar_app = Litestar.from_scope(scope)
db_session = await litestar_app.di_context.solve(AsyncSession)
...
await next_app(scope, send, receive)
when using type-based DI, this has the added benefit of being type-safe, e.g.
.resolve has a signature of async def resolve[T](type_: type[T]) -> T.
Using the same DI context, you can dynamically provide values:
@contextlib.asynccontextmanager
async def lifespan(app: Litestar) -> AsyncGenerator[None]:
app.di_context.provide(MySettings, my_settings)
A whole new logging experience
Litestar's logging integration has been greatly simplified. And by simplified I mean "mostly removed". While it had a noble goal (providing a unified interface for different logging libraries, suited for the needs of usage in a web framework environment), it often fell short of them, and users and developers alike ended up frustrated. And filing and fixing a lot of bugs.
So we went back to the drawing board and came up with an integration that's reduced the
logging interface to the bare minimum Litestar actually needs, for the few logging
features it has: You can provide an object conforming to the stdlibs logging.Logger
interface or, alternatively, to structlog's logger interface, which can be toggle via a
log_structured=True parameter.
But that's about it. Nothing more to it.
You can read more about it here and there.
Redesigning handler registration
One of the major changes is something that has no visible effects above the surface; In 3.0, Litestar's layer system, i.e. how route handlers, controllers, routers and applications are wired togehter, has been completely rewritten.
Litestar's routing generally works top-down:
- An application register a router, a router registers a handler, etc.
- A handler will receive configuration from the layers above it
class SomeController(Controller):
path = "/controller"
guards = [controller_guard]
@get("/handler", guards=[handler_guard])
async def handler(self) -> None:
pass
app = Litestar([SomeController], guards=[app_guard])
In this example, app registers SomeController, which in turn registers handler,
while handler receives guards from all levels above it.
At least on the API surface this is the case. Underneath, it poses a bit of a technical challenge: If components (handlers, controllers, routers) are meant to be standalone, how can e.g. a handler know about guards registered above it? The usual solution to this would be to make them active elements of the routing process; When a request comes in, it is first handled by the app, which resolves its guards, then if the request path matches a subcomponent, further routing is handed down to it, in this instance the controller, and the same process repeats all the way down until a leaf node in the routing tree is hit, and a response can be generated.
This approach though comes with its own downsides: It requires distributing a lot of the dispatch logic into components, compared to a more centralised design, and it also removes opportunities for optimisation (e.g. if we know all guards / dependencies / etc. a request will hit at once, we can resolve them concurrently or skip those we know can't be reached).
For these reasons, in Litestar 2, a different design was chosen: During the registration process, a component kept track of its parent layers, providing access to them through the handler. The handler could then resolve them lazily whenever needed.
However convenient, this design had a few downsides mainly that:
- It required keeping all the layers around, which were basically just static configuration except the parent tracking
- It had to (deep)copy things frequently, causing complicated workarounds, hampered startup performance and lead to unexpected behaviour in some cases
- Handlers were stateful, opening the door to race conditions and other issues stemming from what's now effectively global state (this was mitigated somewhat by the copying though)
And there were a few things that we could not do with this, for example caching resolved components statically.
The new approach
The new component registration in Litestar 3 departs from the dynamic nature, and fully embraces static configuration; Transient components (i.e. controllers and routers) completely disappear at runtime, and purely function as an intermediate configuration layer. When a handler is added to a router, nothing happens immediately - only a reference to the handler is stored there. The actual registration happens after a component is added to an application. On startup, the application recursively walks all components, bottom-up, and merges them into a new handler. That is, a completely new handler instance will be created, from the merged values of all the layers above it.
In practice this means that
class SomeController(Controller):
path = "/controller"
guards = [controller_guard]
@get("/handler", guards=[handler_guard])
async def handler(self) -> None:
pass
router = Router("/router", [SomeController], guards=[router_guard])
app = Litestar([router], guards=[app_guard])
is, after registration, the same as
@get(
"/router/controller/handler",
guards=[
app_guard,
router_guard,
controller_guard,
handler_guard,
]
)
async def handler() -> None:
pass
app = Litestar([handler])
These are not just semantically the same, but also technically. The original handler supplied by the user is left completely untouched, only the reference to the function is kept. Everything else is re-created with the merged values.
With this change, routers, controllers and handlers now contain virtually no runtime logic any more, and serve purely as configuration objects. It has also proven to provide some modest startup time improvements for larger apps, but the main benefit is maintainability; The code is much more concise, tightly grouped and split by responsibility. Components are now truly independent, and they need not and cannot know anything about the context they're included in.
An exemplary payoff of this redesign is the new zero-overhead middleware exclusions;
Middlewares that are statically excluded from a component (e.g. via an opt key or a
path filter) won't be applied to that component at all, instead of dynamically checking
if the middleware should be skipped at runtime.
(Read more about this here)
More exciting things
There are many more changes, such as middleware configuration constraints, improved cloud file system integration, async native test client and many more.
See 3.x changelog and What's changed in 3.0 for an overview.
Release when?
Litestar 3.0 will probably release some time within 2026. There's one major feature
still waiting for completion before going into beta (DI overhaul). This is currently
blocked by a lack of maintainer resources (i.e. I - Janek - do not have enough time at
the moment to consistently work on it). The groundwork is laid though, the core is
working, all preparations have been made. I currently expect the beta to land by the
end of this summer. After that, the final release date for 3.0 depends on how the beta
goes. Since 3.0 has been developed on main for quite a while now, and people have been
test-driving it, we currently don't expect any major surprises, so we're aiming for ~3
months after the beta release.
And beyond
Technical details aside, what's the future like for Litestar? What are the long term goals? Where are we headed conceptually? How are we doing emotionally? What does Litestar want?
It's been a long time since Litestar 2.0 released, and a lot has changed since then. The project has grown, maintainers came and went, and the project has seen a steady increase in usage.
The project is in a good shape now. Much better than it was when 2.0 was released. There are fewer "big things" on our to-do lists, to the point where I expect that after 3.0, we can put our maintenance focus (even) more on minor improvements, documentation updates and other auxiliary resources.
Furthermore, Litestar has managed to establish a niche in the well satiated web framework space for itself, comfortably placing it as a viable alternative to well known names, albeit not rivalling them in sheer popularity. The Litestar of today has a unique flavour and style to it, that we, I, and a growing community of users have come to cherish. I'm hoping beyond 3.0, we can explore this even further, but I would not expect a major release any time soon after.
Once 3.0 is released and well, I will take some time to focus on msgspec, a project that Litestar relies on a great deal for many of its internals, and where I've recently joined the maintainer team. Keeping Litestar's ecosystem and foundations alive is equally important as the core-framework-work itself, and I'm more than happy about the opportunity for all of this work.
Well then, I hope you feel a bit more informed after reading this. If on the other hand it raised more questions than it answered, don't hesitate to pop over to our discussions page or discord and ask away!