Value Objects & Entities — Building Blocks That Can't Break
Value Objects & Entities — Building Blocks That Can’t Break
The Validation Everywhere Problem
Last time, I showed you how TDD gives you permission to refactor. But there’s a problem I glossed over: what happens when the same validation rule appears in five different places?
I ran into this on a real project. Email validation lived in four separate files:
// ❌ Scattered across the codebase
if (!req.body.email.includes("@")) { return res.status(400).json({ error: "Invalid email" });}
// src/services/userService.tsif (!email.includes("@")) { return err(new InvalidEmailError());}
// src/middleware/validateRegistration.tsif (!input.email.includes("@")) { return res.status(400).json({ field: "email", message: "Invalid" });}
// src/utils/validators.tsexport function isValidEmail(email: string): boolean { return email.includes("@");}Four files. Four implementations. Slightly different error messages. And when the product team asked us to also reject emails longer than 254 characters, I had to find and update all four places.
I missed one. A bug shipped to production.
That’s when I realized the problem wasn’t testing. The problem was design. I was treating validation as something that happens to data, instead of something encoded into data.
In the last article, I introduced the Result<T, E> pattern and hinted at Value Objects. Now let’s build them properly, with TDD driving every decision.
The thesis: Value Objects encode rules into types. Once you have them, validation disappears from your services because the types themselves reject bad data.
What Is a Value Object?
A Value Object is an immutable object defined by its attributes, not its identity. Two Email("[email protected]") objects are equal because their values are equal, not because they share a reference.
Value Objects have four properties:
- Immutability: created once, never modified
- Equality by value:
equals()compares internal state - Self-validating: cannot exist in an invalid state
- No side effects: pure construction
Here’s what that looks like. Compare the primitive approach with the Value Object:
// ❌ Primitive: any string is "valid"const email: string = "not-an-email";const email2: string = "";
// ✅ Value Object: only valid emails can existclass InvalidEmailError { readonly code = "INVALID_EMAIL"; constructor(public readonly input: string) {}}
class Email { private constructor(private readonly value: string) {}
static create(value: string): Result<Email, InvalidEmailError> { const trimmed = value.trim().toLowerCase(); if (!trimmed.includes("@") || !trimmed.includes(".")) { return err(new InvalidEmailError(value)); } if (trimmed.length > 254) { return err(new InvalidEmailError(value)); } return ok(new Email(trimmed)); }
getValue(): string { return this.value; }
equals(other: Email): boolean { return this.value === other.value; }}The private constructor is the key. You can’t call new Email("garbage"). The only way to create an Email is through Email.create(), which validates the input and returns a Result. Invalid states don’t just become hard to create. They become impossible.
This isn’t new. It’s Domain-Driven Design 101. But most TypeScript developers I meet either haven’t encountered it, or they’ve seen it in Java/C# and assumed it was enterprise ceremony. It’s not. It’s the simplest way I know to eliminate an entire class of bugs.
The TDD Cycle for Value Objects
I didn’t design Email on a whiteboard. I let tests drive it. Here’s the full red-green-refactor cycle.
Iteration 1: Basic Creation (Red)
I started by writing the tests I wanted to pass:
describe("Email Value Object", () => { it("should create a valid email", () => {
expect(result.ok).toBe(true); if (result.ok) { } });
it("should reject email without @", () => { const result = Email.create("no-at-sign");
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("INVALID_EMAIL"); } });});Tests fail. Good. That’s the red phase.
Iteration 1: Basic Creation (Green)
The simplest implementation that passes:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function ok<T>(value: T): Result<T, never> { return { ok: true, value };}
function err<E>(error: E): Result<never, E> { return { ok: false, error };}
class InvalidEmailError { readonly code = "INVALID_EMAIL"; constructor(public readonly input: string) {}}
class Email { private constructor(private readonly value: string) {}
static create(value: string): Result<Email, InvalidEmailError> { if (!value.includes("@")) { return err(new InvalidEmailError(value)); } return ok(new Email(value)); }
getValue(): string { return this.value; }}Tests pass. Green.
Iteration 2: Edge Cases (Red)
Now I push the design by adding more tests:
it("should normalize email to lowercase", () => {
expect(result.ok).toBe(true); if (result.ok) { }});
it("should trim whitespace", () => {
expect(result.ok).toBe(true); if (result.ok) { }});
it("should reject empty string", () => { const result = Email.create("");
expect(result.ok).toBe(false);});
it("should reject email without a domain suffix", () => { const result = Email.create("user@localhost");
expect(result.ok).toBe(false);});
it("should reject email over 254 characters", () => { const long = "a".repeat(250) + "@b.co"; const result = Email.create(long);
expect(result.ok).toBe(false);});Iteration 2: Edge Cases (Green)
I update Email.create() to pass all tests:
static create(value: string): Result<Email, InvalidEmailError> { const trimmed = value.trim().toLowerCase(); if (!trimmed.includes("@") || !trimmed.includes(".")) { return err(new InvalidEmailError(value)); } if (trimmed.length > 254) { return err(new InvalidEmailError(value)); } return ok(new Email(trimmed));}Green. All tests pass.
Iteration 3: Equality (Red)
One more behavior I need: can I compare two emails?
describe("Email Equality", () => { it("should consider same emails equal", () => {
if (a.ok && b.ok) { expect(a.value.equals(b.value)).toBe(true); } });
it("should consider different emails unequal", () => {
if (a.ok && b.ok) { expect(a.value.equals(b.value)).toBe(false); } });});Iteration 3: Equality (Green)
equals(other: Email): boolean { return this.value === other.value;}Here’s the insight: the equals() method only exists because a test required it. I didn’t plan it upfront. TDD exerted design pressure: the tests told me what the object needed to do, and I responded.
This is what I mean when I say TDD is about design, not catching bugs.
Building More Value Objects
Once you’ve built one Value Object, the pattern repeats. Same shape. Same TDD cycle. Let me show you two more.
Age Value Object
class InvalidAgeError { readonly code = "INVALID_AGE"; constructor(public readonly reason: string) {}}
class Age { private constructor(private readonly value: number) {}
static create(value: number): Result<Age, InvalidAgeError> { if (!Number.isInteger(value)) { return err(new InvalidAgeError("Age must be a whole number")); } if (value < 0) { return err(new InvalidAgeError("Age cannot be negative")); } if (value < 18) { return err(new InvalidAgeError("Must be at least 18")); } if (value > 150) { return err(new InvalidAgeError("Age seems unrealistic")); } return ok(new Age(value)); }
getValue(): number { return this.value; }
equals(other: Age): boolean { return this.value === other.value; }}And the tests that drove it:
describe("Age Value Object", () => { it("should create a valid age", () => { const result = Age.create(25); expect(result.ok).toBe(true); });
it("should reject negative ages", () => { const result = Age.create(-1); expect(result.ok).toBe(false); });
it("should reject ages under 18", () => { const result = Age.create(17); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.reason).toContain("18"); } });
it("should reject non-integer ages", () => { const result = Age.create(25.5); expect(result.ok).toBe(false); });
it("should reject unrealistic ages", () => { const result = Age.create(200); expect(result.ok).toBe(false); });});Username Value Object
class InvalidUsernameError { readonly code = "INVALID_USERNAME"; constructor(public readonly reason: string) {}}
class Username { private constructor(private readonly value: string) {}
static create(value: string): Result<Username, InvalidUsernameError> { const trimmed = value.trim(); if (trimmed.length < 3) { return err( new InvalidUsernameError("Username must be at least 3 characters") ); } if (trimmed.length > 30) { return err( new InvalidUsernameError("Username must be at most 30 characters") ); } if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) { return err( new InvalidUsernameError( "Username can only contain letters, numbers, and underscores" ) ); } return ok(new Username(trimmed)); }
getValue(): string { return this.value; }
equals(other: Username): boolean { return this.value === other.value; }}Notice the pattern? Every Value Object follows the same shape:
- Private constructor: you can’t create one directly
- Static factory returning
Result<T, E>: validation happens here - Getter for the internal value
equals()for comparison- All validation in the factory
I didn’t plan this pattern. It emerged from writing the same test structure over and over. TDD revealed it.
What Is an Entity?
Value Objects are defined by their attributes. But some things in your domain have identity: they matter because of who they are, not what they contain.
A User with email [email protected] and age 25 is different from another User with the same data but a different ID. The ID is what matters. That’s an Entity.
Entities have four properties:
- Identity: a unique ID that persists across state changes
- Mutable: attributes can change over time (but the ID never does)
- Equality by identity: two users with the same ID are the same, regardless of their current data
- Contains Value Objects: attributes are Value Objects, not primitives
Here’s a UserId Value Object (yes, IDs are Value Objects) and a User Entity:
class UserId { private constructor(private readonly value: string) {}
static generate(): UserId { return new UserId(crypto.randomUUID()); }
static fromString(value: string): UserId { return new UserId(value); }
getValue(): string { return this.value; }
equals(other: UserId): boolean { return this.value === other.value; }}
type ValidationError = InvalidEmailError | InvalidAgeError;
class User { private constructor( private readonly id: UserId, private email: Email, private age: Age ) {}
static create( emailStr: string, ageNum: number ): Result<User, ValidationError> { const emailResult = Email.create(emailStr); if (!emailResult.ok) return err(emailResult.error);
const ageResult = Age.create(ageNum); if (!ageResult.ok) return err(ageResult.error);
return ok(new User(UserId.generate(), emailResult.value, ageResult.value)); }
changeEmail(newEmailStr: string): Result<void, InvalidEmailError> { const result = Email.create(newEmailStr); if (!result.ok) return err(result.error); this.email = result.value; return ok(undefined); }
getEmail(): Email { return this.email; }
getAge(): Age { return this.age; }
getId(): UserId { return this.id; }
equals(other: User): boolean { return this.id.equals(other.id); }}Look at User.create(). It doesn’t validate anything itself. It delegates to Email.create() and Age.create(). The Entity is thin because the Value Objects do the heavy lifting.
And changeEmail(): same pattern. It doesn’t check @ signs. It trusts Email.create() to enforce the rules. One line of delegation instead of five lines of validation.
Here are the tests that drove this Entity:
describe("User Entity", () => { it("should create a user with valid email and age", () => {
expect(result.ok).toBe(true); if (result.ok) { expect(result.value.getAge().getValue()).toBe(25); } });
it("should reject user with invalid email", () => { const result = User.create("not-an-email", 25);
expect(result.ok).toBe(false); });
it("should reject user under 18", () => {
expect(result.ok).toBe(false); });
it("should change email successfully", () => { if (!user.ok) return;
expect(result.ok).toBe(true); });
it("should reject invalid email on change", () => { if (!user.ok) return;
const result = user.value.changeEmail("bad");
expect(result.ok).toBe(false); });
it("should consider users with same ID equal", () => { if (!result.ok) return;
const user = result.value; if (!sameUser.ok) return;
expect(user.equals(user)).toBe(true); expect(user.equals(sameUser.value)).toBe(false); });});The last test is crucial: two User objects with the same email and age but different IDs are not equal. That’s the Entity identity rule, enforced by the test.
Entity vs. Value Object: How to Decide
When I first learned this distinction, I kept asking: “But how do I know which one to use?”
Here’s the decision framework I use:
Does it have a unique identity that persists across changes? ├── Yes → Entity └── No → Value Object
Can it change over time? ├── Yes, and it has identity → Entity ├── Yes, but no identity → Create a new Value Object (immutability) └── No → Value Object
Is equality based on attributes or identity? ├── Attributes → Value Object └── Identity → EntitySome concrete examples from domains I’ve worked in:
| Concept | Type | Why |
|---|---|---|
| Value Object | No identity, immutable, compared by value | |
| User | Entity | Has ID, email can change, compared by ID |
| Money | Value Object | No identity, 5 |
| Order | Entity | Has order ID, items change over time |
| Address | Value Object | Compared by street + city + zip |
| Customer | Entity | Has customer ID, address can change |
| DateRange | Value Object | Compared by start + end |
| Subscription | Entity | Has subscription ID, status changes over time |
When I’m unsure, I ask myself one question: “If two of these had the same data, would they be the same thing?”
- Two emails
[email protected]? Same thing. → Value Object. - Two users named Alice? Could be different people. → Entity.
This isn’t academic. Getting this wrong means either scattering identity logic everywhere (Entity treated as Value Object) or creating unnecessary identity overhead (Value Object treated as Entity).
Real-World: Rebuilding the Registration System
Let me put it all together. Remember that scattered validation from the beginning? Here’s the before and after.
Before: Validation Everywhere
// ❌ 80+ lines of a registration service
class RegistrationService { register(email: string, age: number, username: string): Result<User, Error> { // Validate email if (!email || !email.includes("@") || !email.includes(".")) { return err(new Error("Invalid email")); } const normalizedEmail = email.trim().toLowerCase(); if (normalizedEmail.length > 254) { return err(new Error("Email too long")); }
// Validate age if (typeof age !== "number" || !Number.isInteger(age)) { return err(new Error("Age must be a number")); } if (age < 18) { return err(new Error("Must be at least 18")); }
// Validate username if (!username || username.trim().length < 3) { return err(new Error("Username too short")); } if (username.trim().length > 30) { return err(new Error("Username too long")); } if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) { return err(new Error("Invalid username characters")); }
// Finally, create the user const user = new User(normalizedEmail, age, username.trim()); return ok(user); }}One method. 30+ lines of validation. And if I need to validate an email anywhere else (password reset, email change, account linking), I have to duplicate or extract this logic.
After: Value Objects Do the Work
// ✅ ~15 lines, all validation delegated
type RegistrationError = | InvalidEmailError | InvalidAgeError | InvalidUsernameError;
class RegistrationService { register( emailStr: string, ageNum: number, usernameStr: string ): Result<User, RegistrationError> { const emailResult = Email.create(emailStr); if (!emailResult.ok) return err(emailResult.error);
const ageResult = Age.create(ageNum); if (!ageResult.ok) return err(ageResult.error);
const usernameResult = Username.create(usernameStr); if (!usernameResult.ok) return err(usernameResult.error);
return ok( new User(emailResult.value, ageResult.value, usernameResult.value) ); }}The service doesn’t know what makes an email valid. It doesn’t care. It hands the raw string to Email.create() and lets the Value Object decide. If the rules change (say, we add a check for disposable email domains), we update Email.create() in one place, and every consumer benefits.
Here’s the test for the registration flow:
describe("Registration Service", () => { it("should register a valid user", () => { const service = new RegistrationService();
expect(result.ok).toBe(true); });
it("should reject invalid email during registration", () => { const service = new RegistrationService(); const result = service.register("not-an-email", 25, "alice_99");
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("INVALID_EMAIL"); } });
it("should reject underage user during registration", () => { const service = new RegistrationService();
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("INVALID_AGE"); } });
it("should reject invalid username during registration", () => { const service = new RegistrationService();
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("INVALID_USERNAME"); } });});These tests survive refactoring. They test the behavior, what comes in and what comes out, not the internal structure. I can rewrite the entire RegistrationService tomorrow and these tests won’t blink.
That’s the permission slip from the first article, made concrete.
FAQ
Should every domain concept become a Value Object?
No. Start with primitives. When you see the same validation appearing in two or more places, extract it into a Value Object. Premature abstraction is still premature, even when it’s domain-driven. I usually wait for the second duplication before extracting.
What about performance? Doesn’t wrapping primitives add overhead?
In the domains I work in (web backends, APIs), the overhead is negligible. You’re creating small objects instead of passing strings. The GC cost is trivial. If you’re building a game loop or a real-time system with millions of allocations per frame, this pattern might not fit. But for business logic? The safety is worth the microsecond.
How do I persist Value Objects to a database?
You persist their internal values. An Email becomes a VARCHAR column. An Age becomes an INTEGER. The ORM or query layer unwraps the Value Object with getValue(). The validation still happens at construction time. Your database just stores the raw value. I’ll show this pattern in detail when we get to Repositories.
Summary
Value Objects encode rules into types. Entities compose Value Objects and manage identity.
Together, they give you something powerful: code where invalid states are impossible, not just unlikely.
The TDD insight here is deeper than it looks. I didn’t sit down and decide “I need an Email Value Object.” The tests told me. Every time I wrote a test that said “should reject invalid email,” the design pressure pushed me toward a self-validating type. The Value Object emerged from the tests.
That’s TDD as design, not just testing.
Three things to remember:
- Value Objects are immutable, self-validating, compared by value. They make invalid states impossible.
- Entities have identity, can change over time, compared by ID. They compose Value Objects.
- TDD reveals the pattern. You don’t plan Value Objects. You let the tests push you toward them.
What’s Next?
Value Objects protect single attributes. Entities protect a single object’s identity. But what happens when business rules span multiple objects? “A user can’t place an order over $1000” involves User, Order, OrderLine, and Money. That’s not a single Value Object’s job.
Next time: Aggregates & Repositories — Refactoring Without Fear. We’ll build clusters of objects that enforce business rules together, and abstract away persistence so your domain tests don’t need a database.
Value Objects protect your data. Aggregates protect your invariants. Repositories protect your persistence. Together, they make systems that don’t break.
Continue the TDD & Software Design Series
- TDD Isn’t Just for Catching Bugs — It’s Your Permission Slip
- Value Objects & Entities — Building Blocks That Can’t Break (you are here)
- Aggregates & Repositories — Refactoring Without Fear
- I Stopped Mocking Everything in TDD — Here’s What I Use Instead
- The Result Pattern: Why I Stopped Throwing Exceptions
- TDD in the Age of AI: Who Tests the Tests?
- TDD for Legacy Code: Start With Seams, Not Ideology
Questions? Hit reply. I read everything.
— Corentin
P.S. If you haven’t read the first article yet, start there. This one builds on the Result<T, E> pattern and the “test behavior, not implementation” principle. The whole series makes more sense in order.
Related Posts
TDD Isn't About Bugs — It's Your Permission to Refactor
Learn why test-driven development is really about permission to refactor, not catching bugs. With TypeScript examples, Result<T, E> patterns, and behavior-based testing from 3 years in production.
Beyond PGN: Designing an Ultra-Efficient Chess Storage Format
Every chess database begins with the same question: how do we store a game? PGN is the default answer — human-readable, portable, and surprisingly wasteful. A walkthrough of moving toward binary coordinates and legal-move indexing, and the engineering trade-off between the smallest format and the fastest one.
Context and Cancellation in Go: A Practical Guide
Learn how Go context cancellation works in real systems: timeouts, request lifecycles, errgroup fan-out, and graceful shutdown without leaked goroutines.