eBook – Guide Spring Cloud – NPI EA (cat=Spring Cloud)
announcement - icon

Let's get started with a Microservice Architecture with Spring Cloud:

>> Join Pro and download the eBook

eBook – Mockito – NPI EA (tag = Mockito)
announcement - icon

Mocking is an essential part of unit testing, and the Mockito library makes it easy to write clean and intuitive unit tests for your Java code.

Get started with mocking and improve your application tests using our Mockito guide:

Download the eBook

eBook – Java Concurrency – NPI EA (cat=Java Concurrency)
announcement - icon

Handling concurrency in an application can be a tricky process with many potential pitfalls. A solid grasp of the fundamentals will go a long way to help minimize these issues.

Get started with understanding multi-threaded applications with our Java Concurrency guide:

>> Download the eBook

eBook – Reactive – NPI EA (cat=Reactive)
announcement - icon

Spring 5 added support for reactive programming with the Spring WebFlux module, which has been improved upon ever since. Get started with the Reactor project basics and reactive programming in Spring Boot:

>> Join Pro and download the eBook

eBook – Java Streams – NPI EA (cat=Java Streams)
announcement - icon

Since its introduction in Java 8, the Stream API has become a staple of Java development. The basic operations like iterating, filtering, mapping sequences of elements are deceptively simple to use.

But these can also be overused and fall into some common pitfalls.

To get a better understanding on how Streams work and how to combine them with other language features, check out our guide to Java Streams:

>> Join Pro and download the eBook

eBook – Jackson – NPI EA (cat=Jackson)
announcement - icon

Do JSON right with Jackson

Download the E-book

eBook – HTTP Client – NPI EA (cat=Http Client-Side)
announcement - icon

Get the most out of the Apache HTTP Client

Download the E-book

eBook – Maven – NPI EA (cat = Maven)
announcement - icon

Get Started with Apache Maven:

Download the E-book

eBook – Persistence – NPI EA (cat=Persistence)
announcement - icon

Working on getting your persistence layer right with Spring?

Explore the eBook

eBook – RwS – NPI EA (cat=Spring MVC)
announcement - icon

Building a REST API with Spring?

Download the E-book

Course – LS – NPI EA (cat=Jackson)
announcement - icon

Get started with Spring and Spring Boot, through the Learn Spring course:

>> LEARN SPRING
Course – RWSB – NPI EA (cat=REST)
announcement - icon

Explore Spring Boot 3 and Spring 6 in-depth through building a full REST API with the framework:

>> The New “REST With Spring Boot”

Course – LSS – NPI EA (cat=Spring Security)
announcement - icon

Yes, Spring Security can be complex, from the more advanced functionality within the Core to the deep OAuth support in the framework.

I built the security material as two full courses - Core and OAuth, to get practical with these more complex scenarios. We explore when and how to use each feature and code through it on the backing project.

You can explore the course here:

>> Learn Spring Security

Course – All Access – NPI EA (cat= Spring)
announcement - icon

All Access is finally out, with all of my Spring courses. Learn JUnit is out as well, and Learn Maven is coming fast. And, of course, quite a bit more affordable. Finally.

>> GET THE COURSE
Course – LSD – NPI EA (tag=Spring Data JPA)
announcement - icon

Spring Data JPA is a great way to handle the complexity of JPA with the powerful simplicity of Spring Boot.

Get started with Spring Data JPA through the guided reference course:

>> CHECK OUT THE COURSE

Partner – Moderne – NPI EA (cat=Spring Boot)
announcement - icon

Refactor Java code safely — and automatically — with OpenRewrite.

Refactoring big codebases by hand is slow, risky, and easy to put off. That’s where OpenRewrite comes in. The open-source framework for large-scale, automated code transformations helps teams modernize safely and consistently.

Each month, the creators and maintainers of OpenRewrite at Moderne run live, hands-on training sessions — one for newcomers and one for experienced users. You’ll see how recipes work, how to apply them across projects, and how to modernize code with confidence.

Join the next session, bring your questions, and learn how to automate the kind of work that usually eats your sprint time.

Course – LJB – NPI EA (cat = Core Java)
announcement - icon

Code your way through and build up a solid, practical foundation of Java:

>> Learn Java Basics

1. Overview

When we expose a large language model through our own API, our first instinct is to filter the prompts that users send. Spring AI ships a SafeGuardAdvisor for exactly that job. It blocks any prompt containing a forbidden term before the prompt reaches the model.

