The first prompt you send from Java is usually whatever the user typed.
Then you want the model to answer in a certain style, so you glue some instructions onto the front
of that string. Then you need to drop a value from the request into the middle of it. A few features
later, your controller is full of + signs and String.format calls, and nobody can read the actual
prompt without running the code in their head.
Spring AI gives prompts a proper structure: messages with roles, templates with placeholders, and prompt text that can live in its own file. In this post we’ll build a small “developer assistant” API with three endpoints, and each one shapes its prompt a different way.
Sample code: github.com/Jitu7/Spring-AI/tree/main/prompt-templates
Built with Spring Boot
4.1.2, Spring AI2.0.1and Java27.
If you haven’t set up a Spring AI project before, start with
Your First Chat Endpoint in Spring Boot 4. This project uses the same
three starters, the same BOM and almost the same application.properties, so I won’t repeat that
part here.
What we’re building
All three endpoints live in one controller, DevAssistantController, under /api/dev:
POST /api/dev/ask system message + user message, built by hand
POST /api/dev/commit-messages PromptTemplate with {placeholders}
POST /api/dev/release-notes system prompt loaded from a file in resources
Every endpoint returns the same small JSON shape, {"content": "..."}. The interesting part is what
happens before the call to the model.
The setup
The controller builds its ChatClient the same way as last time: from the injected
ChatClient.Builder, with SimpleLoggerAdvisor as a default advisor. Here’s the
application.properties:
spring.application.name=prompt-templates
#spring.ai.openai.base-url=https://api.openai.com
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.model=gpt-5
logging.level.org.springframework.ai.chat.client.advisor=DEBUG
The last line matters more in this project than it did last time. Without it, SimpleLoggerAdvisor
still runs on every call, but logs nothing. With it, every request shows up in the log with its
messages already rendered: the system message, the user message, and any placeholders filled in.
When you’re working on prompts, that’s the first place to look, and we’ll use it in each section
below.
Start the app now, so you can try each endpoint as we go. You need JDK 27 and an OpenAI API key:
export OPENAI_API_KEY=sk-...
./mvnw package -DskipTests
java -jar target/prompt-templates-0.0.1-SNAPSHOT.jar
Messages have roles
Here’s the first problem. We want the model to answer like a senior colleague: short, practical, ending with something to do next. We could paste those instructions in front of the user’s question, but then the instructions and the question are one blob of text, and the model has to guess where one ends and the other begins.
Chat models solve this with roles. A request is a list of messages, and each one is marked as
coming from someone. Spring AI keeps the list of roles in one small enum, MessageType (trimmed to
the values):
public enum MessageType {
USER("user"),
ASSISTANT("assistant"),
SYSTEM("system"),
TOOL("tool");
// ...
}
Each value has its own message class, and the string in brackets is the role name the model’s API gets:
MessageType |
Class | Who it comes from | What it’s for |
|---|---|---|---|
SYSTEM |
SystemMessage |
You, the developer | The rules: who the model is, how it should answer, what to avoid |
USER |
UserMessage |
The person using your app | The actual request or question |
ASSISTANT |
AssistantMessage |
The model | A reply the model gave earlier, sent back so it remembers the conversation |
TOOL |
ToolResponseMessage |
Your code | The result of a Java method the model asked to call |
You rarely create a MessageType yourself. Picking the class picks the role: new SystemMessage(...)
is a SYSTEM message, new UserMessage(...) is a USER message, and so on. You’ll see the enum
again in the log output below, as messageType=SYSTEM and messageType=USER.
This post only needs the first two. ASSISTANT matters once you keep chat history, and TOOL once
you let the model call your code. We won’t need either of them here.
Note: the Javadoc on TOOL still calls it a “function” message, from before Spring AI renamed
function calling to tool calling. The value the code sends is tool.
The /ask endpoint builds both messages by hand. First, the instructions, as a plain Java text block:
private static final String MENTOR_PERSONA = """
You are a pragmatic staff engineer mentoring a teammate.
Answer in at most five sentences, favour concrete examples over theory,
and finish with one actionable next step.
""";
Then the endpoint puts the two messages into a Prompt and hands that to the ChatClient:
@PostMapping("/ask")
Reply askMentor(@RequestBody @Valid Question question) {
Prompt prompt = new Prompt(List.of(
new SystemMessage(MENTOR_PERSONA),
new UserMessage(question.text())));
return new Reply(chatClient.prompt(prompt).call().content());
}
Prompt is Spring AI’s name for the whole request: a list of messages, plus optional model
settings. Last time we called chatClient.prompt(String), which is a shortcut for “one user
message, nothing else”. Here we pass a full Prompt instead, so we decide every message that goes
out.
The thing to notice is that the user’s text goes into its own UserMessage, and never gets mixed
into the persona string. You can see that in the advisor’s log for one request (trimmed):
DEBUG ... o.s.a.c.c.advisor.SimpleLoggerAdvisor : request: ChatClientRequest[prompt=Prompt{messages=[
SystemMessage{textContent='You are a pragmatic staff engineer mentoring a teammate.
Answer in at most five sentences, favour concrete examples over theory,
and finish with one actionable next step.
', messageType=SYSTEM, ...},
UserMessage{content='Should I use records or Lombok for DTOs in a new Spring Boot project?', messageType=USER, ...}], ...}]
Two messages, two roles. The persona is fixed, and the user can’t change it by typing something clever into the request.
Well, mostly. A system message is a strong hint to the model, not a security boundary. A user who writes “ignore your previous instructions” is sending the model text, and the model decides what to do with it. Separate roles make that kind of attack harder, and they make your prompt easier to read. They don’t make it impossible.
Ask the mentor something:
curl -X POST localhost:8080/api/dev/ask \
-H 'Content-Type: application/json' \
-d '{"text":"Should I use records or Lombok for DTOs in a new Spring Boot project?"}'
{"content":"Use Java records by default for DTOs in a new Spring Boot 3+ (Java 17+) app: they’re concise, immutable, work with Jackson and Jakarta Validation out of the box, and avoid a Lombok dependency. Example: public record UserDto(UUID id, String name) {} and, with validation/defaults, public record CreateUserRequest(@NotBlank String email, int age) { public CreateUserRequest { if (age < 0) age = 0; } }. Use Lombok only when you need what records don’t offer—no-args constructor, mutability, or a builder for complex construction—e.g., @Value @Builder public class UserDto { UUID id; String name; }. Caveats: records have no setters/no-args ctor and Jackson calls the canonical constructor, so handle defaults/optionals in the compact constructor or use boxed types; Spring Boot 3’s Jackson already supports records. \nNext step: Implement one DTO as a record and add a JSON round-trip test to verify serialization/deserialization and validation in your project."}
The persona did its job: a short answer, concrete code instead of theory, and a “Next step” at the end. Notice it says “Spring Boot 3+”, though this project runs on Boot 4. The model only knows what’s in the prompt, and nothing in the prompt mentions our versions. Your wording will differ from run to run, here and in the calls below.
Placeholders with PromptTemplate
The second endpoint suggests commit messages. Its prompt needs two values from the request: a summary of the changes, and how many suggestions to return. This is where string concatenation starts to hurt, so the project uses a template instead:
private static final String COMMIT_MESSAGE_TEMPLATE = """
Here is a summary of the code changes I'm about to commit:
{changes}
Suggest {options} commit messages that follow the Conventional Commits format
(type(scope): subject). Keep each subject under 72 characters, use the
imperative mood, and list one suggestion per line with no extra commentary.
""";
{changes} and {options} are placeholders. You can read the whole prompt top to bottom and see
exactly what the model will get, with two gaps in it. That’s the main reason to use a template:
the prompt stays readable as a prompt.
The endpoint fills the gaps from the request body:
@PostMapping("/commit-messages")
Reply suggestCommitMessages(@RequestBody @Valid CommitRequest request) {
Message userMessage = PromptTemplate.builder()
.template(COMMIT_MESSAGE_TEMPLATE)
.build()
.createMessage(Map.of(
"changes", request.changes(),
"options", request.options()));
return new Reply(chatClient.prompt().messages(userMessage).call().content());
}
createMessage(Map) renders the template with the values in the map and wraps the result in a
UserMessage. Then chatClient.prompt().messages(...) sends it. The map takes Object values, so
options can stay an int. It’s rendered as 3, not "3".
The advisor log shows what the model actually receives, with both gaps filled (trimmed):
DEBUG ... o.s.a.c.c.advisor.SimpleLoggerAdvisor : request: ChatClientRequest[prompt=Prompt{messages=[
UserMessage{content='Here is a summary of the code changes I'm about to commit:
Added @NotBlank validation to the prompt field in ChatController and return 400 for empty prompts
Suggest 3 commit messages that follow the Conventional Commits format
(type(scope): subject). Keep each subject under 72 characters, use the
imperative mood, and list one suggestion per line with no extra commentary.
', messageType=USER, ...}], ...}]
Notice there’s no system message this time. The whole template becomes a single user message.
That’s fine for a one-off task like this. If you also wanted the mentor persona, you’d add a
SystemMessage next to it, exactly as in /ask.
Get commit message suggestions:
curl -X POST localhost:8080/api/dev/commit-messages \
-H 'Content-Type: application/json' \
-d '{"changes":"Added @NotBlank validation to the prompt field in ChatController and return 400 for empty prompts","options":3}'
{"content":"feat(chat): add @NotBlank to prompt; return 400 for empty prompts\nfix(chat-controller): reject empty prompts with 400 via validation\nfeat(api): validate prompt is not blank and send 400 on empty input"}
Three suggestions, one per line, in Conventional Commits format, with no commentary around them:
exactly what the template asked for. The model couldn’t settle on feat or fix, though. Picking
one is still your call.
Under the hood, PromptTemplate uses StringTemplate, a small templating library, through Spring
AI’s StTemplateRenderer. That’s where the curly-brace syntax comes from, and it’s also where the
two catches in this section come from.
Catch 1: a missing value fails loudly
Say someone renames options in the template and forgets the map. I tried it, leaving options out
of the map, and createMessage threw:
IllegalStateException: Not all variables were replaced in the template. Missing variable names are: [options].
This is good behaviour. The default validation mode throws instead of sending the model a prompt
with a literal {options} in it. In the controller it turns into a 500 before any request reaches
the model, so a typo can’t quietly cost you money. Leave it on.
Catch 2: braces in the template break it
This one surprises people. Curly braces mean “placeholder” to StringTemplate, so any brace in the template text itself gets parsed. Prompts often contain JSON examples, so sooner or later you’ll write something like this:
PromptTemplate.builder()
.template("Reply as {\"msg\": \"...\"} for {changes}")
.build()
.createMessage(Map.of("changes", "x"));
And get this:
ERROR o.s.ai.template.st.StTemplateRenderer -- 1:17: '"..."' came as a complete surprise to me
IllegalArgumentException: The template string is not valid.
The fix is to pick different delimiters for that template. StTemplateRenderer lets you choose
them:
PromptTemplate.builder()
.renderer(StTemplateRenderer.builder()
.startDelimiterToken('<')
.endDelimiterToken('>')
.build())
.template("Reply as {\"msg\": \"...\"} for <changes>")
.build()
.createMessage(Map.of("changes", "the diff"));
That renders to Reply as {"msg": "..."} for the diff. The braces are just text now, and
<changes> is the placeholder.
The good news is that braces in the values are fine. I rendered our commit template with
if (x) { return y; } as the changes value, and it came through untouched. Values are inserted
as text, not parsed again. So code and JSON coming from the request are safe. Only the template
itself needs care.
Keeping the system prompt in a file
The third endpoint turns a raw list of changes into release notes. Its instructions are long: a role, six rules, and an output format. A text block in the controller would work, but that many lines of English don’t belong in a Java class. They change for different reasons than the code does, and they’re much easier to review in a pull request when they sit in their own file.
So the project keeps them in src/main/resources/prompts/release-notes-system.st:
You are a release manager who turns raw engineering change lists into release notes
that end users can actually understand.
Rules:
- Start with a one-line headline summarising the theme of this release.
- Group changes under these sections, skipping any that are empty:
New, Improved, Fixed, Breaking Changes.
- Rewrite each change from the user's point of view (what they can now do, or what no longer goes wrong).
- Drop purely internal work (refactors, CI tweaks, dependency bumps) unless it affects users.
- Call out anything that requires action from the user under Breaking Changes, with the required step.
- Keep each bullet to a single sentence.
Output plain text only. Use "-" for bullets and no Markdown headings or bold text.
The controller asks Spring for the file in its constructor. @Value with a classpath: path
injects it as a Spring Resource, a handle to the file that can be read later:
DevAssistantController(ChatClient.Builder builder,
@Value("classpath:/prompts/release-notes-system.st")
Resource releaseNotesSystemPrompt) {
this.chatClient = builder
.defaultAdvisors(new SimpleLoggerAdvisor())
.build();
this.releaseNotesSystemPrompt = releaseNotesSystemPrompt;
}
The endpoint passes that Resource straight to SystemMessage, which reads the file’s content:
@PostMapping("/release-notes")
Reply draftReleaseNotes(@RequestBody @Valid ReleaseRequest request) {
String changeList = request.changes().stream()
.map(change -> "- " + change)
.collect(Collectors.joining("\n"));
Prompt prompt = new Prompt(List.of(
new SystemMessage(releaseNotesSystemPrompt),
new UserMessage("Version " + request.version() + " changes:\n" + changeList)));
return new Reply(chatClient.prompt(prompt).call().content());
}
The user message is built with plain Java, turning the list from the request into - bullets. That
part has no fixed text worth templating, so a stream and joining are the simplest tool for it.
Draft release notes from a mixed bag of changes, including one internal change the rules say to drop and one breaking change:
curl -X POST localhost:8080/api/dev/release-notes \
-H 'Content-Type: application/json' \
-d '{"version":"2.4.0","changes":["Add CSV export to the reports page","Fix crash when uploading files larger than 2 GB","Bump Jackson to 2.19","Remove the deprecated /v1/users endpoint, use /v2/users instead"]}'
{"content":"CSV export for reports, stable large file uploads, and removal of the v1 users API.\n\nNew\n- You can export report data to CSV from the reports page.\n\nFixed\n- Uploading files larger than 2 GB no longer causes a crash.\n\nBreaking Changes\n- The deprecated /v1/users API endpoint has been removed; update any integrations to use /v2/users."}
This is where the system prompt file earns its place. The Jackson bump is gone, because the rules
say to drop internal work. The /v1/users removal sits under Breaking Changes, with the step users
need to take. There’s no Improved section, because nothing belonged in it. Every rule in the file
shows up in the output, and none of them is in the Java code.
The .st file isn’t a template
The .st extension stands for StringTemplate, and it’s the convention for Spring AI prompt files.
But nothing here renders this file. new SystemMessage(Resource) reads the text and sends it
exactly as written.
Today that doesn’t matter, because the file has no placeholders. It will matter on the day someone
adds {version} to it, expecting it to be filled in. It won’t be. The model will get a literal
{version}, and no error will tell you.
If a prompt file needs placeholders, render it with SystemPromptTemplate, the system-message
version of PromptTemplate, which also accepts a Resource:
Message system = new SystemPromptTemplate(releaseNotesSystemPrompt)
.createMessage(Map.of("version", request.version()));
Then you get the same missing-variable check as in the commit endpoint. Until then, I’d keep the file as it is. It’s fixed text, and reading it as fixed text is the honest way to treat it.
One more note: the file is on the classpath, so it’s packed into the jar. You can edit it without touching Java, but you still need to rebuild and redeploy to change it in production.
Validation is part of the prompt
The request records carry more validation than last time:
record Question(@NotBlank String text) {}
record CommitRequest(@NotBlank String changes, @Min(1) @Max(10) int options) {}
record ReleaseRequest(@NotBlank String version, @NotEmpty List<@NotBlank String> changes) {}
@Min(1) @Max(10) on options matters because that number goes straight into the prompt. Without
it, someone could ask for 500 commit messages, and you’d pay for the tokens. List<@NotBlank String>
checks every item in the list, so an empty string can’t sneak in as a change.
Sending "options": 0 gets rejected before any template is rendered:
{"timestamp":"2026-11-07T12:32:57.030Z","status":400,"error":"Bad Request","path":"/api/dev/commit-messages"}
Which one should you use?
All three ways end up as a list of messages, so this is about readability, not capability. Here’s how I’d choose:
- A text block and
new SystemMessage(...)when the instructions are short and fixed. It’s the least machinery, and the prompt sits right next to the code that uses it. PromptTemplateas soon as values go into the middle of the text. It keeps the prompt readable, and the missing-variable check catches mistakes you’d otherwise only find by reading model output.- A file in
resources/promptsonce the prompt is long, or once someone who isn’t writing the Java will want to change it.
ChatClient also has a fluent style, chatClient.prompt().system(...).user(u -> u.text(...).param(...)),
which does the same job without creating Message objects yourself. This project builds them by
hand, which makes each step easier to see, so that’s what I’ve shown here.
Summary
Put your instructions in a system message and the user’s input in a user message, and don’t
concatenate the two. Reach for PromptTemplate when values go into the text, keep the default
validation on, and switch delimiters the moment a template needs literal braces. Move long prompts
into src/main/resources/prompts, but remember that a .st file is only rendered if you pass it
through a template. And keep DEBUG logging on for the advisor package while you work, because
when a prompt misbehaves, the first thing you’ll want to see is exactly what was sent.
