Spring Boot Integration
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page connects the JasperReports engine to a Spring Boot 4 service on Java 21: how the engine is wired as beans, how
compiled templates are cached, how a database connection reaches a report without leaking, and how a very large report is
returned without holding the whole file in memory. It uses the Bookshelf Billing service of this section. Versions are
stated as of 2026-10-09: JasperReports 7.0.8 and Spring Boot 4.1.1. The complete project below (about 150 lines of Java plus
two JRXML files) was compiled and run with those versions on Java 21 language level, against an in-memory H2 database with 20,000 invoice
lines: the invoice endpoint returned a 6-page PDF with a Content-Length, and the streaming endpoint returned a chunked
response of about 540 pages while the JVM ran with -Xmx256m.
The Maven coordinates, a first "Hello PDF" and the generic ResponseEntity and Content-Disposition handling are on
Getting Started with Spring Boot; the pipeline from JRXML
to PDF is explained in JasperReports Architecture & Lifecycle, and
the data inputs in Data Sources & Query Executers. Here only the
Spring-specific parts are added.
What older books describe and what replaced it
The two JasperReports books consulted for this section (B2 and B3) were written for the Servlet, JSP and applet era, and their web chapters no longer match current practice (outdated finding O23). Three patterns are gone or discouraged:
-
The applet viewer and
JRViewerin a browser. Java applets no longer run in browsers, and a Swing viewer is a desktop component, not something a web service returns. A web client receives a PDF (or HTML) and the browser’s own viewer shows it. -
Servlet and JSP code that writes to
HttpServletResponseby hand, with image servlets and browser-specific header workarounds. A controller now returns aResponseEntity, and the framework sets status, type and length. -
Spring’s
JasperReportsPdfViewand the other classes oforg.springframework.web.servlet.view.jasperreports. The Spring Framework 5.0 release notes list JasperReports among the dropped integrations, so code using these views does not compile against Spring Framework 5 or later, which includes Spring Boot 4. Applications either stayed on Spring 4.3 or wrote their own adapter, which is what this page does in a few lines.
Official documentation: the Spring Framework 5.0 release notes (section "Dropped support").
The replacement on the JasperReports side is the official Spring Boot sample, which ships with the library.
The official Spring Boot sample
JasperReports 7.0.8 includes a sample named springboot that "demonstrates running JasperReports Library in a Spring Boot 4
application". A REST controller compiles several report templates and serves them as PDF at /rest/pdf/{reportName}; the
sample needs JDK 17 or later and Maven, and is started from demo/samples/springboot of the library sources with
mvn clean compile spring-boot:run. The report templates are copies of other samples (stretching text fields, images, data
adapters over Excel files and charts), so the sample shows that the engine works under Spring Boot rather than a layered design.
The webapp sample, which the documentation index lists for web applications, is marked "under construction" in 7.0.8, so
the Spring Boot sample is the reference to follow.
The rest of this page takes the same shape (a controller that returns PDF) and adds what a real service needs.
Official documentation: Spring Boot sample and Webapp sample.
Request flow
The sections below follow the diagram: the beans first, then the cache, the connection, and finally the streaming variant.
Project setup
The dependencies are those of the
Getting Started with Spring Boot page (jasperreports,
jasperreports-pdf, jasperreports-fonts and jasperreports-jdt, all 7.0.8), plus spring-boot-starter-webmvc and
spring-boot-starter-jdbc with a database driver. The example uses H2 so that it runs without any server.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<properties>
<java.version>21</java.version>
<jasperreports.version>7.0.8</jasperreports.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>net.sf.jasperreports</groupId>
<artifactId>jasperreports</artifactId>
<version>${jasperreports.version}</version>
</dependency>
<dependency>
<groupId>net.sf.jasperreports</groupId>
<artifactId>jasperreports-pdf</artifactId>
<version>${jasperreports.version}</version>
</dependency>
<dependency>
<groupId>net.sf.jasperreports</groupId>
<artifactId>jasperreports-fonts</artifactId>
<version>${jasperreports.version}</version>
</dependency>
<dependency>
<groupId>net.sf.jasperreports</groupId>
<artifactId>jasperreports-jdt</artifactId>
<version>${jasperreports.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
The table and the application settings are plain Spring Boot. The async request timeout matters for the streaming endpoint described below, because the default is shorter than a large report may need.
-- src/main/resources/schema.sql
CREATE TABLE invoice_line (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
invoice_id BIGINT NOT NULL,
title VARCHAR(120) NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL
);
# src/main/resources/application.properties
spring.datasource.url=jdbc:h2:mem:billing
spring.sql.init.mode=always
spring.mvc.async.request-timeout=120s
For a real database, supply the URL, user and password through environment variables or the platform’s secret store
(SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD), never in the repository.
Official documentation: Spring Boot sample.
Wiring JasperReportsContext as a bean
Every JasperReports manager (JasperCompileManager, JasperFillManager, JasperExportManager, the exporters) works
against a JasperReportsContext: the object that supplies configuration properties, the extension registry and the
repository used to find resources. The static methods such as JasperFillManager.fillReport(...) use the process-wide
DefaultJasperReportsContext, which is fine for a first example. A service is better off with its own context as a Spring
bean, for three reasons: the settings are visible in one place, tests can build a different context, and two reports that
need different settings can use different contexts.
SimpleJasperReportsContext is the ready-made implementation. Its no-argument constructor creates a context on top of the
default one, so jasperreports.properties files on the classpath and the extensions found there still apply, and the
setProperty calls override them. The constructor that takes a JasperReportsContext creates a child context, which is the way to give a
single request its own overrides without changing the shared bean.
package com.example.billing;
import net.sf.jasperreports.engine.JasperReportsContext;
import net.sf.jasperreports.engine.SimpleJasperReportsContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JasperConfig {
/**
* One context for the whole service. It wraps the default context, so the
* jasperreports.properties files and the extension registry still apply,
* and the properties set here override them for every report.
*/
@Bean
JasperReportsContext jasperReportsContext() {
SimpleJasperReportsContext context = new SimpleJasperReportsContext();
context.setProperty("net.sf.jasperreports.jdbc.fetch.size", "500");
return context;
}
}
One property is set as an example: net.sf.jasperreports.jdbc.fetch.size asks the JDBC driver to read rows in batches of 500,
which keeps memory flat for large result sets with drivers that honour the fetch size. Leave
net.sf.jasperreports.awt.ignore.missing.font at its default false, so a missing font fails the report instead of silently
changing the document, as explained in
Fonts & Internationalization. Set the context once at start-up;
treat it as read-only afterwards, because it is shared by all request threads.
Official documentation: the
JasperReportsContext API
and the configuration reference.
A compiled-report cache
Compiling a template parses the JRXML and compiles every expression to bytecode, which takes noticeable time, while the
resulting JasperReport is immutable and safe to share between threads. A web service must therefore compile each
template once, not once per request. The component below does it lazily with a ConcurrentHashMap, and uses the
JasperCompileManager of the shared context. computeIfAbsent guarantees one compilation per template even when
two requests arrive at the same time.
package com.example.billing;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperReport;
import net.sf.jasperreports.engine.JasperReportsContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
/** Compiles each template once and hands the same thread-safe JasperReport to every request. */
@Component
public class CompiledReports {
private final JasperCompileManager compiler;
private final ConcurrentMap<String, JasperReport> cache = new ConcurrentHashMap<>();
public CompiledReports(JasperReportsContext context) {
this.compiler = JasperCompileManager.getInstance(context);
}
public JasperReport get(String name) {
return cache.computeIfAbsent(name, this::compile);
}
private JasperReport compile(String name) {
try (InputStream jrxml = new ClassPathResource("reports/" + name + ".jrxml").getInputStream()) {
return compiler.compile(jrxml);
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (JRException e) {
throw new IllegalStateException("Cannot compile report " + name, e);
}
}
}
Variations that keep the same shape:
-
Compile at build time. The JasperReports Maven plugin compiles
.jrxmlfiles to.jasperfiles during the build. The cache then loads the serialised object withJRLoader.loadObject(InputStream)instead of compiling, which removes the compiler (and thejasperreports-jdtdependency) from the running service. A.jasperfile must be produced by the same JasperReports version that loads it, so recompile on every upgrade. -
Warm up at start-up. Call
get(...)for every known template from anApplicationRunner, so that a broken template fails the deployment instead of the first user request. -
Spring’s cache abstraction. A
@Cacheablemethod returningJasperReportworks too and adds eviction and metrics, but a compiled report is not serialisable to remote caches in a useful way, so keep it in a local cache. -
Hot reload in development. Clear the map when a template changes. Production should not recompile.
The templates are standard v7 JRXML. The invoice template of the example has one parameter used by the query and a page
footer; in v7 the title and pageFooter elements contain the elements directly, without a band wrapper, while detail
still contains bands.
<?xml version="1.0" encoding="UTF-8"?>
<jasperReport name="invoice" language="java" pageWidth="595" pageHeight="842"
columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
<parameter name="INVOICE_ID" class="java.lang.Long"/>
<query language="sql"><![CDATA[
SELECT title, quantity, unit_price FROM invoice_line
WHERE invoice_id = $P{INVOICE_ID} ORDER BY id
]]></query>
<field name="title" class="java.lang.String"/>
<field name="quantity" class="java.lang.Integer"/>
<field name="unit_price" class="java.math.BigDecimal"/>
<title height="40">
<element kind="textField" x="0" y="0" width="515" height="30" fontSize="18.0" bold="true">
<expression><![CDATA["Bookshelf invoice " + $P{INVOICE_ID}]]></expression>
</element>
</title>
<detail>
<band height="20">
<element kind="textField" x="0" y="0" width="320" height="18">
<expression><![CDATA[$F{title}]]></expression>
</element>
<element kind="textField" x="320" y="0" width="60" height="18" hTextAlign="Right">
<expression><![CDATA[$F{quantity}]]></expression>
</element>
<element kind="textField" x="380" y="0" width="135" height="18" hTextAlign="Right" pattern="#,##0.00">
<expression><![CDATA[$F{unit_price}]]></expression>
</element>
</band>
</detail>
<pageFooter height="20">
<element kind="textField" x="0" y="0" width="515" height="18" hTextAlign="Right">
<expression><![CDATA["Page " + $V{PAGE_NUMBER}]]></expression>
</element>
</pageFooter>
</jasperReport>
The second template, all-lines.jrxml, is the same file without the parameter, without the WHERE clause and with the fixed title
"All invoice lines"; it is used by the streaming endpoint below.
Official documentation: the
JasperCompileManager API
and the Maven sample, which compiles templates at build time.
Passing a DataSource and Connection safely
A report with a <query language="sql"> runs its query on a java.sql.Connection that the caller passes to fill. The
rules below keep that safe in a pooled service.
-
Borrow late and return early. Take the connection from the Spring
DataSourceimmediately before the fill and close it in atry-with-resources block that wraps only the fill. The rows are read during the fill, so the connection must stay open untilfillreturns, but the export reads the filledJasperPrintand does not need it. Theinvoicemethod below exports after the block; the streamingallLinesmethod keeps the export inside it for brevity, so for a long export move it after the block so the pooled connection is not held while the bytes are written. -
Never store the connection in a field or a report parameter that outlives the request. A
Connectionis not thread-safe and belongs to one request. Pass it as an argument, asfillexpects. -
Do not give the report more privileges than it needs. The query is designer-written SQL. Use a connection from a role that can only read the tables of the report, ideally a separate read-only
DataSourcefor reports. -
Bind request values as parameters.
$P{INVOICE_ID}in the query becomes a boundPreparedStatementparameter, so a request value cannot change the SQL. Do not use the$P!{...}form with request input, because it pastes text into the query. -
Pass a mutable map.
filladds entries to the parameters map, soMap.of(...)is rejected with anUnsupportedOperationException; use aHashMap. -
Prefer a
JRDataSourcewhen the data comes from the service’s own code. AJRBeanCollectionDataSourceor a small adapter over a repository stream avoids giving the template a connection at all (see the Data Sources page). Beans must be classes with getters: a Java record is rejected as a bean data source in 7.0.8, so use a class with getters or aMap.
package com.example.billing;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import javax.sql.DataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReportsContext;
import org.springframework.stereotype.Service;
@Service
public class InvoiceService {
private final DataSource dataSource;
private final CompiledReports reports;
private final JasperFillManager filler;
private final JasperExportManager exporter;
public InvoiceService(DataSource dataSource, CompiledReports reports, JasperReportsContext context) {
this.dataSource = dataSource;
this.reports = reports;
this.filler = JasperFillManager.getInstance(context);
this.exporter = JasperExportManager.getInstance(context);
}
public byte[] invoice(long invoiceId) throws JRException, SQLException {
Map<String, Object> parameters = new HashMap<>(); // mutable: the engine adds entries
parameters.put("INVOICE_ID", invoiceId);
JasperPrint print;
try (Connection connection = dataSource.getConnection()) {
print = filler.fill(reports.get("invoice"), parameters, connection);
}
return exporter.exportToPdf(print);
}
}
The controller returns the bytes with a known length, so a failure while building the document becomes a clean HTTP 500 before the first byte is sent.
package com.example.billing;
import java.sql.SQLException;
import net.sf.jasperreports.engine.JRException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class InvoiceController {
private final InvoiceService service;
public InvoiceController(InvoiceService service) {
this.service = service;
}
@GetMapping(value = "/invoices/{id}/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> invoice(@PathVariable long id) throws JRException, SQLException {
byte[] pdf = service.invoice(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename("invoice-" + id + ".pdf").build().toString())
.contentLength(pdf.length)
.body(pdf);
}
}
Request curl -o invoice.pdf http://localhost:8080/invoices/7/pdf to try it. Spring’s data access and controller basics are
in REST APIs with Spring Boot; using the same bean for scheduled or batch generation is
shown in Generating Reports from Databases & Services.
Official documentation: the
JasperFillManager API and the
query sample.
Streaming large PDFs
A report with tens of thousands of rows raises two separate memory questions, and a streaming endpoint alone solves only one of them.
-
The fill.
fillbuilds aJasperPrintwith every page of the report. For a 540-page report that is a lot of objects on the heap. The engine’s answer is a virtualizer: pass aJRVirtualizerin theREPORT_VIRTUALIZERparameter and it swaps filled pages to disk when more than a configured number are in memory.JRSwapFileVirtualizerkeeps the pages in one swap file. It must be cleaned up withcleanup()when the report is no longer needed, otherwise the swap file stays on disk. -
The export. Exporting to a
byte[]holds the whole PDF in memory a second time. Exporting directly to the responseOutputStreamwithexportToPdfStreamavoids that copy, andStreamingResponseBodyis how Spring MVC gives the callback that stream, on an async task thread.
The service method below uses both, and the controller returns a ResponseEntity<StreamingResponseBody>. The connection and
the virtualizer are created inside the callback, so they belong to the async thread that runs the report and are released
by it. The first bytes only reach the client after the fill has completed, because the export needs the finished
JasperPrint; what streaming saves is memory and the second copy, not time to first byte.
package com.example.billing;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import javax.sql.DataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JRParameter;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReportsContext;
import net.sf.jasperreports.engine.fill.JRSwapFileVirtualizer;
import net.sf.jasperreports.engine.util.JRSwapFile;
import org.springframework.stereotype.Service;
@Service
public class BigReportService {
private final DataSource dataSource;
private final CompiledReports reports;
private final JasperFillManager filler;
private final JasperExportManager exporter;
public BigReportService(DataSource dataSource, CompiledReports reports, JasperReportsContext context) {
this.dataSource = dataSource;
this.reports = reports;
this.filler = JasperFillManager.getInstance(context);
this.exporter = JasperExportManager.getInstance(context);
}
/** Virtualize the filled pages to disk, then export straight to the response stream. */
public void allLines(OutputStream out) throws JRException, SQLException, IOException {
Path swapDir = Files.createTempDirectory("billing-swap");
// 50 pages stay in memory; older ones go to a swap file of 4096-byte blocks.
JRSwapFileVirtualizer virtualizer =
new JRSwapFileVirtualizer(50, new JRSwapFile(swapDir.toString(), 4096, 1024));
try {
Map<String, Object> parameters = new HashMap<>();
parameters.put(JRParameter.REPORT_VIRTUALIZER, virtualizer);
try (Connection connection = dataSource.getConnection()) {
JasperPrint print = filler.fill(reports.get("all-lines"), parameters, connection);
exporter.exportToPdfStream(print, out);
}
} finally {
virtualizer.cleanup();
Files.deleteIfExists(swapDir);
}
}
}
package com.example.billing;
import java.io.IOException;
import java.sql.SQLException;
import net.sf.jasperreports.engine.JRException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@RestController
public class BigReportController {
private final BigReportService service;
public BigReportController(BigReportService service) {
this.service = service;
}
@GetMapping(value = "/reports/all-lines/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<StreamingResponseBody> allLines() {
StreamingResponseBody body = out -> {
try {
service.allLines(out);
} catch (JRException | SQLException e) {
throw new IOException("Report generation failed", e);
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("all-lines.pdf").build().toString())
.body(body);
}
}
Request it with curl -OJ http://localhost:8080/reports/all-lines/pdf. Points to keep in mind:
-
Errors after the first byte. Once the response is committed, a failure leaves a truncated file and a closed connection instead of an HTTP error status. Validate the request and check that the template compiles (the cache warm-up above) before returning the
ResponseEntity. -
Time limits.
spring.mvc.async.request-timeoutmust be longer than the report. For reports that run for minutes, do not hold an HTTP request at all: start a job, store the PDF (see File storage and object stores) and return a link. -
Threads. The fill and the export are CPU-bound and synchronous. Limit how many reports run at once, with a bounded task executor or a semaphore, so that a burst of requests cannot exhaust the heap or the connection pool.
-
Page limits. A formatted report of hundreds of pages is rarely read on screen; consider whether the user needs a CSV or Excel export instead, which the engine can also produce.
-
Export settings. PDF metadata, compression, encryption and conformance are configured on the exporter, see PDF Export Configuration.
Official documentation: the
JRSwapFileVirtualizer API,
the virtualizer sample and the Spring
asynchronous request and streaming reference.
References
-
JasperReports Spring Boot sample and Webapp sample
-
JasperReports API:
JasperReportsContext,JasperCompileManager,JasperFillManager,JRSwapFileVirtualizer -
Spring Framework 5.0 release notes, dropped JasperReports support