A hands-on comparison of annotation controllers, RouterFunction routing, raw Servlets and Spring Cloud Function

A hands-on comparison of annotation controllers, RouterFunction routing, raw Servlets and Spring Cloud Function

Ask a Spring developer how to expose a REST endpoint, and the answer is almost always the same:

@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello";
}
}

There’s nothing wrong with this — it’s simple, readable and has become the de facto standard.

But Spring Boot has accumulated several other ways to expose HTTP endpoints. Some are more declarative, others are more functional and one doesn’t even involve Spring MVC at all.

If you’ve only ever written annotation-based controllers, you might be surprised by how much flexibility already exists in the framework.

⚡ TL;DR (Quick Recap)

  • @GetMapping remains the default choice for most REST APIs.
  • Functional routing offers a programmatic alternative without controller annotations.
  • Raw Servlets bypass Spring MVC and interact directly with the Servlet API.
  • Spring Cloud Function can automatically expose Java functions as HTTP endpoints.

Why Multiple Approaches Exist

Spring Framework has evolved for more than two decades.

As new programming styles emerged — from annotation-driven development to functional programming and serverless computing — the framework introduced new endpoint models rather than replacing existing ones.

Today, Spring Boot supports several styles simultaneously:

  • traditional Spring MVC controllers
  • functional routing
  • direct Servlet registration
  • function-based HTTP endpoints

Each targets a different problem rather than competing with the others.

Annotation-Based Controllers — The Standard Choice

This is the approach every Spring developer knows.

@RestController
@RequestMapping("/api/annotation")
public class AnnotationController {
@GetMapping("/hello")
public String helloGetMapping() {
return "Hello from @GetMapping";
}
@RequestMapping(
value = "/hello-classic",
method = RequestMethod.GET)
public String helloRequestMapping() {
return "Hello from @RequestMapping(method = GET)";
}
}

Both methods expose GET endpoints.

The only difference is syntax:

  • @GetMapping is specialized and concise — it is a composed meta-annotation that narrows down the attributes of @RequestMapping
  • @RequestMapping(method = GET) is the original, more generic annotation.

For modern applications, @GetMapping should almost always be preferred.

Choose annotation-based controllers when:

  • building REST APIs
  • using validation
  • integrating Spring Security
  • generating OpenAPI documentation
  • relying on Spring MVC features

For the overwhelming majority of applications, this remains the best option.

Functional Endpoints — Routing in Java Code

Spring MVC also supports functional routing. Unlike annotation-based controllers, routes are declared programmatically.

@Bean
RouterFunction<ServerResponse> functionalRoutes(HelloHandler handler) {
return RouterFunctions.route()
.GET("/api/functional/hello", handler::hello)
.build();
}

The request handling logic moves into a dedicated handler.

@Component
class HelloHandler {
public ServerResponse hello(ServerRequest request) {
return ServerResponse.ok()
.body("Hello from Functional Handler");
}
}

Many developers associate RouterFunction with WebFlux. That association is understandable but outdated — Spring MVC has offered the same programming model through the org.springframework.web.servlet.function package since Spring Framework 5.2, entirely on the Servlet stack.

Functional endpoints provide:

  • explicit routing definitions
  • no controller annotations
  • easy route composition
  • straightforward unit testing — you can call handler.hello(mockRequest) directly with no Spring context required
  • separation between routing and request handling

They work particularly well for infrastructure services or APIs with a small number of endpoints.

Raw Servlet Registration — Going Below Spring MVC

Sometimes you don’t want Spring MVC involved at all. Spring Boot still allows direct Servlet registration.

@Bean
public ServletRegistrationBean<HelloServlet> rawServletBean() {
return new ServletRegistrationBean<>(new HelloServlet(), "/api/raw-servlet/hello");
}


public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().write("Hello from Raw Servlet");
}
}

This endpoint never reaches Spring MVC. Instead, the embedded servlet container invokes the servlet directly.

Although uncommon, raw servlets are still useful when:

  • migrating legacy Java EE applications
  • integrating existing servlet libraries
  • avoiding Spring MVC request processing overhead
  • implementing specialized infrastructure endpoints

You lose many Spring MVC conveniences:

  • no automatic JSON conversion
  • no validation
  • no controller advice
  • no argument binding

Everything is handled manually using the Servlet API.

“Bypasses Spring MVC” does not mean “bypasses security.” A raw servlet still sits behind the same SecurityFilterChain as your annotation-based and functional endpoints, so Spring Security filters still apply by default. What you do lose is anything wired through Spring MVC specifically — CORS configuration on WebMvcConfigurer, @ControllerAdvice exception handling and CSRF token handling tied to the MVC layer. If you register a raw servlet, explicitly verify your security configuration's URL patterns cover it — don't assume it inherits protections you configured elsewhere.

Spring Cloud Function — Functions as HTTP Endpoints

Spring Cloud Function introduces another programming model. Instead of writing controllers, you simply expose a Java function.

@Bean("hello-supplier")
Supplier<String> helloSupplier() {
return () -> "Hello from Spring Cloud Function";
}

Only after adding spring-cloud-function-web does the function become an HTTP endpoint.

GET /cloud/hello-supplier

Configuration is minimal.

spring:
cloud:
function:
web:
path: /cloud

By default, the bean name becomes the endpoint path.

No controller. No router. Just a Java function.

Spring Cloud Function was designed for portability.

The same function can execute:

  • as an HTTP endpoint
  • inside AWS Lambda
  • inside Azure Functions
  • inside Google Cloud Functions
  • through messaging systems such as Kafka or RabbitMQ

The business logic remains unchanged while the execution model changes.

Choosing the Right Approach

Rather than thinking about these approaches as competitors, it is better to see them as tools.

Annotation Controllers

  • REST APIs
  • CRUD services
  • enterprise applications
  • OpenAPI integration

Functional Endpoints

  • lightweight services
  • infrastructure APIs
  • modular routing
  • developers preferring explicit configuration

Raw Servlets

  • legacy integrations
  • specialized servlet behavior
  • low-level HTTP handling

Spring Cloud Function

  • serverless applications
  • portable business logic
  • event-driven architectures
  • function-oriented services

Final Takeaways

Most developers will continue using @RestController, and that's perfectly reasonable. It provides the richest integration with the Spring ecosystem and remains the most productive option for everyday REST APIs.

However, understanding the alternatives broadens your toolkit. Functional routing offers a clean, programmatic style for lightweight services. Raw servlet registration remains valuable for low-level integrations and legacy compatibility. Spring Cloud Function demonstrates how business logic can be written once and exposed through multiple execution models, including HTTP and serverless platforms.

Spring Boot doesn’t promote one approach over another — it gives developers the flexibility to choose the abstraction that best fits the problem they’re solving.

You can find an example on GitHub.

Originally posted on marconak-matej.medium.com.