Enums, Value Objects and Domain Modeling
Learning Objectives
Use scoped enums and understand their advantages over traditional enums.
Distinguish value objects from entities.
Build immutable, self-validating value objects.
Separate DTOs from domain objects and avoid the anemic domain model.
Prerequisites: Modules 3, 12, 16.
Intuition and Definition
An enum names a fixed set of values. C++ enum class is scoped (Gear::Park) and does not convert implicitly to int; traditional enum leaks names into the enclosing scope and converts silently.
A value object is defined entirely by its attributes: two Money(19.99, "EUR") are interchangeable. It is immutable and validates itself.
An entity has an identity that persists while its attributes change: a Vehicle with VIN WVW… is the same vehicle after a repaint.
A DTO (data transfer object) is a dumb structure for crossing a boundary (JSON, database rows) — no behaviour, no invariants.
An anemic domain model has entities that are just DTOs with getters/setters while all rules live in "service" classes — the rules then get duplicated and bypassed.
Why It Matters
Primitive obsession — passing double, string, int everywhere — invites mixing up kilometres and miles, Celsius and Fahrenheit, customer IDs and order IDs. Value objects make such errors impossible to compile.
Syntax and Language Notes
enum class Gear : uint8_t { Park, Reverse, Neutral, Drive }; // scoped, explicit underlying type// Java (not run): enums are full classes; records are concise immutable value carriers
enum Gear { PARK, REVERSE, NEUTRAL, DRIVE }
record Money(long cents, String currency) {
Money { if (currency.length() != 3) throw new IllegalArgumentException("ISO code"); }
}public enum Gear { Park, Reverse, Neutral, Drive }
public sealed record Money(long Cents, string Currency); // value equality built infrom enum import Enum
class Gear(Enum):
PARK = "P"; REVERSE = "R"; NEUTRAL = "N"; DRIVE = "D"type Gear = "P" | "R" | "N" | "D"; // union of literal types (often preferred to enum)
const GEARS = ["P", "R", "N", "D"] as const;Complete Example
C# records give value equality for free — compare with a class:
#include <compare>
#include <iostream>
#include <stdexcept>
#include <string>
enum class Unit { Kilometres, Miles };
class Distance { // value object
double km_;
explicit Distance(double km) : km_(km) {}
public:
static Distance kilometres(double v) { if (v < 0) throw std::invalid_argument("negative distance"); return Distance(v); }
static Distance miles(double v) { return kilometres(v * 1.609344); }
double in(Unit u) const { return u == Unit::Kilometres ? km_ : km_ / 1.609344; }
Distance operator+(const Distance& o) const { return Distance(km_ + o.km_); }
auto operator<=>(const Distance&) const = default;
};
class Vin { // value object with validation
std::string value_;
public:
explicit Vin(std::string v) : value_(std::move(v)) {
if (value_.size() != 17) throw std::invalid_argument("VIN must have 17 characters");
for (char c : value_) if (c == 'I' || c == 'O' || c == 'Q') throw std::invalid_argument("VIN may not contain I, O or Q");
}
const std::string& str() const { return value_; }
bool operator==(const Vin&) const = default;
};
class Vehic
…10 mi = 16.0934 km; longer: miles
same vehicle after repaint? 1, odometer 120.5 km
rejected SHORT: VIN must have 17 characters
rejected WVWZZZ1KZ6W12345O: VIN may not contain I, O or Qvar a = new MoneyRecord(1999, "EUR");
var b = new MoneyRecord(1999, "EUR");
Console.WriteLine($"records equal: {a == b}");
var c = new MoneyClass(1999, "EUR");
var d = new MoneyClass(1999, "EUR");
Console.WriteLine($"classes equal: {c == d}");
var discounted = a with { Cents = 1499 }; // non-destructive mutation
Console.WriteLine(discounted);
public sealed record MoneyRecord(long Cents, string Currency);
public sealed class MoneyClass(long cents, string currency)
{
public long Cents { get; } = cents;
public string Currency { get; } = currency;
}records equal: True
classes equal: False
MoneyRecord { Cents = 1499, Currency = EUR }Step-by-Step Walkthrough
Distance has a private constructor; named factories convert units, so a raw double can never be mistaken for a distance.
Equality and ordering compare the stored kilometres, so 10 miles > 10 km works across units.
Vin validates on construction: length 17, no I/O/Q. An invalid VIN object can't exist.
Vehicle is an entity: sameVehicleAs compares VINs only, so the repainted, driven car is still the same vehicle.
Behaviour (drive, repaint) lives in the entity — not in a separate VehicleService that manipulates setters.
C# records implement value equality, ToString and with-copies automatically; classes compare by reference.
Common Mistakes
Primitive obsession — void transfer(string from, string to, double amount).
Mutable value objects — if Money changes, every holder sees the change.
Entities compared by all fields — two snapshots of the same customer with different addresses are still the same customer.
Anemic models — rules scattered through services; any caller can skip them by using setters.
Traditional enums converting to int and colliding names (RED in two enums).
Using DTOs as domain objects, so a JSON field rename ripples through business logic.
Trade-offs and Alternatives
Value objects add types and conversions at boundaries (JSON ↔ Money). They pay off where mistakes are costly (money, units, identifiers) — not every string needs a class. DTOs stay at the edges (API, persistence) and are mapped to domain objects (Module 71).
Real-World Use
Examples: Money, Temperature, VehicleId, DateRange, EmailAddress, Measurement{value, unit}. Unit confusion has destroyed spacecraft (Mars Climate Orbiter, 1999: pound-seconds vs newton-seconds) — exactly what unit-carrying value types prevent.
Practice
Q1. (Easy) Name two advantages of enum class over enum.
Answer: Enumerators are scoped (no name clashes) and there's no implicit conversion to int.
Q2. (Medium) Is an Address a value object or an entity in an online shop?
Answer: Usually a value object (two identical addresses are interchangeable); a Customer who has an address is the entity. In a postal-service system that tracks a specific delivery point over time, it might be an entity.
Q3. (Advanced) Write an immutable EmailAddress value object that normalises case of the domain, validates one @, and compares by value.
#include <algorithm>
#include <cctype>
#include <iostream>
#include <stdexcept>
#include <string>
class EmailAddress {
std::string value_;
public:
explicit EmailAddress(std::string raw) {
auto at = raw.find('@');
if (at == std::string::npos || at == 0 || at + 1 >= raw.size() || raw.find('@', at + 1) != std::string::npos)
throw std::invalid_argument("invalid email: " + raw);
std::transform(raw.begin() + static_cast<long>(at), raw.end(), raw.begin() + static_cast<long>(at),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
value_ = std::move(raw);
}
const std::string& str() const { return value_; }
bool operator==(const EmailAddress&) const = default;
};
int main() {
EmailAddress a("[email protected]"), b("[email protected]");
std::cout << a.str() << " equal: " << (a == b) << "\n";
try { EmailAddress bad("no-at-sign"); } catch (const std::exception& e) { std::cout << e.what() << "\n"; }
}[email protected] equal: 1
invalid email: no-at-signInterview Question
Q1. "What's the difference between an entity and a value object, and how does it affect equality and mutability?"
Answer: An entity has a stable identity (ID) that persists through state changes; equality is by identity, and it is usually mutable through behaviour methods that enforce invariants. A value object has no identity; equality is by all attributes, and it should be immutable — "changing" it means replacing it with a new value. This drives implementation: entities implement equals/hashCode on the ID only; value objects on all fields (records/dataclasses/<=> do this automatically).
Quick Quiz
Q1. Should a value object have setters?
Answer: No — it should be immutable; create a new value instead.
Q2. What is primitive obsession?
Answer: Using raw primitives (strings, doubles) for domain concepts instead of small types with validation and meaning.
Q3. Where do DTOs belong?
Answer: At system boundaries (APIs, persistence), mapped to and from domain objects.
Summary
Scoped enums prevent name clashes and implicit conversions.
Value objects: immutable, validated, compared by value. Entities: identity, behaviour, invariants.
Keep DTOs at the edges; put behaviour in the domain to avoid anemic models.
Related: Module 3, Module 16, Module 74 (DDD), Module 62 (serialization).