That covers only half of the problem. A perfectly innocent-looking prompt can still produce a response that we’d rather not return. The model answers from its own knowledge. The user never has to name the thing we’re trying to hide.

In this tutorial, we’ll close that gap with a custom advisor that inspects the model’s response before ChatClient hands it back. We’ll set up the input guard first, find the hole in it, then stack an output guard on top.

2. Setting Up the Project

A chat starter is the only dependency we need. Let’s add the Google GenAI one:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-google-genai</artifactId>
    <version>1.1.2</version>
</dependency>

The latest version of spring-ai-starter-model-google-genai is available in the Maven Repository. Advisors are model-agnostic, so any other chat starter works here just as well.

Next, we point the starter at our Gemini API key and pick a model:

spring:
  ai:
    google:
      genai:
        api-key: ${GEMINI_API_KEY}
        chat:
          options:
            model: gemini-3.5-flash

Throughout the tutorial, we’ll build a Java coding assistant with one rule: it must never recommend the legacy date API over java.time.

3. Blocking Sensitive Prompts

SafeGuardAdvisor is Spring AI’s built-in answer to unwanted input. It scans the prompt for a list of sensitive words. When it finds one, it short-circuits the advisor chain. The model is never called, and the caller gets a canned response instead.

Let’s register one as a default advisor on our ChatClient, next to a system prompt that sets the assistant’s role:

@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
    SafeGuardAdvisor inputGuard = SafeGuardAdvisor.builder()
      .sensitiveWords(List.of("SimpleDateFormat", "java.util.Date"))
      .failureResponse("Use java.time instead.")
      .build();

    return builder.defaultSystem(SYSTEM_PROMPT)
      .defaultAdvisors(inputGuard)
      .build();
}

The advisor compares each word against the prompt with a plain substring check, so the match is case-sensitive and exact. It also scans the whole prompt, system message included. That has a catch. If our system prompt spelled out SimpleDateFormat, the guard would reject every request.

The guard now stops any question that names the legacy API before it reaches the model:

private static final String FAILURE_RESPONSE = "Use java.time instead.";

@Test
void givenLegacyApiPrompt_whenCallingTheModel_thenItIsBlocked() {
    String answer = chatClient.prompt()
      .user("How do I use SimpleDateFormat?")
      .call()
      .content();

    assertThat(answer).isEqualTo(FAILURE_RESPONSE);
}

The model is never invoked at all, which makes this the cheapest guardrail we can put in place.

4. Why Input Filtering Isn’t Enough

SafeGuardAdvisor only ever sees the prompt. A developer can ask about a class without ever typing its name. Asking “How did we parse date strings in Java 7?” matches nothing on our list, so it reaches the model. Since java.time didn’t exist before Java 8, it has only one honest answer:

In Java 7, date strings were parsed using the java.text.SimpleDateFormat
class by defining a format pattern to convert the string into a
java.util.Date object.

The guard held, and the legacy API came back anyway. Input filtering only matches what the user typed, and the model is under no obligation to stay inside that vocabulary. To catch this, we have to look at what comes back.

5. Creating the Output Advisor

Spring AI has no built-in advisor that filters response content, so we’ll write our own. It takes the same three settings as SafeGuardAdvisor, and we’ll build it in two steps: the class and its builder, then the filtering logic.

5.1. Skeleton and Builder

An advisor that inspects responses only needs the CallAdvisor interface, which Spring AI uses for non-streaming calls. Our class holds the three values the builder configures, and exposes two of them through the Advisor contract:

public class OutputSafeGuardAdvisor implements CallAdvisor {

    private List<String> sensitiveWords;
    private String failureResponse;
    private int order;

    private OutputSafeGuardAdvisor(
      List<String> sensitiveWords, String failureResponse, int order) {
        this.sensitiveWords = sensitiveWords;
        this.failureResponse = failureResponse;
        this.order = order;
    }

    public static Builder builder() {
        return new Builder();
    }

    @Override
    public String getName() {
        return getClass().getSimpleName();
    }

    @Override
    public int getOrder() {
        return order;
    }
}

The builder mirrors the one on SafeGuardAdvisor, so both advisors read the same way at the call site:

public static class Builder {

    private List<String> sensitiveWords = List.of();
    private String failureResponse
      = "Response contained forbidden content.";
    private int order = Ordered.LOWEST_PRECEDENCE - 1;

    public Builder sensitiveWords(String... sensitiveWords) {
        this.sensitiveWords = Arrays.asList(sensitiveWords);
        return this;
    }

