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 in
from 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 Q
var 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("Ana@Example.COM"), b("Ana@example.com");
    std::cout << a.str() << " equal: " << (a == b) << "\n";
    try { EmailAddress bad("no-at-sign"); } catch (const std::exception& e) { std::cout << e.what() << "\n"; }
}
Ana@example.com equal: 1
invalid email: no-at-sign

Interview 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).