1. Overview

In this lesson, we move from a basic model request to prompts using message roles and templates.

The relevant module we need to import when starting this lesson is: prompts-roles-start.

If we want to reference the fully implemented lesson, we can import: prompts-roles-end.

2. Understanding Prompts, Messages, and Roles

In Spring AI, a Prompt represents one or more Message objects, possibly with model options, and the entire collection is sent to the model in one request. In addition to the Prompt(String) constructor, which accepts plain text and wraps it in a UserMessage, we can also use Prompt(List<Message> messages) or Prompt(Message… messages) when we need to supply multiple messages at once. We can also construct prompts using the ChatClient fluent API’s .system() and .user() methods, which make the intended role of each message more explicit than assembling Message objects by hand.

The model processes all the messages in a prompt together, and together they must fit within the model’s context window, the maximum number of tokens it can process in a single prompt.

Spring AI defines four message types, each carrying a distinct role in the conversation:

Message Type Description
System Sets persona, constraints, and behavior instructions for the model
User Carries the end user’s actual question or instruction
Assistant Represents the model’s previous response in the conversation
Tool Carries the result returned from a tool or function call

This lesson focuses on separating System instructions from User input so the two purposes aren’t accidentally mixed; we’ll also take a brief look at the Assistant and Tool roles later in this lesson.

3. Building Prompts with Message Roles

Now that we understand how roles structure a request, let’s implement this in code. We’ll create a new endpoint that asks the model to act as a Marketing Project Manager.

3.1. Defining Role Instructions with System Messages

In our example, the System message describes the requested Marketing Project Manager role and its intended scope: campaign timelines, budgets, and coordination with the Creative Team. It also asks the model to redirect requests for ad copy or visual design to the appropriate creative specialist.

Let’s open ChatController.java and add a new endpoint:

@GetMapping("/marketing-pm")
public String marketingPmChat(@RequestParam String message) {
    return chatClient.prompt()
        .system("""
            You are a Marketing Project Manager. \
            You focus on campaign timelines, budgets, \
            and coordinating the creative team. \
            You do NOT write ad copy or design visuals yourself. \
            If a user asks you to write a slogan or design a logo, \
            politely tell them that is a task for the Creative Team \
            and suggest they brief the Copywriter or Art Director.""")
        .user(message)
        .call()
        .content();
}

In our first example, we’ll build the request one message at a time.

Let’s walk through what this does:

  • The .system() call establishes the persona and instructions for the model; everything in that string acts as internal guidance that influences how the model responds
  • The .user() call passes the HTTP request parameter as the actual question or instruction from the end user
  • Under the hood, Spring AI wraps both messages into a single Prompt object
  • In the fluent chain, .call() selects synchronous response handling and returns a CallResponseSpec
  • The terminal method .content() triggers the actual model invocation and extracts the response text

Keep in mind, there’s no guarantee the model will follow every rule we specify in System and User messages. An LLM’s behavior is nondeterministic.

3.2. Testing Role-Based Instructions in Action

Let’s run the application and test it with Postman. We’ll start by asking questions within the project manager’s scope.

We send a GET request to http://localhost:8080/marketing-pm with the query parameter message set to We are launching a new product in Q4. How should I structure the timeline? The parameter is used as the User message, and the resulting Prompt is sent to the model, which generates output: a phased project-management timeline working backward from a Q4 launch.

The response should contain the output and have the 200 OK as its status code:

Excellent! A Q4 product launch requires a well-structured timeline to ensure everything aligns perfectly. As your Marketing Project Manager, I'd recommend working backward from your target launch date in Q4.

Here's a general timeline structure, which we can then populate with specific dates once we know the exact launch week:

---

**Product Launch Timeline: Q4 Launch**

...

Next, we send another GET request with message set to Write a catchy slogan for our new coffee brand. In this run, the response identifies slogan writing as the task of the Creative Team, specifically, the Copywriters. It recommends preparing a brief:

As a Marketing Project Manager, my focus is on campaign timelines, budgets, and coordinating the creative team. Crafting catchy slogans is a task for our talented **Copywriter**.

I recommend you brief them with details about our new coffee brand – its unique selling points, target audience, and desired brand voice – and they'll be able to develop several slogan options for you.

Note: these blocks show example responses rather than guaranteed output. LLM wording, length, and formatting can vary between runs.

The existing /chat endpoint from the start project still works exactly as before. The new endpoint is an addition, not a replacement.

3.3. Understanding Role Boundaries and Security

Separating System and User messages improves instruction hierarchy and reduces accidental instruction mixing, but it influences model behavior only probabilistically.

However, this separation does not come with any security guarantees. The model may ignore the System message, fall to a prompt-injection attack, or repeat or otherwise expose the System message. Therefore, System instructions must not contain sensitive information or be treated as confidential.

