Guice on the Web: The Servlet Extension Nobody Mentions Anymore
Series: Guice for JVM Engineers · II (Advanced & Real-World) — Part 7 of 8
Here’s a fact that surprises people: Guice has shipped a servlet integration since roughly forever, and it’s genuinely good. ServletModule lets you register servlets and filters in type-safe Java instead of a web.xml incantation, and it gives you two web-aware scopes — @RequestScoped and @SessionScoped — that make “one instance per HTTP request” a binding decision rather than a ThreadLocal you maintain by hand. For a raw servlet application, it is the most pleasant way to wire the whole thing.
The catch — and we’ll be honest about it up front — is that almost nobody writes raw servlet applications in 2026. You reach for Spring Boot, Micronaut, Quarkus, Ktor, or (if you’re in this corner of the JVM world) Misk, and the framework owns request handling. So treat this post as knowing your tools: the servlet extension is the right answer for a narrow, real band of situations, and understanding it demystifies how every “web scope” you’ve ever used actually works underneath.
It lives in its own artifact — com.google.inject.extensions:guice-servlet — separate from guice-core. Add that dependency and you get the com.google.inject.servlet package.
Registering servlets and filters: ServletModule
You don’t write web.xml. You subclass ServletModule and override configureServlets(), then use a small embedded DSL: serve("/path").with(SomeServlet) maps a URL pattern to a servlet, and filter("/*").through(SomeFilter) maps one to a filter. Both take URL patterns (/users/*, *.json) — there are serveRegex / filterRegex variants if you need real regular expressions.
import com.google.inject.servlet.ServletModule
import jakarta.servlet.http.HttpServlet
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
class HelloServlet : HttpServlet() {
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
resp.contentType = "text/plain"
resp.writer.write("Hello from Guice")
}
}
class WebModule : ServletModule() {
override fun configureServlets() {
// Filters run in declaration order, before any servlet.
filter("/*").through(RequestLoggingFilter::class.java)
serve("/hello").with(HelloServlet::class.java)
}
}import com.google.inject.servlet.ServletModule;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
resp.setContentType("text/plain");
resp.getWriter().write("Hello from Guice");
}
}
public class WebModule extends ServletModule {
@Override
protected void configureServlets() {
// Filters run in declaration order, before any servlet.
filter("/*").through(RequestLoggingFilter.class);
serve("/hello").with(HelloServlet.class);
}
}The DSL is honest about its types: with(...) accepts a Class<? extends HttpServlet> and through(...) accepts a Class<? extends Filter>, so the compiler stops you from mapping /hello to something that isn’t actually a servlet. That’s the whole pitch over web.xml — the wiring is code your IDE understands, and your servlets are now real Guice-managed objects, so they can have @Inject constructors and pull dependencies from the graph like anything else.
One rule the extension enforces: every servlet and filter must be a singleton. If you don’t annotate the class @Singleton, the DSL effectively treats it as one anyway — there’s a single instance per mapping, the way the Servlet spec expects. Don’t fight this by stuffing per-request state into servlet fields; that’s exactly what the request scope is for, which we’ll get to.
You can also pass init params (the <init-param> equivalent) by handing a Map<String, String> as a second argument to with or through, and you can register multiple ServletModules — their filter and servlet orderings compose in the order the modules are installed.
Bootstrapping: GuiceFilter + a context listener
The servlet container doesn’t know about Guice, and Guice’s pipeline doesn’t run unless the container routes requests through it. Bridging the two takes exactly two pieces.
First, GuiceFilter (from com.google.inject.servlet) is a real jakarta.servlet.Filter you register at the top of your filter chain, mapped to /*. Every request flows through it, and it dispatches to the filters and servlets you declared in configureServlets(). Second, GuiceServletContextListener is an abstract ServletContextListener you subclass to build the injector. Its contextInitialized calls your getInjector(), stashes the result on the ServletContext, and points GuiceFilter at it.
So web.xml (or your programmatic equivalent) registers the filter and the listener; the listener builds the injector with your ServletModule installed:
import com.google.inject.Guice
import com.google.inject.Injector
import com.google.inject.servlet.GuiceServletContextListener
class AppServletConfig : GuiceServletContextListener() {
override fun getInjector(): Injector =
Guice.createInjector(
WebModule(),
// ...your other application modules
)
}import com.google.inject.Guice;
import com.google.inject.Injector;
import com.google.inject.servlet.GuiceServletContextListener;
public class AppServletConfig extends GuiceServletContextListener {
@Override
protected Injector getInjector() {
return Guice.createInjector(
new WebModule()
// ...your other application modules
);
}
}The web.xml half is small and unglamorous — register com.google.inject.servlet.GuiceFilter mapped to /*, and register your AppServletConfig as a <listener>. From then on, all servlet/filter dispatch goes through Guice. Notably, you do not declare your individual servlets in web.xml; GuiceFilter does that dispatch internally based on your serve(...) mappings. The container only ever sees GuiceFilter.
The payoff: @RequestScoped and @SessionScoped
This is the part worth carrying with you even if you never write web.xml again. The extension defines two scope annotations in com.google.inject.servlet: @RequestScoped (one instance per HTTP request) and @SessionScoped (one instance per HTTP session). Installing a ServletModule binds them to the scope implementations ServletScopes.REQUEST and ServletScopes.SESSION, which key their backing maps off the current request and HttpSession respectively.
Annotate a type with @RequestScoped and Guice gives every injection point in the same request the same instance, then throws it away when the request ends — perfect for a per-request context object (the authenticated user, a request id, a transaction handle):
import com.google.inject.servlet.RequestScoped
import jakarta.inject.Inject
@RequestScoped
class RequestContext @Inject constructor() {
var userId: String? = null
val requestId: String = java.util.UUID.randomUUID().toString()
}
// Anything injected within the same request shares one RequestContext;
// a new request gets a fresh one.
class GreetingService @Inject constructor(
private val context: RequestContext,
) {
fun greet() = "Hello, user ${context.userId} (req ${context.requestId})"
}import com.google.inject.servlet.RequestScoped;
import jakarta.inject.Inject;
import java.util.UUID;
@RequestScoped
public class RequestContext {
private String userId;
private final String requestId = UUID.randomUUID().toString();
@Inject RequestContext() {}
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getRequestId() { return requestId; }
}
// Anything injected within the same request shares one RequestContext;
// a new request gets a fresh one.
public class GreetingService {
private final RequestContext context;
@Inject GreetingService(RequestContext context) { this.context = context; }
public String greet() {
return "Hello, user " + context.getUserId()
+ " (req " + context.getRequestId() + ")";
}
}Better still, the extension pre-binds the servlet primitives into the request scope for you. Inside a request you can inject HttpServletRequest, HttpServletResponse, and HttpSession directly — they’re @Provides @RequestScoped bindings backed by GuiceFilter’s current request — plus a @RequestParameters Map<String, String[]> for the raw query/form parameters. You inject the request like any other dependency, no ThreadLocal plumbing of your own:
import com.google.inject.servlet.RequestScoped
import jakarta.inject.Inject
import jakarta.servlet.http.HttpServletRequest
@RequestScoped
class CurrentUser @Inject constructor(request: HttpServletRequest) {
val token: String? = request.getHeader("Authorization")
}import com.google.inject.servlet.RequestScoped;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
@RequestScoped
public class CurrentUser {
private final String token;
@Inject CurrentUser(HttpServletRequest request) {
this.token = request.getHeader("Authorization");
}
}@SessionScoped works identically but keys off the HttpSession instead — one instance per user session, ideal for a shopping cart or a per-user preferences cache. Just remember that session-scoped objects live as long as the session and must survive serialization if your container replicates sessions; don’t put a database connection in there.
Going deeper: jakarta vs javax, and when this is the right tool today
The single most important compatibility fact: Guice 7’s servlet extension targets jakarta.servlet, not javax.servlet. I verified this directly in the 7.0.0 sources — ServletModule, GuiceFilter, and GuiceServletContextListener all import jakarta.servlet.* (jakarta.servlet.Filter, jakarta.servlet.http.HttpServlet, and friends). This mirrors the move in Part 1 from javax.inject to jakarta.inject: Guice 7 is a Jakarta-era release end to end.
Concretely, that means your servlet container must be Jakarta-based — Tomcat 10+, Jetty 11+, anything implementing Servlet 5.0 or later. If you’re stuck on an older Tomcat 9 / javax.servlet stack, Guice 7’s servlet extension will not link against it, and you’d need to stay on Guice 6 (or earlier) for the javax-flavored version. Check this before you upgrade Guice on a legacy web app; it’s the kind of mismatch that surfaces as a baffling ClassNotFoundException at startup rather than a compile error.
So when is the servlet extension actually the right call? A short, real list:
- A genuinely raw servlet/JSP application with no higher-level web framework, where you want DI and per-request scoping without adopting Spring.
- A small embedded HTTP surface — an admin endpoint, a health-check servlet, a webhook receiver — bolted onto a service that already uses Guice for everything else, and where pulling in a full framework would be overkill.
- Maintaining or understanding an existing Guice-servlet app (these absolutely exist in the wild, and Misk’s request handling is conceptually descended from these scopes).
If you’re greenfielding a real web service today, I won’t pretend otherwise: reach for a modern framework, and let it integrate DI. The servlet extension is a sharp, well-built tool for a shrinking set of jobs — not the default.
Gotchas
GuiceFiltermust be in the chain, mapped to/*. Forget to register it (or the context listener) and yourserve/filtermappings silently do nothing — the container never routes requests into Guice’s pipeline. This is the number-one “why is my servlet 404ing” mistake.- Servlets and filters are singletons, full stop. Don’t store per-request state in their fields — it’ll leak across concurrent requests. Inject a
@RequestScopedobject for per-request state instead. - Don’t cache a
@RequestScopedinstance in a singleton. Injecting a request-scoped object straight into a singleton’s constructor captures one request’s instance forever (and Guice will often refuse with a scope-mismatch error). Inject aProvider<RequestContext>and call.get()per request, or restructure so the singleton receives the scoped object as a method argument. - Outside a request, request-scoped injection blows up. Resolving
HttpServletRequest(or any@RequestScopedbinding) on a background thread or at startup throws anOutOfScopeException— there is no request to scope to. For deliberately running request-scoped code off-thread,ServletScopesoffersscopeRequest(...)andtransferRequest(...)to seed or carry a scope across threads; that’s the supported escape hatch, not a hack. - Jakarta-only. As above — Servlet 5.0+ container required. Don’t mix a
javax.servlet-era container with Guice 7. - Filter and servlet order is the declaration order across modules. If two
ServletModules both map/*, the precedence is the order you install the modules, then the order of calls withinconfigureServlets(). Surprising filter ordering usually traces back to module install order.
What’s next
You now know how Guice plugs into a servlet stack — the ServletModule DSL, the GuiceFilter-plus-listener bootstrap, and the web scopes that make per-request and per-session state a binding decision — and, just as usefully, when to skip all of it in favor of a framework.
That wraps the heavy machinery. The finale steps back to the things you’ll use on every Guice project regardless of stack: how to test code wired with Guice without standing up the whole graph, the Kotlin idioms that file down Guice’s ::class.java rough edges, and the handful of opinions that keep a large injector legible.
Next: Part 8 — Testing, Kotlin, and Best Practices, the practical send-off for the whole series.
Target keyword(s): guice servlet, guice @RequestScoped.
Comments