Module 5 — The Advisors API (Middleware Pattern)

Module 5 — The Advisors API (Middleware Pattern)

1. Advisors as AI-Interaction Middleware

The framing established in Module 1 bears repeating precisely because this module makes it concrete: an Advisor intercepts a request before it reaches the model and/or the response after it comes back — the same conceptual shape as a servlet filter chain or a Spring Security filter chain, just operating on ChatClientRequest/ChatClientResponse instead of HttpServletRequest/HttpServletResponse.

Why this is the “core extensibility mechanism,” not just one feature among many: nearly everything else in this track that looks like a distinct capability — memory, RAG, tool calling, logging, evaluation hooks — is actually implemented as an Advisor, composed into the same chain. There isn’t a separate “memory subsystem” and “RAG subsystem” wired in through different mechanisms; they’re all the same extension point, which is precisely why they compose together predictably (when ordering is correct — section 3).

Request flow:   ChatClientRequest → Advisor1 → Advisor2 → Advisor3 → ChatModel
Response flow:  ChatClientResponse ← Advisor1 ← Advisor2 ← Advisor3 ← ChatModel

Each advisor in the chain can: inspect/modify the outgoing request, decide whether to call the next advisor in the chain at all (short-circuiting), and inspect/modify the incoming response before passing it back up the chain.

Talking point: “Once you see that memory, RAG, and tool calling are all just Advisors, the framework stops looking like a grab-bag of AI features and starts looking like one coherent middleware pattern applied to several different cross-cutting concerns. That’s a deliberate architectural choice, and it’s the same one Spring made decades ago with Filters and Spring Security’s filter chain — a small number of well-understood extension points, reused for many different concerns, rather than a bespoke mechanism per feature.”


2. Built-In Advisors — Memory, RAG, Logging/Observation

Memory advisors — covered in depth in Module 6, but the relevant point here is that they’re a request-side concern primarily: they inject prior conversation history into the outgoing request before it reaches the model, and (depending on implementation) persist the new exchange after the response returns.

chatClient.prompt()
    .advisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
    .user("And what about the alternative I mentioned earlier?")
    .call()
    .content();

RAG — QuestionAnswerAdvisor — the canonical example of an advisor doing real work on both sides of the chain: on the request side, it queries a VectorStore for documents relevant to the user’s question and injects them into the prompt as context; the model then answers grounded in that injected context rather than its parametric knowledge alone.

chatClient.prompt()
    .advisors(QuestionAnswerAdvisor.builder(vectorStore)
        .searchRequest(SearchRequest.builder().topK(5).build())
        .build())
    .user("What does our refund policy say about items damaged in transit?")
    .call()
    .content();

Logging/Observation advisors — typically pure response-side (and request-side for full request logging) observers that don’t transform the content at all, just record it — SimpleLoggerAdvisor is the basic built-in example, and the framework’s Micrometer-based observability (Module 10) hooks into this same general pattern for tracing spans across the chain.

ChatClient.builder(chatModel)
    .defaultAdvisors(new SimpleLoggerAdvisor())   // logs every request/response pair
    .build();

Talking point on why grouping these three together matters: “Memory, RAG, and logging sit at very different points on the ‘does this change behavior’ spectrum — memory and RAG actively transform what the model sees and can change the answer; logging is a pure side-effect that never alters the request or response content at all. That distinction matters for ordering: a side-effect-only advisor like logging is safe almost anywhere in the chain, but a content-transforming advisor like RAG or memory has to sit at a specific point relative to other content-transforming advisors, or you get the kind of subtle correctness bug covered in the next section.”


3. Advisor Chain Ordering and Mutation

This is the section that turns the conceptual model into real engineering judgment — and it’s worth treating with the same seriousness as proxy ordering in the AOP module of your Java/Spring track, because the failure mode is the same shape: silent incorrectness, not a thrown exception.

