In the last post, we called Jev from Spring Boot by hand. We built a RestClient, a handful of records, and a properties class. It worked. But every new app that talks to Jev needs those same files again, and copying them from project to project gets old fast. So I packaged it all up into a Jev Spring Boot starter. You add one dependency, set one environment variable, inject a JevClient, and you're asking typed questions.
In this tutorial you'll install the starter, build a small support ticket triage app with it, and then open the starter up. By the end you'll know how every Spring Boot starter you use actually works.
📦 Get the Code
Follow along with the complete working example.
A Quick Jev Recap
If you missed the first post, start with Getting Started with Jev in Spring Boot 4. Here's the short version.
Jev, from TypeSafe, is not a large language model (LLM). You don't get back free-form text. You ask questions and get typed answers. There are three primitives:
- noul: a yes or no style answer
- choice: pick one label from a list you provide
- score: rate something against levels you define
There's no official Java SDK (software development kit) for Jev. You get an HTTP endpoint. That's why last time we wired up our own client. The hand-built version lives in hello-jev-spring if you want to compare the before and after.
One thing I really like about Jev is the pricing model. You only pay for input tokens, not output tokens. For classification and routing work, that makes it fast and cheap. It doesn't replace an LLM. It's an "and," not an "or."
What Is a Spring Boot Starter?
You're probably already using starters, even if you've never thought about them. Head over to start.spring.io, add the Spring Web dependency, and click Explore. In the generated pom.xml you'll see spring-boot-starter-webmvc.
A starter is a bundle of three things:
- The dependencies a technology needs
- Properties you can configure
- Auto-configuration that creates beans for you
If you've ever built a Spring web app without Boot, you know how much wiring that saves. You don't have to plug a dozen pieces together yourself.
Starters aren't only for Spring projects either. Add Java Template Engine (JTE) on start.spring.io and you'll see jte-spring-boot-starter-4. That one comes from the community.
Spring Boot Starter Naming Conventions
Notice the naming difference. Official Spring starters begin with spring-boot-starter-. Third-party starters put their own name first, like jte-spring-boot-starter-4. This is a convention from the Spring Boot docs, and it lets you tell the two apart at a glance.
That's why this one is named jev-spring-boot-starter. It's a community starter, not an official Spring project.
Why Use the Jev Spring Boot Starter?
Less code is the obvious win. But a good starter also follows Boot's rules, and that's where it earns its place in your pom.xml.
- It reuses Boot's
RestClient.Builder. The starter clones the builder Spring Boot already configured. Your message converters and timeouts carry over. - It backs off to your bean. Define your own
JevClientand the starter steps aside. - No HTTP calls at startup. Your app boots even if the Jev API is slow or down.
- It fails fast on a missing key. You get a clear error instead of a mystery 401 later.
- IDE completion for
jev.*properties. The configuration processor generates metadata, so your IDE knows what's available. - Virtual threads on JDK 21+. Turn them on with one property and blocking HTTP calls become cheap.
To be clear, you don't have to use this starter. Other Jev starters showed up within the first 48 hours, and some of them cover more use cases. I wanted something small and simple. It also makes a great example for learning how starters work.
How to Install the Jev Spring Boot Starter
The starter is version 0.1.0-SNAPSHOT, and it's not on Maven Central. That means you can't add it from start.spring.io. You install it into your local Maven repository first.
Clone the repository and run the Maven wrapper:
git clone https://github.com/danvega/jev-spring-boot-starter.git
cd jev-spring-boot-starter
./mvnw clean install
When it finishes, you'll see Maven install the starter into ~/.m2/repository/dev/danvega/jev-spring-boot-starter. Now any project on your machine can depend on it.
The tests run offline, so you don't need an API key for this step. CI runs against Spring Boot 4.0.0, 4.0.8, and 4.1.1 across Java 17, 21, and 25.
Building a Triage App with the Jev Spring Boot Starter
Let's build something with it. We'll create an app that takes a messy support ticket and answers three questions. Is it urgent? Which team should handle it? How severe is it?
Create the Project
Go to start.spring.io and pick the latest Spring Boot version (I used 4.1.1). Set the group to dev.danvega and the artifact to tickets. Choose YAML for configuration and add Spring Web as the only dependency. Click Generate and open the project in your IDE.
Add the Dependency
Open pom.xml and add the starter:
<dependency>
<groupId>dev.danvega</groupId>
<artifactId>jev-spring-boot-starter</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
Reload Maven. If this fails to resolve, you probably skipped the ./mvnw clean install step. Go back and run it first.
Set Your API Key
The starter reads your key from the TYPESAFE_API_KEY environment variable:
export TYPESAFE_API_KEY=your-key-here
That's the only required setting. There's no enable annotation and no configuration class.
Optional Configuration
You don't need any properties. I like to add a few anyway in application.yml:
spring:
threads:
virtual:
enabled: true
http:
clients:
connect-timeout: 5s
read-timeout: 30s
The timeout settings aren't specific to this starter. They're standard Spring Boot properties for RestClient. Because the starter builds on Boot's RestClient.Builder, it picks them up for free.
If you need to override Jev settings, the starter exposes them under the jev prefix:
jev:
api-key: ${TYPESAFE_API_KEY}
base-url: https://api.typesafe.ai
model: jev-latest
enabled: true
The values above are the defaults. The model defaults to jev-latest, and enabled defaults to true.
Write the Triage Controller
Now for the fun part. First, a record to hold the incoming ticket:
public record Ticket(String message) {
}
Next, a REST controller that injects JevClient through the constructor:
@RestController
public class TriageController {
private final JevClient jev;
public TriageController(JevClient jev) {
this.jev = jev;
}
@PostMapping("/triage")
public JevResponse triage(@RequestBody Ticket ticket) {
return jev.evaluate(ticket.message(), Map.of(
"urgent", Question.noul("Does this customer need urgent help?"),
"team", Question.choice("Which team should handle this ticket?",
"billing", "integrations", "support"),
"severity", Question.score("How severe is this issue?",
"Cosmetic, no impact to functionality",
"Feature is degraded but a workaround exists",
"Blocking issue, no workaround exists")
));
}
}
Let's walk through it. JevClient came from the starter. We never defined it, and Spring injects it anyway.
The evaluate method takes two things. The first is the state, which is the text Jev reasons about (our ticket message). The second is a map of named questions.
Question is a sealed interface with three records behind it, one for each Jev primitive. The static factories noul, choice, and score keep things readable. choice takes a varargs list of labels. score takes a varargs list of levels, ordered from least to most severe.
You won't always ask all three. If you only need to route tickets, a single choice question is enough. I'm showing all three here so you can see each type.
Run It and Test the Endpoint
Start the app and send a ticket:
curl -X POST localhost:8080/triage \
-H "Content-Type: application/json" \
-d '{"message": "My Stripe integration has been failing for three days. I cannot accept payments!"}'
The JevResponse comes back with an answer for each question. For the Stripe ticket, urgent came back true. The team choice was integrations with a confidence of 1.0, along with the probability for each label. The severity score was 2.0, the top of our three levels. The response also includes usage with input and output token counts.
Try a couple of calmer tickets to see the answers change:
- "Could you send me a copy of last month's invoice when you have a chance?" routes to
billingwith a severity near zero. - "Where can I change the color theme in my dashboard?" routes to
supportwith a severity of 0.0.
Reading Typed Answers
Returning the raw JevResponse is fine for a demo. In a real app you'll want to pull out specific values. The response has named accessors for each primitive:
JevResponse response = jev.evaluate(ticket.message(), questions);
var urgent = response.noul("urgent").noul();
var team = response.choice("team").choice();
var confidence = response.choice("team").confidence();
var severity = response.score("severity").score();
The examples/support-triage project in the repo does exactly this. It maps the answers into its own Triage record with team, confidence, urgency, and severity. It also includes a test that uses a local HTTP stub, so it runs without an API key.
The client also validates that the answer names and types match the questions you asked. It makes one attempt with no retries. If you want retries, add them around your call.
How the Jev Spring Boot Starter Works Under the Hood
So how did JevClient show up in our app without any configuration? Let's open the starter. The same pattern applies to every Spring Boot starter, including the official ones.
The AutoConfiguration.imports File
Start in src/main/resources/META-INF/spring/. There's a file named org.springframework.boot.autoconfigure.AutoConfiguration.imports. It contains one line, the fully qualified name of JevAutoConfiguration.
This is how Spring Boot finds the starter. At startup, Boot reads every AutoConfiguration.imports file on the classpath. It treats each listed class as a candidate for auto-configuration. No component scanning, no annotation on your main class. If the jar is on the classpath, Boot sees it.
JevAutoConfiguration and Conditions
Here's a simplified look at the auto-configuration class. Check the repo for the full version and imports.
@AutoConfiguration(after = RestClientAutoConfiguration.class)
@ConditionalOnClass(RestClient.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(prefix = "jev", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(JevProperties.class)
public class JevAutoConfiguration {
@Bean
@ConditionalOnMissingBean
JevClient jevClient(RestClient.Builder builder, JevProperties properties) {
// Fail fast with a clear message if no key is configured
Assert.hasText(properties.getApiKey(), "Set TYPESAFE_API_KEY or jev.api-key");
RestClient restClient = builder.clone()
.baseUrl(properties.getBaseUrl())
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getApiKey())
.build();
return new JevClient(restClient, properties.getModel());
}
}
Each annotation answers the question "should this configuration run?"
@AutoConfiguration(after = RestClientAutoConfiguration.class)makes sure Boot'sRestClient.Builderexists before we ask for it.@ConditionalOnClass(RestClient.class)only runs whenRestClientis on the classpath. The starter brings inspring-boot-starter-restclient, so it will be.@ConditionalOnWebApplicationlimits it to servlet apps. This starter is built for Spring MVC, not every scenario.@ConditionalOnPropertylets you switch it off withjev.enabled=false.matchIfMissing = truemeans it's on by default.@ConditionalOnMissingBeanis the back-off. If you define your ownJevClientbean, yours wins and the starter does nothing.
Notice what the bean method doesn't do. It never calls the Jev API. It builds a client and returns it. That's why the app starts even when the network is down.
The builder.clone() call matters too. It copies Boot's configured builder, so your timeouts and converters come along. Cloning also means the starter's base URL and auth header don't leak into other RestClient instances in your app.
JevProperties and IDE Completion
JevProperties is annotated with @ConfigurationProperties("jev"). It binds jev.api-key, jev.base-url, jev.model, and jev.enabled from your configuration. If jev.api-key isn't set, it falls back to TYPESAFE_API_KEY.
The starter also includes the Spring Boot configuration processor. At build time it generates metadata for these properties. That's why your IDE autocompletes jev. in application.yml and shows the defaults.
The Spring Boot reference docs have a great section on creating your own auto-configuration if you want to build a starter for your own team.
Overriding the Starter's Defaults
A starter should get you going fast without locking you in. Here are the escape hatches:
- Your own
JevClientbean. Define one and@ConditionalOnMissingBeanbacks off. - Timeouts. Set
spring.http.clients.connect-timeoutandspring.http.clients.read-timeout. - Interceptors. Register a
RestClientCustomizerbean to add logging or custom headers. - Per-call model. Build a
JevRequestwith a model string and pass it toevaluate(JevRequest).
One Dependency Instead of Six Files
We went from six hand-written files to one dependency. You installed the Jev Spring Boot starter locally and built a triage app that returns three typed answers. Then we opened the starter to see the imports file, the conditions, and the properties class that make it work.
That's the real value of Spring. It handles the infrastructure so you can focus on the business logic. I don't want to build a REST client every time I talk to Jev. Give me a JevClient and let me get to work.
Next time we'll use this starter for some bigger examples, like classification, model routing, and real-time use cases. If you want those in your inbox, sign up for the newsletter. In the meantime, grab the starter, compare it with hello-jev-spring, and try building a starter of your own.
Happy Coding
Related Articles
Spring Boot 3 to 4 Migration: Fixing Every Break
A hands-on Spring Boot 3 to 4 migration: upgrade a real app, then fix Jackson 3, modular starters, the missing H2 console, test imports, and JSpecify.
Spec-Driven Development for Solo Developers: My Plan
Spec-driven development without the team process. The lightweight plan I use as a solo developer, from one PRODUCT.md to a retro, on a Spring Boot Stripe store.
Spring AI TypeSafe: Build a Model Router From Scratch
Learn Spring AI TypeSafe by building a model router from an empty app. One Choice question to Jev picks the cheapest OpenAI model tier for every prompt.
$ subscribe --list weeklyish
Enjoyed this post? Get the next one.
Java, Spring, and what I'm learning — one email, weekly(ish). Unsubscribe anytime.