Skip to main content

Custom Argument Resolvers

Argument resolvers let you inject arbitrary objects into handler method parameters. Instead of calling a service inside every handler, declare a parameter and have the framework produce its value automatically — for every handler that needs it.


The BotArgumentResolver Interface

import java.lang.reflect.Parameter;

public interface BotArgumentResolver {

/**
* Called once at startup for each handler parameter.
* Return true to claim ownership of this parameter.
*/
boolean supportsParameter(Parameter parameter);

/**
* Called at invocation time. Return the resolved value (may be null).
* Only called when supportsParameter returned true.
*/
Object resolveArgument(Parameter parameter, BotRequest botRequest, BotResponse botResponse);
}
Java reflection — not Spring's MethodParameter

Parameter here is java.lang.reflect.Parameter, not Spring's MethodParameter.

SpringStandard Java
parameter.getParameterType()parameter.getType()
parameter.hasParameterAnnotation(A.class)parameter.isAnnotationPresent(A.class)

How the Factory Works

BotArgumentResolverFactory auto-collects every BotArgumentResolver bean via List<BotArgumentResolver> injection. For each method parameter it streams through the list and returns the value from the first matching resolver. Unmatched parameters receive null.

Use @Order(n) to control priority — lower value = higher priority. Built-in resolvers carry no explicit order, so any @Order value below Integer.MAX_VALUE takes precedence over them.

@Component
@Order(10) // runs before unordered built-in resolvers
public class MyResolver implements BotArgumentResolver { ... }

Built-in Resolvers

All 16 resolvers are registered automatically by CoreAutoConfiguration.

Resolver classResolved type / annotationNotes
BotUpdateArgumentResolverUpdateRaw Telegram update object
BotUserArgumentResolverUserMessage sender (set by BotContextSetterFilter)
BotChatArgumentResolverChatChat context (set by BotContextSetterFilter)
BotRequestArgumentResolverBotRequestCurrent request object
BotResponseArgumentResolverBotResponseMutable response accumulator
BotTelegramClientArgumentResolverTelegramClientTelegram API client
BotMetadataArgumentResolverBotMetadataBot info: id, username, token
BotThrowableArgumentResolverThrowable (or any subclass)Exception — only populated in @BotExceptionHandler
BotCallbackQueryDataArgumentResolver@BotCallbackQueryData StringCallback query data string
BotCommandArgumentResolver@BotCommandValue StringMatched command, e.g. /start
BotTextArgumentResolver@BotTextValue StringFull message text
BotCommandQueryParamBotArgumentResolver@BotCommandQueryParam TPositional second token after command, converted via Jackson
BotContactArgumentResolverContactContact — only in @BotContact handlers
BotLocationArgumentResolverLocationLocation — only in @BotLocation handlers
BotMarkupContextArgumentResolverBotMarkupContextDynamic markup params — available only in @BotMarkup factory methods
BotLocaleArgumentResolver (core-i18n)LocaleUser locale — requires core-i18n on classpath

Notes on @BotCommandQueryParam

@BotCommandQueryParam extracts the second whitespace-separated token from the command message text and converts it to the parameter type using Jackson's ObjectMapper.convertValue. Only one such parameter is supported per method — it is purely positional, not named.

// Message text: "/search 42"
@BotCommand("/search")
public String onSearch(@BotCommandQueryParam Integer productId) {
return "Looking up product #" + productId;
}

Examples

1. Inject a Domain Object

Resolve your application's AppUser entity by the Telegram user ID on every request:

import java.lang.reflect.Parameter;

@Component
public class AppUserArgumentResolver implements BotArgumentResolver {

private final AppUserRepository userRepository;

public AppUserArgumentResolver(AppUserRepository userRepository) {
this.userRepository = userRepository;
}

@Override
public boolean supportsParameter(Parameter parameter) {
return AppUser.class.isAssignableFrom(parameter.getType());
}

@Override
public Object resolveArgument(Parameter parameter,
BotRequest botRequest,
BotResponse botResponse) {
long telegramId = botRequest.getUser().getId();
return userRepository.findByTelegramId(telegramId)
.orElseThrow(() ->
new IllegalStateException("User not registered: " + telegramId));
}
}
@BotController
public class ProfileController {

@BotCommand("/profile")
public String onProfile(AppUser appUser) {
return "Name: " + appUser.getFullName() + "\nEmail: " + appUser.getEmail();
}

@BotCommand("/settings")
public String onSettings(AppUser appUser, BotRequest request) {
return "Editing settings for " + appUser.getFullName();
}
}

