AgentSkillsCN

datetime

DateTime规则:优先采用UTC存储,在响应边界进行KST转换,并配备常用实用工具

SKILL.md
--- frontmatter
name: datetime
description: DateTime rules for UTC-first storage, KST conversion at response boundary, and common utilities
triggers:
  - datetime
  - utc
  - kst
  - timezone
  - date format
argument-hint: ""

DateTime handling rules

Overview

This document defines rules for handling date/time across all layers. UTC is the single source of truth for all internal operations.

Key Principle: Store and process in UTC. Convert to KST only at the final display boundary. All controller inputs must be UTC.

Core rules

RuleDescription
Internal timezoneUTC everywhere (JVM, DB, domain logic)
Controller inputMust be UTC. If KST arrives, convert to UTC immediately
Controller outputUTC by default. Convert to KST only when display requires it
KST conversionUse .toKst() extension only at the response boundary
UtilitiesUse io.glory.common.utils.datetime for all datetime operations

JVM timezone configuration

Set UTC as the JVM default timezone in every bootstrap -app module.

kotlin
fun main(args: Array<String>) {
    TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
    runApplication<MyApplication>(*args)
}

IMPORTANT: Without this configuration, LocalDateTime.now() returns system-local time (KST on Korean servers), causing inconsistencies.


Controller input: always UTC

IMPORTANT: All datetime inputs in controllers must be UTC. If a client sends KST, convert to UTC immediately in the Controller or Facade layer before you pass it to domain.

Correct: UTC input

kotlin
@PostMapping("/events")
fun createEvent(
    @Valid @RequestBody request: CreateEventApiRequest,
): ResponseEntity<ApiResource<EventDto>> {
    // request.startAt is already UTC — pass directly
    val domainRequest = CreateEventRequest(
        name = request.name,
        startAt = request.startAt,
    )
    return ApiResource.success(eventFacade.create(domainRequest))
}

Correct: KST input with immediate UTC conversion

Convert at the entry point when an external client or legacy system sends KST.

kotlin
@PostMapping("/events")
fun createEvent(
    @Valid @RequestBody request: CreateEventApiRequest,
): ResponseEntity<ApiResource<EventDto>> {
    // Client sends KST — convert to UTC immediately
    val utcStartAt = request.startAt.toUtc()
    val domainRequest = CreateEventRequest(
        name = request.name,
        startAt = utcStartAt,
    )
    return ApiResource.success(eventFacade.create(domainRequest))
}

Using ZonedDateTime for explicit timezone

Use ZonedDateTime and normalize to UTC when timezone-aware input is needed.

kotlin
data class CreateEventApiRequest(
    val name: String,
    @param:JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX'['VV']'")
    val startAt: ZonedDateTime,
)

// In Controller or Facade
val utcStartAt = request.startAt
    .withZoneSameInstant(ZoneOffset.UTC)
    .toLocalDateTime()

Incorrect: passing KST to domain

kotlin
// Bad: KST datetime leaks into domain layer
@PostMapping("/events")
fun createEvent(@RequestBody request: CreateEventApiRequest) {
    // request.startAt is KST but passed as-is — domain stores KST as UTC
    eventFacade.create(CreateEventRequest(startAt = request.startAt))
}

Controller output: KST conversion at response boundary

IMPORTANT: Convert to KST only when you build the API response DTO. Domain and Service layers must never perform KST conversion.

Correct: convert in Facade or Response DTO

kotlin
import io.glory.common.utils.extensions.toKst

// Option 1: Convert in Facade
@Component
class EventFacade(
    private val eventQueryApplication: EventQueryApplication,
) {
    fun findById(id: Long): EventDto {
        val event = eventQueryApplication.findById(id)
        return EventDto(
            id = event.id,
            name = event.name,
            startAt = event.startAt.toKst(),      // UTC → KST at response boundary
            createdAt = event.createdAt.toKst(),   // UTC → KST at response boundary
        )
    }
}

// Option 2: Convert in Response DTO factory
data class EventDto(
    val id: Long,
    val name: String,
    val startAt: LocalDateTime,
    val createdAt: LocalDateTime,
) {
    companion object {
        fun from(info: EventInfo) = EventDto(
            id = info.id,
            name = info.name,
            startAt = info.startAt.toKst(),
            createdAt = info.createdAt.toKst(),
        )
    }
}

Incorrect: KST conversion in domain

kotlin
// Bad: KST conversion inside Service
@Service
class EventService {
    fun findById(id: Long): EventInfo {
        val entity = eventRepository.findById(id)
        return EventInfo(
            startAt = entity.startAt.toKst(),  // WRONG — domain must stay UTC
        )
    }
}

DateTime lifecycle

code
[Client Request]
  UTC datetime input (or KST → convert to UTC immediately)
    ↓
[Controller / Facade]
  Ensure UTC before passing to domain
    ↓
[Domain (Application → Service → Repository)]
  All operations in UTC. No KST awareness.
    ↓
[Database]
  Stored as UTC
    ↓
[Domain → Facade / Response DTO]
  Convert to KST with .toKst() only if display requires it
    ↓
[Client Response]
  KST for display, or UTC for machine-to-machine

Utility classes

IMPORTANT: Use io.glory.common.utils.datetime for all datetime operations. Do not use raw java.time formatting or manual calculations.

