JPA Entity 欄位型別規範
本規範定義應用 Entity 的持久化型別基線。設計必須同時通過 MySQL、 PostgreSQL、SQL Server、Oracle 與 H2;資料庫原生型別只能作為經驗證的 專案例外,不得成為參考實作預設。
選擇順序
- 先確認欄位的業務語意、null 語意、查詢需求與資料量上限。
- 選能直接表達語意的 Java 型別。
- 用標準 JPA 或明確的
AttributeConverter映射。 - 以五種目標資料庫驗證 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 |
|---|---|---|---|
Instant | Date | 必須帶 Z 或 numeric offset 的 ISO 8601 timestamp | Instant |
LocalDate | YYYY-MM-DD 字串 | YYYY-MM-DD | LocalDate |
LocalTime | 無時區時間字串 | HH:mm[:ss[.SSS]] | LocalTime |
LocalDateTime | 無時區日期時間字串 | YYYY-MM-DDTHH:mm[:ss[.SSS]] | LocalDateTime |
前端的 Local 型別全程保留字串,後端以 Java 對應型別直接反序列化。框架 HTTP client 只把帶
Z/offset 的 Instant wire value 轉成 Date,不得把 LocalDate、LocalTime 或
LocalDateTime 轉成 Date。
選用 Local 型別是一項業務語意決策:若需求已明確指定生日、假日、週期牆鐘等純 Local 語意, 視為人類已指定;否則 AI 只能提出建議,經人類確認並寫入 US、Domain Model 或 API Spec 後才採用。
時間點一律使用 Instant
凡是業務上能由情境取得「日期 + 時間 + 時區」,Entity 都儲存
Instant。時區與缺省時間由應用層解讀,轉成 Instant 後才進入
Entity。
這包含:
- 系統稽核、事件、Log、交易與狀態轉換時間。
- 訂單成立、配送、交付、結帳、到期與截止時間。
- 使用者在瀏覽器輸入日期時間,由前端應用層依已決定的輸入時區解析。
- UI 只輸入日期,但業務規則可提供生效時間與時區的日期,例如配送日 的截止時間。
欄位以 ...At 表達時間點,例如 orderedAt、deliveryAt、
closesAt。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。
月、日、時間或週期本身具有獨立業務語意時,可用 YearMonth、
MonthDay、LocalTime 等建立 value object,再以
AttributeConverter 或 @Embeddable 持久化;不得把它們當時間點。例如
信用卡到期月份應建模為 YearMonth,不是任選該月某一天。
地點與排程
已排定的一次性事件以 Instant 儲存。若日後仍需以原地點規則呈現或
重新計算,另存 IANA zone ID(例如 Asia/Taipei),不要只存 offset。
週期性排程是「本地日期/時間 + zoneId + recurrence」的規則,不是一個
單獨時間點;將排程規則建模為 value object,實際發生的 occurrence
仍轉成 Instant。
Entity 的可攜基線不直接持久化 LocalDateTime、OffsetDateTime 或
ZonedDateTime 作為時間點。
只有日期的 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、Base64String或columnDefinition。 - 不把無上限的大型內容放進一般聚合,避免每次載入 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,而
TEXT/BLOB 只在列內保留小型 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 與識別
數值與金額
- 計數與序號使用
Integer或Long;選擇可涵蓋完整生命週期的範圍。 - 金額、比率與任何精確小數使用
BigDecimal,明確宣告precision/scale。可攜上限為precision <= 38、scale <= 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 型別,不得在 UUID 與 String
間混用。沒有明確邊界需求時使用 UUID,保留編譯期型別安全。
UUID surrogate key 以 VARCHAR(36) 作為五種資料庫共同基線。欄位級
@JdbcTypeCode(SqlTypes.VARCHAR) 與 @Column(length = 36) 是可稽核的
persistence contract,必須明確保留;全域 UUID JDBC mapping 不取代欄位宣告。
資料庫與 wire representation 都維持 36 字元 UUID 字串,選擇 Java UUID 或
String 不改變 schema 與線上格式。
不搭配 UUID 生成策略的 assigned String @Id,只允許用於穩定、不可變,而且
本身就是 Entity identity 的 natural/business/protocol key,並須在設計文檔
明確記錄其語意、長度與不可變條件。訂單號、客戶編號、外部 ID 等可變或僅供
查找的業務鍵另設 String 欄位,不以主鍵承擔。
例如 tenant 模式的租戶識別碼同時是認證 claim、TenantContext 與 Hibernate
discriminator 的 protocol identity,可採 assigned String @Id;tenant_id
也維持相同的 String 型別,不受 generated surrogate UUID 基線約束。
只有確有序列排序、儲存成本或既有整合需求,而且在設計文檔記錄理由時,
才可例外使用 Long surrogate key。
Boolean、null 與預設值
- 必填二態使用 primitive
boolean、nullable = 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+ 使用_UTF8collation、Oracle 使用AL32UTF8,使一般VARCHAR可完整儲存 Unicode。 - 不以全域 nationalized character flag 補救舊資料庫字元集。該 flag
會改變所有
String的 JDBC 與 DDL 映射,連識別碼、PK/FK 與索引欄位 也會受影響。無法升級資料庫 baseline 的舊系統,須記錄例外並只對必要 的業務文字欄位明確使用@Nationalized,同時提供 schema migration 與 Unicode round-trip 測試。 - 不使用
char、Character、Character[]、Base64 二進位字串、@Lob或columnDefinition。
結構、關聯與生命週期
Value object
| 問題 | 選擇 |
|---|---|
| 有獨立 identity / lifecycle | Entity |
| 結構固定且需查詢內部欄位 | @Embeddable |
| 可化約為一個 canonical scalar | AttributeConverter |
| 原子、無內部 SQL 查詢 | JSON converter |
Value object 優先使用 immutable record/class。持久化 value object 是 領域型別,不是 API DTO。
Collection
- 依語意選
List、Set、Map;除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 明確指定
LAZY;FetchType.EAGER一律不使用, 包含認證主體這類「一次要載完整個 graph」的需求(見下)。 - cascade 只給真正的 lifecycle owner;
orphanRemoval只給私有子項。 - shared Entity 不使用 cascade remove。
@ManyToMany只限沒有額外屬性的對稱關係;有屬性就建立 join Entity。optional、nullable與 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。
禁止清單
可攜基線不得使用:
@Lob、Blob、Clob、columnDefinition- 資料庫原生 JSON / enum / array
Date、Calendar、java.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 的@EntityGraphfetch plan,見 Relationship)- 以
spring.jpa.open-in-view承擔 lazy 關聯的載入
相關設計另見 資料層設計、 樂觀鎖、檔案儲存、 ADR-027 JSON 與 ADR-028 關聯。