2. Custom Annotation — @BotLanguage

Inject the Telegram user's languageCode as a plain String using a custom annotation:

Define the annotation

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface BotLanguage {}

Create the resolver

import java.lang.reflect.Parameter;

@Component
public class BotLanguageArgumentResolver implements BotArgumentResolver {

@Override
public boolean supportsParameter(Parameter parameter) {
return parameter.isAnnotationPresent(BotLanguage.class)
&& String.class.equals(parameter.getType());
}

@Override
public Object resolveArgument(Parameter parameter,
BotRequest botRequest,
BotResponse botResponse) {
User user = botRequest.getUser();
return (user != null && user.getLanguageCode() != null)
? user.getLanguageCode()
: "en";
}
}

Use in handlers

@BotCommand("/start")
public String onStart(@BotLanguage String lang) {
return switch (lang) {
case "ru" -> "Привет! Я бот.";
case "uz" -> "Salom! Men bot.";
default -> "Hello! I'm a bot.";
};
}

3. BotMarkupContext in @BotMarkup Factory Methods

BotMarkupContext carries runtime parameters from a handler's .withMarkup("id", params) call into the keyboard factory method. It is only available inside @BotMarkup methods — it resolves to BotMarkupContext.empty() everywhere else.

// In a handler — pass parameters to the factory
@BotCommand("/items")
public PlainReply onItems(BotRequest request) {
int currentPage = 0;
return PlainReply.of("Items (page 1):")
.withMarkup("item_list", Map.of("page", currentPage, "size", 5));
}
// In a @BotConfiguration class — receive parameters via BotMarkupContext
@BotConfiguration
public class ItemMarkups {

@BotMarkup("item_list")
public InlineKeyboardMarkup itemList(BotMarkupContext ctx) {
int page = ctx.get("page", Integer.class);
int size = ctx.getOrDefault("size", 10);
return buildPaginatedKeyboard(page, size);
}

private InlineKeyboardMarkup buildPaginatedKeyboard(int page, int size) {
// ... build the keyboard
}
}

BotMarkupContext API:

MethodDescription
<T> T get(String key)Typed retrieval (unchecked cast)
<T> T get(String key, Class<T> type)Typed retrieval with explicit type
<T> T getOrDefault(String key, T defaultValue)Typed retrieval with fallback
boolean has(String key)Key presence check
Map<String, Object> asMap()Unmodifiable view of all parameters
static BotMarkupContext empty()Empty context singleton

4. Read Request Attributes Set by a Filter

Filters can attach arbitrary data to BotRequest via setAttribute. A resolver (or handler) reads it back later in the same request lifecycle.

Filter stores the tenant ID

@Component
@Order(BotFilterOrder.CONTEXT_SETTER + 10)
public class TenantContextFilter implements BotFilter {

private final TenantResolver tenantResolver;

public TenantContextFilter(TenantResolver tenantResolver) {
this.tenantResolver = tenantResolver;
}

@Override
public void doFilter(BotRequest request, BotResponse response, BotFilterChain chain) {
Long chatId = request.getChat() != null ? request.getChat().getId() : null;
if (chatId != null) {
String tenantId = tenantResolver.resolve(chatId);
request.setAttribute("tenantId", tenantId);
}
chain.doFilter(request, response);
}
}

Resolver reads the attribute

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantId {}
@Component
public class TenantIdArgumentResolver implements BotArgumentResolver {

@Override
public boolean supportsParameter(Parameter parameter) {
return parameter.isAnnotationPresent(TenantId.class)
&& String.class.equals(parameter.getType());
}

@Override
public Object resolveArgument(Parameter parameter,
BotRequest botRequest,
BotResponse botResponse) {
return botRequest.getAttribute("tenantId");
}
}

Use in handlers

@BotCommand("/dashboard")
public String onDashboard(@TenantId String tenantId, AppUser appUser) {
return "Tenant: " + tenantId + " | User: " + appUser.getFullName();
}

5. Per-Chat Session Data

Store and retrieve arbitrary chat-scoped state using a ConcurrentHashMap keyed by chat ID:

