Spring Boot integration with SolrJ
|
This section documents the current Solr line (10.0; 9.10.x the maintained 9.x branch), written and verified against the Apache Solr Reference Guide. No specific patch version is pinned. Some capabilities (the Solr Operator on Kubernetes, the package-manager ecosystem, Learning To Rank model training, and expert plugin development) are linked, not documented in depth. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Apache Solr already covers the headline fact: Spring Data Solr is
retired and Spring Boot ships no Solr auto-configuration, so a Spring Boot application talks to Solr through
SolrJ, the official Java client, wired up by hand. This page works through that integration in depth — the
SolrClient bean, indexing, querying, the JSON Request API from Java, error handling, and integration testing
with Testcontainers — building on the shorter example there.
Why there is no starter
Spring Data for Apache Solr was moved to the Spring Attic in 2020 and never adapted for Spring Boot 3+/4; see
Spring Data for Apache Solr
discontinued. There is no spring-boot-starter-data-solr, no SolrTemplate, and no
spring.data.solr.* configuration namespace. What remains is org.apache.solr:solr-solrj — a plain Java
client with no Spring dependency — so a Spring Boot application supplies the parts a starter would otherwise
generate: a configuration-properties record, a SolrClient @Bean, and whatever repository-style wrapper the
application wants around SolrInputDocument and SolrQuery. None of that is Solr-specific ceremony; it is the
same shape as wiring any third-party client (a JDBC DataSource, an HTTP client) that has no Boot
auto-configuration.
A SolrClient bean bound from configuration properties
Bind the endpoint from an @ConfigurationProperties record rather than hardcoding it, and expose exactly one
SolrClient bean for the rest of the application to inject. Use Http2SolrClient against a single node (or a
load balancer in front of several); use CloudSolrClient against SolrCloud, pointed at the ZooKeeper ensemble
so it discovers collection topology itself instead of a fixed URL.
@ConfigurationProperties(prefix = "app.solr")
public record SolrProperties(
String baseUrl,
List<String> zkHosts,
String collection,
Duration connectionTimeout,
Duration requestTimeout) {
}
@Configuration
@EnableConfigurationProperties(SolrProperties.class)
public class SolrConfig {
@Bean(destroyMethod = "close")
@ConditionalOnProperty(prefix = "app.solr", name = "zk-hosts", matchIfMissing = true, havingValue = "")
public SolrClient http2SolrClient(SolrProperties props) {
return new Http2SolrClient.Builder(props.baseUrl())
.withConnectionTimeout(props.connectionTimeout().toMillis(), TimeUnit.MILLISECONDS)
.withRequestTimeout(props.requestTimeout().toMillis(), TimeUnit.MILLISECONDS)
.build();
}
// SolrCloud: point CloudSolrClient at ZooKeeper instead of a single node's URL. It resolves
// collection -> shard -> leader/replica topology itself and re-resolves it as the cluster changes.
@Bean(destroyMethod = "close")
@ConditionalOnProperty(prefix = "app.solr", name = "zk-hosts")
public SolrClient cloudSolrClient(SolrProperties props) {
CloudSolrClient client = new CloudSolrClient.Builder(props.zkHosts(), Optional.empty())
.withConnectionTimeout(props.connectionTimeout().toMillis(), TimeUnit.MILLISECONDS)
.build();
client.setDefaultCollection(props.collection());
return client;
}
}
app:
solr:
base-url: http://localhost:8983/solr
collection: books
connection-timeout: 3s
request-timeout: 30s
# zk-hosts: [ zk1:2181, zk2:2181, zk3:2181 ] # set to switch to CloudSolrClient/SolrCloud
The destroyMethod = "close" is not optional bookkeeping: both client implementations hold a connection pool
(and, for CloudSolrClient, a ZooKeeper watch) that must be released on context shutdown, exactly like a JDBC
DataSource.
Indexing with SolrInputDocument
SolrInputDocument is a field-name-to-value map, built up per document and sent through SolrClient.add.
Nothing infers the schema for you — every field name and value has to match what
the schema declares, the same requirement as a raw
/update POST.
@Service
public class BookIndexService {
private final SolrClient solr;
private final String collection;
public BookIndexService(SolrClient solr, SolrProperties props) {
this.solr = solr;
this.collection = props.collection();
}
public void index(Book book) throws SolrServerException, IOException {
SolrInputDocument doc = new SolrInputDocument();
doc.addField("id", book.id());
doc.addField("title", book.title());
doc.addField("genre_ss", book.genre());
doc.addField("in_stock_i", book.inStock() ? 1 : 0);
solr.add(collection, doc);
solr.commit(collection); // soft-commit for near-real-time visibility, see below
}
public void indexAll(List<Book> books) throws SolrServerException, IOException {
List<SolrInputDocument> docs = books.stream().map(this::toDoc).toList();
solr.add(collection, docs); // one round trip for the whole batch
solr.commit(collection);
}
private SolrInputDocument toDoc(Book book) {
SolrInputDocument doc = new SolrInputDocument();
doc.addField("id", book.id());
doc.addField("title", book.title());
doc.addField("genre_ss", book.genre());
return doc;
}
}
Batch the add call across many documents rather than committing after each one — exactly the guidance on
Indexing & updates. commit here maps to a soft commit
(fast, near-real-time visibility); call solr.commit(collection, true, true) (waitFlush, waitSearcher) or
configure autoCommit in solrconfig.xml when a hard commit to durable storage is what the workload needs — see the soft/hard commit trade-off on Apache Solr. Atomic field updates and
version-based optimistic concurrency — both reachable from SolrJ the same way, through
SolrInputDocument and solr.add — are covered in depth on
Partial updates & concurrency.
Querying with SolrQuery and QueryResponse
SolrQuery is a fluent parameter builder; SolrClient.query sends it to /select and parses the response
into a QueryResponse, whose getResults() is a SolrDocumentList of field-name-to-value maps.
@Service
public class BookSearchService {
private final SolrClient solr;
private final String collection;
public BookSearchService(SolrClient solr, SolrProperties props) {
this.solr = solr;
this.collection = props.collection();
}
public List<Book> search(String text, String genre, int rows) throws SolrServerException, IOException {
SolrQuery query = new SolrQuery(text);
query.set("defType", "edismax");
query.set("qf", "title^2 author");
query.addFilterQuery("genre_ss:" + genre); // fq: not scored, cached independently of q
query.setRows(rows);
query.setFields("id", "title", "genre_ss");
QueryResponse response = solr.query(collection, query);
return response.getResults().stream()
.map(doc -> new Book(
(String) doc.getFieldValue("id"),
(String) doc.getFieldValue("title"),
(String) doc.getFieldValue("genre_ss")))
.toList();
}
}
query versus filter query (fq) keeps the same meaning as everywhere else in these pages: put scoring
conditions in q/qf, put yes/no conditions in addFilterQuery — see
Query basics & parameters. Binding results straight into
domain objects with the @Field-annotated DocumentObjectBinder (solr.query(…).getBeans(Book.class)) is
an alternative to reading SolrDocument maps by hand; it works well for a stable, mostly-flat schema and gets
awkward once dynamic fields or per-request field lists are in play, where the map-based access above stays
more direct.
Calling the JSON Request API from Java
The JSON Request API is the better default for application code
precisely because it removes hand-built query strings; from SolrJ, the equivalent is
JsonQueryRequest (package org.apache.solr.client.solrj.request.json) instead of SolrQuery. It builds the
same JSON body the curl examples on that page POST by hand, still without escaping local-params syntax inside
a string.
public List<Book> searchJson(String text, String genre) throws SolrServerException, IOException {
JsonQueryRequest request = new JsonQueryRequest()
.setQuery(text)
.withFilter("genre_ss:" + genre)
.withParam("qf", "title^2 author")
.withParam("defType", "edismax");
QueryResponse response = request.process(solr, collection);
return response.getResults().stream()
.map(doc -> new Book(
(String) doc.getFieldValue("id"),
(String) doc.getFieldValue("title"),
(String) doc.getFieldValue("genre_ss")))
.toList();
}
# The request JsonQueryRequest builds and POSTs on the wire, for comparison with the Java call above.
curl "http://localhost:8983/solr/books/select" \
-H 'Content-Type: application/json' \
-d '{ "query": "darkness", "filter": "genre_ss:science fiction", "params": { "qf": "title^2 author", "defType": "edismax" } }'
# https://solr.apache.org/guide/solr/latest/query-guide/json-request-api.html
withFilter can be called repeatedly to add several filter clauses, and withParam reaches any parameter
without a dedicated JSON key — the SolrJ equivalent of the params escape hatch described on
The JSON Request API. JsonQueryRequest also accepts a
ModifiableSolrParams in its constructor when a request needs to override or merge parameters set elsewhere,
and the same object model is what the JSON Facet API (Faceting) hangs off
when facets are built from Java rather than by hand.
Error handling and retries
SolrJ surfaces failures as checked exceptions the code above already declares:
-
SolrServerException— the request reached Solr but failed there: a malformed query, a missing field, or (per Partial updates & concurrency) an HTTP409version conflict, delivered as aBaseHttpSolrClient.RemoteSolrException(orSolrException, with.code()giving the HTTP status) wrapped inside it. -
IOException— the request never reached Solr at all: connection refused, a timeout, or a network partition.
public void indexWithRetry(Book book) throws SolrServerException, IOException {
int attempts = 0;
while (true) {
try {
index(book);
return;
} catch (BaseHttpSolrClient.RemoteSolrException e) {
if (e.code() == 409) {
throw e; // version conflict: re-read and retry with the fresh _version_, don't blind-retry
}
throw e;
} catch (IOException e) {
if (++attempts >= 3) {
throw e;
}
sleepWithBackoff(attempts); // transient: connection reset, timeout -- safe to retry
}
}
}
A version conflict (409) is not a transient failure to retry blindly — the fix is to re-read the document
and its current version and reapply the change, exactly as described on
Partial updates & concurrency; retrying the same
stale version only produces the same 409 again. A connection failure or timeout, by contrast, is safe to
retry with backoff, and read requests get some of that for free: CloudSolrClient already retries a failed
read against another replica of the same shard before giving up, and Http2SolrClient against a single node
does not, since there is no other node to fall back to. Wrapping the retry loop in Spring Retry’s
@Retryable (see Unit and Integration Testing for
the surrounding test setup) is a reasonable alternative to hand-rolled backoff once more than one call site
needs the same policy.
Integration testing with the Testcontainers SolrContainer
SolrClient calls are worth exercising against a real server rather than a mock — the schema, the analysis
chain, and the query parser all live in Solr itself, not in application code, so a mock proves nothing about
whether a query or a document actually matches the configured schema. The Testcontainers
Solr module starts a disposable Solr node in Docker; this
follows the same @Testcontainers/@Container pattern as every other container-backed test on
Unit and Integration Testing. There is no
@ServiceConnection support for Solr (Spring Boot has no Solr auto-configuration to connect), so the test
wires the Http2SolrClient to the container’s mapped port itself.
@Testcontainers
class BookIndexServiceIT {
@Container
static SolrContainer solr = new SolrContainer(DockerImageName.parse("solr:9.7"))
.withCollection("books");
static SolrClient client;
@BeforeAll
static void setUpClient() {
client = new Http2SolrClient.Builder(
"http://" + solr.getHost() + ":" + solr.getSolrPort() + "/solr")
.build();
}
@AfterAll
static void tearDownClient() throws IOException {
client.close();
}
@Test
void indexesAndFindsABook() throws Exception {
BookIndexService service = new BookIndexService(client, new SolrProperties(
null, null, "books", Duration.ofSeconds(3), Duration.ofSeconds(30)));
service.index(new Book("1", "A Wizard of Earthsea", "fantasy", true));
QueryResponse response = client.query("books", new SolrQuery("title:Earthsea"));
assertThat(response.getResults()).hasSize(1);
}
}
withCollection("books") creates the collection from the image’s bundled _default configset on container
start, which is enough for a schemaless smoke test; a test that depends on a specific
schema or analysis chain
should instead mount that configset into the container so the test runs against the same configuration
production does. Declaring solr static shares one container across every test method in the class — started once via the @Testcontainers JUnit 5 extension — the same trade-off between speed and per-test
isolation as the PostgreSQL/Kafka/Redis containers on
Unit and Integration Testing.
References
Continue with Query basics & parameters for what the
parameters set through SolrQuery/JsonQueryRequest actually mean, or back to
Apache Solr for the wider Solr-versus-Elasticsearch picture.