    public Builder sensitiveWords(List<String> sensitiveWords) {
        this.sensitiveWords = sensitiveWords;
        return this;
    }

    public Builder failureResponse(String failureResponse) {
        this.failureResponse = failureResponse;
        return this;
    }

    public Builder order(int order) {
        this.order = order;
        return this;
    }

    public OutputSafeGuardAdvisor build() {
        return new OutputSafeGuardAdvisor(
          sensitiveWords, failureResponse, order);
    }
}

The varargs overload of sensitiveWords() is one SafeGuardAdvisor doesn’t offer. The default order deserves a closer look. Spring AI terminates the chain with an internal ChatModelCallAdvisor whose order is exactly Ordered.LOWEST_PRECEDENCE. An advisor that shares that value can be sorted behind the terminal one. The terminal advisor never delegates any further. Ours would silently never run.

Defaulting to Ordered.LOWEST_PRECEDENCE – 1 puts us last among the advisors that do execute. Spring AI’s own StructuredOutputValidationAdvisor leaves the same gap, defaulting to Ordered.LOWEST_PRECEDENCE – 2000.

5.2. Implementing adviseCall()

Unlike an input guard, an output advisor starts by letting the call through. We invoke the rest of the chain first, and only then inspect what came back:

@Override
public ChatClientResponse adviseCall(
  ChatClientRequest request, CallAdvisorChain chain) {
    ChatClientResponse response = chain.nextCall(request);
    ChatResponse chatResponse = response.chatResponse();
    if (chatResponse == null || chatResponse.getResults().isEmpty()) {
        return response;
    }

    List<Generation> results = chatResponse.getResults();
    List<Generation> filteredResults = results.stream()
      .map(this::filterGeneration)
      .toList();
    if (filteredResults.equals(results)) {
        return response;
    }

    ChatResponse filtered = new ChatResponse(
      filteredResults, chatResponse.getMetadata());

    return response.mutate()
      .chatResponse(filtered)
      .build();
}

A response with no ChatResponse or generations gives us nothing to inspect, so we hand it straight back. Otherwise, we map over every Generation and compare the filtered list against the original. Generation implements equals(), so that’s a value comparison, not an identity check, and when nothing changed we return the original response.

The replacement swaps the message but keeps the generation’s metadata, so the finish reason survives:

private Generation filterGeneration(Generation generation) {
    if (!containsSensitiveWord(generation.getOutput().getText())) {
        return generation;
    }

    return new Generation(
      new AssistantMessage(failureResponse), generation.getMetadata());
}

private boolean containsSensitiveWord(String text) {
    if (!StringUtils.hasText(text)) {
        return false;
    }

    return sensitiveWords.stream()
      .anyMatch(text::contains);
}

6. Registering the Advisor

Registering the output guard means adding it to the same defaultAdvisors() call:

@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
    SafeGuardAdvisor inputGuard = SafeGuardAdvisor.builder()
      .sensitiveWords(List.of("SimpleDateFormat", "java.util.Date"))
      .failureResponse("Use java.time instead.")
      .build();

    OutputSafeGuardAdvisor outputGuard = OutputSafeGuardAdvisor.builder()
      .sensitiveWords("DateFormat", "java.util.Date",
        "Calendar", "Timestamp")
      .failureResponse("Use java.time instead.")
      .build();

    return builder.defaultSystem(SYSTEM_PROMPT)
      .defaultAdvisors(inputGuard, outputGuard)
      .build();
}

The two guards deliberately watch different word lists. The output guard watches DateFormat rather than SimpleDateFormat, which also catches the parent class, and adds Calendar and Timestamp, the neighboring legacy types a model reaches for. Adding those to the input guard would be counterproductive: a developer asking about Calendar isn’t the problem, an answer that recommends it is.

We don’t set an order: SafeGuardAdvisor defaults to 0 and runs first, while our default puts the output guard last.

7. Testing the Guardrail

The advisor is a plain class, so we can exercise it without a model. Let’s start with the fixture:

private static final String FAILURE_RESPONSE = "Use java.time instead.";
private static final String LEAKED_ANSWER = "Java 7 used SimpleDateFormat.";
private static final String CLEAN_ANSWER = "LocalDate.now() returns today.";

private OutputSafeGuardAdvisor advisor = OutputSafeGuardAdvisor.builder()
  .sensitiveWords("SimpleDateFormat", "java.util.Date")
  .failureResponse(FAILURE_RESPONSE)
  .build();

