Your Compiler Could Have Caught This Bug. It Didn't.
This post accompanies episode 2 of my DDD series — building a real parcel locker system in Spring Boot from scratch. Source code on GitLab.
We had a method like this in our codebase:
public Parcel assignParcelDimensions(
Integer weightKg,
Integer heightCm,
Integer widthCm,
Integer depthCm) {
this.weightKg = weightKg;
this.heightCm = heightCm;
this.widthCm = widthCm;
this.depthCm = depthCm;
return this;
}
Four Integer parameters in a row. Swap two — it compiles. Passes review. Ships. And somewhere in production, a package ends up in the wrong locker because weight and width got mixed up.
The compiler had all the information it needed to stop this. We just didn't give it the right types.
The Problem: Primitive Obsession
Here's the full Parcel domain class before we touched it:
public final class Parcel {
private final UUID parcelId; // any UUID fits — even a lockerId
private Integer weightKg; // mutable, non-final
private Integer heightCm;
private Integer widthCm;
private Integer depthCm;
private String lockerSlotId;
private String deliveryAddress; // identical type to lockerSlotId
private final LocalDateTime createdAt;
}
And Locker:
public final class Locker {
private final String lockerId;
private final String postalCode; // three naked strings
private final String city; // with no connection between them
private final String street; // and no format validation
}
This has a name: Primitive Obsession. Using raw built-in types where domain concepts need their own type with their own rules.
The problems in Parcel alone:
-
UUID parcelId— any UUID works here, including alockerIdpassed by mistake -
Integer weightKgis non-final — weight can change after registration, which should never happen - Four integers in
assignParcelDimensionswith no type safety between them -
String deliveryAddressandString lockerSlotIdare identical types with completely different semantics
What Is a Value Object?
A Value Object represents a domain concept through its value, not its identity. Three properties, all required:
1. Immutable. Once created, it cannot change. No setters. Java records enforce this — every component is final.
2. Value equality. Two Value Objects with the same content are equal, regardless of reference. Records give you equals() and hashCode() automatically.
3. Self-validating. A Value Object cannot exist in an invalid state. Validation happens in the compact constructor. If you hold one, it is valid — no exceptions, no "did someone validate this first?"
That third property is the one that changes how you write code. You stop writing defensive checks everywhere and start designing types that can't hold bad data.
Step 1: Unit — The Smallest Value Object
Before tackling dimensions, we need a Unit. Right now the unit information lives only in field names — weightKg, heightCm. The type itself tells you nothing.
public record Unit(String symbol, String name) {
public Unit {
if (symbol == null || symbol.isBlank())
throw new IllegalArgumentException(
"Unit symbol cannot be null or blank"
);
if (name == null || name.isBlank())
throw new IllegalArgumentException(
"Unit name cannot be null or blank"
);
}
public static final Unit KILOGRAM = new Unit("kg", "kilogram");
public static final Unit CENTIMETER = new Unit("cm", "centimeter");
public static final Unit GRAM = new Unit("g", "gram");
}
Common units are static constants — immutable singletons, safe to share freely.
Note: A subtle bug worth flagging — if you write
if (symbol != null && !symbol.isBlank()) throw ...you're throwing when the value is valid. The correct guard is== null || isBlank(). Easy to miss, breaks everything.
Step 2: PackageSize — Replacing Three Integers
Should width, height, and depth be three separate Value Objects or one? In our domain, dimensions always travel together — they describe one concept. One Value Object:
public record PackageSize(int width, int height, int depth, Unit unit) {
public PackageSize {
if (width <= 0 || height <= 0 || depth <= 0)
throw new IllegalArgumentException(
"All dimensions must be positive. Got: "
+ width + "x" + height + "x" + depth
);
if (unit == null)
throw new IllegalArgumentException("Unit is required");
if (unit.equals(Unit.CENTIMETER)
&& (width > 120 || height > 120 || depth > 180))
throw new IllegalArgumentException(
"Exceeds maximum dimensions (120x120x180 cm)"
);
}
public LockerSlotSize requiredSlotSize() {
if (width <= 40 && height <= 40 && depth <= 60) return LockerSlotSize.SMALL;
if (width <= 60 && height <= 60 && depth <= 100) return LockerSlotSize.MEDIUM;
if (width <= 100 && height <= 100 && depth <= 150) return LockerSlotSize.LARGE;
// Reachable: 110x50x50 passes the constructor (110 <= 120)
// but doesn't fit any slot (110 > 100 for LARGE)
throw new IllegalStateException(
"Parcel doesn't fit any locker slot: "
+ width + "x" + height + "x" + depth
);
}
}
Why is the max 120 and not 100? Our largest locker slot is 100×100×150. But a slightly oversized parcel should still be registerable — it just can't be assigned to a slot. These are two different business rules. The constructor enforces the absolute limit. Assignment logic enforces the locker limit. Conflating them would mean you can't even create an object for an oversized parcel.
The assignParcelDimensions method now becomes:
// Before — four integers, any order
public Parcel assignParcelDimensions(Integer weightKg,
Integer heightCm, Integer widthCm, Integer depthCm)
// After — one type, no positions to swap
public Parcel assignDimensions(PackageSize size)
Step 3: PackageWeight — Immutability With Operations
public record PackageWeight(int value, Unit unit) {
public PackageWeight {
if (value <= 0)
throw new IllegalArgumentException("Weight must be positive");
if (unit == null)
throw new IllegalArgumentException("Unit is required");
if (unit.equals(Unit.KILOGRAM) && value > 30)
throw new IllegalArgumentException(
"Max 30kg for locker delivery, got: " + value
);
}
public PackageWeight add(PackageWeight other) {
if (!this.unit.equals(other.unit))
throw new IllegalArgumentException(
"Cannot add weights with different units"
);
return new PackageWeight(this.value + other.value, this.unit);
}
public boolean requiresSignature() {
return unit.equals(Unit.KILOGRAM) && value > 10;
}
}
add() returns a new PackageWeight. The original is unchanged. Operations on Value Objects produce new values — they never mutate state. This also means PackageWeight is safe to share, safe to use as a map key, safe to cache.
The field in Parcel goes from private Integer weightKg (non-final, mutable) to private final PackageWeight weight. Weight cannot change after registration.
Step 4: LockerAddress — Grouping Related Strings
Three naked strings in Locker become one:
public record LockerAddress(String street, String city, String postalCode) {
public LockerAddress {
if (street == null || street.isBlank())
throw new IllegalArgumentException("Street is required");
if (city == null || city.isBlank())
throw new IllegalArgumentException("City is required");
if (postalCode == null || !postalCode.matches("\\d{2}-\\d{3}"))
throw new IllegalArgumentException(
"Invalid postal code. Expected XX-XXX, got: " + postalCode
);
}
}
Locker goes from three separate fields to:
private final LockerAddress address;
The postal code format is now a type-level guarantee. You can't create a LockerAddress with a malformed postal code. There's no moment to forget the validation — it runs at construction or not at all.
How to Persist Value Objects
Three approaches, all legitimate:
@Embeddable — put the JPA annotation directly on the Value Object. JPA stores its fields inline in the parent table. This is what Vaughn Vernon uses in his IDDD reference code. One annotation imports jakarta.persistence, which is a light coupling you may or may not care about.
AttributeConverter — best for single-value objects like ParcelId. Zero annotations on the domain class, truly final fields, clean separation.
@Converter(autoApply = true)
public class ParcelIdConverter
implements AttributeConverter<ParcelId, String> {
@Override
public String convertToDatabaseColumn(ParcelId id) {
return id != null ? id.value().toString() : null;
}
@Override
public ParcelId convertToEntityAttribute(String value) {
return value != null
? new ParcelId(UUID.fromString(value))
: null;
}
}
Separate JPA entity + mapper — full isolation. Domain has zero persistence knowledge. Most code, most control. The right call if you're planning to support multiple storage backends.
For this project: AttributeConverter for identifiers, @Embeddable for multi-field objects like PackageSize and LockerAddress.
Where They Live
parcels/
domain/model/
Parcel.java ← Aggregate Root
Unit.java ← Value Object
PackageSize.java ← Value Object
PackageWeight.java ← Value Object
ParcelId.java ← Value Object
adapter/out/persistence/
ParcelJpaEntity.java ← @Entity lives here, not in domain/
lockers/
domain/model/
Locker.java ← Aggregate Root
LockerAddress.java ← Value Object
LockerId.java ← Value Object
domain/model/ is the innermost layer. Zero framework annotations — unless you choose @Embeddable, which is your decision, not a rule.
The Point
If you hold a Value Object, it is valid. You don't need to check. You can't forget to check. The type guarantees it.
assignParcelDimensions(Integer, Integer, Integer, Integer) is gone. You cannot swap weight and width anymore — they're different types and the compiler knows it.
Next episode: Aggregate Root. We take these Value Objects and enforce the real business rules on Parcel and Locker — the domain classes, not the JPA entities. What can change after a parcel is registered? What cannot? That's where the Aggregate Root lives.
Source code: gitlab.com/PaszekDevv/locker — branch part1-valueobjects
Branch off, try your own approach, open a discussion. That's the point.
Top comments (0)