Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
Windows Tipps & SecurityNighthawk M7 Pro im Test: Flexibler, aber teurer 5G-Router(21.09.2026 um 10:30 Uhr)
Sichere ProgrammierungNeue Gmail-Funktion: So sparst du jetzt Zeit bei Einmalcodes(21.09.2026 um 10:00 Uhr)
Sichere ProgrammierungYour GIF exporter is fine — the container is the problem(21.09.2026 um 10:01 Uhr)
Sichere ProgrammierungCSS, Motion, or GSAP? I Choose by Who Owns the Animation(21.09.2026 um 10:12 Uhr)
Windows Tipps & SecurityNighthawk M7 Pro im Test: Flexibler, aber teurer 5G-Router(21.09.2026 um 10:30 Uhr)
Sichere ProgrammierungNeue Gmail-Funktion: So sparst du jetzt Zeit bei Einmalcodes(21.09.2026 um 10:00 Uhr)
Sichere ProgrammierungYour GIF exporter is fine — the container is the problem(21.09.2026 um 10:01 Uhr)
Sichere ProgrammierungCSS, Motion, or GSAP? I Choose by Who Owns the Animation(21.09.2026 um 10:12 Uhr)
Intelligence View
⚡ tsecurity.de Intelligence

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 c…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!




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 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");
}






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.

Ähnliche Beiträge
🔍 Verwandte News

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

Thematisch verwandte Begriffe: Primitive, Obsession, Spring, Boot · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-94030 | A security vulnerability has been detected in SerenityOS up to 3d83e4509…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick