Mastering Java Date and Time with Quarkus: Build a Fun Birthday API Across Calendars and Planets
Learn how to handle Java’s java.time API, work with world calendars, and compute planetary ages by turning a birthday into a Quarkus application.
APIs usually handle invoices, orders, or users. Rarely do they deal with something as personal as your birthday. But dates and times are hard, and age calculations reveal almost every pitfall in the Java java.time
API.
In this hands-on tutorial we’ll turn a birthday into a playground for learning:
Compute your age in different units (seconds, days, years).
Express it across multiple calendars (ISO, Japanese, Chinese, Islamic).
Project it onto planetary years (Mercury, Mars, Jupiter).
Show the same instant worldwide, so you see when you were born in New York, Tokyo, or São Paulo.
It’s fun, but also a serious exercise in correctness. By the end, you’ll have a Quarkus application that returns ages as JSON and a small HTML page to play with.
Prerequisites
You need:
Java 17+
Maven 3.9+
Quarkus CLI (optional but convenient)
Podman (or Docker) if you want to run a container
Bootstrap the project
We’ll start with a simple Quarkus app that already has REST, OpenAPI, and templating:
quarkus create app org.acme:age-everywhere:1.0.0 \
-x rest-jackson,smallrye-openapi,rest-qute
cd age-everywhere
This gives you a Maven project with dev mode and live reload. And if you do not want to step through each single file, go directly to my Github repository and download the working example.
Dependencies
We’ll add two extra libraries:
ICU4J for the Chinese lunisolar calendar.
ThreeTen-Extra for additional temporal helpers.
Update pom.xml
:
<!-- Non-ISO calendars -->
<dependency>
<groupId>com.ibm.icu</groupId>
<artifactId>icu4j</artifactId>
<version>77.1</version>
</dependency>
<dependency>
<groupId>org.threeten</groupId>
<artifactId>threeten-extra</artifactId>
<version>1.8.0</version>
</dependency>
</dependencies>
And configure Swagger UI for easy testing. Update the applications.properties file.
# src/main/resources/application.properties
quarkus.http.port=8080
quarkus.smallrye-openapi.path=/q/openapi
quarkus.swagger-ui.always-include=true
Data models
Let’s define the objects we’ll return. Keep them as Java records. Create the following files in: src/main/java/org/acme/age
package org.acme.age;
public record PlanetaryAge(
double onMercuryYears,
double onVenusYears,
double onEarthYears,
double onMarsYears,
double onJupiterYears,
double onSaturnYears,
double onUranusYears,
double onNeptuneYears) {
}
package org.acme.age;
public record CalendarBreakdown(
String iso,
String japanese,
String minguo,
String thaiBuddhist,
String hijrah,
String chinese,
String note) {
}
package org.acme.age;
public record WorldTime(
String city,
String zone,
String localBirthTime) {
}
package org.acme.age;
import java.util.List;
public record AgeResponse(
String inputDob,
String timezone,
long epochSecondsLived,
long daysLived,
String isoPeriod,
String humanPeriod,
long ageYearsFloor,
PlanetaryAge planets,
CalendarBreakdown calendars,
List<WorldTime> worldTimes) {
}
The service
This is where the real logic lives. We compute:
Period and duration since birth.
Planetary ages.
Non-ISO calendar representations.
The same instant worldwide.
package org.acme.age;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Period;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.chrono.HijrahDate;
import java.time.chrono.JapaneseDate;
import java.time.chrono.MinguoDate;
import java.time.chrono.ThaiBuddhistDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.ibm.icu.util.ChineseCalendar;
public class AgeService {
private static final double MERCURY = 0.2408467;
private static final double VENUS = 0.61519726;
private static final double EARTH = 1.0;
private static final double JUPITER = 11.862615;
private static final double SATURN = 29.447498;
private static final double URANUS = 84.016846;
private static final double NEPTUNE = 164.79132;
private static final double MARS_SOL_SECONDS = 88775.244;
public AgeResponse compute(LocalDate dob, ZoneId zone) {
ZonedDateTime now = ZonedDateTime.now(zone);
ZonedDateTime birth = dob.atStartOfDay(zone);
long totalSeconds = ChronoUnit.SECONDS.between(birth, now);
long days = ChronoUnit.DAYS.between(birth, now);
Period period = Period.between(dob, now.toLocalDate());
String human = "%d years, %d months, %d days".formatted(
period.getYears(), period.getMonths(), period.getDays());
double earthYears = totalSeconds / (365.2425 * 24 * 3600.0);
PlanetaryAge pa = new PlanetaryAge(
earthYears / MERCURY,
earthYears / VENUS,
earthYears / EARTH,
(totalSeconds / MARS_SOL_SECONDS) / 668.5991,
earthYears / JUPITER,
earthYears / SATURN,
earthYears / URANUS,
earthYears / NEPTUNE);
CalendarBreakdown cb = calendars(dob, zone);
List<WorldTime> wt = worldTimes(dob, zone);
return new AgeResponse(
dob.toString(),
zone.toString(),
totalSeconds,
days,
period.toString(),
human,
period.getYears(),
pa,
cb,
wt);
}
private CalendarBreakdown calendars(LocalDate dob, ZoneId zone) {
DateTimeFormatter f = DateTimeFormatter.ISO_LOCAL_DATE;
JapaneseDate jp = JapaneseDate.from(dob);
MinguoDate mg = MinguoDate.from(dob);
ThaiBuddhistDate th = ThaiBuddhistDate.from(dob);
HijrahDate hj = HijrahDate.from(dob);
ZonedDateTime zdt = dob.atStartOfDay(zone);
long millis = zdt.toInstant().toEpochMilli();
ChineseCalendar cc = new ChineseCalendar();
cc.setTimeInMillis(millis);
int cy = cc.get(ChineseCalendar.EXTENDED_YEAR);
int cm = cc.get(ChineseCalendar.MONTH) + 1;
int cd = cc.get(ChineseCalendar.DAY_OF_MONTH);
boolean leap = cc.get(ChineseCalendar.IS_LEAP_MONTH) == 1;
String chinese = "Y%04d-%s%02d-%02d".formatted(
cy, leap ? "L" : "", cm, cd);
return new CalendarBreakdown(
dob.format(f),
jp.toString(),
mg.toString(),
th.toString(),
hj.toString(),
chinese,
"Chinese calendar is lunisolar. Leap months may occur.");
}
private List<WorldTime> worldTimes(LocalDate dob, ZoneId originalZone) {
ZonedDateTime birth = dob.atStartOfDay(originalZone);
Instant instant = birth.toInstant();
return List.of(
new WorldTime("Berlin", "Europe/Berlin", instant.atZone(ZoneId.of("Europe/Berlin")).toString()),
new WorldTime("New York", "America/New_York", instant.atZone(ZoneId.of("America/New_York")).toString()),
new WorldTime("Tokyo", "Asia/Tokyo", instant.atZone(ZoneId.of("Asia/Tokyo")).toString()),
new WorldTime("São Paulo", "America/Sao_Paulo",
instant.atZone(ZoneId.of("America/Sao_Paulo")).toString()),
new WorldTime("Sydney", "Australia/Sydney", instant.atZone(ZoneId.of("Australia/Sydney")).toString()));
}
}
Notice how the worldTimes list shows the same instant worldwide. You see your exact birth moment in multiple cities.
REST resource
The REST layer is just a thin wrapper.
package org.acme.age;
import java.time.LocalDate;
import java.time.ZoneId;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@Path("/age")
@Produces(MediaType.APPLICATION_JSON)
@ApplicationScoped
public class AgeResource {
private final AgeService svc = new AgeService();
@GET
public AgeResponse get(@QueryParam("dob") String dob,
@QueryParam("tz") @DefaultValue("Europe/Berlin") String tz) {
if (dob == null || dob.isBlank()) {
throw new BadRequestException("Query param 'dob' is required (YYYY-MM-DD)");
}
LocalDate date = LocalDate.parse(dob);
ZoneId zone = ZoneId.of(tz);
return svc.compute(date, zone);
}
}
Add a simple UI
A single Qute page makes it interactive. Create the file: src/main/resources/templates/age.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Age Everywhere</title>
</head>
<body>
<h1>Age in Every Calendar</h1>
<form id="f">
<label>DOB: <input name="dob" value="1980-09-27"></label>
<label>Time zone: <input name="tz" value="Europe/Berlin"></label>
<button type="submit">Compute</button>
</form>
<pre id="out"></pre>
<script>
document.getElementById('f').addEventListener('submit', async (e) => {
e.preventDefault();
const dob = e.target.dob.value;
const tz = e.target.tz.value;
const res = await fetch(`/age?dob={|${encodeURIComponent(dob)}|}&tz={|${encodeURIComponent(tz)}|}`);
document.getElementById('out').textContent = JSON.stringify(await res.json(), null, 2);
});
</script>
</body>
</html>
Note: Take a close look how I escape the Java Script functions in the template. This is called “Unparsed Character Data”. You could also just simply escape the first {
, i.e. something like $\{encodeURIComponent(tz)}.
The page is served via a REST endpoint: src/main/java/org/acme/age/PageResource.java
package org.acme.age;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/")
public class PageResource {
@Inject
Template age;
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance page() {
return age.instance();
}
}
Note: if you only see a String representation of your Template, make sure to check that you have included quarkus-rest-qute as dependency!
Run and test
Start in dev mode:
quarkus dev
Visit:
UI: http://localhost:8080/
Swagger: http://localhost:8080/q/swagger-ui
Test via curl:
curl "http://localhost:8080/age?dob=1984-09-27&tz=Europe/Berlin" | jq
Sample output:
{
"inputDob": "1984-09-27",
"timezone": "Europe/Berlin",
"epochSecondsLived": 1292485528,
"daysLived": 14959,
"isoPeriod": "P40Y11M15D",
"humanPeriod": "40 years, 11 months, 15 days",
"ageYearsFloor": 40,
"planets": {
"onMercuryYears": 170.0552090629853,
"onVenusYears": 66.57577753293327,
"onEarthYears": 40.957235920630104,
"onMarsYears": 21.7754945250666,
"onJupiterYears": 3.452631306050993,
"onSaturnYears": 1.3908562255655847,
"onUranusYears": 0.4874883772788865,
"onNeptuneYears": 0.24854000757218342
},
"calendars": {
"iso": "1984-09-27",
"japanese": "Japanese Showa 59-09-27",
"minguo": "Minguo ROC 73-09-27",
"thaiBuddhist": "ThaiBuddhist BE 2527-09-27",
"hijrah": "Hijrah-umalqura AH 1405-01-02",
"chinese": "Y4621-09-03",
"note": "Chinese calendar is lunisolar. Leap months may occur."
},
"worldTimes": [
{
"city": "Berlin",
"zone": "Europe/Berlin",
"localBirthTime": "1984-09-27T00:00+02:00[Europe/Berlin]"
},
{
"city": "New York",
"zone": "America/New_York",
"localBirthTime": "1984-09-26T18:00-04:00[America/New_York]"
},
{
"city": "Tokyo",
"zone": "Asia/Tokyo",
"localBirthTime": "1984-09-27T07:00+09:00[Asia/Tokyo]"
},
{
"city": "São Paulo",
"zone": "America/Sao_Paulo",
"localBirthTime": "1984-09-26T19:00-03:00[America/Sao_Paulo]"
},
{
"city": "Sydney",
"zone": "Australia/Sydney",
"localBirthTime": "1984-09-27T08:00+10:00[Australia/Sydney]"
}
]
}
Notice how your birthday in Berlin was still the previous evening in New York.
Learning notes
This small app highlights several lessons:
Period
vsDuration
: One counts calendar units, the other exact seconds. Use both.Time zones matter: Always specify a
ZoneId
to avoid off-by-one surprises.Non-ISO calendars are built-in: Japanese, Thai, Islamic are in
java.time.chrono
. Others like Chinese need libraries.Same instant worldwide: Use
Instant
as the universal anchor, then project into local zones.
Production notes
If you run this in production:
Validate input dates and reject future values.
Provide clear error responses (RFC 7807 Problem Details).
Pin library versions and update ICU4J regularly.
Consider caching responses by DOB and zone.
Want to extend this further?
Add Hebrew or Persian calendars via ICU4J.
Show the countdown to the next birthday in every zone.
Add a
/planet/{name}
endpoint for your “first birthday on Mars.”
Containerize it
# Package the application
./mvnw -DskipTests package
# Build image with Podman
podman build -t localhost/age-everywhere:1.0.0 -f src/main/docker/Dockerfile.jvm .
#Run Container
podman run --rm -p 8080:8080 localhost/age-everywhere:1.0.0
Time is Complicated
Turning your birthday into an API is a playful way to explore a tricky part of Java: dates, times, and calendars. If you can survive leap months in the Chinese calendar and line up the same instant across continents, you’re ready for enterprise-grade systems.
But beyond the code, it’s also a reminder that age isn’t just a number. It’s stories, milestones, and different ways of looking at the same journey. Whether you measure in Earth years, Mars sols, or Japanese imperial eras, it’s still your time.
Time is complicated, but building this app shows that you can master it and maybe even enjoy the ride around the sun.