The MVC API provides an annotation-driven alternative to the Script API for defining routes. Jooby uses an annotation processor to generate source code that defines and executes these routes. By default, the generated classes are suffixed with an underscore (_).
If you use Gradle 6.0 or later, or a modern Maven setup, Jooby leverages incremental annotation processing. This means the compiler only processes classes that have changed since the last build, significantly speeding up compilation times.
You can control incremental processing via compiler arguments:
tasks.withType(JavaCompile) {
options.compilerArgs += [
'-parameters',
'-Ajooby.incremental=true'
]
}kapt {
arguments {
arg('jooby.incremental', true)
}
}By setting jooby.incremental=false, you disable incremental processing entirely, forcing a full recompilation of the project every time. (Defaults to true).
The io.jooby.annotation package contains all the annotations available for MVC routes.
import io.jooby.annotation.*;
@Path("/mvc") // (1)
public class Controller {
@GET // (2)
public String sayHi() {
return "Hello Mvc!";
}
}
public class App extends Jooby {
{
mvc(new Controller_()); // (3)
}
public static void main(String[] args) {
runApp(args, App::new);
}
}import io.jooby.annotation.*
import io.jooby.kt.runApp
@Path("/mvc") // (1)
class Controller {
@GET // (2)
fun sayHi(): String {
return "Hello Mvc!"
}
}
fun main(args: Array<String>) {
runApp(args) {
mvc(Controller_()) // (3)
}
}-
Set a base path pattern. The
@Pathannotation can be applied at the class or method level. -
Define the HTTP method.
-
Register the generated controller (
Controller_) in the main application.
To quickly create a new MVC project, use the jooby console:
jooby create myapp --mvcThe Jooby CLI automatically configures the Maven/Gradle build and sets up the annotation processor for you.
Unlike some frameworks, Jooby does not use classpath scanning. MVC routes must be explicitly registered in your application configuration:
public class App extends Jooby {
{
mvc(new MyController_());
}
public static void main(String[] args) {
runApp(args, App::new);
}
}import io.jooby.kt.runApp
fun main(args: Array<String>) {
runApp(args) {
mvc(MyController_())
}
}The javadoc:Jooby[mvc, io.jooby.MvcExtension] method installs the MVC routes. You can pass an instance directly, or if the controller constructor is annotated with @Inject (e.g., jakarta.inject.Inject), the generated code will attempt to resolve the dependencies from the registry.
HTTP parameter extraction is handled via @*Param annotations.
You can also use the generic javadoc:annotation.Param[] annotation to extract a parameter from multiple sources with a specific fallback order.
Extract headers using the javadoc:annotation.HeaderParam[] annotation:
public class MyController {
@GET
public String handle(@HeaderParam String token) { // (1)
// ...
}
}class MyController {
@GET
fun handle(@HeaderParam token: String): String { // (1)
// ...
}
}-
Accesses the HTTP header named
token.
Unlike JAX-RS, specifying the parameter name inside the annotation is optional. Jooby infers it from the variable name. However, you must provide the name explicitly if the HTTP header is not a valid Java identifier:
public class MyController {
@GET
public String handle(@HeaderParam("Last-Modified-Since") long lastModifiedSince) {
// ...
}
}class MyController {
@GET
fun handle(@HeaderParam("Last-Modified-Since") lastModifiedSince: Long): String {
// ...
}
}Extract cookies using the javadoc:annotation.CookieParam[] annotation:
public class MyController {
@GET
public String handle(@CookieParam String token) { // (1)
// ...
}
}class MyController {
@GET
fun handle(@CookieParam token: String): String { // (1)
// ...
}
}-
Accesses the cookie named
token.
As with headers, provide the explicit name if the cookie key contains dashes or invalid Java characters (e.g., @CookieParam("token-id") String tokenId).
Extract path variables using the javadoc:annotation.PathParam[] annotation:
public class MyController {
@Path("/{id}")
public String handle(@PathParam String id) {
// ...
}
}class MyController {
@Path("/{id}")
fun handle(@PathParam id: String): String {
// ...
}
}Extract query string variables using the javadoc:annotation.QueryParam[] annotation:
public class MyController {
@Path("/")
public String handle(@QueryParam String q) {
// ...
}
}class MyController {
@Path("/")
fun handle(@QueryParam q: String): String {
// ...
}
}Extract form-data or multipart parameters using the javadoc:annotation.FormParam[] annotation:
public class MyController {
@POST("/")
public String handle(@FormParam String username) {
// ...
}
}class MyController {
@POST("/")
fun handle(@FormParam username: String): String {
// ...
}
}The HTTP request body does not require an explicit annotation. Simply define the POJO in the method signature:
public class MyController {
@POST("/")
public String handle(MyObject body) {
// ...
}
}class MyController {
@POST("/")
fun handle(body: MyObject): String {
// ...
}
}The javadoc:annotation.BindParam[] annotation allows for custom data binding from the HTTP request directly into an object.
public class Controller {
@GET("/{foo}")
public String bind(@BindParam MyBean bean) {
return "with custom mapping: " + bean;
}
}class Controller {
@GET("/{foo}")
fun bind(@BindParam bean: MyBean) = "with custom mapping: $bean"
}public record MyBean(String value) {
public static MyBean of(Context ctx) {
// Build MyBean entirely from the Context
return new MyBean(ctx.path("foo").value());
}
}class MyBean(val value: String) {
companion object {
@JvmStatic
fun of(ctx: Context): MyBean {
// Build MyBean entirely from the Context
return MyBean(ctx.path("foo").value())
}
}
}How @BindParam works:
-
It looks for a public method/function on the target class that accepts a javadoc:Context[] and returns the target type.
-
By default, it looks for this factory method on the parameter type itself (
MyBean), but will fall back to searching the Controller class.
Alternatively, you can specify a distinct factory class and/or method name:
@BindParam(MyFactoryClass.class) @BindParam(value = MyFactoryClass.class, fn = "fromContext")
Extract flash attributes using the javadoc:annotation.FlashParam[] annotation:
public class MyController {
@GET
public String handle(@FlashParam String success) { // (1)
// ...
}
}class MyController {
@GET
fun handle(@FlashParam success: String): String { // (1)
// ...
}
}-
Accesses the flash attribute named
success.
Extract specific session attributes using the javadoc:annotation.SessionParam[] annotation:
public class MyController {
@GET
public String handle(@SessionParam String userId) { // (1)
// ...
}
}class MyController {
@GET
fun handle(@SessionParam userId: String): String { // (1)
// ...
}
}-
Accesses the session attribute named
userId.
You can also request the entire javadoc:Session[] object:
public class MyController {
@GET
public String handle(Session session) { // (1)
// ...
}
}class MyController {
@GET
fun handle(session: Session): String { // (1)
// ...
}
}-
If no session exists yet, a new session will be created. To avoid this and only retrieve an existing session, use
Optional<Session>as the parameter type.
Extract specific context attributes using the javadoc:annotation.ContextParam[] annotation:
public class MyController {
@GET
public String handle(@ContextParam String userId) { // (1)
// ...
}
}class MyController {
@GET
fun handle(@ContextParam userId: String): String { // (1)
// ...
}
}-
Accesses the context attribute named
userId.
You can also request all javadoc:Context[getAttributes, text="attributes"] at once:
public class MyController {
@GET
public String handle(@ContextParam Map<String, Object> attributes) { // (1)
// ...
}
}class MyController {
@GET
fun handle(@ContextParam attributes: Map<String, Any>): String { // (1)
// ...
}
}-
To retrieve all context attributes, the parameter must be typed as a
Map<String, Object>(orMap<String, Any>in Kotlin).
Use the javadoc:annotation.Param[] annotation to search for a parameter across multiple sources with an explicitly defined fallback order:
import static io.jooby.annotation.ParamSource.QUERY;
import static io.jooby.annotation.ParamSource.PATH;
public class FooController {
@GET("/{foo}")
public String multipleSources(@Param({ QUERY, PATH }) String foo) {
return "foo is: " + foo;
}
}import io.jooby.annotation.ParamSource.QUERY
import io.jooby.annotation.ParamSource.PATH
class FooController {
@GET("/{foo}")
fun multipleSources(@Param(QUERY, PATH) foo: String) = "foo is: $foo"
}If a request is made to /bar?foo=baz, the result will be foo is: baz because the QUERY parameter takes precedence over the PATH parameter in the annotation array.
The MVC module provides first-class support for Projections via annotations. This allows you to define the response view declaratively, keeping your controller logic clean and focused on data retrieval.
There are two ways to define a projection in an MVC controller.
You can annotate your method with @Project and provide the selection DSL:
@GET
@Project("(id, name)")
public List<User> listUsers() {
return service.findUsers();
}Alternatively, you can define the projection directly within the HTTP method annotation (e.g., @GET, @POST) using the projection attribute:
@GET(value = "/users", projection = "(id, name, email)")
public List<User> listUsers() {
return service.findUsers();
}The Jooby Annotation Processor automatically handles the conversion of your method’s return type. You are not forced to return a Projected instance; you can simply return your POJO or Collection, and Jooby will wrap it for you at compile-time.
However, if you need manual control (for example, to dynamically toggle validation), you can still return a Projected instance explicitly:
@GET
public Projected<User> getUser(String id) {
User user = service.findById(id);
return Projected.wrap(user)
.failOnMissingProperty(true)
.include("(id, status)");
}|
Note
|
For more details on the Selection DSL syntax and available JSON engines, please refer to the Core Projections documentation. |
The default HTTP status code returned by an MVC route is 200 OK, except for void methods annotated with @DELETE, which automatically return 204 No Content.
If you need to return a different status code, you have two options: 1. Inject the javadoc:Context[] into your method and call javadoc:Context[setResponseCode, io.jooby.StatusCode]. 2. Return a javadoc:StatusCode[] instance directly from the method.
Any MVC method returning a non-blocking type (CompletableFuture, Single, Maybe, Flowable, Mono, Flux) is automatically handled as a non-blocking route.
Kotlin suspend functions are also supported natively:
class SuspendMvc {
@GET
@Path("/delay")
suspend fun delayed(ctx: Context): String {
delay(100)
return ctx.getRequestPath()
}
}
fun main(args: Array<String>) {
runApp(args) {
mvc(SuspendMvc_())
}
}A non-blocking route runs on the event loop by default, where blocking is NOT allowed. For more details, see the NonBlocking Responses section.
MVC routes follow the standard Jooby Execution Model.
By default, if your route returns a blocking type (like a String or a POJO), Jooby automatically dispatches the execution to the worker executor. If it returns a non-blocking type (or is a suspend function), it runs on the event loop.
If you need explicit control over where a specific blocking MVC route executes, use the javadoc:annotation.Dispatch[] annotation:
public class MyController {
@GET("/blocking")
@Dispatch // (1)
public String blocking() {
return "I'm blocking";
}
}import io.jooby.annotation.*
class MyController {
@GET("/blocking")
@Dispatch // (1)
fun blocking(): String {
return "I'm blocking"
}
}-
Forces the route to run in the
WORKERexecutor, safely allowing blocking calls.
The javadoc:annotation.Dispatch[] annotation also supports routing execution to a named, custom executor:
public class MyController {
@GET("/blocking")
@Dispatch("single") // (1)
public String blocking() {
return "I'm blocking";
}
}import io.jooby.annotation.*
class MyController {
@GET("/blocking")
@Dispatch("single") // (1)
fun blocking(): String {
return "I'm blocking"
}
}-
Dispatches execution to the executor registered under the name
single.
The custom executor must be registered in the application before the MVC route is registered:
{
executor("single", Executors.newSingleThreadExecutor());
mvc(new MyController_());
}{
executor("single", Executors.newSingleThreadExecutor())
mvc(MyController_())
}Alternatively, you can use JAX-RS annotations to define MVC routes.
import javax.ws.rs.GET;
import javax.ws.rs.Path;
@Path("/jaxrs")
public class Resource {
@GET
public String getIt() {
return "Got it!";
}
}import javax.ws.rs.GET
import javax.ws.rs.Path
@Path("/jaxrs")
class Resource {
@GET
fun getIt(): String {
return "Got it!"
}
}These annotations work exactly like the Jooby native MVC annotations.
(Note: Jooby does not implement the full JAX-RS specification, nor is there a plan to do so. Support for these annotations exists primarily to allow integration with third-party tools, like Swagger/OpenAPI generators, that rely on them).
For each MVC controller class, a new class is generated ending with an underscore (_). This generated class mimics the constructors of the source class. (If the constructor is annotated with @Inject, a default constructor is automatically generated).
Any annotations found on the controller methods will be persisted as route attributes, unless explicitly excluded by the jooby.skipAttributeAnnotations compiler option.
You can access the generated routes at runtime:
{
var routes = mvc(new MyController_());
routes.forEach(route -> {
// Modify or inspect the route
});
}| Option | Type | Default | Description |
|---|---|---|---|
|
boolean |
true |
Runs the annotation processor in debug mode. |
|
boolean |
true |
Hints to Maven/Gradle to perform incremental compilation. Essential for fast development iteration. |
|
array |
[] |
A comma-separated list of annotations to skip during bytecode generation (i.e., do not attach them as route attributes). |
|
boolean |
false |
Sets the |
|
string |
Adds a prefix to the generated class name. |
|
|
string |
_ |
Sets the suffix for the generated class name. |
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.jooby</groupId>
<artifactId>jooby-apt</artifactId>
<version>${jooby.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<compilerArg>-Ajooby.debug=false</compilerArg>
<compilerArg>-Ajooby.incremental=true</compilerArg>
<compilerArg>-Ajooby.skipAttributeAnnotations=FooAnnotation,BarAnnotation</compilerArg>
</compilerArgs>
</configuration>
</plugin>tasks.withType(JavaCompile) {
options.compilerArgs += [
'-parameters',
'-Ajooby.debug=false',
'-Ajooby.incremental=true',
'-Ajooby.skipAttributeAnnotations=FooAnnotation,BarAnnotation'
]
}|
Important
|
The execution order of annotation processors is critical. If you are using |