DateFormatter (parsing & formatting)

kotlin
import io.glory.common.utils.datetime.DateFormatter.toDate
import io.glory.common.utils.datetime.DateFormatter.toDateTime
import io.glory.common.utils.datetime.DateFormatter.toStr
import io.glory.common.utils.datetime.DateFormatter.toKorean

// Parsing
"2025-01-24".toDate()                    // LocalDate
"20250124".numericToDate()               // LocalDate
"2025-01-24T14:30:00".toDateTime()       // LocalDateTime
"20250124143000".numericToDateTime()     // LocalDateTime

// Formatting
date.toStr()            // "2025-01-24"
date.toNumericStr()     // "20250124"
date.toKorean()         // "2025년 1월 24일"
dateTime.toStr()        // "2025-01-24T14:30:00"
dateTime.toKorean()     // "2025년 1월 24일 14시 30분"

Timezone conversion

kotlin
import io.glory.common.utils.extensions.toKst
import io.glory.common.utils.extensions.toUtc

// UTC → KST (for display)
val utcNow = LocalDateTime.now()     // UTC (JVM default)
val kstNow = utcNow.toKst()         // KST LocalDateTime

val utcZoned = ZonedDateTime.now()   // UTC
val kstZoned = utcZoned.toKst()     // KST ZonedDateTime

// KST → UTC (for storage/processing)
val kstInput = request.startAt       // KST from client
val utcInput = kstInput.toUtc()      // UTC LocalDateTime

val kstZonedInput = request.scheduledAt  // KST ZonedDateTime
val utcZonedInput = kstZonedInput.toUtc() // UTC ZonedDateTime
MethodDirectionUse Case
LocalDateTime.toKst()UTC → KSTDisplay in Facade / Response DTO
ZonedDateTime.toKst()UTC → KSTDisplay in Facade / Response DTO
LocalDateTime.toUtc()KST → UTCNormalize KST input at controller boundary
ZonedDateTime.toUtc()KST → UTCNormalize KST input at controller boundary

SearchDates (date range for queries)

kotlin
import io.glory.common.utils.datetime.SearchDates

val dates = SearchDates.lastMonth()
val dates = SearchDates.of(startDate, endDate)
val dates = SearchDates.lastDays(7)
val dates = SearchDates.thisWeek()

// Use in SearchCondition
data class OrderSearchCondition(
    val status: OrderStatus? = null,
    val searchDates: SearchDates = SearchDates.lastMonth(),
)

Range classes

kotlin
import io.glory.common.utils.datetime.LocalDateRange
import io.glory.common.utils.datetime.LocalDateTimeRange

val range = LocalDateRange(startDate, endDate)
date in range                    // containment check
range.overlaps(otherRange)       // overlap check
range.daysBetween()              // day count

val dtRange = LocalDateTimeRange(startDt, endDt)
dateTime in dtRange
dtRange.hoursBetween()

Date extensions

kotlin
import io.glory.common.utils.extensions.isToday
import io.glory.common.utils.extensions.isPast
import io.glory.common.utils.extensions.getAge

date.isToday()
date.isPast()
birthDate.getAge()           // International age
birthDate.getKoreanAge()     // Korean age

ISO-8601 formats

TypeFormatExample
Dateyyyy-MM-dd2025-01-02
TimeHH:mm:ss14:30:00
DateTimeyyyy-MM-dd'T'HH:mm:ss2025-01-02T14:30:00
DateTime UTCyyyy-MM-dd'T'HH:mm:ss'Z'2025-01-02T05:30:00Z
ZonedDateTimeyyyy-MM-dd'T'HH:mm:ssXXX'['VV']'2025-01-02T14:30:00+09:00[Asia/Seoul]

Anti-patterns

Anti-PatternProblemCorrect
Missing TimeZone.setDefault(UTC) in main()LocalDateTime.now() returns KSTSet UTC in every bootstrap main()
KST input passed to domain as-isUTC/KST mixed in DBConvert to UTC at controller/facade
.toKst() in Service or ApplicationDomain polluted with display concern.toKst() only in Facade or Response DTO
ZonedDateTime.now(ZoneId.of("Asia/Seoul"))Bypasses UTC-first ruleLocalDateTime.now() + .toKst() when needed
plusHours(9) / minusHours(9) for conversionFragile, ignores DST edge casesUse .toKst() / .toUtc() extensions
Raw DateTimeFormatter for parsingInconsistent formatsUse DateFormatter from common module
String type for date fieldsNo validation, format ambiguityUse LocalDate, LocalDateTime, ZonedDateTime
@DateTimeFormat(pattern = "yyyyMMdd")Non-ISO format in APIUse @DateTimeFormat(iso = ISO.DATE)

Summary checklist

Before submitting code, verify:

  • Bootstrap main() sets TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
  • All controller datetime inputs are UTC
  • KST inputs are converted to UTC at the controller/facade boundary
  • Domain and Service layers have no KST conversion logic
  • .toKst() is used only in Facade or Response DTO for display
  • DateFormatter from common module is used for parsing/formatting
  • SearchDates is used for date range queries
  • All datetime fields use proper types (LocalDate, LocalDateTime, ZonedDateTime), not String
  • ISO-8601 format is used for all API date/time representations