The concrete, real bug already referenced twice in this track: memory advisors needed to be explicitly placed outside the tool-calling advisor by default. If memory persistence happened inside the tool-calling loop, the intermediate tool-call exchanges — which are internal plumbing, not meaningful user-facing dialogue — risked being persisted into the stored conversation history alongside genuine turns. The fix moved memory advisor placement outward in the default chain construction so this couldn’t happen by accident.

The general ordering principle this illustrates: advisors that should see the full, final user-facing exchange (memory, certain logging) belong outside advisors that perform internal, multi-step work (tool calling, recursive iteration) whose intermediate steps aren’t meant to be treated as first-class conversation turns.

// Correct conceptual ordering — memory wraps the OUTSIDE of tool-calling iteration,
// not interleaved inside it
chatClient.prompt()
    .advisors(
        MessageChatMemoryAdvisor.builder(chatMemory).build(),  // outer: sees final exchange only
        // tool-calling advisor sits effectively "inside" this — its intermediate 
        // tool-call/tool-result messages are internal to resolving one turn,
        // not separately persisted turns themselves
    )
    .tools(new OrderTools())
    .user("Cancel my order if it hasn't shipped")
    .call()
    .content();

Advisor chain mutation — a related but distinct concern from ordering: the chain itself (the list of advisors and their configuration) needs to be treated as effectively immutable once constructed for a given request, to avoid race conditions or unexpected behavior when the same ChatClient instance is reused across concurrent requests (a very normal pattern — most apps build one ChatClient bean and reuse it for every request). The recent fix enforcing non-null ToolCallAdvisor.Builder and correcting advisor chain mutation addressed exactly this — preventing a shared, reused chain configuration from being mutated unsafely under concurrent use.

Talking point: “A ChatClient bean is almost always a singleton serving concurrent requests, the same as any other Spring-managed bean — which means its advisor chain has to behave like immutable, thread-safe configuration, not something that gets adjusted per-request by mutating shared state. The fix here is conceptually identical to any other ‘don’t mutate shared singleton state from a request thread’ bug class in Spring applications generally — it just happens to be inside the AI framework’s own internals this time rather than in application code, which is exactly why the framework needed to add explicit enforcement around it.”


4. Recursive Advisors — Controlled Iteration for Agentic Loops

This is the 2026 direction flagged in the Spring I/O conference content from the table-of-contents research — genuinely new territory, worth presenting as “here’s where the framework is heading” rather than settled, stable API.

The problem Recursive Advisors solve: ordinary advisor chains are linear — request flows down through advisors once, response flows back up once. But real agentic behavior (the MemGPT/Claude-Code-style pattern referenced explicitly in the conference material) requires iteration: the model produces output, that output gets evaluated, and depending on the evaluation, the system might loop back — call another tool, retry with corrected instructions, refine an answer — multiple times before producing a final result. A single linear pass through advisors can’t express “go back and try again with this feedback.”

The conceptual shape of a Recursive Advisor (the API is still maturing in 2.0 milestones, so this is presented at the pattern level rather than as stable, fixed syntax):

1. Request enters the recursive advisor
2. Advisor calls the model (possibly via the rest of the chain)
3. Advisor evaluates the result against some condition 
   (did the tool call succeed? did output pass a validation check? 
   has a goal been achieved per the Judge framework?)
4. If the condition isn't met AND iteration limit isn't exhausted:
   loop back to step 2, potentially with modified input 
   (e.g., injecting the failure reason as feedback)
5. If the condition is met, or the limit is reached:
   pass the (possibly final, possibly best-effort) result onward

Why this needs to be a controlled, bounded mechanism rather than an open-ended loop — and this is the genuinely important engineering point, not just a syntax detail: an uncontrolled agentic loop risks infinite iteration, runaway cost (every iteration is a billed model call), and compounding errors if each iteration’s “correction” is itself unreliable. A Recursive Advisor needs an explicit iteration cap, an explicit termination condition, and ideally an explicit cost/time budget — the same defensive bounding you’d put around any retry loop calling an external, billed, occasionally-unreliable service, just with the added wrinkle that the “retry logic” here is itself driven by another probabilistic model call evaluating success, not a deterministic status code check.

