n4nAI

Spring Boot @ConfigurationProperties for LLM providers

Step-by-step guide to using Spring Boot @ConfigurationProperties for multi-provider LLM setups, with code and verification for robust config.

n4n Team3 min read627 words

Audio narration

Coming soon — every post will get a voice note here.

Wiring multiple LLM providers into a Spring Boot service gets messy fast when API keys and base URLs live in scattered @Value fields. Using spring boot configurationproperties multi-provider llm bindings gives you a single typed object, relaxed YAML mapping, and startup validation. This guide walks through a concrete setup you can copy, from annotated classes to a runtime client factory.

Step 1: Define the root configuration class

Start with a top-level @ConfigurationProperties bean. Prefix it with llm so every setting lives under that namespace. Annotate with @Validated to fail fast on missing keys instead of throwing NullPointerException three layers deep in a request handler.

package com.example.llm.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

import jakarta.validation.constraints.NotNull;
import java.util.LinkedHashMap;
import java.util.Map;

@Validated
@ConfigurationProperties(prefix = "llm")
public class LlmProperties {

    @NotNull
    private Map<String, ProviderConfig> providers = new LinkedHashMap<>();

    public Map<String, ProviderConfig> getProviders() {
        return providers;
    }

    public void setProviders(Map<String, ProviderConfig> providers) {
        this.providers = providers;
    }
}

The providers map is the backbone of spring boot configurationproperties multi-provider llm patterns. Adding a new vendor is a YAML edit, not a code change. If you later need a default provider, add a separate defaultProvider string field with @NotBlank.

Step 2: Model provider-specific settings

Each provider entry needs more than a base URL and key. Production calls fail on timeouts, require organization headers, or restrict which models are callable. Use a plain class (or Java record if you adopt constructor binding later) with sane defaults.

public class ProviderConfig {

    @NotNull
    private String baseUrl;

    @NotNull
    private String apiKey;

    private String organization;

    private int connectTimeoutMs = 5_000;

    private int readTimeoutMs = 30_000;

    private List<String> models = List.of();

    public String getBaseUrl() { return baseUrl; }
    public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
    public String getApiKey() { return apiKey; }
    public void setApiKey(String apiKey) { this.apiKey = apiKey; }
    public String getOrganization() { return organization; }
    public void setOrganization(String organization) { this.organization = organization; }
    public int getConnectTimeoutMs() { return connectTimeoutMs; }
    public void setConnectTimeoutMs(int ms) { this.connectTimeoutMs = ms; }
    public int getReadTimeoutMs() { return readTimeoutMs; }
    public void setReadTimeoutMs(int ms) { this.readTimeoutMs = ms; }
    public List<String> getModels() { return models; }
    public void setModels(List<String> models) { this.models = models; }
}

Keep the class free of logic. Any per-provider behavior belongs in the client factory, not the config object.

Step 3: Bind from application.yml

Spring Boot relaxes kebab-case, camelCase, and underscore binding, so write YAML the way your ops team expects. Reference environment variables for secrets; never commit keys.

llm:
  providers:
    openai:
      base-url: https://api.openai.com/v1
      api-key: ${OPENAI_API_KEY}
      models:
        - gpt-4o
        - gpt-4o-mini
    local:
      base-url: http://localhost:8081/v1
      api-key: dummy
      connect-timeout-ms: 1000
      read-timeout-ms: 60000

You can also point a provider at a gateway. For example, n4n.ai exposes one OpenAI-compatible endpoint across 240+ models and applies automatic fallback when a backend is rate-limited, so a single base-url and api-key covers dozens of vendors without custom fallback code in your service.

Step 4: Enable and inject the config

Register the properties class. If your main application class is in the root package, @SpringBootApplication already triggers scanning, but explicit @EnableConfigurationProperties is clearer for library modules.

@Configuration
@EnableConfigurationProperties(LlmProperties.class)
public class LlmConfig {
}

Inject LlmProperties where you need it. Constructor injection keeps the dependency immutable and testable.

@Service
public class ChatService {

    private final LlmProperties props;

    public ChatService(LlmProperties props) {
        this.props = props;
    }

