1. Overview

Handling date and time is a fundamental part of many Java applications. Over the years, Java has evolved in dealing with dates, introducing better solutions to simplify things for developers.

In this tutorial, we’ll first explore Java’s history with dates, starting with older classes. Then, we’ll move on to modern best practices, ensuring we can work confidently with dates.

2. Legacy Approaches

Before the java.time package came along, the Date and Calendar classes primarily handled date management. These classes worked, but they had their quirks.

2.1. The java.util.Date Class

The java.util.Date class was Java’s original solution for handling dates, but it has a few shortcomings:

  • It’s mutable, meaning we could run into thread-safety issues.
  • There’s no support for time zones.
  • It uses confusing method names and return values, like getYear(), which returns the number of years since 1900.
  • Many methods are now deprecated.

Creating a Date object using its no-argument constructor represents the current date and time (the moment the object is created). Let’s instantiate a Date object and print its value:

Date now = new Date();
logger.info("Current date and time: {}", now);

This will output the current date and time, like Wed Sep 24 10:30:45 PDT 2024. While this constructor is still functional, it’s no longer recommended for new projects for the reasons mentioned.

2.2. The java.util.Calendar Class

After facing limitations with Date, Java introduced the Calendar class, which offered improvements:

  • Support for various calendar systems
  • Time zone management
  • More intuitive ways to manipulate dates

We can also manipulate dates using Calendar:

Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, 5);
Date fiveDaysLater = cal.getTime();

In this example, we calculate the date 5 days from the current date and store it in a Date object.

But even Calendar has its flaws:

  • Like Date, it’s still mutable and not thread-safe.
  • Its API can be confusing and complex, like the zero-based indexing for months.

3. Modern Approach: The java.time Package

With Java 8, the java.time package arrived, providing a modern, robust API for handling dates and times. It was designed to solve many problems with the older Date and Calendar classes, making date and time manipulation more intuitive and user-friendly.

Inspired by the popular Joda-Time library, java.time is now the core Java solution for working with dates and times.

3.1. Key Classes in java.time

The java.time package offers several important classes frequently used in real-world applications. These classes can be grouped into three main categories:

Time Containers:

  • LocalDate: Represents just the date (without time or time zone)
  • LocalTime: Represents the time but without a date or time zone
  • LocalDateTime: Combines date and time but without the time zone
  • ZonedDateTime: Includes both date and time along with a time zone
  • Instant: Represents a specific point on the timeline, similar to a timestamp

Time Manipulators:

  • Duration: Represents a time-based amount of time (for example, “5 hours” or “30 seconds”)
  • Period: Represents a date-based amount of time (such as “2 years, 3 months”)
  • TemporalAdjusters: Provides methods to adjust dates (like finding the next Monday)
  • Clock: Provides the current date-time using a time zone and allows for time control

Formatters/Printers:

3.2. Advantages of java.time

The java.time package brings several improvements over the older date and time classes:

  • Immutability: All classes are immutable, ensuring thread safety.
  • Clear API: Methods are consistent, making the API easier to understand.
  • Focused Classes: Each class has a specific role, whether it handles storing a date, manipulating it, or formatting it.
  • Formatting and Parsing: Built-in methods make it easy to format and parse dates.

4. Examples of Using java.time

Before diving into more advanced features, let’s start with the basics of creating date and time representations using the java.time package. Once we have a solid foundation, we’ll explore how to adjust dates and how to format and parse them.

4.1. Creating Date Representations

The java.time package provides several classes to represent different aspects of dates and times. Let’s create a basic date using LocalDate, LocalTime, and LocalDateTime:

@Test
void givenCurrentDateTime_whenUsingLocalDateTime_thenCorrect() {
    LocalDate currentDate = LocalDate.now(); // Current date
    LocalTime currentTime = LocalTime.now(); // Current time
    LocalDateTime currentDateTime = LocalDateTime.now(); // Current date and time

    assertThat(currentDate).isBeforeOrEqualTo(LocalDate.now());
    assertThat(currentTime).isBeforeOrEqualTo(LocalTime.now());
    assertThat(currentDateTime).isBeforeOrEqualTo(LocalDateTime.now());
}

We can also create specific dates and times by passing the required parameters:

