Prompt engineering isn't magic, it's an interface contract, and treating it that way changed how I build with Spring AI in production Java. After years of API-first thinking, I found the same discipline (clear boundaries, predictable inputs, testable outputs) applies directly to prompts. Here's what actually holds up...
Before you write a single instruction, you need to decide how the model behaves. This is the part people skip, and it bites them later. Temperature is the first knob I touch, and I'm pretty opinionated about it: anything involving classification, extraction, or structured output gets a temperature near 0.1. Anything generative — resume improvement suggestions, candidate summary write-ups — gets somewhere between 0.4 and 0.7. I learned this the hard way when our early Job Fit scoring used the default temperature and gave inconsistent skill-match percentages for the exact same resume submitted twice in a row. Not a good look when a candidate screenshots two different scores and asks support why they don't match. ChatOptions extractionOptions = ChatOptions.builder() .model(openRouterModelId) // resolved through our OpenRouter/DeepSeek provider config .temperature(0.1) .maxTokens(300) .build(); Spring AI's .entity() mapping is one of my favorite features in the whole library. Instead of parsing JSON out of a chat response with regex like some kind of caveman, you map straight to a Java record or enum: public enum FitLevel { STRONG_MATCH, PARTIAL_MATCH, WEAK_MATCH } FitLevel result = chatClient.prompt(resumeExtractionPrompt) .options(extractionOptions) .call() .entity(FitLevel.class); That one line saved a surprising amount of glue code. Before we adopted it, our ResumeService had a small library of try/catch blocks just to coerce loosely formatted model output into an enum. Now a mapping failure surfaces immediately if the model drifts off-format, which is honestly better because you find the problem in staging instead of a Sentry alert three weeks later. Zero-shot: fine until it isn't Zero-shot prompting is what everyone starts with, and for good reason: no examples, just clear instructions, and the model figures it out. For simple classification tasks it's often good enough. Sentiment result = chatClient.prompt(""" Classify this candidate note as POSITIVE, NEUTRAL, or NEGATIVE. Note: "Strong technical skills but communication was a concern during the panel interview." """) .options(ChatOptions.builder().temperature(0.1).maxTokens(5).build()) .call() .entity(Sentiment.class); We use zero-shot for exactly this kind of thing in the ATS: quick sentiment tagging on interviewer notes, basic yes/no gating before a heavier pipeline kicks in. It's cheap, it's fast, and when the task is genuinely simple, adding examples is just wasted tokens. Where zero-shot falls apart is anything with a specific output shape the model hasn't seen a thousand times in training. Early on I tried zero-shot for extracting structured skill lists from resumes and got wildly inconsistent JSON — sometimes nested arrays, sometimes flat strings, once a weird markdown table for no reason I could explain. That's when few-shot earned its keep. Few-shot: where the real reliability comes from Few-shot prompting is the technique that moved our résumé parsing from "works on my machine" to "works on the fortieth weird PDF a candidate uploads." You give the model two or three examples of the exact input/output pattern you want, and it locks onto the format instead of guessing. String parsedSkills = chatClient.prompt(""" Extract skills from the candidate's experience section into valid JSON. EXAMPLE 1: Input: "5 years building REST APIs with Spring Boot and PostgreSQL, some Kafka experience." Output: { "skills": ["Spring Boot", "REST APIs", "PostgreSQL", "Kafka"], "years_experience": 5 } EXAMPLE 2: Input: "Frontend dev, mostly React and TypeScript, dabbled in Node." Output: { "skills": ["React", "TypeScript", "Node.js"], "years_experience": null } Now parse this: Input: "%s" """.formatted(resumeSnippet)) .options(ChatOptions.builder().temperature(0.1).maxTokens(250).build()) .call() .content(); Two examples isn't a magic number. For most of our extraction tasks, two was enough. For the more ambiguous ones — parsing salary expectations out of free text, where people write "80k-90k," "$85k," "flexible," and "market rate" in the same dataset — I bumped it to four and saw a real drop in malformed output. If your task has multiple valid output shapes depending on context, show the model each shape at least once. Don't assume it'll generalize from a single pattern. One thing that annoyed me at first: every extra example costs tokens, and at scale that's real money when you're running this against thousands of resumes a day. We ended up caching the few-shot prefix separately and only interpolating the resume snippet, since some providers handle repeated prompt prefixes more efficiently. Worth checking whether your provider supports that before you eat the cost on every call. System prompts: where the model's "job description" lives This is the technique I probably underused for the first year of building ITJobOpportunities. I kept cramming instructions into the user prompt on every call, which meant duplicating the same boilerplate — "respond only in JSON," "never invent skills not mentioned in the text," "keep tone neutral and professional" — across a dozen different service methods. Painful to maintain, and worse, painful to update when we needed to tweak behavior across the board. Spring AI's .system() method fixes this cleanly: CandidateAssessment assessment = chatClient.prompt() .system(""" You are generating a client-facing candidate assessment for a recruiter. Be factual and evidence-based. Reference only skills and experience explicitly stated in the resume or interview notes. Never speculate about a candidate's personality or cultural fit. Return valid JSON matching the provided schema. """) .user(candidateNotesPrompt) .options(ChatOptions.builder().temperature(0.3).build()) .call() .entity(CandidateAssessment.class); That system prompt is basically the constitution for our CandidateSummaryController pipeline. It's the same instruction block for every call, and when we decided assessments needed to stop making personality judgments — a legitimate concern a recruiter raised, and a fair one — we changed it in exactly one place instead of hunting through scattered prompt strings. That alone is worth adopting system prompts even if you don't care about any other technique in this post. Role prompting is a close cousin, and I use it more sparingly. Telling the model "you are a senior technical recruiter with ten years of experience screening backend engineers" does shift tone and vocabulary in the output, sometimes usefully. But I've also seen it make models overconfident, inventing plausible-sounding technical judgments that weren't grounded in anything the candidate actually wrote. If you use role prompting, pair it with an explicit instruction to stick to the provided facts. Otherwise you're just giving the model permission to sound authoritative while making things up. Contextual prompting and why grounding matters more than clever wording Contextual prompting means giving the model the surrounding situation, not just the raw task. For Job Fit, that's the difference between: Score this resume against this job. and: Score this resume against this job posting. The job requires 3+ years of Spring Boot and PostgreSQL. The candidate's resume is below. Only count skills explicitly mentioned; do not infer skills from job titles alone. The second version consistently produced more defensible scores in our testing, mostly because it closes off the model's tendency to be generous — "Senior Java Developer" implies Spring Boot, right? No, not necessarily, and the model shouldn't assume it does. Here's roughly how that looks wired into the async Job Fit flow: record JobFitResult(int score, List<String> matchedSkills, List<String> missingSkills, String summary) {} JobFitResult fit = chatCli...