Now we stub the chain with Mockito and feed it a generation that leaks:

@Test
void givenAnswerNamingLegacyApi_whenAdvisingCall_thenAnswerIsReplaced() {
    CallAdvisorChain chain = chainReturning(generation(LEAKED_ANSWER));

    ChatClientResponse response = advisor.adviseCall(request(), chain);

    assertThat(textOf(response)).isEqualTo(FAILURE_RESPONSE);
}

A clean answer takes the early return, so we get the very same instance back:

@Test
void givenCleanAnswer_whenAdvisingCall_thenItIsReturnedUntouched() {
    ChatClientResponse original = responseOf(generation(CLEAN_ANSWER));
    CallAdvisorChain chain = chainReturning(original);

    ChatClientResponse response = advisor.adviseCall(request(), chain);

    assertThat(response).isSameAs(original);
}

End to end, the question that defeated the input guard now comes back filtered:

@Test
void givenJava7Question_whenCallingTheModel_thenAnswerIsBlocked() {
    String answer = chatClient.prompt()
      .user("How did we parse date strings in Java 7?")
      .call()
      .content();

    assertThat(answer).isEqualTo(FAILURE_RESPONSE);
}

8. Limitations

Substring matching is blunt in both directions. It’s case-sensitive, so a stray capitalization slips through. It also has no notion of word boundaries. That’s why our list spells out java.util.Date rather than a bare Date. The shorter term would also reject a good answer built on LocalDate. Paraphrases defeat it outright.

It’s also a call-only guard. OutputSafeGuardAdvisor implements CallAdvisor but not StreamAdvisor, so a streamed response bypasses it. Filtering a stream is genuinely harder, because a sensitive word can straddle two chunks and the earlier ones have already reached the client.

For stronger guarantees, a moderation model is a better tool than a word list.

9. Conclusion

In this article, we built a custom Spring AI advisor that inspects the model’s response and replaces it when it contains something we’d rather not return. Pairing it with SafeGuardAdvisor covers both directions: one guard rejects the prompt, the other rejects the answer. Neither is complete on its own, but together they close the obvious gaps with very little code.

As always, the full source code is available over on GitHub.

Baeldung Pro – NPI EA (cat = Baeldung)
announcement - icon

Baeldung Pro comes with both absolutely No-Ads as well as finally with Dark Mode, for a clean learning experience:

>> Explore a clean Baeldung

eBook – HTTP Client – NPI EA (cat=HTTP Client-Side)
announcement - icon

The Apache HTTP Client is a very robust library, suitable for both simple and advanced use cases when testing HTTP endpoints. Check out our guide covering basic request and response handling, as well as security, cookies, timeouts, and more:

>> Download the eBook

eBook – Java Concurrency – NPI EA (cat=Java Concurrency)
announcement - icon

Handling concurrency in an application can be a tricky process with many potential pitfalls. A solid grasp of the fundamentals will go a long way to help minimize these issues.

Get started with understanding multi-threaded applications with our Java Concurrency guide:

>> Download the eBook

eBook – Java Streams – NPI EA (cat=Java Streams)
announcement - icon

Since its introduction in Java 8, the Stream API has become a staple of Java development. The basic operations like iterating, filtering, mapping sequences of elements are deceptively simple to use.

But these can also be overused and fall into some common pitfalls.

To get a better understanding on how Streams work and how to combine them with other language features, check out our guide to Java Streams:

>> Join Pro and download the eBook

eBook – Persistence – NPI EA (cat=Persistence)
announcement - icon

Working on getting your persistence layer right with Spring?

Explore the eBook

Course – LS – NPI EA (cat=REST)

announcement - icon

Get started with Spring Boot and with core Spring, through the Learn Spring course:

>> CHECK OUT THE COURSE

Partner – Moderne – NPI EA (tag=Refactoring)
announcement - icon

Modern Java teams move fast — but codebases don’t always keep up. Frameworks change, dependencies drift, and tech debt builds until it starts to drag on delivery. OpenRewrite was built to fix that: an open-source refactoring engine that automates repetitive code changes while keeping developer intent intact.

The monthly training series, led by the creators and maintainers of OpenRewrite at Moderne, walks through real-world migrations and modernization patterns. Whether you’re new to recipes or ready to write your own, you’ll learn practical ways to refactor safely and at scale.

If you’ve ever wished refactoring felt as natural — and as fast — as writing code, this is a good place to start.

eBook Jackson – NPI EA – 3 (cat = Jackson)
guest
0 Comments
Oldest
Newest