@Test
void givenSpecificDateTime_whenUsingLocalDateTime_thenCorrect() {
    LocalDate date = LocalDate.of(2024, Month.SEPTEMBER, 18);
    LocalTime time = LocalTime.of(10, 30);
    LocalDateTime dateTime = LocalDateTime.of(date, time);

    assertEquals("2024-09-18", date.toString());
    assertEquals("10:30", time.toString());
    assertEquals("2024-09-18T10:30", dateTime.toString());
}

4.2. Adjusting Date Representations with TemporalAdjusters

Once we have a date representation, we can adjust it using TemporalAdjusters. The TemporalAdjusters class provides a set of predefined methods to manipulate dates:

@Test
void givenTodaysDate_whenUsingVariousTemporalAdjusters_thenReturnCorrectAdjustedDates() {
    LocalDate today = LocalDate.now();

    LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
    assertThat(nextMonday.getDayOfWeek())
        .as("Next Monday should be correctly identified")
        .isEqualTo(DayOfWeek.MONDAY);

    LocalDate firstDayOfMonth = today.with(TemporalAdjusters.firstDayOfMonth());
    assertThat(firstDayOfMonth.getDayOfMonth())
        .as("First day of the month should be 1")
        .isEqualTo(1);
}

In addition to predefined adjusters, we can create custom adjusters for specific needs:

@Test
void givenCustomTemporalAdjuster_whenAddingTenDays_thenCorrect() {
    LocalDate specificDate = LocalDate.of(2024, Month.SEPTEMBER, 18);
    TemporalAdjuster addTenDays = temporal -> temporal.plus(10, ChronoUnit.DAYS);
    LocalDate adjustedDate = specificDate.with(addTenDays);

    assertEquals(
      today.plusDays(10),
      adjustedDate,
      "The adjusted date should be 10 days later than September 18, 2024"
    );
}

4.3. Formatting Dates

The DateTimeFormatter class in the java.time.format package allows us to format and parse date-time objects in a thread-safe manner:

@Test
void givenDateTimeFormat_whenFormatting_thenVerifyResults() {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm");
    LocalDateTime specificDateTime = LocalDateTime.of(2024, 9, 18, 10, 30);

    String formattedDate = specificDateTime.format(formatter);
    LocalDateTime parsedDateTime = LocalDateTime.parse("18-09-2024 10:30", formatter);

    assertThat(formattedDate).isNotEmpty().isEqualTo("18-09-2024 10:30");
}

We can use predefined formats or custom patterns depending on our needs.

4.4. Parsing Dates

Similarly, DateTimeFormatter can parse a string representation back into a date or time object:

@Test
void givenDateTimeFormat_whenParsing_thenVerifyResults() {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm");

    LocalDateTime parsedDateTime = LocalDateTime.parse("18-09-2024 10:30", formatter);

    assertThat(parsedDateTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getYear()).isEqualTo(2024);
                assertThat(time.getMonth()).isEqualTo(Month.SEPTEMBER);
                assertThat(time.getDayOfMonth()).isEqualTo(18);
                assertThat(time.getHour()).isEqualTo(10);
                assertThat(time.getMinute()).isEqualTo(30);
            });
}

4.5. Working with Time Zones via OffsetDateTime and OffsetTime

When working with different time zones, the OffsetDateTime and OffsetTime classes are useful for handling date and time values or offsets from UTC:

@Test
void givenVariousTimeZones_whenCreatingOffsetDateTime_thenVerifyOffsets() {
    ZoneId parisZone = ZoneId.of("Europe/Paris");
    ZoneId nyZone = ZoneId.of("America/New_York");

    OffsetDateTime parisTime = OffsetDateTime.now(parisZone);
    OffsetDateTime nyTime = OffsetDateTime.now(nyZone);

    assertThat(parisTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getOffset().getTotalSeconds())
                        .isEqualTo(parisZone.getRules().getOffset(Instant.now()).getTotalSeconds());
            });

    // Verify time differences between zones
    assertThat(ChronoUnit.HOURS.between(nyTime, parisTime) % 24)
            .isGreaterThanOrEqualTo(5)  // NY is typically 5-6 hours behind Paris
            .isLessThanOrEqualTo(7);
}

