跳至主要内容

JPA Entity 欄位型別規範

本規範定義應用 Entity 的持久化型別基線。設計必須同時通過 MySQL、 PostgreSQL、SQL Server、Oracle 與 H2;資料庫原生型別只能作為經驗證的 專案例外,不得成為參考實作預設。

選擇順序

  1. 先確認欄位的業務語意、null 語意、查詢需求與資料量上限。
  2. 選能直接表達語意的 Java 型別。
  3. 用標準 JPA 或明確的 AttributeConverter 映射。
  4. 以五種目標資料庫驗證 DDL、讀寫 round trip、排序與索引行為。

不得以單一資料庫的方便性覆蓋業務語意,也不得用 columnDefinition 把 Entity 綁定特定方言。

型別速查

語意Entity 型別與映射
已確定的時間點Instant
永遠只有日曆日期LocalDate
未來地點時間Instant;另存 IANA zoneId(需要保留地點語意時)
整數Integer / Long
金額、比率、精確小數BigDecimal + 明確 precision / scale
必填二態boolean + 明確初值 + nullable = false
可缺二態Boolean;第三狀態用 enum
有限狀態enum + EnumType.STRING
一般文字String + 業務長度
大型文字String + @Column(length = Length.LONG32)
小型、有上限的二進位byte[] + 明確長度
大型二進位檔案儲存或獨立 content Entity
原子、無 SQL 查詢需求的結構型別化 value / collection + JSON converter
固定且需查詢的值物件@Embeddable
可化約為單一標準值的值物件AttributeConverter<VO, Scalar>
可獨立識別、查詢或管理生命週期關聯 Entity

日期時間

跨端型別契約

日期時間先依業務語意選型,再決定前端、wire 與後端型別。尚未由需求確立為純日曆/牆鐘語意的 日期時間欄位,預設視為時間軸上的點。

語意型別前端執行期JSON wire format後端 Java/Entity
InstantDate必須帶 Z 或 numeric offset 的 ISO 8601 timestampInstant
LocalDateYYYY-MM-DD 字串YYYY-MM-DDLocalDate
LocalTime無時區時間字串HH:mm[:ss[.SSS]]LocalTime
LocalDateTime無時區日期時間字串YYYY-MM-DDTHH:mm[:ss[.SSS]]LocalDateTime

前端的 Local 型別全程保留字串,後端以 Java 對應型別直接反序列化。框架 HTTP client 只把帶 Z/offset 的 Instant wire value 轉成 Date,不得把 LocalDateLocalTimeLocalDateTime 轉成 Date

選用 Local 型別是一項業務語意決策:若需求已明確指定生日、假日、週期牆鐘等純 Local 語意, 視為人類已指定;否則 AI 只能提出建議,經人類確認並寫入 US、Domain Model 或 API Spec 後才採用。

時間點一律使用 Instant

凡是業務上能由情境取得「日期 + 時間 + 時區」,Entity 都儲存 Instant。時區與缺省時間由應用層解讀,轉成 Instant 後才進入 Entity。

這包含:

  • 系統稽核、事件、Log、交易與狀態轉換時間。
  • 訂單成立、配送、交付、結帳、到期與截止時間。
  • 使用者在瀏覽器輸入日期時間,由前端應用層依已決定的輸入時區解析。
  • UI 只輸入日期,但業務規則可提供生效時間與時區的日期,例如配送日 的截止時間。

欄位以 ...At 表達時間點,例如 orderedAtdeliveryAtclosesAt。Spring Data audit 欄位沿用既有慣例 createdDate / lastModifiedDate,不為命名一致性改寫框架契約。

應用層注入 Clock 取得現在;不得在 Entity 內直接依賴系統預設時區。 Hibernate JDBC 時區固定為 UTC。跨資料庫基線只保證微秒精度,不得假設 奈秒 round trip 不變。

Instant 的 API 交換

Instant 的 API 欄位只交換單一 ISO 8601 timestamp,且必須帶 Z 或 numeric offset:

{ "scheduledAt": "2026-08-15T01:00:00.000Z" }