Talking point connecting this to the Judge framework from the broader table of contents: “Recursive Advisors and the Judge framework are designed to work together — the Judge framework is what gives a recursive loop a non-trivial way to decide ‘did this actually succeed,’ since for agentic tasks that’s rarely as simple as checking an HTTP status code. The hard engineering problem in agentic loops isn’t the looping mechanism itself, which is a fairly standard bounded-retry pattern — it’s building a trustworthy evaluator for ‘is this actually done,’ which is a genuinely harder problem than the control flow around it.”

Talking point on this being a frontier area, appropriate for an interview to be honest about: “This is one of the few areas in this whole curriculum where I’d be candid that the pattern is still settling — Recursive Advisors are part of Spring AI’s 2026 push into agentic territory, and the milestone-release status from Module 1’s versioning discussion applies directly here: I’d prototype with this, understand the shape of the problem it’s solving, but I wouldn’t consider the exact API final the way I would for the stable memory/RAG advisor patterns.”


5. Building a Custom Advisor

The general-purpose extension point — the same mechanism used to implement everything above, available directly to application code for any cross-cutting AI-interaction concern not already covered by a built-in advisor.

public class PiiRedactionAdvisor implements CallAdvisor {

    private static final Pattern EMAIL_PATTERN = 
        Pattern.compile("[\\w.+-]+@[\\w-]+\\.[a-zA-Z]{2,}");

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        // --- Request-side transformation: redact PII before it reaches the model ---
        String originalUserText = request.prompt().getUserMessage().getText();
        String redactedText = EMAIL_PATTERN.matcher(originalUserText).replaceAll("[REDACTED_EMAIL]");

        ChatClientRequest sanitizedRequest = request.mutate()
            .prompt(request.prompt().mutate()
                .messages(replaceUserMessage(request.prompt().getInstructions(), redactedText))
                .build())
            .build();

        // --- Delegate to the next advisor in the chain ---
        ChatClientResponse response = chain.nextCall(sanitizedRequest);

        // --- Response-side: e.g., log that redaction occurred, without altering the response ---
        if (!originalUserText.equals(redactedText)) {
            log.info("PII redacted from outgoing prompt before model call");
        }

        return response;
    }

    @Override
    public String getName() { return "PiiRedactionAdvisor"; }

    @Override
    public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; }  // run early — before anything else sees raw PII
}
chatClient.prompt()
    .advisors(new PiiRedactionAdvisor(), new SimpleLoggerAdvisor())
    .user("My email is bobin@example.com — can you help me draft a follow-up?")
    .call()
    .content();

The getOrder() method is the explicit, declared answer to section 3’s ordering concerns — rather than relying on registration order alone (fragile, implicit), a well-built custom advisor declares its required position explicitly, the same discipline as @Order on Spring beans or filter ordering in a servlet filter chain.

A streaming-aware advisor needs the corresponding StreamAdvisor variant, not just CallAdvisor — and this connects directly back to Module 2’s streaming + tool-calling chunk-dropping caution: any advisor that needs to transform content mid-stream has to handle partial, incrementally-arriving content correctly, not assume it can operate on a complete response the way a CallAdvisor can.

Talking point that closes the module: “Building a custom advisor is where this framework’s middleware philosophy pays off directly — a PII redaction policy, a cost-budget enforcer, a custom audit trail, a business-specific safety check, none of these need to be bolted on as an afterthought wrapping the whole ChatClient call; they’re first-class citizens of the same composable chain as memory and RAG, with the same ordering and mutation discipline applying to custom advisors as to the built-in ones. That consistency — one extension mechanism, applied uniformly — is the actual architectural payoff of treating Advisors as the core abstraction rather than a single ‘AI safety wrapper’ bolted on top.”