Spring WebFlux supports annotation-based controllers using @RestController and @RequestMapping annotations. This programming model is similar to Spring MVC and provides a familiar approach for handling HTTP requests with annotated methods, argument resolvers, and result handlers.
Use @RestController to mark a class as a REST controller where method return values are automatically written to the response body.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}Usage:
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserRepository repository;
public UserController(UserRepository repository) {
this.repository = repository;
}
@GetMapping("/{id}")
public Mono<User> getUser(@PathVariable String id) {
return repository.findById(id);
}
@GetMapping
public Flux<User> listUsers() {
return repository.findAll();
}
@PostMapping
public Mono<User> createUser(@RequestBody User user) {
return repository.save(user);
}
}The @RequestMapping annotation maps HTTP requests to handler methods.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
@Reflective(ControllerMappingReflectiveProcessor.class)
public @interface RequestMapping {
// Name for mapping
String name() default "";
// Path patterns
@AliasFor("path")
String[] value() default {};
@AliasFor("value")
String[] path() default {};
// HTTP methods
RequestMethod[] method() default {};
// Request parameters
String[] params() default {};
// Request headers
String[] headers() default {};
// Consumable media types
String[] consumes() default {};
// Producible media types
String[] produces() default {};
// API version (since Spring 7.0)
String version() default "";
}HTTP method-specific shortcuts:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
String name() default "";
String[] value() default {};
String[] path() default {};
String[] params() default {};
String[] headers() default {};
String[] consumes() default {};
String[] produces() default {};
String version() default "";
}@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.POST)
public @interface PostMapping {
String name() default "";
String[] value() default {};
String[] path() default {};
String[] params() default {};
String[] headers() default {};
String[] consumes() default {};
String[] produces() default {};
String version() default "";
}@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.PUT)
public @interface PutMapping {
String name() default "";
String[] value() default {};
String[] path() default {};
String[] params() default {};
String[] headers() default {};
String[] consumes() default {};
String[] produces() default {};
String version() default "";
}@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.PATCH)
public @interface PatchMapping {
String name() default "";
String[] value() default {};
String[] path() default {};
String[] params() default {};
String[] headers() default {};
String[] consumes() default {};
String[] produces() default {};
String version() default "";
}@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.DELETE)
public @interface DeleteMapping {
String name() default "";
String[] value() default {};
String[] path() default {};
String[] params() default {};
String[] headers() default {};
String[] consumes() default {};
String[] produces() default {};
String version() default "";
}Usage:
@RestController
@RequestMapping("/api/products")
public class ProductController {
// Match GET /api/products
@GetMapping
public Flux<Product> listProducts() {
return productService.findAll();
}
// Match GET /api/products/{id}
@GetMapping("/{id}")
public Mono<Product> getProduct(@PathVariable Long id) {
return productService.findById(id);
}
// Match POST /api/products with JSON content
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public Mono<Product> createProduct(@RequestBody Product product) {
return productService.save(product);
}
// Match PUT /api/products/{id}
@PutMapping("/{id}")
public Mono<Product> updateProduct(@PathVariable Long id, @RequestBody Product product) {
return productService.update(id, product);
}
// Match DELETE /api/products/{id}
@DeleteMapping("/{id}")
public Mono<Void> deleteProduct(@PathVariable Long id) {
return productService.deleteById(id);
}
// Advanced matching with params and headers
@GetMapping(params = "active=true", headers = "X-API-Version=1")
public Flux<Product> getActiveProducts() {
return productService.findActive();
}
}The RequestMappingHandlerMapping class creates request mappings from @RequestMapping annotations.
public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping
implements MatchableHandlerMapping, EmbeddedValueResolverAware {
// Configure path prefixes for controller types
public void setPathPrefixes(Map<String, Predicate<Class<?>>> prefixes) { ... }
// Get path prefixes
public Map<String, Predicate<Class<?>>> getPathPrefixes() { ... }
// Set content type resolver
public void setContentTypeResolver(RequestedContentTypeResolver contentTypeResolver) { ... }
// Get content type resolver
public RequestedContentTypeResolver getContentTypeResolver() { ... }
// Set embedded value resolver
@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) { ... }
// Create mapping from @RequestMapping annotation
@Override
protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) { ... }
// Additional configuration methods...
}The RequestMappingHandlerAdapter class invokes annotated controller methods and handles their arguments and return values.
public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
implements BeanFactoryAware, InitializingBean {
// Configure message readers for request body
public void setMessageReaders(List<HttpMessageReader<?>> messageReaders) { ... }
// Get configured message readers
public List<HttpMessageReader<?>> getMessageReaders() { ... }
// Configure argument resolvers
public void setArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { ... }
// Configure custom argument resolvers
public void setCustomArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { ... }
// Get configured argument resolvers
public List<HandlerMethodArgumentResolver> getArgumentResolvers() { ... }
// Configure initializer methods
public void setInitBinderMethods(List<InvocableHandlerMethod> initBinderMethods) { ... }
// Configure model attribute methods
public void setModelAttributeMethods(List<InvocableHandlerMethod> modelAttributeMethods) { ... }
// Configure reactive adapter registry
public void setReactiveAdapterRegistry(ReactiveAdapterRegistry reactiveAdapterRegistry) { ... }
// Get reactive adapter registry
public ReactiveAdapterRegistry getReactiveAdapterRegistry() { ... }
// Configure blocking execution
public void setBlockingExecutionScheduler(Scheduler scheduler) { ... }
// Configure web binding initializer
public void setWebBindingInitializer(WebBindingInitializer webBindingInitializer) { ... }
// Get web binding initializer
public WebBindingInitializer getWebBindingInitializer() { ... }
@Override
public boolean supports(Object handler) { ... }
@Override
public Mono<HandlerResult> handle(ServerWebExchange exchange, Object handler) { ... }
}Argument resolver annotations for controller method parameters:
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestParam {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
boolean required() default true;
String defaultValue() default ValueConstants.DEFAULT_NONE;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PathVariable {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
boolean required() default true;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestHeader {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
boolean required() default true;
String defaultValue() default ValueConstants.DEFAULT_NONE;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CookieValue {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
boolean required() default true;
String defaultValue() default ValueConstants.DEFAULT_NONE;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestBody {
boolean required() default true;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestPart {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
boolean required() default true;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ModelAttribute {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
boolean binding() default true;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SessionAttribute {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
boolean required() default true;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestAttribute {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
boolean required() default true;
}@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MatrixVariable {
@AliasFor("name")
String value() default "";
@AliasFor("value")
String name() default "";
String pathVar() default ValueConstants.DEFAULT_NONE;
boolean required() default true;
String defaultValue() default ValueConstants.DEFAULT_NONE;
}Usage:
@RestController
@RequestMapping("/api")
public class ExampleController {
// Path variable
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userService.findById(id);
}
// Request parameter
@GetMapping("/search")
public Flux<User> search(@RequestParam String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.search(query, page, size);
}
// Request header
@GetMapping("/data")
public Mono<String> getData(@RequestHeader("Authorization") String authHeader) {
return dataService.fetchData(authHeader);
}
// Cookie value
@GetMapping("/profile")
public Mono<User> getProfile(@CookieValue("sessionId") String sessionId) {
return userService.findBySessionId(sessionId);
}
// Request body
@PostMapping("/users")
public Mono<User> createUser(@RequestBody User user) {
return userService.save(user);
}
// Matrix variable
@GetMapping("/products/{category}")
public Flux<Product> getProducts(@PathVariable String category,
@MatrixVariable(pathVar = "category") List<String> tags) {
return productService.findByCategoryAndTags(category, tags);
}
// Multiple parts
@PostMapping("/upload")
public Mono<String> handleUpload(@RequestPart("file") FilePart file,
@RequestPart("metadata") Metadata metadata) {
return fileService.save(file, metadata);
}
// Session attribute
@GetMapping("/cart")
public Mono<Cart> getCart(@SessionAttribute("cart") Cart cart) {
return Mono.just(cart);
}
// Request attribute
@GetMapping("/internal")
public Mono<String> getInternal(@RequestAttribute("userId") String userId) {
return dataService.getInternalData(userId);
}
// Model attribute
@GetMapping("/form")
public Mono<String> showForm(@ModelAttribute("user") User user) {
return Mono.just("formView");
}
// ServerWebExchange
@GetMapping("/exchange")
public Mono<String> handleExchange(ServerWebExchange exchange) {
return Mono.just(exchange.getRequest().getPath().value());
}
// Principal
@GetMapping("/me")
public Mono<User> getCurrentUser(Principal principal) {
return userService.findByUsername(principal.getName());
}
// Multiple reactive types
@GetMapping("/stream")
public Flux<Event> streamEvents() {
return eventService.streamEvents();
}
@GetMapping("/single")
public Mono<Result> getSingleResult() {
return resultService.getResult();
}
}public interface HandlerMethodArgumentResolver {
// Check if this resolver supports the parameter
boolean supportsParameter(MethodParameter parameter);
// Resolve the argument value
Mono<Object> resolveArgument(MethodParameter parameter,
BindingContext bindingContext,
ServerWebExchange exchange);
}public interface SyncHandlerMethodArgumentResolver {
// Check if this resolver supports the parameter
boolean supportsParameter(MethodParameter parameter);
// Resolve the argument value synchronously
Object resolveArgumentValue(MethodParameter parameter,
BindingContext bindingContext,
ServerWebExchange exchange);
}public class ArgumentResolverConfigurer {
// Add custom argument resolvers before built-in resolvers
public ArgumentResolverConfigurer addCustomResolver(HandlerMethodArgumentResolver... resolvers) { ... }
// Get custom resolvers
public List<HandlerMethodArgumentResolver> getCustomResolvers() { ... }
}Handler result handlers for different return value types:
public class ResponseEntityResultHandler extends AbstractMessageWriterResultHandler
implements HandlerResultHandler {
public ResponseEntityResultHandler(List<HttpMessageWriter<?>> writers,
RequestedContentTypeResolver resolver) { ... }
public ResponseEntityResultHandler(List<HttpMessageWriter<?>> writers,
RequestedContentTypeResolver resolver,
ReactiveAdapterRegistry registry) { ... }
@Override
public boolean supports(HandlerResult result) { ... }
@Override
public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result) { ... }
}public class ResponseBodyResultHandler extends AbstractMessageWriterResultHandler
implements HandlerResultHandler {
public ResponseBodyResultHandler(List<HttpMessageWriter<?>> writers,
RequestedContentTypeResolver resolver) { ... }
public ResponseBodyResultHandler(List<HttpMessageWriter<?>> writers,
RequestedContentTypeResolver resolver,
ReactiveAdapterRegistry registry) { ... }
@Override
public boolean supports(HandlerResult result) { ... }
@Override
public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result) { ... }
}public class ViewResolutionResultHandler extends HandlerResultHandlerSupport
implements HandlerResultHandler, Ordered {
public ViewResolutionResultHandler(List<ViewResolver> viewResolvers,
RequestedContentTypeResolver contentTypeResolver) { ... }
public ViewResolutionResultHandler(List<ViewResolver> viewResolvers,
RequestedContentTypeResolver contentTypeResolver,
ReactiveAdapterRegistry adapterRegistry) { ... }
// Set default view names
public void setDefaultViews(List<View> defaultViews) { ... }
// Get default views
public List<View> getDefaultViews() { ... }
@Override
public boolean supports(HandlerResult result) { ... }
@Override
public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result) { ... }
}The @ResponseBody annotation indicates that the return value should be written to the response body.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ResponseBody {
}The @ResponseStatus annotation marks a method or exception class with a status code and reason.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ResponseStatus {
@AliasFor("code")
HttpStatus value() default HttpStatus.INTERNAL_SERVER_ERROR;
@AliasFor("value")
HttpStatus code() default HttpStatus.INTERNAL_SERVER_ERROR;
String reason() default "";
}Usage:
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
@RestController
public class UserController {
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userService.findById(id)
.switchIfEmpty(Mono.error(new ResourceNotFoundException("User not found")));
}
@DeleteMapping("/users/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteUser(@PathVariable Long id) {
return userService.deleteById(id);
}
}public final class RequestMappingInfo implements RequestCondition<RequestMappingInfo> {
// Get patterns
public PatternsRequestCondition getPatternsCondition() { ... }
// Get methods
public RequestMethodsRequestCondition getMethodsCondition() { ... }
// Get params
public ParamsRequestCondition getParamsCondition() { ... }
// Get headers
public HeadersRequestCondition getHeadersCondition() { ... }
// Get consumes
public ConsumesRequestCondition getConsumesCondition() { ... }
// Get produces
public ProducesRequestCondition getProducesCondition() { ... }
// Get custom condition
public RequestConditionHolder getCustomCondition() { ... }
// Get API version condition
public VersionRequestCondition getVersionCondition() { ... }
@Override
public RequestMappingInfo combine(RequestMappingInfo other) { ... }
@Override
public RequestMappingInfo getMatchingCondition(ServerWebExchange exchange) { ... }
@Override
public int compareTo(RequestMappingInfo other, ServerWebExchange exchange) { ... }
// Builder for RequestMappingInfo
public static Builder paths(String... paths) { ... }
public static class Builder {
public Builder paths(String... paths) { ... }
public Builder methods(RequestMethod... methods) { ... }
public Builder params(String... params) { ... }
public Builder headers(String... headers) { ... }
public Builder consumes(String... consumes) { ... }
public Builder produces(String... produces) { ... }
public Builder mappingName(String name) { ... }
public Builder customCondition(RequestCondition<?> condition) { ... }
public Builder version(String version) { ... }
public Builder options(BuilderConfiguration options) { ... }
public RequestMappingInfo build() { ... }
}
public static class BuilderConfiguration {
public void setPathPatternParser(PathPatternParser parser) { ... }
public PathPatternParser getPathPatternParser() { ... }
public void setContentTypeResolver(RequestedContentTypeResolver resolver) { ... }
public RequestedContentTypeResolver getContentTypeResolver() { ... }
}
}public class InvocableHandlerMethod extends HandlerMethod {
public InvocableHandlerMethod(Object bean, Method method) { ... }
public InvocableHandlerMethod(HandlerMethod handlerMethod) { ... }
// Set argument resolvers
public void setArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { ... }
// Set reactive adapter registry
public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) { ... }
// Invoke the method
public Mono<HandlerResult> invoke(ServerWebExchange exchange, BindingContext bindingContext, Object... providedArgs) { ... }
}public class SyncInvocableHandlerMethod extends HandlerMethod {
public SyncInvocableHandlerMethod(Object bean, Method method) { ... }
public SyncInvocableHandlerMethod(HandlerMethod handlerMethod) { ... }
// Set argument resolvers
public void setArgumentResolvers(List<SyncHandlerMethodArgumentResolver> resolvers) { ... }
// Invoke the method synchronously
public Object invokeForValue(ServerWebExchange exchange, BindingContext bindingContext, Object... providedArgs) { ... }
}public class HandlerMethodArgumentResolverComposite implements HandlerMethodArgumentResolver {
// Add resolver
public HandlerMethodArgumentResolverComposite addResolver(HandlerMethodArgumentResolver resolver) { ... }
// Add resolvers
public HandlerMethodArgumentResolverComposite addResolvers(HandlerMethodArgumentResolver... resolvers) { ... }
public HandlerMethodArgumentResolverComposite addResolvers(List<? extends HandlerMethodArgumentResolver> resolvers) { ... }
// Get resolvers
public List<HandlerMethodArgumentResolver> getResolvers() { ... }
// Clear resolvers
public void clear() { ... }
@Override
public boolean supportsParameter(MethodParameter parameter) { ... }
@Override
public Mono<Object> resolveArgument(MethodParameter parameter,
BindingContext bindingContext,
ServerWebExchange exchange) { ... }
}public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMapping
implements InitializingBean {
// Register handler method
protected void registerHandlerMethod(Object handler, Method method, T mapping) { ... }
// Create handler method
protected HandlerMethod createHandlerMethod(Object handler, Method method) { ... }
// Get handler internal (abstract)
protected abstract T getMappingForMethod(Method method, Class<?> handlerType);
// Get matching mapping (abstract)
protected abstract T getMatchingMapping(T mapping, ServerWebExchange exchange);
// Compare mappings (abstract)
protected abstract Comparator<T> getMappingComparator(ServerWebExchange exchange);
}public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMethodMapping<RequestMappingInfo> {
@Override
protected RequestMappingInfo getMatchingMapping(RequestMappingInfo info, ServerWebExchange exchange) { ... }
@Override
protected Comparator<RequestMappingInfo> getMappingComparator(ServerWebExchange exchange) { ... }
// Handle match
protected void handleMatch(RequestMappingInfo info, String lookupPath, ServerWebExchange exchange) { ... }
// Handle no match
protected HandlerMethod handleNoMatch(Set<RequestMappingInfo> infos, String lookupPath, ServerWebExchange exchange) { ... }
}The @InitBinder annotation identifies methods that customize data binding for controller methods.
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface InitBinder {
// Names of command/form attributes this init-binder applies to
// Default: applies to all
String[] value() default {};
}Usage:
@RestController
@RequestMapping("/users")
public class UserController {
@InitBinder
public void initBinder(WebExchangeDataBinder binder) {
// Customize data binding
binder.addCustomFormatter(new DateFormatter("yyyy-MM-dd"));
binder.setDisallowedFields("id"); // Security: prevent binding to id field
}
@InitBinder("user")
public void initUserBinder(WebExchangeDataBinder binder) {
// Only applies to @ModelAttribute("user")
binder.addValidators(new UserValidator());
}
@PostMapping
public Mono<User> createUser(@ModelAttribute User user) {
// Data binding configured by @InitBinder methods
return userService.save(user);
}
}Data binder for binding request data to Java objects in reactive web applications.
public class WebExchangeDataBinder extends WebDataBinder {
public WebExchangeDataBinder(Object target) { ... }
public WebExchangeDataBinder(Object target, String objectName) { ... }
// Constructor-based binding (since Spring 6.1)
public Mono<Void> construct(ServerWebExchange exchange) { ... }
// Bind query params, form data, or multipart data
public Mono<Void> bind(ServerWebExchange exchange) { ... }
// Get values to bind from the exchange
protected Mono<Map<String, Object>> getValuesToBind(ServerWebExchange exchange) { ... }
}Base interface for parts in multipart requests.
public interface Part {
// Get part name
String name();
// Get part headers
HttpHeaders headers();
// Get part content as DataBuffer stream
Flux<DataBuffer> content();
// Delete underlying storage
default Mono<Void> delete() { ... }
}Usage:
@PostMapping("/upload")
public Mono<String> handleUpload(@RequestPart("file") Part filePart) {
String filename = filePart.headers().getContentDisposition().getFilename();
Flux<DataBuffer> content = filePart.content();
return DataBufferUtils.write(content, Path.of("/uploads/" + filename))
.then(filePart.delete())
.thenReturn("File uploaded: " + filename);
}Specialized Part for file uploads.
public interface FilePart extends Part {
// Get the original filename
String filename();
// Transfer the file to a destination
Mono<Void> transferTo(Path dest);
Mono<Void> transferTo(File dest);
}Usage:
@PostMapping("/upload")
public Mono<String> handleFileUpload(@RequestPart("file") FilePart filePart) {
String filename = filePart.filename();
Path destination = Path.of("/uploads/" + filename);
return filePart.transferTo(destination)
.thenReturn("File uploaded successfully: " + filename);
}Specialized Part for form fields.
public interface FormFieldPart extends Part {
// Get the form field value as String
String value();
}Usage:
@PostMapping("/submit")
public Mono<String> handleFormSubmit(@RequestPart("username") FormFieldPart usernamePart,
@RequestPart("email") FormFieldPart emailPart) {
String username = usernamePart.value();
String email = emailPart.value();
return userService.register(username, email)
.thenReturn("User registered");
}