不得用無 offset 的日期時間字串冒充 Instant,也不以「無 offset 日期時間 + zoneId」作為 一般 Instant wire shape。只有時區本身是該筆資料不可遺失的業務事實(例如週期規則、原地點 呈現或日後需依地點重新計算)時,才另設 zoneId 欄位;它不負責補完 Instant

時區只用於解析與投影

Instant 本身沒有時區。時區只參與「牆鐘輸入 ↔ Instant」、日期邊界建立與顯示投影:

  • 前端預設:使用者輸入與檢視 Instant 時,以瀏覽器時區解析及投影;這是一般 UI 預設, 不需額外標示。
  • 前端業務時區例外:改用後端業務時區,必須由人類指定,或由 AI 建議並經人類確認。 前端須取得後端公布、與 Clock 一致的權威 IANA zone,並在 UI 明確標示業務時區;選定後 不得在缺設定時靜默退回瀏覽器時區。
  • 後端預設:後端把無時區日期/牆鐘資料解析成 Instant,或把 Instant 投影成業務日期 時間時,使用注入 Clock 的業務時區。直接產生時間點使用 clock.instant()
  • 已是 Instant:前後端收到 Instant 後不得再次套用時區改變時間點;時區只用於投影與 驗證。

欄位名稱與業務用途不會自動改變前端預設。...At...From...To 只能表達時間點語意, 不能據此推論應採業務時區。

LocalDate 只表達純日曆日期

只有「日期本身就是完整事實」,且沒有合理的發生時刻、逾期點或耗時 語意時才使用 LocalDate,例如生日、假日與紀念日。

判斷句:如果應用層能依業務情境補出有效時間與時區,這不是純日期, 應在邊界轉為 Instant

月、日、時間或週期本身具有獨立業務語意時,可用 YearMonthMonthDayLocalTime 等建立 value object,再以 AttributeConverter@Embeddable 持久化;不得把它們當時間點。例如 信用卡到期月份應建模為 YearMonth,不是任選該月某一天。

地點與排程

已排定的一次性事件以 Instant 儲存。若日後仍需以原地點規則呈現或 重新計算,另存 IANA zone ID(例如 Asia/Taipei),不要只存 offset。

週期性排程是「本地日期/時間 + zoneId + recurrence」的規則,不是一個 單獨時間點;將排程規則建模為 value object,實際發生的 occurrence 仍轉成 Instant

Entity 的可攜基線不直接持久化 LocalDateTimeOffsetDateTimeZonedDateTime 作為時間點。

只有日期的 Instant 輸入

使用者只選日期、欄位仍經確認為 Instant 時,應用層可以依上下文補出時間,但推論發生前必須 由人類確認。起迄情境的預設建議為:

  • 起日/起點:該日 00:00:00.000
  • 迄日/終點:該日 23:59:59.999

兩者都依該欄位已決定的時區建立,再轉成 Instant。通用元件與 Service 不得靜默補值;推論 落在具名 application assembler/query boundary,並記錄於 US、SBE 或 API Spec。

若特定應用需要涵蓋毫秒以下資料、採半開區間或有其他精度要求,可經明確設計改用「下一日 00:00 exclusive」;這是應用層例外,不是框架預設。

二進位、大型文字與 JSON

二進位

  • 小型且有明確上限的內容用 primitive byte[],並宣告 @Column(length = ...)
  • 大型內容由檔案儲存處理;需要 database storage 時,metadata 與 content 分成不同 Entity,content 欄位使用 @Column(length = Length.LONG32)
  • 不使用 Byte[]Blob@Lob、Base64 StringcolumnDefinition
  • 不把無上限的大型內容放進一般聚合,避免每次載入 Entity 都攜帶內容。

大型文字

大型但仍適合單欄原子讀寫的文字使用:

import org.hibernate.Length;

@Column(length = Length.LONG32)
private String content;

任意大小、需要串流、版本化或獨立存取控制的內容改走檔案儲存。不得使用 Clob@Lob

「plaintext 有上限」與「持久欄位應使用大型文字」並不衝突。經加密、Base64、JSON envelope 或其他編碼後會膨脹的值,應在 application boundary 驗 plaintext 上限,持久欄位則以 Length.LONG32 承載 encoded value;不要用 VARCHAR(10000) 之類 magic length 猜測膨脹量。 MySQL 會把同列所有 VARCHAR 的最大 byte 數與 overhead 計入 65,535-byte row limit,而 TEXTBLOB 只在列內保留小型 pointer,因此多個超大 VARCHAR 即使實際資料很短,也可能在 DDL 階段失敗。詳見 MySQL Column Count and Row Size Limits

