Batch Invoicing Made Easy: Build a Year-End Insurance Processor with Quarkus, JBeret, and PDFBox
Learn how to handle millions of policies, calculate premiums, and generate compliant PDFs step by step in modern Java.
My oldest daughter just got her driver’s license. As any parent in Germany knows, that meant an immediate call to my insurance company. Adding a younger driver isn’t just a small update, it changes premiums, applies different risk classifications, and of course, affects the year-end invoice. That experience reminded me of how much complexity insurers deal with behind the scenes.
Now imagine not one new driver, but millions of policy adjustments across the country. Enterprise insurers must run year-end invoice jobs that calculate premiums, apply taxes, generate legal documents, and stay compliant for ten years. It’s deterministic, heavy on business rules, and requires strong auditability. That’s textbook batch processing.
In this tutorial, you’ll build such a production-style batch system in Quarkus using:
JBeret (Jakarta Batch, JSR-352) for large-scale processing
REST with Jackson for triggering and monitoring jobs
PDFBox via Quarkiverse to generate legally compliant invoice PDFs
We’ll cover everything: from chunked batch reading to premium calculation, from PDF generation to REST monitoring. Along the way, we’ll mirror real German insurance business rules like Regionalklassen (regional risk zones), Typklassen (vehicle type classes), and the Versicherungssteuer (insurance tax).
Prerequisites
Before we start, you’ll need:
Java 21
Podman 5+ (Docker works; swap commands accordingly)
Quarkus CLI 3.27+ (optional but handy).
Bootstrap the project
We start by creating a Quarkus project with the right extensions.
quarkus create app com.acme:year-end-invoices:1.0.0 \
-x io.quarkiverse.jberet:quarkus-jberet,io.quarkiverse.pdfbox:quarkus-pdfbox,quarkus-rest-jackson,hibernate-orm-panache,jdbc-postgresql,hibernate-validator
cd year-end-invoicesThis gives us everything we need: batch processing, PDF generation, REST endpoints, Panache ORM, and PostgreSQL support.
If you just want to look at the code and not follow along, feel free to grab the complete project from my Github repository.
Configuration
Quarkus favors convention, but we still need a few key settings in src/main/resources/application.properties
# Datasource
quarkus.hibernate-orm.database.generation.strategy=update
# JBeret repository in JDBC
# Name of datasource is the default; can be overridden
quarkus.jberet.repository.type=jdbc
# Batch tuning
# safe defaults; tweak for your machine
quarkus.jberet.thread-pool.size=8
# Business defaults
billing.tax.default-rate=0.19
billing.pdf.output-dir=target/invoicesHere’s what’s happening:
The schema is auto-updated.
Batch jobs persist their execution state into PostgreSQL (
jdbc).We configure an 8-thread pool to allow parallel chunk execution.
The default insurance tax (19%) is injected via config.
PDFs go into
target/invoices.
Domain model
Insurance runs are data-heavy. We define a domain model that captures customers, vehicles, policies, claims, and invoices.
Customer.java
A customer contains all identifying, address, and payment data. Note the IBAN/BIC fields, crucial for SEPA.
package com.acme.domain;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
@Entity
public class Customer extends PanacheEntity {
@NotBlank
public String fullName;
@NotBlank
public String street;
@NotBlank
public String postalCode;
@NotBlank
public String city;
@NotBlank
public String countryCode;
@NotBlank
public String iban;
@NotBlank
public String bic;
@Email
@NotBlank
public String email;
}Vehicle.java
German premiums depend heavily on vehicle class (Typklasse) and region class (Regionalklasse).
package com.acme.domain;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
@Entity
public class Vehicle extends PanacheEntity {
public String vin;
public String registration;
public String typklasse; // vehicle type class
public String regionalklasse; // regional risk zone (Bundesland mapping)
}ClaimsHistory.java
This entity determines SF-Klasse, the no-claims bonus system.
package com.acme.domain;
import java.time.LocalDate;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
@Entity
public class ClaimsHistory extends PanacheEntity {
@ManyToOne(optional = false)
public Customer customer;
public int yearsNoClaim; // simplified input to compute SF-Klasse
public int claimsCountLastYear;
public LocalDate updatedAt;
}Policy.java
Policies link customers and vehicles, hold coverage type, validity, and base premium.
package com.acme.domain;
import java.math.BigDecimal;
import java.time.LocalDate;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(indexes = {
@Index(name = “idx_policy_active_to”, columnList = “validTo”),
@Index(name = “idx_policy_region”, columnList = “bundesland”)
})
public class Policy extends PanacheEntity {
@ManyToOne(optional = false)
public Customer customer;
@ManyToOne(optional = false)
public Vehicle vehicle;
public String coverage;
public String bundesland;
public LocalDate validFrom;
public LocalDate validTo;
public BigDecimal baseAnnualPremium;
public boolean cancelled;
}Invoice.java
An invoice represents the output of the batch run.
package com.acme.domain;
import java.math.BigDecimal;
import java.time.LocalDate;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(indexes = @Index(name = “idx_invoice_year”, columnList = “year”))
public class Invoice extends PanacheEntity {
@ManyToOne(optional = false)
public Policy policy;
public int year;
public BigDecimal netAmount;
public BigDecimal insuranceTax;
public BigDecimal grossAmount;
public String pdfPath;
public LocalDate dueDate;
public String paymentFrequency;
}Batch job design
Batch processing in Quarkus (via JBeret) revolves around chunk-oriented steps. Each chunk consists of:
A reader to fetch policies
A processor to calculate premiums and taxes
A writer to persist invoices and generate PDFs
src/main/resources/META-INF/batch.xml
<?xml version=”1.0” encoding=”UTF-8”?>
<batch-artifacts xmlns=”http://xmlns.jcp.org/xml/ns/javaee”>
<ref id=”com.acme.batch.PolicyPagingReader” class=”com.acme.batch.PolicyPagingReader”/>
<ref id=”com.acme.batch.InvoiceProcessor” class=”com.acme.batch.InvoiceProcessor”/>
<ref id=”com.acme.batch.InvoiceWriter” class=”com.acme.batch.InvoiceWriter”/>
</batch-artifacts>We configure everything in Job XML: We’ll also partition by Bundesland to scale.
Job XML
This tells JBeret: read policies, process them into invoices, then write PDFs and database entries.
src/main/resources/META-INF/batch-jobs/year-end-invoice.xml
<?xml version=”1.0” encoding=”UTF-8”?>
<job id=”year-end-invoice” xmlns=”http://xmlns.jcp.org/xml/ns/javaee” version=”1.0”>
<properties>
<property name=”chunkSize” value=”#{jobParameters["chunkSize"] ?: 100}” />
<property name=”year” value=”#{jobParameters["year"]}” />
<property name=”dryRun” value=”#{jobParameters["dryRun"] ?: "false"}” />
<property name=”bundesland” value=”#{jobParameters["bundesland"] ?: ""}” />
</properties>
<step id=”generate-invoices”>
<chunk item-count=”#{jobProperties["chunkSize"]}”>
<reader ref=”com.acme.batch.PolicyPagingReader”/>
<processor ref=”com.acme.batch.InvoiceProcessor”/>
<writer ref=”com.acme.batch.InvoiceWriter”/>
<skippable-exception-classes>
<include class=”jakarta.validation.ValidationException” />
</skippable-exception-classes>
</chunk>
</step>
</job>JBeret uses job XML to wire reader/processor/writer artifacts. If you are new to JSR-352, the IBM whitepaper is a compact primer. (IBM) And yes, it’s old!
Reader
PolicyPagingReader.java
PolicyPagingReader pulls policies page by page.
package com.acme.batch;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Iterator;
import java.util.List;
import com.acme.domain.Policy;
import jakarta.batch.api.chunk.AbstractItemReader;
import jakarta.batch.runtime.context.JobContext;
import jakarta.batch.runtime.context.StepContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
@Named(”com.acme.batch.PolicyPagingReader”)
public class PolicyPagingReader extends AbstractItemReader {
@Inject
JobContext jobCtx;
@Inject
StepContext stepCtx;
@PersistenceContext
EntityManager em;
private Iterator<Policy> buffer;
private int page = 0;
private int pageSize = 200;
@Override
public void open(Serializable checkpoint) {
Object cs = jobCtx.getProperties().get(”chunkSize”);
if (cs != null)
pageSize = Integer.parseInt(cs.toString());
}
@Override
public Object readItem() {
if (buffer == null || !buffer.hasNext()) {
List<Policy> next = fetchPage(page++, pageSize);
if (next.isEmpty())
return null;
buffer = next.iterator();
}
return buffer.next();
}
private List<Policy> fetchPage(int page, int size) {
int year = Integer.parseInt(jobCtx.getProperties().getProperty(”year”));
String bundesland = jobCtx.getProperties().getProperty(”bundesland”);
LocalDate day = LocalDate.of(year, 12, 31);
String base = “SELECT p FROM Policy p WHERE p.validFrom <= :day AND p.validTo >= :day AND p.cancelled=false”;
if (bundesland != null && !bundesland.isBlank())
base += “ AND p.bundesland = :bl”;
var q = em.createQuery(base, Policy.class)
.setParameter(”day”, day)
.setFirstResult(page * size)
.setMaxResults(size);
if (bundesland != null && !bundesland.isBlank())
q.setParameter(”bl”, bundesland);
return q.getResultList();
}
}
Register the reader as policyReader via @Named. The Quarkus JBeret extension auto-discovers CDI beans with matching simple names. (We use explicit @jakarta.inject.Named(”com.acme.batch.PolicyPagingReader”) names here.)
Processor
InvoiceProcessor applies business rules. Notice how we simulate SF-Klasse, Typklasse, and Regionalklasse with multipliers. This keeps the tutorial simple but somewhat realistic. It is much more complicated in real insurances though!
package com.acme.batch;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import com.acme.domain.Invoice;
import com.acme.domain.Policy;
import jakarta.batch.api.chunk.ItemProcessor;
import jakarta.batch.runtime.context.JobContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
@Named(”com.acme.batch.InvoiceProcessor”)
public class InvoiceProcessor implements ItemProcessor {
@Inject
JobContext jobCtx;
@Override
public Object processItem(Object item) {
Policy p = (Policy) item;
int year = Integer.parseInt(jobCtx.getProperties().getProperty(”year”));
BigDecimal taxRate = new BigDecimal(jobCtx.getProperties().getProperty(”billing.tax.default-rate”, “0.19”));
BigDecimal annualNet = premiumFor(p);
BigDecimal tax = annualNet.multiply(taxRate).setScale(2, RoundingMode.HALF_UP);
BigDecimal gross = annualNet.add(tax);
Invoice inv = new Invoice();
inv.policy = p;
inv.year = year;
inv.netAmount = annualNet;
inv.insuranceTax = tax;
inv.grossAmount = gross;
inv.dueDate = LocalDate.of(year + 1, 1, 15);
inv.paymentFrequency = “ANNUAL”;
return inv;
}
private BigDecimal premiumFor(Policy p) {
BigDecimal base = p.baseAnnualPremium;
// Heuristics to mimic Regionalklassen/Typklassen/SF-Klasse:
BigDecimal regionFactor = switch (String.valueOf(p.vehicle.regionalklasse)) {
case “HH”, “BE” -> new BigDecimal(”1.10”);
case “BY”, “BW” -> new BigDecimal(”1.05”);
default -> BigDecimal.ONE;
};
BigDecimal typeFactor = switch (String.valueOf(p.vehicle.typklasse)) {
case “SPORT” -> new BigDecimal(”1.15”);
case “SUV” -> new BigDecimal(”1.08”);
default -> BigDecimal.ONE;
};
BigDecimal sfBonus = new BigDecimal(”0.90”); // 10% bonus for example
return base.multiply(regionFactor).multiply(typeFactor).multiply(sfBonus).setScale(2, RoundingMode.HALF_UP);
}
}Writer + PDF
Invoices are persisted and rendered into PDFs. The PdfService uses Quarkus PDFBox to draw legal text, totals, and due dates. Every invoice ends up as a file on disk and a row in the DB. You will also need to put IBMPlexSans-Regular.ttf and IBMPlexSans-Bold.ttf into your /resources folder.
package com.acme.batch;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import com.acme.domain.Invoice;
import com.acme.service.PdfService;
import jakarta.batch.api.chunk.AbstractItemWriter;
import jakarta.batch.runtime.context.JobContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
@Named(”com.acme.batch.InvoiceWriter”)
public class InvoiceWriter extends AbstractItemWriter {
@Inject
EntityManager em;
@Inject
PdfService pdfService;
@Inject
JobContext jobCtx;
private boolean dryRun;
private Path outDir;
@Override
public void open(Serializable checkpoint) throws Exception {
dryRun = Boolean.parseBoolean(jobCtx.getProperties().getProperty(”dryRun”, “false”));
outDir = Path.of(jobCtx.getProperties().getProperty(”billing.pdf.output-dir”, “target/invoices”));
Files.createDirectories(outDir);
}
@Override
@Transactional
public void writeItems(List<Object> items) throws Exception {
if (!dryRun) {
for (Object o : items) {
Invoice inv = (Invoice) o;
em.persist(inv);
em.flush();
var pdf = pdfService.render(inv);
inv.pdfPath = pdf.toString();
em.merge(inv);
em.flush();
}
}
}
}PdfService.java
The PdfService is a service class that generates professional PDF invoices using Apache PDFBox. It takes an Invoice object as input and creates a formatted PDF document containing customer information, policy details, billing breakdown (net amount, tax, total), and payment information, then saves it to the filesystem and returns the file path.
package com.acme.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.time.format.DateTimeFormatter;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import com.acme.domain.Invoice;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PdfService {
public Path render(Invoice invoice) throws IOException {
final PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDPageContentStream stream = new PDPageContentStream(doc, page);
// Load fonts
PDFont titleFont = PDType0Font.load(doc,
getClass().getClassLoader().getResourceAsStream(”IBMPlexSans-Bold.ttf”));
PDFont regularFont = PDType0Font.load(doc,
getClass().getClassLoader().getResourceAsStream(”IBMPlexSans-Regular.ttf”));
float yPosition = 750;
float leftMargin = 50;
float rightMargin = 550;
// Rendering Logic. See repository for full file!
stream.close();
// Save to file
String fileName = “invoice_” + invoice.id + “_” + invoice.year + “.pdf”;
Path filePath = Path.of(”target/invoices”, fileName);
Files.createDirectories(filePath.getParent());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
Files.write(filePath, baos.toByteArray());
doc.close();
return filePath;
}
Quarkus PDFBox integrates Apache PDFBox and works in native too. (docs.quarkiverse.io)
Seed data for a realistic run
src/main/resources/import.sql
-- Customers
INSERT INTO customer (id, fullName, street, postalCode, city, countryCode, iban, bic, email)
VALUES
(1, ‘Max Mustermann’, ‘Beispielstraße 1’, ‘80331’, ‘München’, ‘DE’, ‘DE44500105175407324931’, ‘INGDDEFFXXX’, ‘max@example.org’),
(2, ‘Erika Musterfrau’, ‘Hauptstraße 5’, ‘50667’, ‘Köln’, ‘DE’, ‘DE88500105174607324932’, ‘INGDDEFFXXX’, ‘erika@example.org’);
-- Vehicles
INSERT INTO vehicle (id, vin, registration, typklasse, regionalklasse)
VALUES
(1, ‘VIN001’, ‘M-1001’, ‘SPORT’, ‘BY’),
(2, ‘VIN002’, ‘K-2001’, ‘STD’, ‘NW’);
-- Policies
INSERT INTO policy (id, customer_id, vehicle_id, coverage, bundesland, validFrom, validTo, baseAnnualPremium, cancelled)
VALUES
(1,
(SELECT id FROM customer WHERE email=’max@example.org’),
(SELECT id FROM vehicle WHERE vin=’VIN001’),
‘VK’, ‘BY’, ‘2024-01-01’, ‘2026-12-31’, 620.00, false),
(2,
(SELECT id FROM customer WHERE email=’erika@example.org’),
(SELECT id FROM vehicle WHERE vin=’VIN002’),
‘HP’, ‘NW’, ‘2024-01-01’, ‘2026-12-31’, 480.00, false);
-- Claims history
INSERT INTO claimshistory (id, customer_id, yearsNoClaim, claimsCountLastYear, updatedAt)
VALUES
(1,
(SELECT id FROM customer WHERE email=’max@example.org’),
5, 0, current_date),
(2,
(SELECT id FROM customer WHERE email=’erika@example.org’),
2, 1, current_date);REST control plane
The BatchResource is a REST API controller that provides endpoints for managing batch invoice generation jobs, including starting jobs with parameters like year, region, and chunk size, checking job execution status, and listing generated invoices from the database. It uses JBeret’s JobOperator to interact with the batch processing framework and returns structured JSON responses with job execution details and invoice counts.
BatchResource.java
package com.acme.api;
import java.util.Properties;
import com.acme.domain.Invoice;
import jakarta.batch.operations.JobOperator;
import jakarta.batch.runtime.BatchRuntime;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/batch/invoices”)
@Produces(MediaType.APPLICATION_JSON)
public class BatchResource {
private final JobOperator jobOperator = BatchRuntime.getJobOperator();
@Inject
EntityManager em;
@POST
@Path(”/run”)
public Response run(@QueryParam(”year”) int year,
@QueryParam(”bundesland”) @DefaultValue(”“) String bundesland,
@QueryParam(”chunk”) @DefaultValue(”200”) int chunk,
@QueryParam(”dryRun”) @DefaultValue(”false”) boolean dryRun) {
Properties p = new Properties();
p.setProperty(”year”, String.valueOf(year));
p.setProperty(”bundesland”, bundesland);
p.setProperty(”chunkSize”, String.valueOf(chunk));
p.setProperty(”dryRun”, String.valueOf(dryRun));
long execId = jobOperator.start(”year-end-invoice”, p);
return Response.accepted().entity(new StartResponse(execId)).build();
}
@GET
@Path(”/status/{executionId}”)
public Response status(@PathParam(”executionId”) long id) {
var exec = jobOperator.getJobExecution(id);
var steps = jobOperator.getStepExecutions(id);
return Response.ok(new StatusResponse(
new ExecutionInfo(exec.getExecutionId(), exec.getJobName(), exec.getBatchStatus().toString(),
exec.getExitStatus(),
exec.getStartTime() != null
? exec.getStartTime().toInstant().atZone(java.time.ZoneId.systemDefault())
.toLocalDateTime()
: null,
exec.getEndTime() != null
? exec.getEndTime().toInstant().atZone(java.time.ZoneId.systemDefault())
.toLocalDateTime()
: null),
steps.stream().map(step -> new StepInfo(step.getStepName(), step.getBatchStatus().toString(),
step.getExitStatus(),
step.getStartTime() != null
? step.getStartTime().toInstant().atZone(java.time.ZoneId.systemDefault())
.toLocalDateTime()
: null,
step.getEndTime() != null
? step.getEndTime().toInstant().atZone(java.time.ZoneId.systemDefault())
.toLocalDateTime()
: null))
.toList()))
.build();
}
@GET
@Path(”/list”)
public Response listInvoices() {
try {
var invoices = em.createQuery(”SELECT i FROM Invoice i”, Invoice.class).getResultList();
return Response.ok(”Found “ + invoices.size() + “ invoices in database”).build();
} catch (Exception e) {
return Response.ok(”Error querying invoices: “ + e.getMessage()).build();
}
}
public record StartResponse(long executionId) {
}
public record StatusResponse(ExecutionInfo execution, java.util.List<StepInfo> steps) {
}
public record ExecutionInfo(long executionId, String jobName, String batchStatus,
String exitStatus, java.time.LocalDateTime startTime,
java.time.LocalDateTime endTime) {
}
public record StepInfo(String stepName, String batchStatus, String exitStatus,
java.time.LocalDateTime startTime, java.time.LocalDateTime endTime) {
}
}You can also add quarkus-jberet-rest to get a prebuilt REST management surface. (Quarkus)
Run and verify
Start Quarkus:
quarkus devKick off a full run for 2025, Bavaria only:
curl -s -X POST "http://localhost:8080/batch/invoices/run?year=2025&bundesland=BY&chunk=150&dryRun=false"
# {”executionId”:1}Poll status:
curl -s "http://localhost:8080/batch/invoices/status/1" | jq .Check generated PDFs and totals:
ls -1 target/invoices | headOpen one PDF from target/invoices/invoice_1_2025.pdf.
Expected: invoices persisted in invoice table; PDFs present; due date set to 2026-01-15; gross = net + 19% tax.
Scale and performance
Increase thread pool via
quarkus.jberet.thread-pool.size. Use a thread count that matches CPU cores and DB capacity.Partitioning: for very large runs, create a partitioned step that injects region partitions. JBeret’s partitioning applies separate reader/processor/writer instances per partition, which scales predictably on multicore.
Chunk size: start with 200, adjust after measuring DB round-trips and memory pressure.
Fault tolerance
Skip limits are set for
ValidationExceptionin the job XML. Extend with data quality exceptions as needed.Retries: wrap PDF generation in idempotent logic, writing to a temp file and moving atomically to final name.
What-ifs and variations
Once you have the basic year-end batch job running, there are several directions you can evolve it further. One obvious extension is to introduce flexible payment frequencies. Instead of always producing an annual invoice, the system could split the gross amount into installments, for example quarterly or monthly, and generate multiple due dates accordingly. Another realistic scenario in car insurance is handling multi-vehicle households. Instead of producing a separate invoice for each policy, you could group multiple vehicles under the same customer and generate a single PDF with line items, making it easier for the policy holder to understand their charges. You can also differentiate between renewals and new business. By parameterizing the run type, such as renewal, new, or all, the job can apply different rules and templates depending on whether a policy is continuing or just started. Finally, as the number of policies grows into the millions, you will want to scale the job across regions. A common approach is to implement true partitioning in JBeret where each partition processes one Bundesland. This not only improves throughput but also keeps the data processing aligned with regional classifications that already play a role in premium calculation. Together, these evolutions mirror real requirements in the insurance industry and give you rich opportunities to practice advanced batch design in Quarkus.
When a drivers license celebration turns into a tutorial
What started with my daughter’s driver’s license ends with millions of invoices. With Quarkus, JBeret, and PDFBox, we can turn complex German insurance rules into a scalable, auditable batch system.
From customers and vehicles to invoices and PDFs, we’ve built a full year-end run that mirrors what real insurers do. Only this time, you can run it on your laptop.
Batch jobs may not be glamorous, but they are the backbone of financial systems. And Quarkus makes them modern, fast, and fun to build.





Once again, I admire the realistic nature of this showcase. Jakarta Batch being one of my prefered specs, I was several times in the position of having to demonstrate it to development teams versed to Spring Batch and, more often than not, I was finishing with a poor job, with a couple of steps in a simplistic execution flow.
One of the aspects that has always bothered me is the junction between the batch processing and the REST layer. Me too, in my workshops, I imagined a couple of endpoints responsible to start jobs, to manage status, retries, etc., but I have a kind of artificial design feeling because, in practice, I've never had a case where exposing a REST API in front of a batch processor brought a real business value.
If I have time, I'll try to implement the use case in Camel, and to compare both implementations.