Why I Moved AI Configuration Out of application.properties and Into TOML
A practical Quarkus and LangChain4j walkthrough using typed TOML config and local Ollama models
I like configuration files that I can read at 7am half awake. For AI-infused apps, that’s harder than it should be. Prompts are multi-line strings. Few-shot examples turn into arrays of structured data. And feature flags multiply once you add memory, tools, or RAG.
This tutorial shows a pragmatic pattern: keep all AI-related configuration in one TOML file, deserialize it into typed Java objects, and feed it into LangChain4j AI services at runtime. We’ll run everything locally with Ollama and let Quarkus Dev Services start Ollama for us in dev mode, including pulling the model if needed.
What you’ll build:
ai-config-<profile>.tomlfiles insrc/main/resources/A
TomlConfigLoaderthat loads the right file based on the active Quarkus profileA small AI service whose system prompt is injected dynamically from TOML
A REST API to inspect config and chat with the model
Prerequisites
JDK 21+
Maven 3.9+
Quarkus CLI (optional)
A container runtime for Dev Services (Podman or Docker)
Ollama itself does not need to be installed when you rely on Dev Services, but it’s still useful for troubleshooting and pre-pulling models.
Bootstrap the project
Create a new Quarkus app with below command or start from my Github repository.
quarkus create app dev.demo:toml-ai-config
--extension='rest-jackson,io.quarkiverse.langchain4j:quarkus-langchain4j-ollama'
cd toml-ai-configThis gives you both REST endpoints with Jackson support and LangChain4j with Ollama access.
Add TOML support (Jackson TomlMapper)
Quarkus uses Jackson 2.x in most setups, so we’ll use the stable Jackson TOML module from the 2.x line.
Add this to your pom.xml:
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-toml</artifactId>
<version>2.20.1</version>
</dependency>Jackson lists 2.20.0 as a stable release.
Configure Ollama + Dev Services
Create src/main/resources/application.properties:
# Pick a small-ish local model to keep the demo snappy.
quarkus.langchain4j.ollama.chat-model.model-name=qwen3:1.7b
quarkus.langchain4j.ollama.chat-model.temperature=0.2
# Local inference can take time.
quarkus.langchain4j.timeout=60s
# Optional: you can log the raw traffic while learning.
quarkus.langchain4j.ollama.log-requests=true
quarkus.langchain4j.ollama.log-responses=trueDev Services note: in dev mode, Quarkus can automatically start Ollama and pull models, but first startup can take a while. Pre-pulling is still a good idea if you want fast restarts. I personally still prefer my native little Ollama install on my machine. Grab it from here.
Create the TOML configuration files
Create src/main/resources/ai-config.toml (defaults):
title = “AI Assistant Configuration”
version = “1.0.0”
environment = “default”
[model]
provider = “ollama”
name = “qwen3:1.7b”
temperature = 0.2
max_tokens = 800
timeout_seconds = 60
[prompts]
system = “”“
You are a helpful assistant for Java developers.
Be concise. Prefer short paragraphs.
If you are unsure, say what should be verified.
“”“
[[prompts.examples]]
user = “What is Quarkus?”
assistant = “Quarkus is a Java framework optimized for fast startup and low memory usage, designed for container and cloud-native runtimes.”
[[prompts.examples]]
user = “Why TOML over JSON for prompts?”
assistant = “TOML supports multi-line strings and comments, so prompt configuration stays readable and self-documenting.”
[features]
enable_memory = false
enable_tools = false
enable_rag = false
log_requests = true
log_responses = true
[features.limits]
max_history_messages = 10
max_context_length = 4096Now create an environment-specific override file for dev, for example src/main/resources/ai-config-dev.toml:
title = “AI Assistant Dev Configuration”
version = “1.0.0”
environment = “development”
[model]
provider = “ollama”
name = “qwen3:1.7b”
temperature = 0.0
max_tokens = 800
timeout_seconds = 60
[prompts]
system = “”“
You are in development mode.
Explain things step-by-step and include a quick verification command when possible.
“”“
[[prompts.examples]]
user = “What is Quarkus?”
assistant = “Quarkus is a Java framework optimized for fast startup and low memory usage, designed for container and cloud-native runtimes.”
[features]
enable_memory = false
enable_tools = false
enable_rag = false
log_requests = true
log_responses = true
[features.limits]
max_history_messages = 10
max_context_length = 4096This gives you a simple, explicit workflow:
ai-config.toml= defaultsai-config-dev.toml= dev overridesai-config-prod.toml= production policy prompts, lower logging, stricter limits
Create typed configuration classes
Create src/main/java/dev/demo/toml/config/AiConfiguration.java:
package dev.demo.toml.config;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
public class AiConfiguration {
private String title;
private String version;
private String environment;
private ModelConfig model;
private PromptsConfig prompts;
private FeaturesConfig features;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
}
public String getEnvironment() {
return environment;
}
public void setEnvironment(String environment) {
this.environment = environment;
}
public ModelConfig getModel() {
return model;
}
public void setModel(ModelConfig model) {
this.model = model;
}
public PromptsConfig getPrompts() {
return prompts;
}
public void setPrompts(PromptsConfig prompts) {
this.prompts = prompts;
}
public FeaturesConfig getFeatures() {
return features;
}
public void setFeatures(FeaturesConfig features) {
this.features = features;
}
public static class ModelConfig {
private String provider;
private String name;
private Double temperature;
@JsonProperty(”max_tokens”)
private Integer maxTokens;
@JsonProperty(”timeout_seconds”)
private Integer timeoutSeconds;
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Double getTemperature() {
return temperature;
}
public void setTemperature(Double temperature) {
this.temperature = temperature;
}
public Integer getMaxTokens() {
return maxTokens;
}
public void setMaxTokens(Integer maxTokens) {
this.maxTokens = maxTokens;
}
public Integer getTimeoutSeconds() {
return timeoutSeconds;
}
public void setTimeoutSeconds(Integer timeoutSeconds) {
this.timeoutSeconds = timeoutSeconds;
}
}
public static class PromptsConfig {
private String system;
private List<PromptExample> examples;
public String getSystem() {
return system;
}
public void setSystem(String system) {
this.system = system;
}
public List<PromptExample> getExamples() {
return examples;
}
public void setExamples(List<PromptExample> examples) {
this.examples = examples;
}
}
public static class PromptExample {
private String user;
private String assistant;
public String getUser() {
return user;
}
public void setUser(String user) {
this.user = user;
}
public String getAssistant() {
return assistant;
}
public void setAssistant(String assistant) {
this.assistant = assistant;
}
}
public static class FeaturesConfig {
@JsonProperty(”enable_memory”)
private Boolean enableMemory;
@JsonProperty(”enable_tools”)
private Boolean enableTools;
@JsonProperty(”enable_rag”)
private Boolean enableRag;
@JsonProperty(”log_requests”)
private Boolean logRequests;
@JsonProperty(”log_responses”)
private Boolean logResponses;
private LimitsConfig limits;
public Boolean getEnableMemory() {
return enableMemory;
}
public void setEnableMemory(Boolean enableMemory) {
this.enableMemory = enableMemory;
}
public Boolean getEnableTools() {
return enableTools;
}
public void setEnableTools(Boolean enableTools) {
this.enableTools = enableTools;
}
public Boolean getEnableRag() {
return enableRag;
}
public void setEnableRag(Boolean enableRag) {
this.enableRag = enableRag;
}
public Boolean getLogRequests() {
return logRequests;
}
public void setLogRequests(Boolean logRequests) {
this.logRequests = logRequests;
}
public Boolean getLogResponses() {
return logResponses;
}
public void setLogResponses(Boolean logResponses) {
this.logResponses = logResponses;
}
public LimitsConfig getLimits() {
return limits;
}
public void setLimits(LimitsConfig limits) {
this.limits = limits;
}
}
public static class LimitsConfig {
@JsonProperty(”max_history_messages”)
private Integer maxHistoryMessages;
@JsonProperty(”max_context_length”)
private Integer maxContextLength;
public Integer getMaxHistoryMessages() {
return maxHistoryMessages;
}
public void setMaxHistoryMessages(Integer maxHistoryMessages) {
this.maxHistoryMessages = maxHistoryMessages;
}
public Integer getMaxContextLength() {
return maxContextLength;
}
public void setMaxContextLength(Integer maxContextLength) {
this.maxContextLength = maxContextLength;
}
}
}Load the right TOML file per Quarkus profile
Create src/main/java/dev/demo/toml/service/TomlConfigLoader.java:
package dev.demo.toml.service;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
import org.eclipse.microprofile.config.ConfigProvider;
import com.fasterxml.jackson.dataformat.toml.TomlMapper;
import dev.demo.toml.config.AiConfiguration;
import io.smallrye.config.SmallRyeConfig;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class TomlConfigLoader {
private static final Logger LOG = Logger.getLogger(TomlConfigLoader.class.getName());
private final TomlMapper tomlMapper = new TomlMapper();
private AiConfiguration configuration;
private String loadedResourceName;
@PostConstruct
void init() {
reload();
}
public synchronized void reload() {
List<String> profiles = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class).getProfiles();
String activeProfile = Optional.ofNullable(profiles)
.filter(list -> !list.isEmpty())
.map(list -> list.get(0))
.orElse(null);
ResourceInfo resourceInfo = resolveConfigResource(activeProfile);
loadedResourceName = resourceInfo.name();
try (InputStream inputStream = resourceInfo.inputStream()) {
configuration = tomlMapper.readValue(inputStream, AiConfiguration.class);
LOG.info(() -> “Loaded TOML config from “ + loadedResourceName + “ (title=” + configuration.getTitle()
+ “, env=” + configuration.getEnvironment() + “)”);
} catch (IOException e) {
throw new ConfigurationLoadException(”Failed to load TOML configuration from “ + loadedResourceName, e);
}
}
/**
* Resolves the configuration resource based on the active profile.
* Tries profile-specific file first, then falls back to default.
*
* @param activeProfile the active profile name, or null if no profile is active
* @return ResourceInfo containing the input stream and resource name
* @throws ConfigurationLoadException if no configuration file is found
*/
private ResourceInfo resolveConfigResource(String activeProfile) {
String defaultFile = “ai-config.toml”;
String profileFile = Optional.ofNullable(activeProfile)
.map(profile -> “ai-config-” + profile + “.toml”)
.orElse(null);
// Try profile-specific file first
if (profileFile != null) {
InputStream profileStream = resource(profileFile);
if (profileStream != null) {
return new ResourceInfo(profileStream, profileFile);
}
}
// Fall back to default file
InputStream defaultStream = resource(defaultFile);
if (defaultStream != null) {
return new ResourceInfo(defaultStream, defaultFile);
}
// No configuration found
String checkedFiles = buildCheckedFilesMessage(profileFile, defaultFile);
throw new ConfigurationLoadException(”No ai-config TOML found (checked “ + checkedFiles + “)”);
}
/**
* Builds an error message listing which configuration files were checked.
*
* @param profileFile the profile-specific file name, or null
* @param defaultFile the default file name
* @return formatted message listing checked files
*/
private String buildCheckedFilesMessage(String profileFile, String defaultFile) {
return (profileFile != null)
? profileFile + “ and “ + defaultFile
: defaultFile;
}
/**
* Record holding information about a resolved configuration resource.
*
* @param inputStream the input stream for reading the resource
* @param name the name of the resource file
*/
private record ResourceInfo(InputStream inputStream, String name) {
}
private InputStream resource(String name) {
return Thread.currentThread().getContextClassLoader().getResourceAsStream(name);
}
public AiConfiguration getConfiguration() {
return configuration;
}
public String getLoadedResourceName() {
return loadedResourceName;
}
}If you start Quarkus with -Dquarkus.profile=dev, it will pick ai-config-dev.toml. Otherwise it falls back to ai-config.toml.
Let’s also make sure we care about exception handling in this case.
package dev.demo.toml.service;
/**
* Exception thrown when TOML configuration cannot be loaded.
* This is an unchecked exception that wraps underlying I/O or parsing errors.
*/
public class ConfigurationLoadException extends RuntimeException {
public ConfigurationLoadException(String message) {
super(message);
}
public ConfigurationLoadException(String message, Throwable cause) {
super(message, cause);
}
}Compose a “system prompt” from TOML, including examples
You can push examples into the user message, but I prefer keeping them in the system prompt for this kind of “policy + style” guidance.
Create src/main/java/dev/demo/toml/service/PromptComposer.java:
package dev.demo.toml.service;
import dev.demo.toml.config.AiConfiguration;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PromptComposer {
public String buildSystemPrompt(AiConfiguration cfg) {
StringBuilder sb = new StringBuilder();
sb.append(cfg.getPrompts().getSystem().trim()).append(”\n\n”);
if (cfg.getPrompts().getExamples() != null && !cfg.getPrompts().getExamples().isEmpty()) {
sb.append(”Examples:\n”);
for (AiConfiguration.PromptExample ex : cfg.getPrompts().getExamples()) {
sb.append(”User: “).append(ex.getUser()).append(”\n”);
sb.append(”Assistant: “).append(ex.getAssistant()).append(”\n\n”);
}
}
sb.append(”Constraints:\n”);
sb.append(”- Keep responses within “).append(cfg.getFeatures().getLimits().getMaxContextLength())
.append(” chars of context budget.\n”);
sb.append(”- Prefer short paragraphs.\n”);
return sb.toString();
}
}This is intentionally simple. You’ll probably evolve it into a more structured policy builder later.
Create the LangChain4j AI service with template placeholders
Create src/main/java/dev/demo/toml/ai/ConfigurableAiService.java:
package dev.demo.toml.ai;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
import jakarta.enterprise.context.ApplicationScoped;
@RegisterAiService
@ApplicationScoped
public interface ConfigurableAiService {
@SystemMessage(”{systemPrompt}”)
@UserMessage(”{userMessage}”)
String chat(String systemPrompt, String userMessage);
}LangChain4j in Quarkus supports placeholders in @SystemMessage and @UserMessage, backed by Qute-style templating.
Expose REST endpoints
Create src/main/java/dev/demo/toml/api/TomlConfigResource.java:
package dev.demo.toml.api;
import java.util.Map;
import dev.demo.toml.ai.ConfigurableAiService;
import dev.demo.toml.config.AiConfiguration;
import dev.demo.toml.service.PromptComposer;
import dev.demo.toml.service.TomlConfigLoader;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path(”/ai-config”)
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class TomlConfigResource {
@Inject
TomlConfigLoader configLoader;
@Inject
PromptComposer promptComposer;
@Inject
ConfigurableAiService ai;
@GET
@Path(”/config”)
public Map<String, Object> config() {
AiConfiguration cfg = configLoader.getConfiguration();
return Map.of(
“loadedFrom”, configLoader.getLoadedResourceName(),
“title”, cfg.getTitle(),
“version”, cfg.getVersion(),
“environment”, cfg.getEnvironment(),
“model”, Map.of(
“provider”, cfg.getModel().getProvider(),
“name”, cfg.getModel().getName(),
“temperature”, cfg.getModel().getTemperature()),
“features”, Map.of(
“enableMemory”, cfg.getFeatures().getEnableMemory(),
“enableTools”, cfg.getFeatures().getEnableTools(),
“enableRag”, cfg.getFeatures().getEnableRag()));
}
@POST
@Path(”/reload”)
public Map<String, Object> reload() {
configLoader.reload();
return Map.of(”loadedFrom”, configLoader.getLoadedResourceName());
}
@POST
@Path(”/chat”)
public ChatResponse chat(ChatRequest request) {
AiConfiguration cfg = configLoader.getConfiguration();
String systemPrompt = promptComposer.buildSystemPrompt(cfg);
String answer = ai.chat(systemPrompt, request.message());
return new ChatResponse(cfg.getModel().getName(), answer);
}
public record ChatRequest(String message) {
}
public record ChatResponse(String model, String response) {
}
}You now have:
/ai-config/configto see what loaded/ai-config/reloadto reload TOML (handy in dev mode)/ai-config/chatto call the model with the TOML-driven prompt
Run and verify
Start dev mode:
quarkus devIf Ollama is not running, Dev Services can start it for you in dev mode and pull the configured models.
Verify config:
curl http://localhost:8080/ai-config/configResult:
{
“environment”: “development”,
“model”: {
“provider”: “ollama”,
“temperature”: 0.0,
“name”: “qwen3:1.7b”
},
“title”: “AI Assistant Configuration”,
“version”: “1.0.0”,
“loadedFrom”: “ai-config-dev.toml”,
“features”: {
“enableTools”: false,
“enableMemory”: false,
“enableRag”: false
}
}Chat:
curl -X POST http://localhost:8080/ai-config/chat \
-H "Content-Type: application/json" \
-d '{"message":"Explain TOML in simple terms for a Java developer."}'Reload TOML after edits:
curl -X POST http://localhost:8080/ai-config/reloadExpected outcome: you should see responses coming from your local Ollama model (the response will vary), and your config endpoint should show which TOML file was loaded.
Production notes
Keep the pattern, but tighten the edges.
In production, I recommend:
Treat TOML as application config, not user input.
Store production TOML as a mounted file (ConfigMap/Secret) and keep it immutable per release.
Put “high-risk knobs” (tools, RAG, logging) behind both:
a TOML flag (for readability), and
a Quarkus config override (for emergency operations)
Also remember: model runtime settings like logging and timeouts belong in application.properties (or env vars) because they affect the client integration directly. The Ollama extension exposes these toggles.
Summary
You built a Quarkus service that loads AI configuration from TOML into typed Java objects, selects environment-specific TOML files by profile, and injects prompts dynamically into a LangChain4j AI service, all while running locally via Ollama Dev Services.
Clean prompts beat clever prompts.




The multiline string support in TOML is actually a gamechanger here. I've been stuffing prompts into YAML but the escaping gets messy fast, especially with nested placeholders. One thingi learned the hard way is versioning these config files separately from app releases, since prompt tweaks can dramatically shift model behavior without touching code. Keeping them in source contrl but treating them like runtime assets makes rollback way easier.