JSON

JSON 只用於「原子更新、資料量有界、SQL 不需查詢內部欄位」的結構。 Entity 保留型別化 value object、List<T>Map<String, T>,以 AttributeConverter<T, String> 序列化,資料欄使用 @Column(length = Length.LONG32)

Converter 必須:

  • 對格式錯誤與序列化失敗 fail fast,保留原始例外原因。
  • 明確定義 null、空集合與空物件的不同語意。
  • 以具體型別反序列化;除非 schema 本來就動態,避免 Map<String, Object>
  • 不因錯誤回傳空集合、空物件或 null,以免資料損毀被隱藏。

需要 WHERE、JOIN、排序、唯一性、部分更新或獨立生命週期時,應正規化為 欄位、@Embeddable@ElementCollection 或子 Entity。

可攜基線不使用資料庫原生 JSON、@JdbcTypeCode(SqlTypes.JSON)、 集合上的 @JdbcTypeCode(SqlTypes.LONGVARCHAR)@Lob

Scalar 與識別

數值與金額

  • 計數與序號使用 IntegerLong;選擇可涵蓋完整生命週期的範圍。
  • 金額、比率與任何精確小數使用 BigDecimal,明確宣告 precision / scale。可攜上限為 precision <= 38scale <= 30
  • rounding mode 是業務規則,必須在運算邊界明確指定。
  • 金額必須同時具有 currency;不可只存裸 amount。
  • 不使用 float / double 表達金額或需要精確相等的值。

Enum

Enum 明確使用 @Enumerated(EnumType.STRING);不得使用 ordinal 或資料庫 原生 enum。enum constant 名稱是持久化契約,改名需資料遷移。若值必須 獨立於 Java 名稱演進,使用 JPA 3.2 @EnumeratedValue 標示明確穩定代碼; 舊版 provider 才使用受測 converter。未知值 fail fast。

主鍵與業務鍵

新 Entity 若需要由系統產生 surrogate primary key,必須使用 GenerationType.UUID。Java 屬性預設使用 UUID

@Id
@GeneratedValue(strategy = GenerationType.UUID)
@JdbcTypeCode(SqlTypes.VARCHAR)
@Column(length = 36, nullable = false, updatable = false)
private UUID id;

需要減少 API、事件或外部整合邊界的型別轉換時,可受控改用 String

@Id
@GeneratedValue(strategy = GenerationType.UUID)
@JdbcTypeCode(SqlTypes.VARCHAR)
@Column(length = 36, nullable = false, updatable = false)
private String id;

String 方案仍是 UUID 生成策略。採用時須在設計文檔記錄需要消除的邊界 轉換與選擇理由,且只能承載 provider 產生的 canonical UUID 字串,不得自行填入 一般業務字串。同一 identity 的 Entity 屬性、repository ID generic、FK/關聯欄位 與 service/query 參數必須選定並維持同一 Java 型別,不得在 UUIDString 間混用。沒有明確邊界需求時使用 UUID,保留編譯期型別安全。

UUID surrogate key 以 VARCHAR(36) 作為五種資料庫共同基線。欄位級 @JdbcTypeCode(SqlTypes.VARCHAR)@Column(length = 36) 是可稽核的 persistence contract,必須明確保留;全域 UUID JDBC mapping 不取代欄位宣告。 資料庫與 wire representation 都維持 36 字元 UUID 字串,選擇 Java UUIDString 不改變 schema 與線上格式。

不搭配 UUID 生成策略的 assigned String @Id,只允許用於穩定、不可變,而且 本身就是 Entity identity 的 natural/business/protocol key,並須在設計文檔 明確記錄其語意、長度與不可變條件。訂單號、客戶編號、外部 ID 等可變或僅供 查找的業務鍵另設 String 欄位,不以主鍵承擔。 例如 tenant 模式的租戶識別碼同時是認證 claim、TenantContext 與 Hibernate discriminator 的 protocol identity,可採 assigned String @Idtenant_id 也維持相同的 String 型別,不受 generated surrogate UUID 基線約束。

只有確有序列排序、儲存成本或既有整合需求,而且在設計文檔記錄理由時, 才可例外使用 Long surrogate key。

Boolean、null 與預設值

  • 必填二態使用 primitive booleannullable = false 與明確 Java initializer。
  • Boolean 只用於「尚未提供」確有意義的情況;第三種業務狀態使用 enum。
  • 不在 persistent 欄位使用 Optional 或 sentinel value。
  • 集合初始化為非 null;null 與 empty 若有不同語意,必須明確建模。
  • 業務預設由 Java 建構/欄位初值建立,不依賴 DB default 或 trigger。
  • 避免 nullable unique 欄位;各資料庫對多個 null 的唯一性行為不同。

String

  • 每個 String 都宣告業務上限;validation 的 @Size@Column(length = ...) 對齊。
  • 必填文字使用 @NotBlank + nullable = false
  • 選填文字在應用邊界把 blank 正規化為 null;不得依賴 null 與空字串 不同,Oracle 會把空字串視為 null。
  • 電話、郵遞區號、帳號與數字外觀識別碼仍是 String
  • 大小寫不敏感查詢/唯一性使用 Locale.ROOT 正規化後的 canonical 欄位,不把 function index 當可攜基線。
  • Unicode 由資料庫字元集與 collation baseline 保證;不設定 hibernate.use_nationalized_character_data,沿用 Hibernate 預設的 false。MySQL 使用 utf8mb4、PostgreSQL 使用 UTF8、SQL Server 2019+ 使用 _UTF8 collation、Oracle 使用 AL32UTF8,使一般 VARCHAR 可完整儲存 Unicode。
  • 不以全域 nationalized character flag 補救舊資料庫字元集。該 flag 會改變所有 String 的 JDBC 與 DDL 映射,連識別碼、PK/FK 與索引欄位 也會受影響。無法升級資料庫 baseline 的舊系統,須記錄例外並只對必要 的業務文字欄位明確使用 @Nationalized,同時提供 schema migration 與 Unicode round-trip 測試。
  • 不使用 charCharacterCharacter[]、Base64 二進位字串、 @LobcolumnDefinition

結構、關聯與生命週期

Value object

問題選擇
有獨立 identity / lifecycleEntity
結構固定且需查詢內部欄位@Embeddable
可化約為一個 canonical scalarAttributeConverter
原子、無內部 SQL 查詢JSON converter

Value object 優先使用 immutable record/class。持久化 value object 是 領域型別,不是 API DTO。

Collection

  • 依語意選 ListSetMap;除 byte[] 外不使用 array。
  • 需要穩定順序時明確持久化順序,不依賴資料庫自然順序。
  • 小型原子集合可用 JSON;需查詢元素但無 identity 可用 @ElementCollection;有 identity / lifecycle 則用子 Entity。
  • 集合保持 non-null,association 預設 LAZY
  • Set 元素必須有穩定且與 persistence lifecycle 相容的 equality。

Relationship

Entity association 與 scalar ID 的選擇依 aggregate boundary、完整性、 生命週期與歷史快照需求決定,不以「避免 JOIN」作唯一理由。

  • 所有 association 明確指定 LAZYFetchType.EAGER 一律不使用, 包含認證主體這類「一次要載完整個 graph」的需求(見下)。
  • cascade 只給真正的 lifecycle owner;orphanRemoval 只給私有子項。
  • shared Entity 不使用 cascade remove。
  • @ManyToMany 只限沒有額外屬性的對稱關係;有屬性就建立 join Entity。
  • optionalnullable 與 Bean Validation 必須一致。
  • 關聯方向保持最少;API 回傳 DTO,不直接序列化 Entity graph。
  • 多租戶關聯必須在應用層驗證兩端屬於同一 tenant。

「一次載完」不是 EAGER 的理由——用 fetch plan