    public ProviderConfig getProvider(String name) {
        ProviderConfig c = props.getProviders().get(name);
        if (c == null) throw new IllegalArgumentException("Unknown provider: " + name);
        return c;
    }
}

This keeps spring boot configurationproperties multi-provider llm access centralized behind one service method.

Step 5: Build a provider-aware client factory

Instantiating HTTP clients per request wastes connections. Build a RestClient per provider once and cache it.

@Component
public class LlmClientFactory {

    private final LlmProperties props;
    private final Map<String, RestClient> cache = new ConcurrentHashMap<>();

    public LlmClientFactory(LlmProperties props) {
        this.props = props;
    }

    public RestClient clientFor(String providerName) {
        return cache.computeIfAbsent(providerName, name -> {
            ProviderConfig c = props.getProviders().get(name);
            if (c == null) throw new IllegalStateException("No config for " + name);
            return RestClient.builder()
                .baseUrl(c.getBaseUrl())
                .defaultHeader("Authorization", "Bearer " + c.getApiKey())
                .build();
        });
    }
}

If a provider sets organization, add .defaultHeader("OpenAI-Organization", c.getOrganization()). The factory hides transport details from callers.

Step 6: Validate model allowlists at startup

Silent misconfiguration is worse than a crash. Add a @PostConstruct check or a custom Validator. The example below logs a warning; for stricter environments, throw IllegalStateException.

@PostConstruct
public void validateModels() {
    props.getProviders().forEach((name, c) -> {
        if (c.getModels().isEmpty()) {
            log.warn("Provider {} has no models listed; calls may fail at routing time", name);
        }
    });
}

To enforce non-empty lists, annotate the field with @Size(min = 1) and ensure @Validated is present on the root class. Spring will reject the application context before it serves traffic.

Step 7: Verify the wiring with a test

A context test proves the binding works without booting the full app. Use @SpringBootTest with @TestPropertySource or an explicit application-test.yml.

@SpringBootTest
@TestPropertySource(properties = {
    "llm.providers.openai.base-url=https://api.openai.com/v1",
    "llm.providers.openai.api-key=test"
})
class LlmPropertiesTest {

    @Autowired LlmProperties props;

    @Test
    void bindsOpenAi() {
        assertThat(props.getProviders()).containsKey("openai");
        assertThat(props.getProviders().get("openai").getApiKey()).isEqualTo("test");
    }
}

Run it:

./gradlew test --tests LlmPropertiesTest

Success means the test is green and no BindException appears in logs. For a running service, log props.getProviders().keySet() at startup or expose a simple actuator endpoint that returns the provider names.

Step 8: Use constructor binding for immutable config

If you prefer immutable objects, enable constructor binding. Remove setters, add a @ConstructorBinding annotation, and ensure the class is instantiated by Spring via the properties constructor.

@ConstructorBinding
@ConfigurationProperties(prefix = "llm")
public class LlmProperties {

    private final Map<String, ProviderConfig> providers;

    public LlmProperties(Map<String, ProviderConfig> providers) {
        this.providers = providers;
    }

    public Map<String, ProviderConfig> getProviders() {
        return providers;
    }
}

With this approach, ProviderConfig should also be a record or immutable class. The trade-off is slightly more verbose YAML-to-constructor mapping, but you eliminate accidental mutation after startup.

Step 9: Override per environment

Spring Boot loads application-{profile}.yml on top of the base file. Keep secrets in application-prod.yml or vault injection, and use local or dummy providers in application-dev.yml. Because the structure is a map, you can completely replace the providers block per profile without touching Java.

# application-dev.yml
llm:
  providers:
    openai:
      base-url: https://api.openai.com/v1
      api-key: ${OPENAI_API_KEY}
    mock:
      base-url: http://localhost:9999/v1
      api-key: test

Using spring boot configurationproperties multi-provider llm setups this way keeps your configuration typed, testable, and free of scattered string lookups. You can extend the map with new vendors in seconds and swap implementations behind the factory without leaking provider details into business code.

Tagsspring-bootconfigurationmulti-providerllm-setup

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All spring boot ai integration posts →