@Component
public class BotSessionArgumentResolver implements BotArgumentResolver {

private final ConcurrentHashMap<Long, BotSession> sessions = new ConcurrentHashMap<>();

@Override
public boolean supportsParameter(Parameter parameter) {
return BotSession.class.isAssignableFrom(parameter.getType());
}

@Override
public Object resolveArgument(Parameter parameter,
BotRequest botRequest,
BotResponse botResponse) {
long chatId = botRequest.getChat().getId();
return sessions.computeIfAbsent(chatId, id -> new BotSession());
}
}
// Simple mutable session value object
public class BotSession {
private int stepIndex = 0;
private final Map<String, String> data = new HashMap<>();

public int getStepIndex() { return stepIndex; }
public void nextStep() { stepIndex++; }
public void reset() { stepIndex = 0; data.clear(); }
public void put(String key, String value) { data.put(key, value); }
public String get(String key) { return data.get(key); }
}
@BotController
public class WizardController {

@BotCommand("/wizard")
public String start(BotSession session) {
session.reset();
return "Step 1: Enter your name.";
}

@BotText
public String onText(BotSession session, @BotTextValue String text) {
session.put("step_" + session.getStepIndex(), text);
session.nextStep();
return "Got: " + text + ". Continue or /wizard to restart.";
}
}
note

For production use, prefer core-chatstate or a Redis-backed store. The in-memory map above is lost on restart and not distributed-safe.


6. Override a Built-in Resolver

Register a @Bean with the same resolved type as a built-in resolver and give it a lower @Order value to run first. All beans registered as @Bean in a @Configuration class also override @ConditionalOnMissingBean-guarded beans of the same type.

The example below replaces the built-in BotUserArgumentResolver with one that enriches User from a cache before returning it:

@Configuration
public class ResolverConfig {

@Bean
@Order(1) // runs before the built-in resolver (no @Order = Integer.MAX_VALUE)
public BotArgumentResolver enrichedUserResolver(UserCacheService cache) {
return new BotArgumentResolver() {

@Override
public boolean supportsParameter(Parameter parameter) {
return User.class.isAssignableFrom(parameter.getType());
}

@Override
public Object resolveArgument(Parameter parameter,
BotRequest botRequest,
BotResponse botResponse) {
User raw = botRequest.getUser();
return raw != null ? cache.enrich(raw) : null;
}
};
}
}
warning

If you override a built-in resolver by type, the built-in resolver still runs for any other parameter that matched it first (factory returns the first match). Use @Order carefully to control which resolver wins for each parameter type.


Testing Argument Resolvers

Resolvers are plain Java objects — no Spring context required:

class AppUserArgumentResolverTest {

@Test
void supportsAppUserType() throws Exception {
AppUserRepository repo = mock(AppUserRepository.class);
var resolver = new AppUserArgumentResolver(repo);

Parameter param = SampleHandler.class
.getMethod("handle", AppUser.class)
.getParameters()[0];

assertTrue(resolver.supportsParameter(param));
}

@Test
void resolvesAppUserByTelegramId() throws Exception {
AppUserRepository repo = mock(AppUserRepository.class);
when(repo.findByTelegramId(42L)).thenReturn(Optional.of(new AppUser("Alice")));

var resolver = new AppUserArgumentResolver(repo);

User telegramUser = new User();
telegramUser.setId(42L);

BotRequest request = new BotRequest();
request.setUser(telegramUser);

Parameter param = SampleHandler.class
.getMethod("handle", AppUser.class)
.getParameters()[0];

AppUser result = (AppUser) resolver.resolveArgument(param, request, null);

assertEquals("Alice", result.getFullName());
}

// Minimal class for reflective parameter lookup
static class SampleHandler {
public void handle(AppUser appUser) {}
}
}

Key points:

  • Obtain a real java.lang.reflect.Parameter via reflection — mock it only when the resolver does not inspect its type or annotations directly.
  • Pass null for arguments the resolver under test does not use.
  • No @SpringBootTest or application context needed.

Summary

What you needHow to do it
Inject any type by classparameter.getType().isAssignableFrom(...) in supportsParameter
Inject by annotationparameter.isAnnotationPresent(MyAnnotation.class)
Access Telegram dataUse botRequest.getUser(), .getChat(), .getUpdate()
Access cross-component databotRequest.getAttribute("key") (set by a filter)
Run before built-in resolvers@Order(n) with n < Integer.MAX_VALUE
Dynamic markup parametersDeclare BotMarkupContext in @BotMarkup factory method

See also: