Spring JPAの@Embeddableと@Embeddedは、値オブジェクト(Value Object)をエンティティ設計に取り入れるための強力な機能です。住所(Address)、金額(Money)、期間(Period)といった複合的な概念を、単一のプリミティブ型ではなく意味のある型として表現することで、ドメインモデルの表現力が向上し、コードの可読性・保守性が大幅に改善されます。本記事では、Spring JPA値オブジェクト設計の基本から、@AttributeOverrideによるカラム名カスタマイズ、@ElementCollectionによる値オブジェクトコレクション、DDD(ドメイン駆動設計)的なアプローチまで、実践的な実装パターンを体系的に解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Spring Data JPA |
3.4.x(Spring Boot Starterに含まれる) |
| Hibernate |
6.6.x(Spring Data JPAに含まれる) |
| Jakarta Persistence |
3.2 |
| データベース |
PostgreSQL 16 / MySQL 8.x / H2 Database |
| ビルドツール |
Maven または Gradle |
| IDE |
VS Code または IntelliJ IDEA |
事前に以下の準備を完了してください。
- JDK 17以上のインストール
- Spring Boot + Spring Data JPAプロジェクトの基本構成
- エンティティとリポジトリの基本知識(エンティティ設計とリポジトリパターンを参照)
値オブジェクトとは#
値オブジェクト(Value Object)は、ドメイン駆動設計(DDD)における重要な構成要素です。値オブジェクトには以下の特徴があります。
| 特徴 |
説明 |
| 不変性(Immutability) |
一度作成されたら変更されない |
| 同値性(Value Equality) |
IDではなく、すべての属性の値で等価性を判定 |
| 自己検証(Self-Validation) |
不正な値を持つオブジェクトは生成できない |
| 副作用がない(Side-Effect Free) |
メソッドは新しいオブジェクトを返すか、何も返さない |
プリミティブ型のみでエンティティを設計すると、ドメインの意図が失われます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// プリミティブ型のみの設計(アンチパターン)
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 住所情報がバラバラに散らばっている
private String streetAddress;
private String city;
private String state;
private String postalCode;
private String country;
// 金額情報も同様
private BigDecimal creditLimitAmount;
private String creditLimitCurrency;
}
|
この設計では、住所や金額という概念がコード上で明示されず、関連するフィールドのグループが把握しにくくなります。値オブジェクトを導入することで、この問題を解決できます。
@Embeddableと@Embeddedの基本#
JPAでは、@Embeddableと@Embeddedアノテーションを使用して値オブジェクトを実装します。
@Embeddableアノテーション#
@Embeddableは、他のエンティティに埋め込み可能なコンポーネントクラスであることを示します。@Embeddableクラスは独自のテーブルを持たず、埋め込み先のエンティティのテーブルにカラムとしてマッピングされます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
|
import jakarta.persistence.Embeddable;
@Embeddable
public class Address {
private String streetAddress;
private String city;
private String state;
private String postalCode;
private String country;
// JPAが必要とするデフォルトコンストラクタ
protected Address() {
}
public Address(String streetAddress, String city, String state,
String postalCode, String country) {
this.streetAddress = streetAddress;
this.city = city;
this.state = state;
this.postalCode = postalCode;
this.country = country;
}
// getters(setterは不要 - 不変オブジェクトとして設計)
public String getStreetAddress() {
return streetAddress;
}
public String getCity() {
return city;
}
public String getState() {
return state;
}
public String getPostalCode() {
return postalCode;
}
public String getCountry() {
return country;
}
// equalsとhashCodeの実装(値オブジェクトの同値性判定に必要)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Address address = (Address) o;
return Objects.equals(streetAddress, address.streetAddress)
&& Objects.equals(city, address.city)
&& Objects.equals(state, address.state)
&& Objects.equals(postalCode, address.postalCode)
&& Objects.equals(country, address.country);
}
@Override
public int hashCode() {
return Objects.hash(streetAddress, city, state, postalCode, country);
}
@Override
public String toString() {
return String.format("%s, %s, %s %s, %s",
streetAddress, city, state, postalCode, country);
}
}
|
@Embeddedアノテーション#
@Embeddedは、エンティティ内で@Embeddableクラスを使用することを示します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@Embedded
private Address address;
protected Customer() {
}
public Customer(String name, String email, Address address) {
this.name = name;
this.email = email;
this.address = address;
}
// getters
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public Address getAddress() {
return address;
}
// 住所を変更する場合は新しいAddressオブジェクトで置き換え
public void changeAddress(Address newAddress) {
this.address = newAddress;
}
}
|
生成されるテーブル構造#
上記のエンティティ定義から、以下のようなテーブルが生成されます。
1
2
3
4
5
6
7
8
9
10
11
|
CREATE TABLE customer (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(255),
email VARCHAR(255),
street_address VARCHAR(255),
city VARCHAR(255),
state VARCHAR(255),
postal_code VARCHAR(255),
country VARCHAR(255),
PRIMARY KEY (id)
);
|
Addressクラスの各フィールドが、Customerテーブルのカラムとして直接マッピングされます。Hibernateはデフォルトで、フィールド名をsnake_case形式のカラム名に変換します。
実践的な値オブジェクト実装例#
住所(Address)の完全な実装#
住所は値オブジェクトの代表的な例です。バリデーションロジックを含む完全な実装を示します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
|
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.util.Objects;
@Embeddable
public class Address {
@Column(name = "street_address", nullable = false, length = 200)
private String streetAddress;
@Column(name = "city", nullable = false, length = 100)
private String city;
@Column(name = "state", length = 100)
private String state;
@Column(name = "postal_code", nullable = false, length = 20)
private String postalCode;
@Column(name = "country", nullable = false, length = 2)
private String countryCode;
protected Address() {
}
private Address(String streetAddress, String city, String state,
String postalCode, String countryCode) {
this.streetAddress = streetAddress;
this.city = city;
this.state = state;
this.postalCode = postalCode;
this.countryCode = countryCode;
}
// ファクトリメソッドでバリデーションを実施
public static Address of(String streetAddress, String city, String state,
String postalCode, String countryCode) {
validateStreetAddress(streetAddress);
validateCity(city);
validatePostalCode(postalCode);
validateCountryCode(countryCode);
return new Address(streetAddress, city, state, postalCode, countryCode);
}
// 日本の住所用ファクトリメソッド
public static Address ofJapan(String postalCode, String prefecture,
String city, String streetAddress) {
return of(streetAddress, city, prefecture, postalCode, "JP");
}
private static void validateStreetAddress(String streetAddress) {
if (streetAddress == null || streetAddress.isBlank()) {
throw new IllegalArgumentException("Street address cannot be empty");
}
if (streetAddress.length() > 200) {
throw new IllegalArgumentException(
"Street address cannot exceed 200 characters");
}
}
private static void validateCity(String city) {
if (city == null || city.isBlank()) {
throw new IllegalArgumentException("City cannot be empty");
}
}
private static void validatePostalCode(String postalCode) {
if (postalCode == null || postalCode.isBlank()) {
throw new IllegalArgumentException("Postal code cannot be empty");
}
}
private static void validateCountryCode(String countryCode) {
if (countryCode == null || countryCode.length() != 2) {
throw new IllegalArgumentException(
"Country code must be a 2-letter ISO code");
}
}
// getters
public String getStreetAddress() {
return streetAddress;
}
public String getCity() {
return city;
}
public String getState() {
return state;
}
public String getPostalCode() {
return postalCode;
}
public String getCountryCode() {
return countryCode;
}
public boolean isJapan() {
return "JP".equals(countryCode);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Address address = (Address) o;
return Objects.equals(streetAddress, address.streetAddress)
&& Objects.equals(city, address.city)
&& Objects.equals(state, address.state)
&& Objects.equals(postalCode, address.postalCode)
&& Objects.equals(countryCode, address.countryCode);
}
@Override
public int hashCode() {
return Objects.hash(streetAddress, city, state, postalCode, countryCode);
}
@Override
public String toString() {
if (isJapan()) {
return String.format("〒%s %s %s %s",
postalCode, state, city, streetAddress);
}
return String.format("%s, %s, %s %s, %s",
streetAddress, city, state, postalCode, countryCode);
}
}
|
金額(Money)の実装#
金額を表す値オブジェクトは、通貨と金額を一体として扱うことで、異なる通貨間の計算ミスを防止します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
|
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
@Embeddable
public class Money {
@Column(name = "amount", nullable = false, precision = 19, scale = 4)
private BigDecimal amount;
@Column(name = "currency", nullable = false, length = 3)
private String currencyCode;
protected Money() {
}
private Money(BigDecimal amount, String currencyCode) {
this.amount = amount;
this.currencyCode = currencyCode;
}
public static Money of(BigDecimal amount, String currencyCode) {
validateAmount(amount);
validateCurrencyCode(currencyCode);
Currency currency = Currency.getInstance(currencyCode);
BigDecimal normalizedAmount = amount.setScale(
currency.getDefaultFractionDigits(),
RoundingMode.HALF_UP
);
return new Money(normalizedAmount, currencyCode);
}
public static Money of(String amount, String currencyCode) {
return of(new BigDecimal(amount), currencyCode);
}
public static Money yen(BigDecimal amount) {
return of(amount.setScale(0, RoundingMode.HALF_UP), "JPY");
}
public static Money yen(long amount) {
return yen(BigDecimal.valueOf(amount));
}
public static Money usd(BigDecimal amount) {
return of(amount, "USD");
}
public static Money zero(String currencyCode) {
return of(BigDecimal.ZERO, currencyCode);
}
private static void validateAmount(BigDecimal amount) {
if (amount == null) {
throw new IllegalArgumentException("Amount cannot be null");
}
}
private static void validateCurrencyCode(String currencyCode) {
if (currencyCode == null || currencyCode.length() != 3) {
throw new IllegalArgumentException(
"Currency code must be a 3-letter ISO code");
}
try {
Currency.getInstance(currencyCode);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Invalid currency code: " + currencyCode);
}
}
// 加算
public Money add(Money other) {
requireSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currencyCode);
}
// 減算
public Money subtract(Money other) {
requireSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currencyCode);
}
// 乗算
public Money multiply(BigDecimal multiplier) {
BigDecimal newAmount = this.amount.multiply(multiplier)
.setScale(getCurrency().getDefaultFractionDigits(),
RoundingMode.HALF_UP);
return new Money(newAmount, this.currencyCode);
}
public Money multiply(int multiplier) {
return multiply(BigDecimal.valueOf(multiplier));
}
// 割合を適用(税率計算など)
public Money applyRate(BigDecimal rate) {
return multiply(rate.add(BigDecimal.ONE));
}
private void requireSameCurrency(Money other) {
if (!this.currencyCode.equals(other.currencyCode)) {
throw new IllegalArgumentException(
String.format("Currency mismatch: %s vs %s",
this.currencyCode, other.currencyCode));
}
}
// getters
public BigDecimal getAmount() {
return amount;
}
public String getCurrencyCode() {
return currencyCode;
}
public Currency getCurrency() {
return Currency.getInstance(currencyCode);
}
public boolean isPositive() {
return amount.compareTo(BigDecimal.ZERO) > 0;
}
public boolean isNegative() {
return amount.compareTo(BigDecimal.ZERO) < 0;
}
public boolean isZero() {
return amount.compareTo(BigDecimal.ZERO) == 0;
}
public boolean isGreaterThan(Money other) {
requireSameCurrency(other);
return this.amount.compareTo(other.amount) > 0;
}
public boolean isLessThan(Money other) {
requireSameCurrency(other);
return this.amount.compareTo(other.amount) < 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return amount.compareTo(money.amount) == 0
&& Objects.equals(currencyCode, money.currencyCode);
}
@Override
public int hashCode() {
return Objects.hash(amount.stripTrailingZeros(), currencyCode);
}
@Override
public String toString() {
Currency currency = getCurrency();
return String.format("%s %s",
currency.getSymbol(),
amount.setScale(currency.getDefaultFractionDigits(),
RoundingMode.HALF_UP));
}
}
|
期間(DateRange)の実装#
開始日と終了日のペアを表す値オブジェクトです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
|
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
@Embeddable
public class DateRange {
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
protected DateRange() {
}
private DateRange(LocalDate startDate, LocalDate endDate) {
this.startDate = startDate;
this.endDate = endDate;
}
public static DateRange of(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException("Dates cannot be null");
}
if (startDate.isAfter(endDate)) {
throw new IllegalArgumentException(
"Start date must be before or equal to end date");
}
return new DateRange(startDate, endDate);
}
public static DateRange ofDays(LocalDate startDate, int days) {
if (days < 0) {
throw new IllegalArgumentException("Days must be non-negative");
}
return of(startDate, startDate.plusDays(days));
}
public static DateRange ofCurrentMonth() {
LocalDate now = LocalDate.now();
return of(now.withDayOfMonth(1),
now.withDayOfMonth(now.lengthOfMonth()));
}
// getters
public LocalDate getStartDate() {
return startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public long getDays() {
return ChronoUnit.DAYS.between(startDate, endDate) + 1;
}
public boolean contains(LocalDate date) {
return !date.isBefore(startDate) && !date.isAfter(endDate);
}
public boolean overlaps(DateRange other) {
return !this.endDate.isBefore(other.startDate)
&& !this.startDate.isAfter(other.endDate);
}
public boolean isActive() {
return contains(LocalDate.now());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
DateRange dateRange = (DateRange) o;
return Objects.equals(startDate, dateRange.startDate)
&& Objects.equals(endDate, dateRange.endDate);
}
@Override
public int hashCode() {
return Objects.hash(startDate, endDate);
}
@Override
public String toString() {
return String.format("%s ~ %s", startDate, endDate);
}
}
|
@AttributeOverrideによるカラム名カスタマイズ#
@AttributeOverrideを使用すると、埋め込まれる@Embeddableクラスのフィールドに対応するカラム名を上書きできます。これは、複数の同一型@Embeddableを埋め込む場合や、既存のテーブルスキーマに合わせる必要がある場合に特に有用です。
基本的な使い方#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Company {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Embedded
@AttributeOverrides({
@AttributeOverride(
name = "streetAddress",
column = @Column(name = "hq_street", length = 200)
),
@AttributeOverride(
name = "city",
column = @Column(name = "hq_city", length = 100)
),
@AttributeOverride(
name = "state",
column = @Column(name = "hq_state", length = 100)
),
@AttributeOverride(
name = "postalCode",
column = @Column(name = "hq_postal_code", length = 20)
),
@AttributeOverride(
name = "countryCode",
column = @Column(name = "hq_country", length = 2)
)
})
private Address headquarters;
// constructors, getters, setters...
}
|
生成されるテーブル:
1
2
3
4
5
6
7
8
9
10
|
CREATE TABLE company (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(255),
hq_street VARCHAR(200),
hq_city VARCHAR(100),
hq_state VARCHAR(100),
hq_postal_code VARCHAR(20),
hq_country VARCHAR(2),
PRIMARY KEY (id)
);
|
複数の同一型Embeddableの埋め込み#
1つのエンティティに同じ@Embeddable型を複数埋め込む場合、@AttributeOverrideでカラム名の競合を解決する必要があります。
配送先と請求先住所の例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
|
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
@Embedded
@AttributeOverrides({
@AttributeOverride(
name = "streetAddress",
column = @Column(name = "shipping_street")
),
@AttributeOverride(
name = "city",
column = @Column(name = "shipping_city")
),
@AttributeOverride(
name = "state",
column = @Column(name = "shipping_state")
),
@AttributeOverride(
name = "postalCode",
column = @Column(name = "shipping_postal_code")
),
@AttributeOverride(
name = "countryCode",
column = @Column(name = "shipping_country")
)
})
private Address shippingAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(
name = "streetAddress",
column = @Column(name = "billing_street")
),
@AttributeOverride(
name = "city",
column = @Column(name = "billing_city")
),
@AttributeOverride(
name = "state",
column = @Column(name = "billing_state")
),
@AttributeOverride(
name = "postalCode",
column = @Column(name = "billing_postal_code")
),
@AttributeOverride(
name = "countryCode",
column = @Column(name = "billing_country")
)
})
private Address billingAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(
name = "amount",
column = @Column(name = "total_amount")
),
@AttributeOverride(
name = "currencyCode",
column = @Column(name = "total_currency")
)
})
private Money totalPrice;
protected Order() {
}
public Order(String orderNumber, Address shippingAddress,
Address billingAddress, Money totalPrice) {
this.orderNumber = orderNumber;
this.shippingAddress = shippingAddress;
this.billingAddress = billingAddress;
this.totalPrice = totalPrice;
}
// getters...
public Long getId() {
return id;
}
public String getOrderNumber() {
return orderNumber;
}
public Address getShippingAddress() {
return shippingAddress;
}
public Address getBillingAddress() {
return billingAddress;
}
public Money getTotalPrice() {
return totalPrice;
}
// 配送先と請求先が同じかどうかを判定
public boolean hasSameShippingAndBillingAddress() {
return Objects.equals(shippingAddress, billingAddress);
}
}
|
生成されるテーブル:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
CREATE TABLE orders (
id BIGINT NOT NULL AUTO_INCREMENT,
order_number VARCHAR(255),
shipping_street VARCHAR(255),
shipping_city VARCHAR(255),
shipping_state VARCHAR(255),
shipping_postal_code VARCHAR(255),
shipping_country VARCHAR(255),
billing_street VARCHAR(255),
billing_city VARCHAR(255),
billing_state VARCHAR(255),
billing_postal_code VARCHAR(255),
billing_country VARCHAR(255),
total_amount DECIMAL(19, 4),
total_currency VARCHAR(3),
PRIMARY KEY (id)
);
|
@ElementCollectionによる値オブジェクトコレクション#
エンティティが複数の値オブジェクトを保持する場合、@ElementCollectionを使用します。値オブジェクトのコレクションは、専用のテーブルに保存されます。
電話番号リストの例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
|
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import java.util.Objects;
@Embeddable
public class PhoneNumber {
public enum PhoneType {
HOME, WORK, MOBILE, FAX
}
@Column(name = "phone_number", nullable = false, length = 20)
private String number;
@Enumerated(EnumType.STRING)
@Column(name = "phone_type", nullable = false, length = 10)
private PhoneType type;
protected PhoneNumber() {
}
private PhoneNumber(String number, PhoneType type) {
this.number = number;
this.type = type;
}
public static PhoneNumber of(String number, PhoneType type) {
if (number == null || number.isBlank()) {
throw new IllegalArgumentException("Phone number cannot be empty");
}
if (type == null) {
throw new IllegalArgumentException("Phone type cannot be null");
}
// 数字、ハイフン、括弧、プラス記号のみを許可
if (!number.matches("^[\\d\\-\\(\\)\\+\\s]+$")) {
throw new IllegalArgumentException("Invalid phone number format");
}
return new PhoneNumber(normalizeNumber(number), type);
}
public static PhoneNumber mobile(String number) {
return of(number, PhoneType.MOBILE);
}
public static PhoneNumber work(String number) {
return of(number, PhoneType.WORK);
}
private static String normalizeNumber(String number) {
return number.replaceAll("[\\s\\-\\(\\)]", "");
}
public String getNumber() {
return number;
}
public PhoneType getType() {
return type;
}
public boolean isMobile() {
return type == PhoneType.MOBILE;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PhoneNumber that = (PhoneNumber) o;
return Objects.equals(number, that.number) && type == that.type;
}
@Override
public int hashCode() {
return Objects.hash(number, type);
}
@Override
public String toString() {
return String.format("%s (%s)", number, type);
}
}
|
@ElementCollectionを使用したエンティティ#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
|
import jakarta.persistence.CollectionTable;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OrderColumn;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
@Entity
public class Contact {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Embedded
private Address primaryAddress;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "contact_phone_numbers",
joinColumns = @JoinColumn(name = "contact_id")
)
@OrderColumn(name = "display_order")
private List<PhoneNumber> phoneNumbers = new ArrayList<>();
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "contact_additional_addresses",
joinColumns = @JoinColumn(name = "contact_id")
)
private List<Address> additionalAddresses = new ArrayList<>();
protected Contact() {
}
public Contact(String name, Address primaryAddress) {
this.name = name;
this.primaryAddress = primaryAddress;
}
// 電話番号の追加
public void addPhoneNumber(PhoneNumber phoneNumber) {
this.phoneNumbers.add(phoneNumber);
}
// 電話番号の削除
public void removePhoneNumber(PhoneNumber phoneNumber) {
this.phoneNumbers.remove(phoneNumber);
}
// 携帯番号の取得
public Optional<PhoneNumber> getMobileNumber() {
return phoneNumbers.stream()
.filter(PhoneNumber::isMobile)
.findFirst();
}
// 追加住所の追加
public void addAddress(Address address) {
this.additionalAddresses.add(address);
}
// getters
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Address getPrimaryAddress() {
return primaryAddress;
}
// 不変のリストを返す
public List<PhoneNumber> getPhoneNumbers() {
return Collections.unmodifiableList(phoneNumbers);
}
public List<Address> getAdditionalAddresses() {
return Collections.unmodifiableList(additionalAddresses);
}
}
|
生成されるテーブル構造#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
-- メインテーブル
CREATE TABLE contact (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(255),
street_address VARCHAR(255),
city VARCHAR(255),
state VARCHAR(255),
postal_code VARCHAR(255),
country_code VARCHAR(2),
PRIMARY KEY (id)
);
-- 電話番号コレクションテーブル
CREATE TABLE contact_phone_numbers (
contact_id BIGINT NOT NULL,
phone_number VARCHAR(20) NOT NULL,
phone_type VARCHAR(10) NOT NULL,
display_order INT,
FOREIGN KEY (contact_id) REFERENCES contact(id)
);
-- 追加住所コレクションテーブル
CREATE TABLE contact_additional_addresses (
contact_id BIGINT NOT NULL,
street_address VARCHAR(255),
city VARCHAR(255),
state VARCHAR(255),
postal_code VARCHAR(255),
country_code VARCHAR(2),
FOREIGN KEY (contact_id) REFERENCES contact(id)
);
|
@ElementCollectionの注意点#
@ElementCollectionには以下の特性と注意点があります。
| 項目 |
説明 |
| ライフサイクル |
親エンティティに完全に依存する(親が削除されるとコレクションも削除) |
| 遅延ロード |
デフォルトはLAZY。アクセス時に追加クエリが発生する |
| 更新処理 |
コレクションを変更すると、全件削除→再挿入となる場合がある |
| インデックス |
@OrderColumnで順序を保持可能 |
コレクションの頻繁な更新が想定される場合は、値オブジェクトではなく独立したエンティティとしてモデリングすることを検討してください。
DDD的な設計アプローチ#
値オブジェクトをDDD(ドメイン駆動設計)の観点から効果的に活用するためのアプローチを説明します。
ドメインの概念を型で表現する#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
// 注文ID(識別子として機能する値オブジェクト)
@Embeddable
public class OrderId {
@Column(name = "order_id", nullable = false, length = 36)
private String value;
protected OrderId() {
}
private OrderId(String value) {
this.value = value;
}
public static OrderId generate() {
return new OrderId(UUID.randomUUID().toString());
}
public static OrderId of(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Order ID cannot be empty");
}
return new OrderId(value);
}
public String getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OrderId orderId = (OrderId) o;
return Objects.equals(value, orderId.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return value;
}
}
|
数量(Quantity)の実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
@Embeddable
public class Quantity {
@Column(name = "quantity", nullable = false)
private int value;
protected Quantity() {
}
private Quantity(int value) {
this.value = value;
}
public static Quantity of(int value) {
if (value < 0) {
throw new IllegalArgumentException("Quantity cannot be negative");
}
return new Quantity(value);
}
public static Quantity zero() {
return new Quantity(0);
}
public Quantity add(Quantity other) {
return new Quantity(this.value + other.value);
}
public Quantity subtract(Quantity other) {
if (this.value < other.value) {
throw new IllegalArgumentException(
"Cannot subtract: result would be negative");
}
return new Quantity(this.value - other.value);
}
public Money multiply(Money unitPrice) {
return unitPrice.multiply(this.value);
}
public int getValue() {
return value;
}
public boolean isZero() {
return value == 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Quantity quantity = (Quantity) o;
return value == quantity.value;
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return String.valueOf(value);
}
}
|
複合値オブジェクトの構成#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
|
// 注文明細(値オブジェクトの組み合わせ)
@Embeddable
public class OrderLine {
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(name = "product_name", nullable = false)
private String productName;
@Embedded
@AttributeOverrides({
@AttributeOverride(
name = "value",
column = @Column(name = "quantity")
)
})
private Quantity quantity;
@Embedded
@AttributeOverrides({
@AttributeOverride(
name = "amount",
column = @Column(name = "unit_price")
),
@AttributeOverride(
name = "currencyCode",
column = @Column(name = "unit_currency")
)
})
private Money unitPrice;
protected OrderLine() {
}
public OrderLine(Long productId, String productName,
Quantity quantity, Money unitPrice) {
this.productId = productId;
this.productName = productName;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
public Long getProductId() {
return productId;
}
public String getProductName() {
return productName;
}
public Quantity getQuantity() {
return quantity;
}
public Money getUnitPrice() {
return unitPrice;
}
// 小計を計算
public Money getSubtotal() {
return quantity.multiply(unitPrice);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OrderLine orderLine = (OrderLine) o;
return Objects.equals(productId, orderLine.productId)
&& Objects.equals(quantity, orderLine.quantity)
&& Objects.equals(unitPrice, orderLine.unitPrice);
}
@Override
public int hashCode() {
return Objects.hash(productId, quantity, unitPrice);
}
}
|
ドメインサービスとの連携#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@Service
public class PricingService {
private static final BigDecimal TAX_RATE = new BigDecimal("0.10");
public Money calculateTax(Money amount) {
return amount.multiply(TAX_RATE);
}
public Money calculateTotalWithTax(Money subtotal) {
return subtotal.applyRate(TAX_RATE);
}
public Money calculateOrderTotal(List<OrderLine> lines) {
return lines.stream()
.map(OrderLine::getSubtotal)
.reduce(Money.zero("JPY"), Money::add);
}
}
|
アーキテクチャ全体図#
値オブジェクトを含むドメイン層のアーキテクチャを以下の図に示します。
graph TB
subgraph "Presentation Layer"
Controller[REST Controller]
end
subgraph "Application Layer"
Service[Application Service]
DomainService[Domain Service]
end
subgraph "Domain Layer"
Entity[Entity]
ValueObject[Value Objects]
Repository[Repository Interface]
Entity --> ValueObject
ValueObject --> |Address| VO1[Address]
ValueObject --> |Money| VO2[Money]
ValueObject --> |Quantity| VO3[Quantity]
ValueObject --> |DateRange| VO4[DateRange]
end
subgraph "Infrastructure Layer"
RepositoryImpl[JPA Repository]
DB[(Database)]
end
Controller --> Service
Service --> DomainService
Service --> Repository
Repository -.-> RepositoryImpl
RepositoryImpl --> DBよくある誤解とアンチパターン#
アンチパターン1: setterを公開する#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 悪い例:setterを公開して不変性を破壊
@Embeddable
public class Money {
private BigDecimal amount;
private String currencyCode;
// setterを公開すると不変性が損なわれる
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public void setCurrencyCode(String currencyCode) {
this.currencyCode = currencyCode;
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// 良い例:不変オブジェクトとして設計
@Embeddable
public class Money {
private BigDecimal amount;
private String currencyCode;
protected Money() {}
private Money(BigDecimal amount, String currencyCode) {
this.amount = amount;
this.currencyCode = currencyCode;
}
// ファクトリメソッドで新しいインスタンスを作成
public static Money of(BigDecimal amount, String currencyCode) {
return new Money(amount, currencyCode);
}
// 変更が必要な場合は新しいインスタンスを返す
public Money add(Money other) {
return new Money(this.amount.add(other.amount), this.currencyCode);
}
}
|
アンチパターン2: equalsとhashCodeを実装しない#
1
2
3
4
5
6
7
|
// 悪い例:equalsとhashCodeがない
@Embeddable
public class Address {
private String city;
private String street;
// equalsとhashCodeが未実装
}
|
値オブジェクトは同値性によって比較されるため、equalsとhashCodeの実装は必須です。
アンチパターン3: 過度に大きな値オブジェクト#
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 悪い例:すべてを1つの値オブジェクトに詰め込む
@Embeddable
public class CustomerInfo {
private String firstName;
private String lastName;
private String email;
private String phone;
private String street;
private String city;
private String postalCode;
private String country;
// ... さらに多くのフィールド
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 良い例:適切に分割された値オブジェクト
@Entity
public class Customer {
@Embedded
private PersonName name; // 名前を表す値オブジェクト
@Embedded
private Email email; // メールアドレスを表す値オブジェクト
@Embedded
private PhoneNumber phone; // 電話番号を表す値オブジェクト
@Embedded
private Address address; // 住所を表す値オブジェクト
}
|
アンチパターン4: nullを許容する値オブジェクト#
1
2
3
4
5
6
7
8
9
10
|
// 悪い例:nullを許容する値オブジェクト
@Embeddable
public class Money {
private BigDecimal amount; // nullの可能性がある
public Money add(Money other) {
// NullPointerExceptionの可能性
return new Money(this.amount.add(other.amount));
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// 良い例:ファクトリメソッドでnullチェック
@Embeddable
public class Money {
private BigDecimal amount;
public static Money of(BigDecimal amount, String currencyCode) {
if (amount == null) {
throw new IllegalArgumentException("Amount cannot be null");
}
return new Money(amount, currencyCode);
}
// nullの代わりにゼロを表す特別なインスタンスを使用
public static Money zero(String currencyCode) {
return of(BigDecimal.ZERO, currencyCode);
}
}
|
アンチパターン5: ビジネスロジックがない値オブジェクト#
1
2
3
4
5
6
7
8
9
|
// 悪い例:単なるデータの入れ物
@Embeddable
public class Money {
private BigDecimal amount;
private String currencyCode;
public BigDecimal getAmount() { return amount; }
public String getCurrencyCode() { return currencyCode; }
}
|
1
2
3
4
5
6
7
8
9
10
11
12
|
// 良い例:ドメインロジックを含む
@Embeddable
public class Money {
private BigDecimal amount;
private String currencyCode;
// ドメインロジック
public Money add(Money other) { ... }
public Money multiply(BigDecimal factor) { ... }
public boolean isPositive() { ... }
public boolean isGreaterThan(Money other) { ... }
}
|
まとめと実践Tips#
値オブジェクト設計のチェックリスト#
本記事で解説したSpring JPAの@Embeddableと@Embeddedを使用した値オブジェクト設計のポイントをまとめます。
| チェック項目 |
確認内容 |
| 不変性 |
setterを排除し、状態変更時は新しいインスタンスを返しているか |
| バリデーション |
ファクトリメソッドで不正な値を拒否しているか |
| 同値性 |
equalsとhashCodeを適切に実装しているか |
| デフォルトコンストラクタ |
JPA用にprotectedで定義しているか |
| カラム定義 |
@Columnで長さ、精度、NOT NULL制約を明示しているか |
| ドメインロジック |
単なるデータホルダーではなく、振る舞いを持っているか |
実践Tips#
-
Java Records との組み合わせ: Java 16以降では、Recordsを値オブジェクトのベースとして使用できます。ただし、JPAとの互換性を確認する必要があります。
-
Lombokの活用: @Valueや@Builderを使用してボイラープレートコードを削減できます。ただし、デフォルトコンストラクタには注意が必要です。
-
テストファーストのアプローチ: 値オブジェクトは副作用がないため、単体テストが容易です。テストを先に書くことで、設計の妥当性を検証できます。
-
ドキュメント化: 値オブジェクトの意図と制約をJavadocで明確に記述してください。
1
2
3
4
5
6
7
8
9
10
11
12
|
/**
* 金額を表す値オブジェクト。
* 通貨と金額を一体として扱い、異なる通貨間の計算を防止します。
*
* <p>このクラスは不変です。金額を変更する操作は新しいインスタンスを返します。</p>
*
* @see Currency
*/
@Embeddable
public class Money {
// ...
}
|
関連記事#
Spring JPAの値オブジェクト設計をさらに深く理解するために、以下の関連記事も参照してください。
参考リンク#