DEV Community

Dominik Paszek
Dominik Paszek

Posted on

Primitive Obsession in Spring Boot DDD — before and after replacing UUID/Integer/String with Value Objects (code + video)

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 a lockerId passed by mistake
  • Integer weightKg is non-final — weight can change after registration, which should never happen
  • Four integers in assignParcelDimensions with no type safety between them
  • String deliveryAddress and String lockerSlotId are 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");
}
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
            );
    }
}
Enter fullscreen mode Exit fullscreen mode

Locker goes from three separate fields to:

private final LockerAddress address;
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)