Functional Programming in Java for AI Pipelines
Functional Java refreshed for AI work: streams for document pipelines, Optional for safe metadata access, and CompletableFuture for concurrent model calls — with practical examples.
On this page
Functional Java is not academic here. Document ingestion, embedding pipelines and concurrent model calls are naturally expressed as streams and futures. This is a targeted refresh aimed at the patterns you will actually write.
Key Takeaways
- Streams express an ingestion pipeline (load → filter → split → embed) as the steps it is.
Optionalis the right tool for the frequently-missing fields in model response metadata.CompletableFuturecomposes asynchronous stages with explicit dependencies, timeouts and fallbacks.- Prefer clarity over cleverness — a stream that needs a comment to parse should be a loop.
Streams for document pipelines
An ingestion pipeline is a sequence of transformations. Streams let you write it as one:
List<Document> chunks = sources.stream()
.filter(source -> source.text() != null && !source.text().isBlank())
.map(source -> new Document(source.text(), Map.of("id", source.id())))
.flatMap(doc -> splitter.apply(List.of(doc)).stream())
.toList();
vectorStore.add(chunks);Read top to bottom, it says exactly what happens: drop empty sources, wrap each in a Document,
split each into chunks, collect. The imperative equivalent is three nested loops and a mutable list.
Grouping and counting
Collectors handle the aggregations you need for analytics and cost reporting:
// Count retrieved chunks by source, to see which documents dominate answers.
Map<String, Long> bySource = retrievedChunks.stream()
.collect(Collectors.groupingBy(
d -> (String) d.getMetadata().get("sourceId"),
Collectors.counting()));
// Sum token usage across a batch of responses.
long totalTokens = responses.stream()
.mapToLong(r -> r.getMetadata().getUsage().getTotalTokens())
.sum();Optional for missing metadata
Model responses are loosely structured. Usage may be absent, finish reasons vary, tool calls appear
only sometimes. Optional makes the absent case impossible to forget:
public long promptTokens(ChatResponse response) {
return Optional.ofNullable(response.getMetadata().getUsage())
.map(Usage::getPromptTokens)
.orElse(0L);
}CompletableFuture for concurrent and composed calls
When you need several model calls and some depend on others, CompletableFuture wires the
dependency graph explicitly.
// Independent calls, run concurrently, combined at the end.
CompletableFuture<String> summary =
CompletableFuture.supplyAsync(() -> summarise(document), executor);
CompletableFuture<Sentiment> sentiment =
CompletableFuture.supplyAsync(() -> classify(document), executor);
CompletableFuture<Report> report = summary.thenCombine(sentiment,
(s, sent) -> new Report(s, sent));
Report result = report.get(30, TimeUnit.SECONDS);Timeouts and fallbacks
A model call can hang. Compose the failure handling in:
String answer = CompletableFuture
.supplyAsync(() -> chatClient.prompt().user(question).call().content(), executor)
.orTimeout(20, TimeUnit.SECONDS)
.exceptionally(ex -> {
log.warn("model call failed or timed out", ex);
return "Sorry — I could not answer that right now.";
})
.join();Method references
Small readability win, used everywhere:
documents.stream()
.map(Document::getText) // instead of d -> d.getText()
.filter(Objects::nonNull)
.forEach(this::index); // instead of t -> this.index(t)Function composition for prompt building
Functions compose, which is handy for building a prompt through stages:
Function<String, String> redactPii = this::redact;
Function<String, String> truncate = s -> s.length() > 4000 ? s.substring(0, 4000) : s;
Function<String, String> prepare = redactPii.andThen(truncate);
String safe = prepare.apply(rawUserInput);A complete example: a scored retrieval pipeline
Putting the pieces together — filter, score, sort, take:
public List<ScoredChunk> topChunks(String query, List<Document> candidates, int k) {
float[] queryVector = embeddingModel.embed(query);
return candidates.stream()
.map(doc -> new ScoredChunk(doc, cosineSimilarity(queryVector, doc.embedding())))
.filter(scored -> scored.score() >= 0.7) // drop weak matches
.sorted(Comparator.comparingDouble(ScoredChunk::score).reversed())
.limit(k)
.toList();
}
record ScoredChunk(Document doc, double score) {}When not to use streams
Streams are the wrong tool when you need:
- Early exit on a side effect — a loop with
breakis clearer thananyMatchplus state. - Index-based logic — streams hide the index; if you need it, loop.
- Checked exceptions — lambdas cannot throw them cleanly; a loop handles them naturally.
Clarity wins. A stream you have to decode is worse than the loop it replaced.
Next
- Reactive programming with Project Reactor — for streaming and back-pressure
- Building a RAG pipeline with Spring Boot — these patterns applied to a real ingestion pipeline
Frequently Asked Questions
Should I use streams or a for-loop for document processing?
CompletableFuture or virtual threads for concurrent model calls?
Why does Optional matter for AI code?
Related tutorials
- Java 17 to 21 — What's New for AI DevelopersThe Java 17-to-21 features that matter most for AI work: records, sealed classes, pattern matching, text blocks and virtual threads — each shown with a concrete AI use case.
- Reactive Programming with Project ReactorProject Reactor for AI developers: Mono, Flux, back-pressure and WebFlux — and the one place they are genuinely the right tool, streaming LLM tokens to a browser.
- Microservices Architecture Deep DiveMicroservices patterns that matter for AI systems: API gateway, circuit breakers around model calls, the saga pattern for agent workflows, and where an AI service fits in the topology.
- Containerization with Docker & KubernetesContainerize and deploy a Spring Boot AI application: a production Dockerfile with layered JARs, Kubernetes deployment with secrets for API keys, health probes and resource limits.