Temporal State in Practice with Quarkus: Build a Fleet Insurance Engine
Snapshots, reinsurance layers, and pro-rated billing using Hibernate ORM Panache and REST Jackson.
I’ve built a lot of enterprise systems, and the insurance related ones were the most fascinating ones. In particular pricing insurance for delivery fleets that change in the middle of a policy. Think simple terms. A company has a policy that covers ten trucks for a year. In month five they add a new truck or one driver gets a speeding ticket. The price they pay for the rest of the year should change. Not for the past. Only for what is left. And we must keep a clear record of what changed, when it changed, and why.
In insurance this is tricky. One vehicle update affects the risk of the whole fleet. Premiums for the remaining term need a fresh calculation. Reinsurance splits can shift because different layers cover different amounts. Billing needs a pro-rated adjustment. To make this reliable we store “temporal state,” which means we save what was true on a specific date so audits and reports match reality.
In this hands-on tutorial we build that backend. You will capture time-based state, trigger the right recalculations, and write an audit trail that explains every step. We use Quarkus REST with Jackson, Hibernate ORM with Panache, and the PostgreSQL JDBC driver. The result is a complete, runnable project. Even if this is only scratching the surface of the actual calculations. But it is hopefully enough to let you sneak into the complexities of temporal based systems. Let’s start.
Prerequisites
Java 17+
Maven 3.8+
Podman (Docker also works)
cURL or HTTP client
This is going to be some more code again. Feel free to check out the full project in my Github repository.
Project bootstrap
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=fleet-insure \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-postgresql,hibernate-validator,scheduler"
cd fleet-insure
3.2 application.properties
Use Dev Services in dev mode or the Podman Compose below for a fixed Postgres.
# JPA
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.jdbc.statement-batch-size=50
quarkus.hibernate-orm.log.sql=true
Domain model
We model current state, temporal membership, snapshots, and reinsurance. Keep historical rows immutable.
Key design choices:
Temporal membership:
PolicyVehicle
connects aVehicle
to aFleetPolicy
witheffectiveFrom
andeffectiveTo
.Snapshots:
FleetPremiumSnapshot
captures a point-in-time result. It ownsVehicleShare
rows and reinsurance allocations for that moment.Optimistic locking:
@Version
on mutable aggregates.Audit: separate
AuditEntry
list with reason and trigger.
Core entities
src/main/java/com/example/domain/FleetPolicy.java
package com.example.domain;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Entity
@Table(name = "fleet_policy")
public class FleetPolicy extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
@Version
public int version;
@Column(nullable = false, unique = true)
public String policyNumber;
@Column(nullable = false)
public String customer;
@Column(nullable = false, precision = 18, scale = 2)
public BigDecimal coverageLimit;
@Column(nullable = false)
public LocalDate effectiveFrom;
@Column(nullable = false)
public LocalDate effectiveTo;
// Optional: rate lock
public LocalDate rateLockUntil;
@OneToMany(mappedBy = "policy", cascade = CascadeType.ALL, orphanRemoval = true)
public List<PolicyVehicle> vehicles; // temporal membership
@OneToMany(mappedBy = "policy", cascade = CascadeType.ALL, orphanRemoval = true)
public List<FleetPremiumSnapshot> snapshots;
@OneToMany(mappedBy = "policy", cascade = CascadeType.ALL, orphanRemoval = true)
public List<AuditEntry> auditEntries;
}
src/main/java/com/example/domain/Vehicle.java
package com.example.domain;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.OffsetDateTime;
@Entity
@Table(name = "vehicle")
public class Vehicle extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
@Version
public int version;
@Column(nullable = false, unique = true, length = 17)
public String vin;
public String makeModel;
public LocalDate purchaseDate;
// Current risk score (0..100). Changes over time through business events.
@Column(nullable = false)
public int currentRiskScore;
// Simple usage label for demo: URBAN, HIGHWAY, MIXED
public String usageProfile;
public OffsetDateTime updatedAt;
@PrePersist
@PreUpdate
void touch() {
updatedAt = OffsetDateTime.now();
}
}
src/main/java/com/example/domain/PolicyVehicle.java
package com.example.domain;
import java.time.LocalDate;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(name = "policy_vehicle", uniqueConstraints = @UniqueConstraint(columnNames = { "policy_id", "vehicle_id",
"effectiveFrom" }))
public class PolicyVehicle extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
public FleetPolicy policy;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
public Vehicle vehicle;
@Column(nullable = false)
public LocalDate effectiveFrom;
// null = still active
public LocalDate effectiveTo;
}
src/main/java/com/example/domain/FleetPremiumSnapshot.java
package com.example.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonBackReference;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapKeyEnumerated;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "fleet_premium_snapshot", indexes = {
@Index(name = "idx_snapshot_policy_asof", columnList = "policy_id, asOf")
})
public class FleetPremiumSnapshot extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JsonBackReference
public FleetPolicy policy;
@Column(nullable = false)
public java.time.LocalDate asOf; // <-- NEW
@Column(nullable = false)
public LocalDateTime calculationDate;
@Column(nullable = false, precision = 18, scale = 2)
public BigDecimal totalPremium;
@Column(nullable = false, length = 128)
public String calculationTrigger;
@ManyToOne(fetch = FetchType.LAZY)
public FleetPremiumSnapshot previousSnapshot;
// Lightweight custom dirty-check guard. If unchanged, skip creating a new
// snapshot.
@Column(nullable = false, length = 64)
public String riskHash;
@OneToMany(mappedBy = "snapshot", cascade = CascadeType.ALL, orphanRemoval = true)
public List<VehicleShare> vehicleShares;
@OneToMany(mappedBy = "snapshot", cascade = CascadeType.ALL, orphanRemoval = true)
public List<SnapshotReinsuranceAllocation> reinsuranceAllocations;
// Month -> exposure units captured at calculation time
@ElementCollection
@CollectionTable(name = "snapshot_exposure", joinColumns = @JoinColumn(name = "snapshot_id"))
@MapKeyEnumerated(EnumType.STRING)
@Column(name = "units", nullable = false, precision = 18, scale = 4)
public Map<java.time.Month, java.math.BigDecimal> exposureUnitsByMonth;
}
src/main/java/com/example/domain/VehicleShare.java
package com.example.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "vehicle_share", indexes = @Index(name = "idx_vehicle_share_snapshot", columnList = "snapshot_id"))
public class VehicleShare extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
public FleetPremiumSnapshot snapshot;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
public Vehicle vehicle;
@Column(nullable = false)
public int riskScore;
@Column(nullable = false, precision = 9, scale = 6)
public BigDecimal fleetPercentage; // 0..1
@Column(nullable = false, precision = 18, scale = 2)
public BigDecimal premiumContribution;
@Column(nullable = false, precision = 18, scale = 4)
public BigDecimal exposureUnits;
public LocalDateTime effectiveFromDate;
public LocalDateTime effectiveToDate;
}
src/main/java/com/example/domain/ReinsuranceLayer.java
package com.example.domain;
import java.math.BigDecimal;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "re_layer")
public class ReinsuranceLayer extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
@Column(nullable = false, unique = true)
public String name;
// Layer band edges on total premium
@Column(nullable = false, precision = 18, scale = 2)
public BigDecimal lowerBound;
@Column(nullable = false, precision = 18, scale = 2)
public BigDecimal upperBound; // inclusive upper or cap. Use large number for "infinity".
}
src/main/java/com/example/domain/SnapshotReinsuranceAllocation.java
package com.example.domain;
import java.math.BigDecimal;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "snapshot_re_allocation")
public class SnapshotReinsuranceAllocation extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
public FleetPremiumSnapshot snapshot;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
public ReinsuranceLayer layer;
@Column(nullable = false, precision = 18, scale = 2)
public BigDecimal allocatedAmount;
}
src/main/java/com/example/domain/AuditEntry.java
package com.example.domain;
import java.time.OffsetDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "audit_entry", indexes = @Index(name = "idx_audit_policy_date", columnList = "policy_id,createdAt"))
public class AuditEntry extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
public FleetPolicy policy;
@Column(nullable = false)
public String reason;
@Column(nullable = false)
public String trigger; // endpoint/event name
@Column(nullable = false)
public OffsetDateTime createdAt = OffsetDateTime.now();
}
The full ER-Diagram is:
Repositories
Panache repositories keep queries clean, including temporal logic.
src/main/java/com/example/repo/FleetPolicyRepo.java
package com.example.repo;
import com.example.domain.FleetPolicy;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class FleetPolicyRepo implements PanacheRepository<FleetPolicy> {
}
src/main/java/com/example/repo/VehicleRepo.java
package com.example.repo;
import com.example.domain.Vehicle;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class VehicleRepo implements PanacheRepository<Vehicle> {
}
src/main/java/com/example/repo/PolicyVehicleRepo.java
package com.example.repo;
import java.time.LocalDate;
import java.util.List;
import com.example.domain.PolicyVehicle;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PolicyVehicleRepo implements PanacheRepository<PolicyVehicle> {
public List<PolicyVehicle> activeOn(long policyId, LocalDate date) {
return find("policy.id = ?1 and effectiveFrom <= ?2 and (effectiveTo is null or effectiveTo >= ?2)",
policyId, date).list();
}
}
src/main/java/com/example/repo/ReinsuranceLayerRepo.java
package com.example.repo;
import com.example.domain.ReinsuranceLayer;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ReinsuranceLayerRepo implements PanacheRepository<ReinsuranceLayer> {
}
Calculation service
Encapsulates rules, transaction boundaries, and snapshot creation. It also implements a lightweight “custom dirty checking” by hashing the risk vector. If nothing changed, no new snapshot is stored.
src/main/java/com/example/service/PremiumCalculator.java
package com.example.service;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import com.example.domain.AuditEntry;
import com.example.domain.FleetPolicy;
import com.example.domain.FleetPremiumSnapshot;
import com.example.domain.ReinsuranceLayer;
import com.example.domain.SnapshotReinsuranceAllocation;
import com.example.domain.Vehicle;
import com.example.domain.VehicleShare;
import com.example.repo.FleetPolicyRepo;
import com.example.repo.PolicyVehicleRepo;
import com.example.repo.ReinsuranceLayerRepo;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
/**
* Computes fleet-wide premium snapshots for a given policy and as-of date.
* - Creates immutable snapshots with vehicle shares and reinsurance allocations
* - Dedupes only when (same asOf + same risk vector)
* - Records an audit entry for each computation
*/
@ApplicationScoped
public class PremiumCalculator {
@Inject
FleetPolicyRepo policyRepo;
@Inject
PolicyVehicleRepo policyVehicleRepo;
@Inject
ReinsuranceLayerRepo layerRepo;
private static final MathContext MC = new MathContext(20, RoundingMode.HALF_UP);
/**
* Demo rate: currency units per risk point per day. Replace with your actuarial
* model.
*/
private static final BigDecimal RATE_PER_RISK_POINT_PER_DAY = new BigDecimal("1.25");
@Transactional
public FleetPremiumSnapshot recalc(long policyId, String trigger, LocalDate asOf) {
FleetPolicy policy = policyRepo.findById(policyId);
if (policy == null)
throw new IllegalArgumentException("Policy not found: " + policyId);
// 1) Determine active vehicles for the as-of date
var memberships = policyVehicleRepo.activeOn(policyId, asOf);
var vehicles = memberships.stream().map(m -> m.vehicle).collect(Collectors.toList());
// 2) Build a stable risk vector and hash it
Map<Long, Integer> riskMap = new TreeMap<>();
for (Vehicle v : vehicles)
riskMap.put(v.id, v.currentRiskScore);
String riskHash = hashRisk(riskMap);
// 3) Find the previous snapshot to decide dedupe and set "previousSnapshot"
// link
FleetPremiumSnapshot previous = FleetPremiumSnapshot
.find("policy = ?1 order by asOf desc, id desc", policy)
.firstResult();
// Dedupe ONLY if the same asOf date and the same risk vector were already
// computed
if (previous != null && asOf.equals(previous.asOf) && previous.riskHash.equals(riskHash)) {
return previous;
}
// 4) Premium math
int totalRisk = vehicles.stream().mapToInt(v -> v.currentRiskScore).sum();
totalRisk = Math.max(totalRisk, 0);
long remainingDays = daysRemaining(policy, asOf);
BigDecimal totalPremium = BigDecimal.ZERO;
List<VehicleShare> shares = new ArrayList<>(vehicles.size());
for (Vehicle v : vehicles) {
BigDecimal pct = totalRisk == 0
? BigDecimal.ZERO
: new BigDecimal(v.currentRiskScore).divide(new BigDecimal(totalRisk), 6, RoundingMode.HALF_UP);
BigDecimal daily = new BigDecimal(v.currentRiskScore).multiply(RATE_PER_RISK_POINT_PER_DAY, MC);
BigDecimal contribution = daily.multiply(new BigDecimal(remainingDays), MC);
VehicleShare s = new VehicleShare();
s.vehicle = v;
s.riskScore = v.currentRiskScore;
s.fleetPercentage = pct;
s.premiumContribution = contribution.setScale(2, RoundingMode.HALF_UP);
s.exposureUnits = new BigDecimal(v.currentRiskScore).setScale(4, RoundingMode.HALF_UP);
s.effectiveFromDate = LocalDateTime.of(asOf, LocalTime.NOON);
shares.add(s);
totalPremium = totalPremium.add(contribution, MC);
}
// 5) Create the snapshot aggregate
FleetPremiumSnapshot snap = new FleetPremiumSnapshot();
snap.policy = policy;
snap.asOf = asOf;
snap.calculationDate = LocalDateTime.now();
snap.calculationTrigger = trigger;
snap.previousSnapshot = previous;
snap.totalPremium = totalPremium.setScale(2, RoundingMode.HALF_UP);
snap.riskHash = riskHash;
for (VehicleShare s : shares)
s.snapshot = snap;
snap.vehicleShares = shares;
// Attribute exposure to the snapshot month (using asOf, not "now")
BigDecimal exposureSum = shares.stream()
.map(vs -> vs.exposureUnits)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(4, RoundingMode.HALF_UP);
snap.exposureUnitsByMonth = Map.of(asOf.getMonth(), exposureSum);
// 6) Reinsurance allocations for each layer based on total premium
List<ReinsuranceLayer> layers = layerRepo.listAll().stream()
.sorted(Comparator.comparing(l -> l.lowerBound))
.toList();
List<SnapshotReinsuranceAllocation> allocations = new ArrayList<>(layers.size());
for (ReinsuranceLayer layer : layers) {
BigDecimal alloc = allocationForLayer(layer, snap.totalPremium).setScale(2, RoundingMode.HALF_UP);
SnapshotReinsuranceAllocation a = new SnapshotReinsuranceAllocation();
a.snapshot = snap;
a.layer = layer;
a.allocatedAmount = alloc;
allocations.add(a);
}
snap.reinsuranceAllocations = allocations;
// 7) Persist via owning aggregate (policy has cascade = ALL)
policy.snapshots = policy.snapshots == null ? new ArrayList<>() : policy.snapshots;
policy.snapshots.add(snap);
// 8) Audit entry
AuditEntry ae = new AuditEntry();
ae.policy = policy;
ae.reason = "Recalculation";
ae.trigger = trigger + " asOf=" + asOf;
policy.auditEntries = policy.auditEntries == null ? new ArrayList<>() : policy.auditEntries;
policy.auditEntries.add(ae);
return snap;
}
// --- helpers -------------------------------------------------------------
private static long daysRemaining(FleetPolicy p, LocalDate asOf) {
if (asOf.isAfter(p.effectiveTo))
return 0;
LocalDate start = asOf;
LocalDate endInclusive = p.effectiveTo;
return Math.max(0, Duration.between(start.atStartOfDay(), endInclusive.plusDays(1).atStartOfDay()).toDays());
// inclusive of policy end date
}
private static BigDecimal allocationForLayer(ReinsuranceLayer layer, BigDecimal total) {
// amount covered in this band = max(min(total, upper) - lower, 0)
BigDecimal capped = total.min(layer.upperBound);
BigDecimal raw = capped.subtract(layer.lowerBound, MC);
return raw.max(BigDecimal.ZERO);
}
private static String hashRisk(Map<Long, Integer> riskMap) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
StringBuilder sb = new StringBuilder();
riskMap.forEach((id, score) -> sb.append(id).append(':').append(score).append(';'));
byte[] hash = md.digest(sb.toString().getBytes());
StringBuilder hex = new StringBuilder();
for (byte b : hash)
hex.append(String.format("%02x", b));
return hex.toString();
} catch (Exception e) {
throw new RuntimeException("Hashing failed", e);
}
}
}
Application services and async job runner
You will support a synchronous recalculation for small changes and an async path for heavy workloads. A simple job table tracks progress.
src/main/java/com/example/domain/RecalcJob.java
package com.example.domain;
import java.time.OffsetDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "recalc_job")
public class RecalcJob extends PanacheEntityBase {
public enum Status {
QUEUED, RUNNING, DONE, FAILED
}
@Id
@GeneratedValue
public Long id;
public Long policyId;
public String trigger;
public String message;
@Enumerated(EnumType.STRING)
public Status status;
public OffsetDateTime createdAt = OffsetDateTime.now();
public OffsetDateTime finishedAt;
}
src/main/java/com/example/service/RecalcJobService.java
package com.example.service;
import java.time.OffsetDateTime;
import java.util.List;
import com.example.domain.RecalcJob;
import io.quarkus.hibernate.orm.panache.Panache;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class RecalcJobService {
// In a real system use a queue. For the tutorial, a simple poller is enough.
@Scheduled(every = "2s")
@Transactional
void runJobs() {
List<RecalcJob> queued = RecalcJob.find("status", RecalcJob.Status.QUEUED).list();
for (var job : queued) {
job.status = RecalcJob.Status.RUNNING;
try {
// Call calc in a new tx for isolation
Panache.getEntityManager().flush();
// Use CDI to obtain PremiumCalculator
PremiumCalculator calc = io.quarkus.arc.Arc.container().instance(PremiumCalculator.class).get();
calc.recalc(job.policyId, job.trigger + " [async]", java.time.LocalDate.now());
job.status = RecalcJob.Status.DONE;
job.message = "Completed";
} catch (Exception e) {
job.status = RecalcJob.Status.FAILED;
job.message = e.getMessage();
}
job.finishedAt = OffsetDateTime.now();
}
}
}
REST layer
Implement endpoints for F1 and F2 plus snapshot and policy views.
src/main/java/com/example/api/dto/VehicleAdditionRequest.java
package com.example.api.dto;
import java.time.LocalDate;
import jakarta.validation.constraints.NotNull;
public class VehicleAdditionRequest {
@NotNull
public String vin;
@NotNull
public String makeModel;
@NotNull
public Integer riskScore;
@NotNull
public LocalDate effectiveFrom;
public String usageProfile;
}
src/main/java/com/example/api/dto/RiskScoreUpdateRequest.java
package com.example.api.dto;
import jakarta.validation.constraints.NotNull;
public class RiskScoreUpdateRequest {
@NotNull
public Integer newRiskScore;
}
src/main/java/com/example/api/PolicyResource.java
package com.example.api;
import java.util.List;
import com.example.api.dto.VehicleAdditionRequest;
import com.example.domain.FleetPolicy;
import com.example.domain.FleetPremiumSnapshot;
import com.example.domain.PolicyVehicle;
import com.example.domain.Vehicle;
import com.example.repo.FleetPolicyRepo;
import com.example.repo.PolicyVehicleRepo;
import com.example.repo.VehicleRepo;
import com.example.service.PremiumCalculator;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/policies")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PolicyResource {
@Inject
FleetPolicyRepo policyRepo;
@Inject
VehicleRepo vehicleRepo;
@Inject
PolicyVehicleRepo pvRepo;
@Inject
PremiumCalculator calculator;
@GET
@Path("/{id}")
public FleetPolicy get(@PathParam("id") long id) {
FleetPolicy p = policyRepo.findById(id);
if (p == null)
throw new NotFoundException();
return p;
}
@GET
@Path("/{id}/snapshots")
public List<FleetPremiumSnapshot> snapshots(@PathParam("id") long id) {
return FleetPremiumSnapshot.find("policy.id", id).list();
}
@POST
@Path("/{id}/vehicles")
@Transactional
public Response addVehicle(@PathParam("id") long policyId, @Valid VehicleAdditionRequest req) {
FleetPolicy policy = policyRepo.findById(policyId);
if (policy == null)
throw new NotFoundException("Policy not found");
Vehicle v = Vehicle.find("vin", req.vin).firstResult();
if (v == null) {
v = new Vehicle();
v.vin = req.vin;
v.makeModel = req.makeModel;
v.currentRiskScore = req.riskScore;
v.usageProfile = req.usageProfile == null ? "MIXED" : req.usageProfile;
vehicleRepo.persist(v);
}
PolicyVehicle pv = new PolicyVehicle();
pv.policy = policy;
pv.vehicle = v;
pv.effectiveFrom = req.effectiveFrom;
pv.persist();
calculator.recalc(policyId, "VEHICLE_ADDED vin=" + v.vin, req.effectiveFrom);
return Response.status(Response.Status.CREATED).entity(v).build();
}
}
src/main/java/com/example/api/VehicleResource.java
package com.example.api;
import java.time.LocalDate;
import com.example.api.dto.RiskScoreUpdateRequest;
import com.example.domain.Vehicle;
import com.example.domain.VehicleShare;
import com.example.service.PremiumCalculator;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/vehicles")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class VehicleResource {
@Inject
PremiumCalculator calculator;
@POST
@Path("/{id}/risk")
@Transactional
public Vehicle updateRisk(@PathParam("id") long id, @Valid RiskScoreUpdateRequest req) {
Vehicle v = Vehicle.findById(id);
if (v == null)
throw new NotFoundException();
v.currentRiskScore = req.newRiskScore;
// For demo, recalc today's state. In real systems, use event time.
// Trigger recomputation for all policies this vehicle belongs to (simplified)
// For tutorial, find policy by a recent snapshot that references this vehicle
VehicleShare snap = VehicleShare.find("vehicle.id", id).firstResult();
if (snap != null) {
calculator.recalc(snap.snapshot.policy.id, "RISK_SCORE_UPDATED vehicle=" + id, LocalDate.now());
}
return v;
}
}
src/main/java/com/example/api/JobResource.java
package com.example.api;
import com.example.domain.RecalcJob;
import com.example.service.PremiumCalculator;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/jobs")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class JobResource {
@Inject
PremiumCalculator calc;
public static class RecalcRequest {
public long policyId;
public String trigger;
}
@POST
@Transactional
public RecalcJob submit(RecalcRequest req) {
RecalcJob j = new RecalcJob();
j.policyId = req.policyId;
j.trigger = req.trigger == null ? "ASYNC_RECALC" : req.trigger;
j.status = RecalcJob.Status.QUEUED;
j.persist();
return j;
}
@GET
@Path("/{id}")
public RecalcJob get(@PathParam("id") long id) {
RecalcJob j = RecalcJob.findById(id);
if (j == null)
throw new NotFoundException();
return j;
}
}
Bootstrap data
Create a bootstrap resource to seed one policy, ten vehicles, six months of snapshots, and three reinsurance layers. You will also demonstrate the pro-rated calculation.
src/main/java/com/example/api/BootstrapResource.java
package com.example.api;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Random;
import com.example.domain.FleetPolicy;
import com.example.domain.PolicyVehicle;
import com.example.domain.ReinsuranceLayer;
import com.example.domain.Vehicle;
import com.example.repo.FleetPolicyRepo;
import com.example.repo.ReinsuranceLayerRepo;
import com.example.service.PremiumCalculator;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/bootstrap")
@Produces(MediaType.APPLICATION_JSON)
public class BootstrapResource {
@Inject
FleetPolicyRepo policyRepo;
@Inject
ReinsuranceLayerRepo layerRepo;
@Inject
PremiumCalculator calculator;
@POST
@Transactional
public FleetPolicy create() {
// Reinsurance layers
if (layerRepo.count() == 0) {
layer("Layer A", "0", "100000");
layer("Layer B", "100000", "250000");
layer("Layer C", "250000", "1000000000");
}
FleetPolicy p = new FleetPolicy();
p.policyNumber = "POL-" + System.currentTimeMillis();
p.customer = "InsureCorp Demo Customer";
p.coverageLimit = new BigDecimal("1000000.00");
p.effectiveFrom = LocalDate.now().minusMonths(6).withDayOfMonth(1);
p.effectiveTo = p.effectiveFrom.plusYears(1).minusDays(1);
p.rateLockUntil = p.effectiveFrom.plusMonths(3);
policyRepo.persist(p);
// 10 vehicles with random risk
Random r = new Random(42);
for (int i = 0; i < 10; i++) {
Vehicle v = new Vehicle();
v.vin = "VIN" + (100000 + i);
v.makeModel = "Truck-" + (i + 1);
v.currentRiskScore = 60 + r.nextInt(30);
v.usageProfile = i % 2 == 0 ? "URBAN" : "HIGHWAY";
v.persist();
PolicyVehicle pv = new PolicyVehicle();
pv.policy = p;
pv.vehicle = v;
pv.effectiveFrom = p.effectiveFrom;
pv.persist();
}
// Historical snapshots: one per month
for (int m = 5; m >= 0; m--) {
LocalDate monthPoint = LocalDate.now().minusMonths(m).withDayOfMonth(15);
calculator.recalc(p.id, "MONTHLY_SNAPSHOT", monthPoint);
}
return p;
}
private void layer(String name, String low, String high) {
ReinsuranceLayer l = new ReinsuranceLayer();
l.name = name;
l.lowerBound = new BigDecimal(low);
l.upperBound = new BigDecimal(high);
layerRepo.persist(l);
}
}
Run and verify
Start services
./mvnw quarkus:dev
Seed data
curl -s -X POST http://localhost:8080/bootstrap | jq .
Expected: one policy with id, vehicles, and initial snapshots.
List snapshots
curl -s http://localhost:8080/policies/1/snapshots \
| jq -r 'sort_by(.asOf)[] | "\(.asOf | strptime("%Y-%m-%d") | strftime("%b %Y")) \(.totalPremium)"'
Expected:
Mar 2025 296756.25
Apr 2025 268275.00
May 2025 240712.50
Jun 2025 212231.25
Jul 2025 184668.75
Aug 2025 156187.50
Each snapshot prices the remaining term only.
In the calculator we do:
Sum the fleet risk:
totalRisk = Σ vehicle.currentRiskScore
(your seed has 735).Compute a daily premium:
daily = totalRisk × 1.25
→735 × 1.25 = 918.75
.Multiply by days remaining in the policy at the snapshot’s
asOf
date.
So as asOf
moves from March → April → …, days remaining shrinks, and the remaining-term premium drops linearly.
You can verify with your numbers:
Mar 15 → remaining days ≈ 323 →
918.75 × 323 = 296,756.25
Apr 15 → remaining days ≈ 292 →
918.75 × 292 = 268,275.00
The difference is one month of days: 31 × 918.75 = 28,481.25
.
Reinsurance allocations step down accordingly because they’re based on the total remaining premium. As the premium falls below upper band thresholds, less (or none) is allocated to higher layers.
F1: Add a vehicle mid-policy
curl -s -X POST http://localhost:8080/policies/1/vehicles \
-H 'Content-Type: application/json' \
-d '{
"vin":"VINNEW1234567890",
"makeModel":"Truck-X",
"riskScore":85,
"effectiveFrom":"'"$(date +%F)"'"
}' | jq .
Now check the latest snapshot. The total premium should increase and vehicle shares should include the new VIN.
curl -s http://localhost:8080/policies/1/snapshots | jq '.[-1] | {totalPremium, calculationTrigger, vehicleShares: (.vehicleShares|length)}'
F2: Risk score change propagation
Pick a vehicle id from the previous output and bump its score.
curl -s -X POST http://localhost:8080/vehicles/3/risk \
-H 'Content-Type: application/json' \
-d '{"newRiskScore":95}' | jq '.id,.currentRiskScore'
curl -s http://localhost:8080/policies/1/snapshots | jq '.[-1] | {totalPremium, calculationTrigger}'
You should see a new snapshot with RISK_SCORE_UPDATED
.
Async recalculation
curl -s -X POST http://localhost:8080/jobs \
-H 'Content-Type: application/json' \
-d '{"policyId":1,"trigger":"BULK_OPERATION"}' | jq '.id'
JOB=... # set this from the previous result
curl -s http://localhost:8080/jobs/$JOB | jq .
Poll until status
becomes DONE
.
Re-Run the Snapshot list
curl -s http://localhost:8080/policies/1/snapshots \
| jq -r 'sort_by(.asOf)[] | "\(.asOf | strptime("%Y-%m-%d") | strftime("%b %Y")) \(.totalPremium)"'
Expected:
Mar 2025 296756.25
Apr 2025 268275.00
May 2025 240712.50
Jun 2025 212231.25
Jul 2025 184668.75
Aug 2025 156187.50
Aug 2025 167075.00
Aug 2025 170538.75
You’re seeing three August rows because you now have three snapshots in the same calendar month:
the scheduled monthly run (mid-month),
the F1 event (vehicle added),
the F2 event (risk score bumped).
Each snapshot has a different asOf date in August and a different risk vector, so they’re all valid. The premium went up after F1/F2 because the daily rate increased enough to outweigh the fewer days remaining.
Quick ways to inspect and/or collapse them:
See what each August row actually is
curl -s http://localhost:8080/policies/1/snapshots \
| jq -r 'sort_by(.asOf, .calculationDate)[]
| "\(.asOf) \(.totalPremium) \(.calculationTrigger)"'
Result:
2025-03-15 296756.25 MONTHLY_SNAPSHOT
2025-04-15 268275.00 MONTHLY_SNAPSHOT
2025-05-15 240712.50 MONTHLY_SNAPSHOT
2025-06-15 212231.25 MONTHLY_SNAPSHOT
2025-07-15 184668.75 MONTHLY_SNAPSHOT
2025-08-15 156187.50 MONTHLY_SNAPSHOT
2025-08-22 167075.00 VEHICLE_ADDED vin=VINNEW1234567890
2025-08-22 170538.75 RISK_SCORE_UPDATED vehicle=3
One line per month (latest snapshot in that month)
curl -s http://localhost:8080/policies/1/snapshots \
| jq -r 'sort_by(.asOf)
| group_by(.asOf[:7])
| map(max_by(.calculationDate))[]
| "\(.asOf | strptime("%Y-%m-%d") | strftime("%b %Y")) \(.totalPremium)"'
What the code demonstrates
Advanced entity design
Temporal membership with
PolicyVehicle
and effective dates.Immutable historical snapshots.
Optimistic locking on mutable aggregates.
Lifecycle and audit
@PrePersist/@PreUpdate
for timestamps.AuditEntry
explains what was calculated, when, and why.
Transaction management
Calculator runs in a single transaction per snapshot.
Async job executes in separate transactions for isolation.
Custom dirty checking
Risk vector hashing prevents redundant snapshots.
Performance tactics
Batch inserts (
statement-batch-size=50
).Narrow queries in repositories.
LAZY collections on heavy relationships.
Simple indexes on snapshot and audit tables.
Temporal queries
PolicyVehicleRepo.activeOn(policyId, date)
answers “what was true when.”
Reinsurance logic
Layer bands allocate portions of premium per snapshot.
Production notes
Replace the demo rate formula with your actuarial model. Keep it side-effect free and purely functional for testability.
Store exposure by month from your data warehouse. Here we use a simple proxy.
Use outbox or messaging for long-running recalculations. A table poller is fine for the tutorial, but a queue is better.
Secure endpoints. Add authn/authz and validation on inputs.
Consider write-behind for audit entries if you see contention.
Revisit indexes after real data arrives. Add composite indexes for
policy_id, calculationDate
andpolicy_id, effectiveFrom
.If you enable 2nd-level cache, pick and configure a cache provider explicitly. Do not rely on defaults.
Troubleshooting
N+1 in JSON: Do not return giant entity graphs. Create DTOs for list views.
Duplicate snapshots: Check
riskHash
comparison and thatasOf
is correct.Stale updates: If you see
OptimisticLockException
, retry the command or switch to a queue.Slow snapshot creation: Profile queries. Ensure
policyVehicle
temporal filter uses the index.
Where to extend
Add removal events with
effectiveTo
closure and a fresh snapshot.Implement reinsurance treaties with attachment and limit per layer, plus ceded and retained shares.
Add regulatory reports over snapshots: “six months historical” queries with pagination and filters.
Replace the job poller with Quarkus Reactive Messaging and PostgreSQL notifications.
Build for correctness first. Then make it fast.
And of course I forgot to link the repository. Here it is:
https://github.com/myfear/ejq_substack_articles/tree/main/fleet-insure