「這個 use case 一定要一次拿到整個 graph」(典型:UserDetails 需要 roles + 每個 role 的 authorities)是查詢的需求,不是欄位的性質。 EAGER 把它烙在 mapping 上,代價是每一個載入該 Entity 的查詢都被迫拖出 整個 graph,包括只要用 id 的那些。正確做法是欄位維持 LAZY,由需要 graph 的查詢在 repository 上顯式宣告 fetch plan:

@EntityGraph(attributePaths = {"roles", "roles.authorities"})
Optional<Account> findWithRolesAndAuthoritiesById(UUID id);

配套紀律:

  • 不得改以 OSIV 代替spring.jpa.open-in-view)。認證鏈、 CommandLineRunner(seed)、排程與訊息消費者都不在 HTTP 請求緒上, OSIV 對它們無效;靠 OSIV 的程式只會在這些路徑上炸,且測試常因走 MockMvc 而看不見。
  • 交易外組裝 wire DTO 時(controller 讀 service 回傳的 Entity), graph 必須在查詢當下就載入,否則 detached 實例讀 lazy 關聯即例外。
  • 分頁不可同時 join fetch collection——Hibernate 會退回記憶體分頁。 改採二段式:先查本頁,再於同一交易內以 fetch plan 初始化該頁實例的關聯。
  • 這條路徑值得一個明確關掉 OSIV 的迴歸測試釘住(@SpringBootTest(properties = "spring.jpa.open-in-view=false")、直接呼叫服務而非走 MockMvc、測試本身 不加 @Transactional),否則日後有人改回未帶 graph 的 finder 不會被擋下。

併發、秘密與衍生值

Optimistic lock

當 lost update 會造成損害時,在該 Entity 加:

@Version
@Column(nullable = false)
private Long version;

version 是 JPA provider 管理的數值,不是時間點;不初始化、不提供一般 setter,應用程式不得設定或遞增。API 回傳 version,更新時在應用層比較 client expected version;通過後由 flush 的 optimistic-lock 檢查處理 競態。衝突回 409,不做 blind retry。Bulk update 會繞過 Entity version, 需另行設計。

Secret

  • Password 使用 adaptive one-way hash,欄位命名 passwordHash;不要對 encoded hash 套使用者密碼長度規則。
  • 高熵 API key / token 可只存 SHA-256 hash。
  • 必須取回的秘密優先放 secret manager;不得已入 DB 時使用 authenticated encryption,查詢需求另存 HMAC fingerprint。
  • 加密 root key 由部署 secret 提供,不進 DB;ciphertext 至少帶格式版本與 key id。 應用有多個持久化加密用途時,以單一 root key ring 搭配穩定 purpose 經 HKDF 衍生用途別 key,不直接共用 root key。輪替時先加入新 key、切換 active key,保留舊 key 到既有密文 重加密完成;並預先定義既有明文資料的 migration。
  • Secret 不出現在 read API、log、exception、toString() 或 equality。
  • 不以明文持久化。

Derived value

  • 可廉價重算且不需查詢的值使用 @Transient 或應用層計算。
  • 因查詢、效能或歷史快照而持久化時,必須有單一更新路徑與一致性規則。
  • 可攜基線不使用 @Formula、computed column、generated column 或 trigger。
  • 相對現在的狀態以顯式 Instant now / Clock 計算,不持久化 isOverdue 之類會自行過期的值。
  • Duration 不直接作可攜欄位;由兩個 Instant 推導,或以 converter / Long 儲存並在欄位名與文件標明單位。
  • Entity 不注入 request context、service 或 cache。

禁止清單

可攜基線不得使用:

  • @LobBlobClobcolumnDefinition
  • 資料庫原生 JSON / enum / array
  • DateCalendarjava.sql.* 日期型別,或 LocalDateTime 作為時間點
  • ordinal enum
  • float / double 金額
  • silent JSON fallback
  • DB default、trigger 或 computed column 承擔核心業務規則
  • 未明確指定 eager/lazy、cascade、長度、nullability 或 decimal scale 的 隱含 mapping
  • FetchType.EAGER(含「這個 use case 一定要一次載完」的情形—— 改用 repository 的 @EntityGraph fetch plan,見 Relationship
  • spring.jpa.open-in-view 承擔 lazy 關聯的載入

相關設計另見 資料層設計樂觀鎖檔案儲存ADR-027 JSONADR-028 關聯