Here we demonstrate how to create OffsetDateTime instances for different time zones and verify their offsets. We start by defining the time zones for Paris and New York using ZoneId. Then, we capture the current time in both zones with OffsetDateTime.now().

The test checks that the Paris time offset matches the expected offset for the Paris time zone. Finally, we verify the time difference between New York and Paris, ensuring it falls within the typical range of 5 to 7 hours, reflecting the standard time zone difference.

4.6. Advanced Use Cases: Clock

The Clock class in the java.time package provides a flexible way to access the current date and time, considering a specific time zone. It is instrumental in scenarios where we need more control over time or when testing time-based logic.

Unlike using LocalDateTime.now(), which gets the system’s current time, Clock allows us to obtain the time relative to a specific time zone or even simulate time for testing purposes. By passing a ZoneId to the Clock.system() method, we can get the current time for any region. For example, in the test case below, we retrieve the current time in the “America/New_York” time zone using the Clock class:

@Test
void givenSystemClock_whenComparingDifferentTimeZones_thenVerifyRelationships() {
    Clock nyClock = Clock.system(ZoneId.of("America/New_York"));

    LocalDateTime nyTime = LocalDateTime.now(nyClock);

    assertThat(nyTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getHour()).isBetween(0, 23);
                assertThat(time.getMinute()).isBetween(0, 59);
                // Verify it's within last minute (recent)
                assertThat(time).isCloseTo(
                        LocalDateTime.now(),
                        within(1, ChronoUnit.MINUTES)
                );
            });
}

This also makes Clock highly useful for applications that must manage multiple time zones or control the flow of time consistently.

5. Migration From Legacy to Modern Classes

We might still need to deal with legacy code or libraries that use Date or Calendar. Fortunately, we can migrate from the old to the new date-time classes.

5.1. Converting Date to Instant

The legacy Date class can be easily converted to an Instant using the toInstant() method. This is helpful when we migrate to classes in the java.time package, as Instant represents a point on the timeline (the epoch):

@Test
void givenSameEpochMillis_whenConvertingDateAndInstant_thenCorrect() {
    long epochMillis = System.currentTimeMillis();
    Date legacyDate = new Date(epochMillis);
    Instant instant = Instant.ofEpochMilli(epochMillis);
    
    assertEquals(
      legacyDate.toInstant(),
      instant,
      "Date and Instant should represent the same moment in time"
    );
}

We can convert a legacy Date to an Instant and ensure they represent the same moment in time by creating both from the same epoch milliseconds.

5.2. Migrating Calendar to ZonedDateTime

When working with Calendar, we can migrate to the more modern ZonedDateTime, which handles both the date and time along with time zone information:

@Test
void givenCalendar_whenConvertingToZonedDateTime_thenCorrect() {
    Calendar calendar = Calendar.getInstance();
    calendar.set(2024, Calendar.SEPTEMBER, 18, 10, 30);
    ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(
      calendar.toInstant(),
      calendar.getTimeZone().toZoneId()
    );

    assertEquals(LocalDate.of(2024, 9, 18), zonedDateTime.toLocalDate());
    assertEquals(LocalTime.of(10, 30), zonedDateTime.toLocalTime());
}

Here, we’re converting a Calendar instance to ZonedDateTime and verifying they represent the same date-time.

6. Best Practices

Let’s now explore some of the best practices for working with java.time classes:

  1. We should use java.time classes for any new projects.
  2. We can use LocalDate, LocalTime, or LocalDateTime when time zones aren’t needed.
  3. When working with time zones or timestamps, use ZonedDateTime or Instant instead.
  4. We should use DateTimeFormatter for parsing and formatting dates.
  5. We should always be explicit about time zones to avoid confusion.

These best practices lay a solid foundation for working with dates and times in Java, ensuring we can handle them efficiently and accurately in our applications.

7. Conclusion

The java.time package introduced in Java 8 has dramatically improved how we handle dates and times. Furthermore, adopting this API ensures cleaner, more maintainable code.

While we may encounter older classes like Date or Calendar, adopting the java.time API for new development is a good idea. Finally, the outlined best practices will help us write cleaner, more efficient, and more maintainable code.

As always, the full source code for this article is over on GitHub.