This lets us use roles to organize instructions, while keeping authorization, input validation, output validation, and confidentiality controls in the application layer.

3.4. Working with Assistant and Tool Messages

Besides System and User, Spring AI also defines Assistant and Tool message types, represented by the AssistantMessage and ToolResponseMessage classes. An Assistant message holds a previous response from the model, and a Tool message holds the result of a tool call the model requested. We construct an AssistantMessage the same way as a SystemMessage or UserMessage, by passing text to its constructor.

4. Using Prompt Templates

So far, we’ve been writing our prompt text directly as Java strings. This works for simple cases, but hardcoded prompt strings are error-prone and difficult to maintain.

Spring AI provides prompt templating with {placeholder} syntax. Instead of concatenating values, we declare named placeholders and bind each parameter explicitly at the call site. Substitution is then performed at runtime.

Let’s add a new endpoint to ChatController that accepts two request parameters and uses an inline template:

@GetMapping("/campaign-plan")
public String campaignPlan(@RequestParam String campaign, @RequestParam String channel) {
    String templateText = """
        Draft a high-level project plan \
        for a {campaign} campaign launching on {channel}. \
        List 3 key milestones.""";

    String response = chatClient.prompt()
        .user(u -> u.text(templateText)
            .param("campaign", campaign)
            .param("channel", channel))
        .call().content();
    return response;
}

Instead of passing a plain string to .user(), we pass a lambda. Inside the lambda, .text() sets the template string containing {campaign} and {channel} placeholders. Each .param() call maps a placeholder name to its actual value.

Spring AI handles the substitution automatically.

Let’s test this. For example, we can send a GET request to http://localhost:8080/campaign-plan with campaign set to Customer Referral and channel set to Email. As a response, we get the project plan for the supplied parameter values:

Here's a high-level project plan for a Customer Referral campaign launching on Email, followed by 3 key milestones.

---

## Project Plan: Customer Referral Campaign (Email Launch)

**Project Goal:** To leverage our existing customer base to acquire new, high-quality customers through a structured referral program, driven primarily via email communication.

...

This confirms that the inline template resolves parameters before the model call.

Spring AI also provides a standalone PromptTemplate class for creating reusable template objects outside the ChatClient chain: we create one with a template string, then call .render() with a Map of variable names to values to get the resolved string, .create() to get a fully-formed Prompt object instead, or .createMessage() to get just the resolved Message. The inline lambda approach works well for a short template local to a single ChatClient call, while the standalone class suits templates that need independent rendering, reuse across multiple call sites, or passing around as an object.

By default, Spring AI also validates that every placeholder in a template has a matching parameter before rendering; if we forget a .param() call, it throws an exception rather than sending an incomplete prompt to the model. This fail-fast behavior catches a missing parameter during development instead of a malformed prompt at runtime. This validation mode is configurable on the builder: WARN logs the mismatch and renders anyway, and NONE skips validation entirely.

5. Create a Prompt from a List of Messages

So far, we’ve built our Prompt one message at a time through the ChatClient fluent chain’s .system() and .user() methods. When we already have a collection of messages, such as conversation history retrieved from a list or another data structure, we don’t have to add them one by one. Both Prompt and ChatClient offer overloaded methods that accept multiple messages together.

Let’s add a new endpoint that assembles a List<Message> directly, including a prior Assistant reply, and passes the whole list to .messages() in a single call:

@GetMapping("/marketing-pm-followup")
public String marketingPmFollowUp(@RequestParam String message) {
    List<Message> messages = List.of(
        new SystemMessage("You are a Marketing Project Manager who coordinates campaign timelines and budgets."),
        new UserMessage("We are launching a new product in Q4. How should I structure the timeline?"),
        new AssistantMessage("Here's a draft campaign timeline: kickoff, creative concepting, production, QA, and media buying."),
        new UserMessage(message));

    return chatClient.prompt()
        .messages(messages)
        .call()
        .content();
}

We build the message list ourselves, including the AssistantMessage holding the model’s earlier reply, and pass it to .messages() in a single call. Spring AI wraps the list into a Prompt internally, exactly as it would with .system() and .user().

In a real application, we’d typically retrieve that prior reply from a session or persisted conversation history rather than hardcoding it.

6. Conclusion

Distinct message roles and reusable templates give us two complementary tools for working with prompts. Message roles separate System instructions from User input, which reduces accidental instruction mixing and improves instruction hierarchy. Templates bind dynamic values to prompts through named placeholders, keeping the template structure readable and the binding explicit.

Because model behavior is probabilistic, the degree to which the model follows role instructions can vary. Authorization, input validation, output validation, and any other security controls remain the application’s responsibility.