Getting Started with Spring Boot
|
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 takes a Spring Boot service from an empty pom.xml to a Quartz job that runs on a cron schedule, can be seen in
/actuator/quartz and is covered by a smoke test. It explains what Spring Boot does for you behind the scenes, how a job
receives its Spring beans and its settings, and which of Spring Framework’s older factory beans are still worth knowing.
The Bookshelf scenario provides the job: a nightly reminder to every member who holds an overdue loan. The vocabulary
(Scheduler, JobDetail, Trigger, job store) is introduced in
Quartz Fundamentals; the reasons to pick Quartz over @Scheduled are in
Standard Scheduling vs Quartz.
It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21, and verified on 5 October 2026:
every snippet below was compiled and run in a test project on exactly those versions (macOS, JDK 25 running Java 21
bytecode, Maven 3.9), and the outputs are quoted, not assumed. Spring Boot 3.5.x uses the same Quartz 2.5.2 and the same
spring.quartz.* property names; what differs is the package of the auto-configuration classes
(org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure), Spring
Framework 6.2.19 instead of 7.0.9, and the absence of the spring-boot-starter-quartz-test starter. Each difference is
called out where it matters and collected in What changed.
Dependencies
Spring Boot manages the Quartz version, so no version number appears in the build file. The starter is all that is needed for in-memory scheduling. A JDBC job store adds the JDBC starter and a driver, and the Actuator endpoint adds the Actuator starter.
| Artifact | What it brings | Scope |
|---|---|---|
|
The |
compile |
|
|
compile, optional |
|
The |
compile, optional |
|
An embedded database, so the JDBC store can be tried without installing anything. Never the production store. |
runtime |
|
|
test |
|
Polling assertions for "the job fired within five seconds". Version managed by Boot. |
test |
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Source: Spring Boot: Quartz Scheduler and the dependency list of spring-boot-smoke-test-quartz at the v4.1.1 tag.
plugins {
java
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.example.bookshelf"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-quartz")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-quartz-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.awaitility:awaitility")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
Source: Spring Boot: Quartz Scheduler. The Gradle build was not executed on the verification machine (no Gradle installation); the artifact names and the plugin versions were checked against Maven Central and the Gradle plugin portal, and the Maven build above was run.
Spring Boot 3.5.x. Use the parent version 3.5.16 (or id("org.springframework.boot") version "3.5.16") and replace
spring-boot-starter-quartz-test with spring-boot-starter-test, because the Quartz test starter does not exist on the 3.5
line (Maven Central has it from 4.0.0). The web starter is called spring-boot-starter-web on 3.5, and
spring-boot-starter-webmvc-test does not exist there either. Everything else, including spring-boot-starter-quartz
itself, keeps its name. The starter pulls Quartz 2.5.2 and Spring Framework 6.2.19.
What QuartzAutoConfiguration does
When Quartz and Spring’s SchedulerFactoryBean are on the classpath, QuartzAutoConfiguration builds one Quartz
Scheduler for the application and hands it everything it needs. Reading the 4.1.1 source gives this list, in this order:
-
It creates a
SchedulerFactoryBeannamedquartzScheduler. The bean is declared@ConditionalOnMissingBean, so defining your ownSchedulerFactoryBeanmakes Boot back off completely.SchedulerFactoryBeanis a SpringFactoryBean, so theScheduleritself is what you inject into your own code. -
It creates a
SpringBeanJobFactory, gives it theApplicationContextand installs it as the scheduler’s job factory. This is what makes constructor injection,@Autowiredand setter injection work in jobs (see the next section). -
It copies the
spring.quartz.settings into the factory bean:auto-startup,startup-delay,wait-for-jobs-to-complete-on-shutdown,overwrite-existing-jobs,scheduler-name(only if you set it, otherwise Spring uses the bean name) and the raw Quartz properties underspring.quartz.properties.(only if the map is not empty). -
It collects every
JobDetailbean and everyTriggerbean, in@Order, and everyCalendarbean (the bean name becomes the calendar name), and registers them with the scheduler. -
It runs every
SchedulerFactoryBeanCustomizerbean in order, last, so a customizer can override anything above. -
Only for
spring.quartz.job-store-type=jdbcand a single candidateDataSource(one bean, or one marked@Primary): it adds a customizer (ordered first) that sets theDataSource, picking the@QuartzDataSourcebean when there is one, and the transaction manager, picking the@QuartzTransactionManagerbean when there is one. It also adds a schema initializer that depends onspring.quartz.jdbc.initialize-schema. The JDBC store is covered on Job Stores & Persistence. -
If the Actuator is present,
QuartzEndpointAutoConfigurationadds thequartzendpoint for theScheduler.
only for job-store-type jdbc)] AC[QuartzAutoConfiguration] SFB[SchedulerFactoryBean
bean name quartzScheduler
job factory SpringBeanJobFactory] SCH[Scheduler
inject it by type] EP[Actuator quartz endpoint] JD --> AC TR --> AC CAL --> AC CUS --> AC PR --> AC DS -.-> AC AC --> SFB SFB --> SCH SCH --> EP
The order in which things happen at startup matters in practice, because jobs and triggers are written to the job store while the scheduler is still in standby, and the scheduler only starts once the whole context is ready.
The settings Boot reads, with the defaults taken from the 4.1.1 source (the complete list, including the JDBC properties, is on Spring Boot Configuration):
| Property | Default | Effect |
|---|---|---|
|
|
|
|
none |
Without it the scheduler is named after the factory bean, |
|
|
Start the scheduler when the context has started. |
|
|
Delay before starting, a |
|
|
Whether closing the context waits for running jobs to finish. |
|
|
Whether bean definitions replace jobs and triggers already stored in a persistent job store. |
|
empty |
Raw Quartz properties such as |
Two things that the auto-configuration does not do are worth knowing from the start. It does not register listeners:
JobListener and TriggerListener beans are ignored unless you register them in a customizer (see
Listeners & Plugins). And it adds no metrics or health indicator for Quartz
(see Observability).
Source: Spring Boot: Quartz Scheduler and QuartzAutoConfiguration.java at the v4.1.1 tag. On 3.5 the same class is QuartzAutoConfiguration.java in the other package.
Jobs: QuartzJobBean and dependency injection
A Quartz job is a class that implements org.quartz.Job. Quartz creates a new instance for every firing and throws it away
afterwards, so a job cannot keep state in its fields between runs (state belongs in the JobDataMap, see
Jobs & Job Data). Spring Framework’s QuartzJobBean is the usual base class:
it implements execute for you, binds the job data to setters, and leaves one method for you to implement, executeInternal.
There are two kinds of input, and they travel by different routes.
-
Spring beans (services, repositories, a
Clock) come from theApplicationContext.SpringBeanJobFactorycallscreateBean(jobClass), which runs the normal bean machinery: the constructor is autowired,@Autowiredfields and methods are injected, and the bean post-processors run. The job is a prototype that Spring creates but does not register as a bean. -
Settings (a limit, a template name, a tenant) come from job data. Each entry of the merged
JobDataMapwhose key matches a setter name is applied to that setter by Spring’sBeanWrapper, which converts the type, for example the String"100"to anint. Keys without a matching setter are ignored.
Constructor injection and a JobDataMap setter
The job below receives the OverdueLoanService through its constructor and the per-run limit through a setter. Nothing in
it is Quartz-specific except the base class and executeInternal.
package bookshelf;
import org.quartz.JobExecutionContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.quartz.QuartzJobBean;
public class OverdueLoanReminderJob extends QuartzJobBean {
private static final Logger log = LoggerFactory.getLogger(OverdueLoanReminderJob.class);
private final OverdueLoanService service; // constructor injection: a Spring bean
private int maxReminders = 50; // setter injection: a JobDataMap entry
public OverdueLoanReminderJob(OverdueLoanService service) {
this.service = service;
}
public void setMaxReminders(int maxReminders) {
this.maxReminders = maxReminders;
}
@Override
protected void executeInternal(JobExecutionContext context) {
int sent = service.remindOverdue(maxReminders);
log.info("Overdue-loan reminders sent: {} (limit {}, trigger {})",
sent, maxReminders, context.getTrigger().getKey());
}
}
Source: QuartzJobBean Javadoc and Spring Boot: Quartz Scheduler, whose example job also uses setters.
Which injection styles work
All the styles in the table were run in the test project, each in a QuartzJobBean subclass fired by a trigger.
| Style | Works | Notes |
|---|---|---|
Constructor taking beans |
yes |
The preferred style: the dependencies are |
|
yes |
Fine for a small job, harder to test without Spring. |
|
yes |
The setter form of the same thing. |
Setter for a bean without |
no |
The setter is never called with the bean, so the field stays |
Setter for a |
yes |
The key must equal the property name: |
Spring bean stored in the |
in memory only |
Works with the RAM store. With a JDBC store the map is serialized and the application fails at startup, see the pitfalls. |
The Boot reference page shows a job whose setMyService setter has no annotation and a comment that says it injects the
bean. In the test above a setter written that way received nothing, while the same setter annotated with @Autowired
worked. Use the constructor, or add the annotation.
Where the data comes from and who wins
A job can receive settings from three places. Spring applies them in a fixed order and a later value replaces an earlier one:
first the SchedulerContext (one map for the whole scheduler, set with SchedulerFactoryBean.setSchedulerContextAsMap), then
the JobDetail’s `JobDataMap, then the Trigger’s `JobDataMap. The merge was measured with the value 1 in the context,
2 on the job and 3 on the trigger.
| Sources present | Value the setter received |
|---|---|
SchedulerContext, |
3 (from the trigger) |
SchedulerContext and |
2 (from the job) |
SchedulerContext only |
1 (from the context) |
The trigger level is how one job class serves several schedules with different settings: two triggers on the same
JobDetail, each with its own usingJobData("maxReminders", n).
A job that implements plain org.quartz.Job (not QuartzJobBean) is populated by SpringBeanJobFactory when the instance is
created, from the same three maps with the same precedence, and gets the same constructor and @Autowired injection. Using
QuartzJobBean is simply the shorter route, and it keeps the binding next to executeInternal.
Source: SpringBeanJobFactory Javadoc and SpringBeanJobFactory.java at the v7.0.9 tag.
Worked example: the Bookshelf overdue-loan reminders
The first Bookshelf job runs every night at 02:00 Europe/Madrid and reminds each member who holds an overdue loan. This section builds it end to end. The business code is deliberately plain Spring, with nothing Quartz-specific, because that is how a job should be written.
package bookshelf;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class OverdueLoanService {
private final LoanRepository loans;
private final ReminderSender sender;
private final Clock clock;
public OverdueLoanService(LoanRepository loans, ReminderSender sender, Clock clock) {
this.loans = loans;
this.sender = sender;
this.clock = clock;
}
/** Sends at most {@code max} reminders and returns how many were sent. */
public int remindOverdue(int max) {
List<Loan> overdue = loans.findOverdue(LocalDate.now(clock));
overdue.stream().limit(max).forEach(sender::send);
return Math.min(max, overdue.size());
}
}
Source: Spring Boot: Quartz Scheduler. Loan is a record, LoanRepository
and ReminderSender are interfaces, and Clock is a bean, so the service can be tested without a database or a mail server.
The schedule is declared as two beans: a JobDetail that says what to run and a Trigger that says when. Spring Boot
finds both by type.
package bookshelf;
import java.util.TimeZone;
import org.quartz.CronScheduleBuilder;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class ReminderSchedule {
@Bean
JobDetail overdueLoanReminderJobDetail(@Value("${bookshelf.reminders.max-per-run}") int maxPerRun) {
return JobBuilder.newJob(OverdueLoanReminderJob.class)
.withIdentity("overdueLoanReminders", "bookshelf")
.withDescription("Remind members about overdue loans")
.usingJobData("maxReminders", maxPerRun)
.storeDurably()
.build();
}
@Bean
Trigger overdueLoanReminderTrigger(JobDetail overdueLoanReminderJobDetail,
@Value("${bookshelf.reminders.cron}") String cron,
@Value("${bookshelf.reminders.time-zone}") TimeZone zone) {
return TriggerBuilder.newTrigger()
.forJob(overdueLoanReminderJobDetail)
.withIdentity("overdueLoanRemindersTrigger", "bookshelf")
.withSchedule(CronScheduleBuilder.cronSchedule(cron)
.inTimeZone(zone)
.withMisfireHandlingInstructionFireAndProceed())
.build();
}
}
What each choice does:
-
withIdentity("overdueLoanReminders", "bookshelf")gives the job a key. The name and group together identify it in the job store, in the Actuator and inSchedulercalls. Without an identity Quartz generates a random one. -
usingJobData("maxReminders", maxPerRun)puts the limit into theJobDataMap, from wheresetMaxRemindersreceives it. -
storeDurably()keeps theJobDetailin the store even when no trigger points at it. Without it a job that has no trigger is rejected at startup (see the pitfalls), and a job whose last trigger is removed disappears. The reminders job is durable so that a trigger can be added, replaced or paused later without recreating the job. The trade-offs are covered on Jobs & Job Data. -
forJob(overdueLoanReminderJobDetail)attaches the trigger to the job by its key. The trigger bean takes theJobDetailbean as a parameter, so Spring creates the job first. -
cronSchedule(cron).inTimeZone(zone)fires on a cron expression in an explicit time zone. Always set the zone: without it the trigger uses the JVM default, and a container that runs in UTC fires at 02:00 UTC, not 02:00 in Madrid. The Quartz grammar is explained on Cron Expressions, and the trigger types on Triggers & Schedules. -
withMisfireHandlingInstructionFireAndProceed()says what to do if the application was down at 02:00: fire once as soon as it is back, then carry on with the normal schedule. Misfires have their own page, Misfires. -
"Business days only" is not part of this first version. The Quartz way to skip holidays is a
Calendarbean, added on Calendars.
The schedule itself (cron expression, zone, limit) is externalised so that the same build can run every few seconds on a developer machine:
spring:
application:
name: bookshelf-service
quartz:
job-store-type: memory # the default; the JDBC store is covered on the job-stores page
wait-for-jobs-to-complete-on-shutdown: true
properties:
org.quartz.threadPool.threadCount: "5"
bookshelf:
reminders:
cron: "0 0 2 * * ?" # 02:00 every night
time-zone: Europe/Madrid
max-per-run: 100
management:
endpoints:
web:
exposure:
include: health,quartz
Source: Spring Boot: Quartz Scheduler and Spring Boot: Actuator Endpoints. Exposing endpoints in general is covered on Metrics & Observability.
Three of those lines deserve a comment. org.quartz.threadPool.threadCount is a raw Quartz property passed through
spring.quartz.properties; the measured default under Boot is 10 threads. wait-for-jobs-to-complete-on-shutdown: true
makes a graceful shutdown wait for a running reminder instead of abandoning it. And management.endpoints.web.exposure.include
must name quartz explicitly, because over HTTP only health is exposed by default.
The job-registration decision at startup
Registering the job and the trigger at startup looks like a plain "add", and with the in-memory store it is: the store is
empty on every start. With a persistent store the beans meet whatever an earlier run left behind, and
spring.quartz.overwrite-existing-jobs decides who wins.
This was measured with the JDBC store on an in-memory H2 database, starting the context twice against the same database and
changing the cron expression from 0 0 2 * * ? to 0 0 3 * * ? and the limit from 100 to 200 between the starts.
overwrite-existing-jobs |
Stored cron after the second start | Stored maxReminders |
|---|---|---|
|
|
100, the old value |
|
|
200, the new value |
So with a persistent store and the default setting, editing the cron expression in application.yml and redeploying changes
nothing. Either set overwrite-existing-jobs: true (the code is then the source of truth for every bean-defined job and
trigger, and a runtime change to those keys is lost at the next restart), or change the trigger at runtime through the
Scheduler API, covered on Dynamic Scheduling & Management.
Source: Spring Boot: Quartz Scheduler, which says that jobs created by configuration do not overwrite jobs read from a persistent job store by default, and SchedulerAccessor.java at the v7.0.9 tag for the exact rule.
Running it and reading /actuator/quartz
Start the application with a cron expression that fires every ten seconds, so the first run does not take until 02:00. The override goes through an environment variable because a cron expression contains spaces:
BOOKSHELF_REMINDERS_CRON='0/10 * * * * ?' ./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8089
Source: Spring Boot: Quartz Scheduler. Spring Boot’s relaxed binding maps
BOOKSHELF_REMINDERS_CRON to bookshelf.reminders.cron.
The log shows the scheduler being built, started after the web server, and then the job firing on the Quartz worker threads (lines shortened):
INFO org.quartz.core.QuartzScheduler : Quartz Scheduler v2.5.2 created.
INFO org.quartz.simpl.RAMJobStore : RAMJobStore initialized.
INFO org.quartz.core.QuartzScheduler : Scheduler meta-data: Quartz Scheduler (v2.5.2) 'quartzScheduler' with instanceId 'NON_CLUSTERED'
NOT STARTED.
Using thread pool 'org.quartz.simpl.SimpleThreadPool' - with 5 threads.
Using job-store 'org.quartz.simpl.RAMJobStore' - which does not support persistence. and is not clustered.
INFO org.quartz.core.QuartzScheduler : JobFactory set to: org.springframework.scheduling.quartz.SpringBeanJobFactory@626b639e
INFO o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8089 (http) with context path '/'
INFO o.s.s.quartz.SchedulerFactoryBean : Starting Quartz Scheduler now
INFO bookshelf.BookshelfApplication : Started BookshelfApplication in 1.171 seconds
INFO [eduler_Worker-1] bookshelf.LoggingReminderSender : Reminder to ada@example.com: 'The Pragmatic Programmer' was due on 2026-10-02
INFO [eduler_Worker-1] bookshelf.LoggingReminderSender : Reminder to linus@example.com: 'Refactoring' was due on 2026-09-25
INFO [eduler_Worker-1] bookshelf.OverdueLoanReminderJob : Overdue-loan reminders sent: 2 (limit 100, trigger bookshelf.overdueLoanRemindersTrigger)
INFO [eduler_Worker-2] bookshelf.OverdueLoanReminderJob : Overdue-loan reminders sent: 2 (limit 100, trigger bookshelf.overdueLoanRemindersTrigger)
Source: Quartz Quick Start Guide, which starts a scheduler and schedules a first job the same way outside Spring.
The meta-data block reports the three facts that decide how the scheduler behaves: the store (RAMJobStore, no
persistence), the thread pool (5 threads, as configured) and that the scheduler is not clustered. The job ran on a different
worker thread each time, as the thread name shows. Quartz hands every firing to whichever pool thread is free.
The quartz Actuator endpoint lists what the scheduler knows. It is read-only apart from one operation, "run this job now".
| Request | Returns |
|---|---|
|
The job group names and the trigger group names. |
|
Every group with the names of its jobs or triggers. |
|
One group in detail; triggers are grouped by type ( |
|
One job: class name, durability, recovery flag, masked data and its triggers with the previous and next fire times. |
|
One trigger: state, type, start, previous and next fire time, priority and the cron expression with its time zone. |
|
Runs the job now, once, and returns the trigger time. Any other |
{
"className": "bookshelf.OverdueLoanReminderJob",
"data": {
"maxReminders": "******"
},
"description": "Remind members about overdue loans",
"durable": true,
"group": "bookshelf",
"name": "overdueLoanReminders",
"requestRecovery": false,
"triggers": [
{
"group": "bookshelf",
"name": "overdueLoanRemindersTrigger",
"previousFireTime": "2026-10-05T09:31:10.000Z",
"nextFireTime": "2026-10-05T09:31:20.000Z",
"priority": 5
}
]
}
{
"group": "bookshelf",
"name": "overdueLoanRemindersTrigger",
"state": "NORMAL",
"type": "cron",
"startTime": "2026-10-05T09:30:52.000Z",
"previousFireTime": "2026-10-05T09:31:10.000Z",
"nextFireTime": "2026-10-05T09:31:20.000Z",
"priority": 5,
"data": {},
"cron": {
"expression": "0/10 * * * * ?",
"timeZone": "Europe/Madrid"
}
}
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"state":"running"}' \
localhost:8089/actuator/quartz/jobs/bookshelf/overdueLoanReminders
{"className":"bookshelf.OverdueLoanReminderJob","group":"bookshelf","name":"overdueLoanReminders","triggerTime":"2026-10-05T09:31:16.005152Z"}
Three behaviours are easy to trip over. The endpoint is not exposed over HTTP unless management.endpoints.web.exposure.include
names it; without that, GET /actuator/quartz returns 404. The job data is masked as because
management.endpoint.quartz.show-values defaults to never; set it to always only for trusted users, optionally together
with management.endpoint.quartz.roles. And the endpoint can trigger a job but cannot pause, resume, delete or reschedule
anything; that needs the Scheduler API. Securing an endpoint that can start jobs is a real concern, and
Production & Best Practices returns to it.
The official smoke test
Spring Boot ships a small Quartz application that it builds and tests on every release, spring-boot-smoke-test-quartz,
and it is the best compact reference for how the authors expect the pieces to fit together. In the v4.1.1 tag it consists of:
-
a
QuartzJobBeansubclass with a singlesetNamesetter, so the job data entrynamereaches it by name, which prints a greeting; -
an application class that declares three
JobDetailbeans (all durable) and fiveTriggerbeans, one or two of each builder kind: twoSimpleTrigger`s (every two seconds, and every 24 hours), a `CronTrigger(03:00 on weekdays), aCalendarIntervalTrigger(weekly) and aDailyTimeIntervalTrigger(Tuesdays and Thursdays between 09:00 and 18:00); -
an
application.propertieswithspring.quartz.job-store-type=jdbcandmanagement.endpoints.web.exposure.include=health,quartz; with H2 on the classpath and the defaultembeddedinitialisation mode, Boot creates the Quartz tables on its own; -
one test that starts the application and waits up to five seconds for the greeting in the captured output, and one web test that calls every
/actuator/quartzpath, expects 404 for unknown names and posts{"state":"running"}to run a job on demand.
Its build uses exactly the dependencies from the table at the top of this page, including spring-boot-starter-quartz-test.
The same two checks, for the Bookshelf job, take this shape. The first test asks the Scheduler what was registered; the
second waits for the job to fire. @SpringBootTest starts the whole application, so the YAML, the beans and the auto-configuration
are all exercised, and the properties on the annotation speed the schedule up.
package bookshelf;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.containsString;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.quartz.CronTrigger;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.TriggerKey;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
@SpringBootTest(properties = {
"bookshelf.reminders.cron=0/2 * * * * ?", // fire every two seconds instead of at 02:00
"bookshelf.reminders.max-per-run=1"
})
@ExtendWith(OutputCaptureExtension.class)
class OverdueLoanReminderSmokeTest {
@Autowired
Scheduler scheduler;
@Test
void jobAndTriggerAreRegistered() throws SchedulerException {
JobDetail job = scheduler.getJobDetail(JobKey.jobKey("overdueLoanReminders", "bookshelf"));
CronTrigger trigger = (CronTrigger) scheduler
.getTrigger(TriggerKey.triggerKey("overdueLoanRemindersTrigger", "bookshelf"));
assertThat(job.isDurable()).isTrue();
assertThat(job.getJobDataMap().getInt("maxReminders")).isEqualTo(1);
assertThat(trigger.getJobKey()).isEqualTo(job.getKey());
assertThat(trigger.getTimeZone().getID()).isEqualTo("Europe/Madrid");
}
@Test
void jobFires(CapturedOutput output) {
await().atMost(Duration.ofSeconds(10))
.until(output::toString, containsString("Overdue-loan reminders sent: 1 (limit 1"));
}
}
Source: spring-boot-smoke-test-quartz
at the v4.1.1 tag, whose SampleQuartzApplicationTests and SampleQuartzApplicationWebTests this test follows. Both tests passed
in the test project (2 tests, 1.1 s). Better ways to test jobs without waiting for a clock, such as calling the job’s service
directly or driving a Scheduler by hand, are on Testing.
Spring Framework’s factory beans
Before Spring Boot and before Quartz had its fluent builders, Spring Framework made Quartz configurable from XML with four
factory beans in org.springframework.scheduling.quartz. They still ship in Spring Framework 7.0.9 and still work with Spring
Boot: a bean of type FactoryBean<JobDetail> or FactoryBean<Trigger> produces a JobDetail or Trigger bean, which the
auto-configuration then collects like any other.
| Factory bean | Produces | What it adds |
|---|---|---|
|
|
Properties instead of |
|
|
Calls a method on a Spring bean ( |
|
|
|
|
|
|
The example below declares one job of each kind, as Java configuration. The first pair is a job class with a cron trigger.
The second pair calls a method on a bean and repeats every second, with concurrent set to false.
package bookshelf;
import java.util.Map;
import java.util.TimeZone;
import org.quartz.JobDetail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.CronTriggerFactoryBean;
import org.springframework.scheduling.quartz.JobDetailFactoryBean;
import org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean;
import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;
@Configuration(proxyBeanMethods = false)
class FactoryBeanSchedule {
@Bean
JobDetailFactoryBean remindersJobDetail() {
JobDetailFactoryBean factory = new JobDetailFactoryBean();
factory.setName("factoryReminders");
factory.setGroup("bookshelf");
factory.setJobClass(OverdueLoanReminderJob.class);
factory.setDurability(true);
factory.setJobDataAsMap(Map.of("maxReminders", 7));
return factory;
}
@Bean
CronTriggerFactoryBean remindersTrigger(JobDetail remindersJobDetail) {
CronTriggerFactoryBean factory = new CronTriggerFactoryBean();
factory.setName("factoryCron");
factory.setGroup("bookshelf");
factory.setJobDetail(remindersJobDetail);
factory.setCronExpression("0 0 2 * * ?");
factory.setTimeZone(TimeZone.getTimeZone("Europe/Madrid"));
factory.setMisfireInstructionName("MISFIRE_INSTRUCTION_FIRE_ONCE_NOW");
return factory;
}
@Bean
MethodInvokingJobDetailFactoryBean cacheRefreshJobDetail(CatalogueCache cache) {
MethodInvokingJobDetailFactoryBean factory = new MethodInvokingJobDetailFactoryBean();
factory.setName("cacheRefresh");
factory.setGroup("bookshelf");
factory.setTargetObject(cache);
factory.setTargetMethod("refresh");
factory.setConcurrent(false);
return factory;
}
@Bean
SimpleTriggerFactoryBean cacheRefreshTrigger(JobDetail cacheRefreshJobDetail) {
SimpleTriggerFactoryBean factory = new SimpleTriggerFactoryBean();
factory.setName("cacheRefreshTrigger");
factory.setGroup("bookshelf");
factory.setJobDetail(cacheRefreshJobDetail);
factory.setStartDelay(500); // milliseconds
factory.setRepeatInterval(1000); // milliseconds
return factory;
}
}
Source: Spring Framework: Task Execution and Scheduling, section
"Using the Quartz Scheduler", and the Javadoc of
JobDetailFactoryBean,
MethodInvokingJobDetailFactoryBean,
SimpleTriggerFactoryBean
and CronTriggerFactoryBean.
In the test project (the same class with a Counter bean in place of CatalogueCache, and a cron expression that fires every two
seconds) both jobs fired: the cron job logged limit 7, and the method-invoking job was called twice within the first seconds.
Reading the 7.0.9 source and inspecting the registered objects shows where the factory beans differ from the builders:
| Detail | What happens |
|---|---|
Trigger priority |
The factory beans leave it at |
|
The default is |
|
It stores the method invoker, which is not serializable, in the |
|
A trigger without |
|
Defaults to the JVM default zone when |
|
|
Names |
Without |
Misfire instruction |
Pass an integer to |
When would you still reach for them? For new code, the builders in the worked example are shorter and map one-to-one onto the
Quartz documentation, so they are the default recommendation. The factory beans remain useful in four situations: an existing
Spring XML configuration that you do not want to rewrite, because the same properties can be set from XML; a shared
library that exposes configurable triggers through @ConfigurationProperties; MethodInvokingJobDetailFactoryBean for a
quick "call this bean method on a schedule" when the in-memory store is enough; and misfireInstructionName when you want the
instruction spelled out as text in configuration. If the only goal is to call a method on a timer with the in-memory
store, @Scheduled is simpler still, and the trade-off is laid out on
Standard Scheduling vs Quartz.
Pitfalls
Every row below was reproduced in the test project unless it says otherwise.
| Pitfall | What you see | Fix |
|---|---|---|
A setter for a Spring bean without |
The setter is never called with the bean, the field stays |
Inject beans through the constructor, or annotate the field or setter with |
A misspelled |
No error. The setter is not called and the default value is used: a job with the key |
Cover the effect in a smoke test; keep job data keys and setter names in constants or in one builder method. |
A Spring bean stored in the |
Startup fails with |
Keep only Strings and primitives in the map, and take beans from the constructor. |
A |
Startup fails with |
Call |
A trigger whose job does not exist |
Startup fails with |
Declare the |
Editing the cron expression with a persistent store |
After a redeploy the old schedule keeps running, because the stored trigger wins unless |
Set the property, or change the trigger at runtime through the |
Leaving the time zone out of a cron trigger |
The trigger uses the JVM zone, so a container in UTC fires at the wrong local hour. |
|
|
|
Use whole seconds, or start the scheduler yourself ( |
The in-memory store in production |
Nothing survives a restart, and every instance runs every job (two instances send every reminder twice). |
Use a JDBC store, see Job Stores & Persistence and Clustering. |
A |
It is ignored by Spring Boot: with the file present the scheduler still had 10 threads and a 5-second misfire threshold. |
Use |
More than one |
No error. Boot silently falls back to the in-memory store, so jobs vanish at restart. |
Mark one |
|
The endpoint is not exposed over HTTP by default and masks job data by default. |
Add |
Mixing factory-bean and builder triggers |
The factory-bean triggers run at priority 0, the builder ones at 5. |
Pick one style, or set |
What changed
| Area | Spring Boot 3.5.16 | Spring Boot 4.1.1 |
|---|---|---|
Auto-configuration package |
|
|
Types you import: |
in the 3.5 package above |
in the 4.x package above; update the imports when upgrading |
Actuator endpoint classes |
|
|
Quartz test starter |
none; use |
|
Quartz version managed by Boot |
2.5.2 |
2.5.2 |
Spring Framework |
6.2.19 |
7.0.9. The package |
|
|
the same names |
Two earlier changes can also surprise someone upgrading Quartz itself, as opposed to Spring Boot. Quartz 2.5 removed the
misspelled method JobDetail.isConcurrentExectionDisallowed(), and community job stores compiled against 2.4 or earlier that
still call it fail at runtime. The MongoDB store is the one that fails silently: it throws NoSuchMethodError on
isConcurrentExectionDisallowed, the scheduler starts and nothing fires (see MongoDB).
The Redis store fails visibly, for other reasons: at startup with Boot’s Jedis 7.4.1 (a NoSuchMethodError on
JedisCommands.del(String)) and, with Jedis 3.3.0, on the JSON (de)serialization of JobDetailImpl (a Jackson
UnrecognizedPropertyException) (see Redis). Neither store works with the Quartz 2.5.2 that
both Boot lines manage.
The full list of version changes and documentation discrepancies found while writing this section is on
Migration & What’s Changed.
Source: Spring Boot 4.0 Migration Guide and the Spring Boot 4.0 release notes at the Spring Boot wiki. The package and class names above were read from the 3.5.16 jars and the 4.1.1 sources.
Where to go next
-
A job that must not overlap, survive a crash or carry state: Jobs & Job Data.
-
Triggers beyond cron, and cron itself: Triggers & Schedules and Cron Expressions.
-
Making the reminders survive a restart: Job Stores & Persistence and PostgreSQL.
-
Every property and the customizer hook: Spring Boot Configuration.
-
Creating and cancelling triggers at runtime, for example one reminder per loan: Dynamic Scheduling & Management.
-
The simpler
@Scheduledalternative: Scheduling & ShedLock.
References
-
Spring Boot: Quartz Scheduler (3.5 version: Spring Boot 3.5: Quartz Scheduler)