Loading content...
In everyday language, "equal" seems straightforward. Two things are equal if they're the same. But in software—particularly in Domain-Driven Design—equality is far more nuanced. The word "same" can mean very different things:
For entities, understanding these distinctions is not merely academic. Incorrect equality implementations cause real bugs: duplicate records in collections, broken caching, failed lookups, and subtle data corruption. This page provides the deep understanding you need to implement entity equality correctly every time.
By the end of this page, you'll understand the three types of equality (reference, value, identity), why entity equality must be based on identity, how to implement equals() and hashCode() correctly, and the contract between these methods that must never be violated.
Before diving into entity-specific equality, let's establish a clear vocabulary for the different types of equality in software systems:
| Type | Definition | Typical Usage | Common Operators/Methods |
|---|---|---|---|
| Reference Equality | Two references point to the exact same object in memory | Checking if two variables refer to the same instance | Java: == for objects; C#: Object.ReferenceEquals(); JS: === for objects |
| Value Equality | Two objects have the same attribute values; they represent the same "value" | Comparing value objects, primitives, or immutable data | Java/C#: overridden equals(); Python: == after __eq__ |
| Identity Equality | Two objects represent the same domain entity (same identity), regardless of attribute values | Comparing entities that may have evolved over time | Custom equals() based on ID field only |
Illustrating the difference:
Consider a Customer entity with the following state at two different points in time:
Time T1: Customer { id: "C-001", name: "Alice", email: "alice@old.com" }
Time T2: Customer { id: "C-001", name: "Alice Smith", email: "alice@new.com" }
Applying our three equality types:
| Comparison | Result | Why |
|---|---|---|
| Reference | Different (probably) | Different objects in memory at T1 vs T2 |
| Value | Different | Name and email attributes differ |
| Identity | Same | Same customer ID (C-001) |
For entities, only identity equality is meaningful. The customer at T1 and T2 is the same customer—she just updated her name and email. If we used value equality, she would appear to be a different customer after every change.
Entity equals() must compare only identity, never attributes. If customer1.id == customer2.id, then customer1.equals(customer2) must return true—even if every other attribute differs.
Using value equality for entities leads to serious bugs. Let's examine the practical consequences:
Concrete example—the Set problem:
123456789101112131415161718192021222324252627282930313233343536
// ❌ BUG: Using value equality for entities class Customer { private String id; private String email; // Value equality - WRONG for entities! @Override public boolean equals(Object o) { if (!(o instanceof Customer)) return false; Customer c = (Customer) o; return Objects.equals(id, c.id) && Objects.equals(email, c.email); } @Override public int hashCode() { return Objects.hash(id, email); }} // Usage that breaks:Set<Customer> customers = new HashSet<>();Customer alice = new Customer("C-001", "old@email.com"); customers.add(alice);System.out.println(customers.contains(alice)); // true alice.setEmail("new@email.com");System.out.println(customers.contains(alice)); // FALSE! Alice is "lost" customers.add(alice);System.out.println(customers.size()); // 2! Duplicate Alice in the set1234567891011121314151617181920212223242526272829303132333435363738
// ✅ CORRECT: Using identity equality class Customer { private final String id; // Immutable ID private String email; // Identity equality - CORRECT for entities @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Customer)) return false; Customer c = (Customer) o; return Objects.equals(id, c.id); // Only compare identity! } @Override public int hashCode() { return Objects.hash(id); // Only hash identity! }} // Usage that works:Set<Customer> customers = new HashSet<>();Customer alice = new Customer("C-001", "old@email.com"); customers.add(alice);System.out.println(customers.contains(alice)); // true alice.setEmail("new@email.com");System.out.println(customers.contains(alice)); // TRUE! Alice is still found customers.add(alice);System.out.println(customers.size()); // 1! No duplicate, add was no-opWhen using entities as HashMap/HashSet keys, the hashCode() must remain constant after the object is stored. Since entity identity is immutable by definition, basing hashCode on identity satisfies this contract. Basing it on mutable attributes breaks the collection.
In Java, C#, and similar languages, equals() and hashCode() form a contract that must be honored. Violating this contract causes subtle, hard-to-debug bugs in collections and maps.
The General Contract:
x.equals(x) must return true for any non-null reference x.x.equals(y) returns true, then y.equals(x) must also return true.x.equals(y) is true and y.equals(z) is true, then x.equals(z) must be true.x.equals(y) must consistently return the same result (assuming no modification to compared fields).x.equals(null) must return false for any non-null reference x.x.equals(y) is true, then x.hashCode() must equal y.hashCode().hashCode() on the same object must return the same value (within a single execution).x.equals(y) is false, their hash codes need not be different (but performance benefits from different hashes).If two objects are equal, they MUST have the same hashCode. If you override equals() without overriding hashCode(), your objects will behave incorrectly in HashSets and HashMaps. This is one of the most common Java/C# bugs.
For entities specifically:
Since entity equality is based on identity alone:
equals() compares only the identity field(s)hashCode() is computed from only the identity field(s)This makes entity equals/hashCode simpler and more reliable than value object implementations.
Let's see complete, production-quality implementations of entity equality across different languages and scenarios:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
/** * Production-quality Entity equality in Java */public class Order { // Identity - always final private final OrderId id; // Other fields (mutable) private OrderStatus status; private List<OrderLine> lines; private Address shippingAddress; public Order(OrderId id, Address shippingAddress) { this.id = Objects.requireNonNull(id, "OrderId is required"); this.shippingAddress = shippingAddress; this.status = OrderStatus.CREATED; this.lines = new ArrayList<>(); } /** * Identity equality only. * * Note the careful handling of: * 1. Self-reference optimization * 2. Null check * 3. Type check (getClass() not instanceof for proper symmetry) * 4. Identity-only comparison */ @Override public boolean equals(Object obj) { // 1. Same reference = definitely equal if (this == obj) return true; // 2. Null = definitely not equal if (obj == null) return false; // 3. Different class = not equal // Using getClass() ensures symmetry for subclasses if (getClass() != obj.getClass()) return false; // 4. Compare identities only Order other = (Order) obj; return Objects.equals(id, other.id); } /** * Hash code from identity only. * This ensures the HashMap contract is honored. */ @Override public int hashCode() { return Objects.hash(id); } // Getter for ID (no setter - ID is immutable) public OrderId getId() { return id; }} /** * Strongly-typed Identity class * This is also an entity-adjacent pattern worth noting */public final class OrderId implements Serializable { private final String value; public OrderId(String value) { if (value == null || value.isBlank()) { throw new IllegalArgumentException( "OrderId cannot be null or blank" ); } this.value = value; } public static OrderId generate() { return new OrderId(UUID.randomUUID().toString()); } public String getValue() { return value; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof OrderId)) return false; return value.equals(((OrderId) obj).value); } @Override public int hashCode() { return value.hashCode(); } @Override public String toString() { return value; }}Notice the use of a generic Entity<T> base class. This pattern ensures all entities in your domain have consistent equality semantics. The base class handles the equality contract; derived entities just specify their ID type.
Entity inheritance introduces subtle equality challenges. Consider this hierarchy:
class Person { Long id; }
class Employee extends Person { String department; }
class Manager extends Employee { List<Employee> reports; }
If a Person with ID 100 and an Employee with ID 100 are compared, are they equal? The answer has significant implications.
person.equals(employee) might return true, but employee.equals(person) might return false if Employee adds extra checks.Solution approaches:
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| getClass() comparison | Only objects of exact same class are equal | Symmetric, clear semantics | Breaks with ORM proxies; different subclasses never equal |
| canEqual() method | Each class defines if peers can be equal | Maintains symmetry with inheritance | More complex implementation |
| instanceof with caution | Allow equality across related types | Works with ORM proxies | Risk of asymmetric equality |
| Avoid entity inheritance | Prefer composition over inheritance for entities | Simplest equality semantics | May require redesign |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
/** * The canEqual pattern for symmetric inheritance equality * (Popularized by Scala case classes) */public class Person { private final Long id; private String name; @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Person)) return false; Person other = (Person) obj; // The key: check if OTHER can be equal to THIS type // This ensures symmetry in inheritance hierarchies if (!other.canEqual(this)) return false; return Objects.equals(id, other.id); } /** * Override in subclasses to narrow who can be equal. * Default: any Person can be compared with this Person. */ protected boolean canEqual(Object other) { return other instanceof Person; } @Override public int hashCode() { return Objects.hash(id); }} public class Employee extends Person { private String department; @Override public boolean equals(Object obj) { if (!super.equals(obj)) return false; // Already checked type in super return true; // Identity only, no extra checks } /** * Narrow canEqual: only Employees can equal Employees * This ensures Person.equals(Employee) returns false * because the Employee's canEqual returns false */ @Override protected boolean canEqual(Object other) { return other instanceof Employee; }} // Now:Person p = new Person(1L);Employee e = new Employee(1L); p.equals(e); // false - Employee.canEqual(Person) is falsee.equals(p); // false - Person is not instanceof Employee // Symmetry preserved!In most DDD systems, avoid entity inheritance entirely. Prefer composition. If inheritance is necessary, use the canEqual pattern and be extremely careful about equality semantics. Document the behavior explicitly.
A practical challenge arises: what about entities that haven't been saved yet? If the database generates IDs, a new entity has no identity until persistence.
Consider this scenario:
Customer c1 = new Customer(); // ID is null
Customer c2 = new Customer(); // ID is also null
c1.equals(c2); // ??? Both have null ID
Are they equal? They shouldn't be—they're different new customers. But both have null IDs.
this == other. Different objects are not equal.12345678910111213141516171819202122232425262728
// Fallback to reference equality for transient @Overridepublic boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Customer)) return false; Customer other = (Customer) obj; // If either ID is null, only equal // if same reference (already checked) if (id == null || other.id == null) { return false; } return Objects.equals(id, other.id);} @Overridepublic int hashCode() { // Use a constant for transient entities // Warning: poor hash table performance, // but correct behavior if (id == null) { return 0; // Or use System.identityHashCode } return Objects.hash(id);}12345678910111213141516171819202122232425262728293031323334353637383940
// Application-generated ID - RECOMMENDED public class Customer { private final CustomerId id; // Private constructor private Customer(CustomerId id, String name) { this.id = Objects.requireNonNull(id); this.name = name; } // Factory method generates ID public static Customer create(String name) { return new Customer( CustomerId.generate(), // UUID name ); } // For rehydration from DB public static Customer reconstitute( CustomerId id, String name ) { return new Customer(id, name); } @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Customer)) return false; // ID is never null! return id.equals(((Customer) obj).id); } @Override public int hashCode() { return id.hashCode(); }}Modern DDD practice strongly favors application-generated UUIDs over database-generated sequences. It simplifies equality, eliminates null-ID issues, enables distributed systems, and improves testability. The performance difference is negligible in most systems.
Equality implementations are easy to get wrong and critical to get right. Comprehensive tests are essential:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
class CustomerEqualityTest { @Test void sameId_areEqual() { CustomerId id = CustomerId.generate(); Customer c1 = Customer.reconstitute(id, "Alice", "a@x.com"); Customer c2 = Customer.reconstitute(id, "Bob", "b@y.com"); // Same ID = equal, despite different attributes assertThat(c1).isEqualTo(c2); assertThat(c1.hashCode()).isEqualTo(c2.hashCode()); } @Test void differentId_areNotEqual() { Customer c1 = Customer.create("Alice"); Customer c2 = Customer.create("Alice"); // Same name // Different IDs = not equal assertThat(c1).isNotEqualTo(c2); } @Test void reflexive_xEqualsX() { Customer c = Customer.create("Alice"); assertThat(c).isEqualTo(c); } @Test void symmetric_xEqualsY_thenYEqualsX() { CustomerId id = CustomerId.generate(); Customer c1 = Customer.reconstitute(id, "Alice", "a@x.com"); Customer c2 = Customer.reconstitute(id, "Bob", "b@y.com"); assertThat(c1.equals(c2)).isEqualTo(c2.equals(c1)); } @Test void transitive_xEqualsY_yEqualsZ_thenXEqualsZ() { CustomerId id = CustomerId.generate(); Customer c1 = Customer.reconstitute(id, "A", "a@x.com"); Customer c2 = Customer.reconstitute(id, "B", "b@y.com"); Customer c3 = Customer.reconstitute(id, "C", "c@z.com"); assertThat(c1).isEqualTo(c2); assertThat(c2).isEqualTo(c3); assertThat(c1).isEqualTo(c3); } @Test void nullSafe_notEqualToNull() { Customer c = Customer.create("Alice"); assertThat(c.equals(null)).isFalse(); } @Test void typeSafe_notEqualToDifferentType() { Customer c = Customer.create("Alice"); Order o = Order.create(); // Different entity type assertThat(c.equals(o)).isFalse(); assertThat(c.equals("Alice")).isFalse(); } @Test void hashCode_consistentWithEquals() { CustomerId id = CustomerId.generate(); Customer c1 = Customer.reconstitute(id, "Alice", "a@x.com"); Customer c2 = Customer.reconstitute(id, "Bob", "b@y.com"); // Equal objects must have equal hash codes if (c1.equals(c2)) { assertThat(c1.hashCode()).isEqualTo(c2.hashCode()); } } @Test void hashCode_stableAfterMutation() { Customer c = Customer.create("Alice"); int originalHash = c.hashCode(); // Mutate the entity c.changeEmail(new Email("new@email.com")); c.relocate(new Address("New Street")); // Hash must NOT change assertThat(c.hashCode()).isEqualTo(originalHash); } @Test void worksInHashSet() { Customer c = Customer.create("Alice"); Set<Customer> set = new HashSet<>(); set.add(c); assertThat(set.contains(c)).isTrue(); // Mutate c.changeEmail(new Email("new@email.com")); // Still found! assertThat(set.contains(c)).isTrue(); // Adding again is idempotent set.add(c); assertThat(set).hasSize(1); }}Consider using libraries like EqualsVerifier (Java) or similar tools that automatically check the equals/hashCode contract. They catch edge cases you might miss.
We've explored the nuanced but critical distinction between identity and value equality. Let's consolidate the key insights:
What's next:
With a solid understanding of entity identity and equality, we're ready to explore practical guidance for designing entities. The next page covers entity design guidelines—patterns, anti-patterns, and best practices that lead to robust, maintainable entity implementations.
You now understand the critical distinction between identity and value equality, can implement entity equality correctly in multiple languages, and know how to test and verify your implementations. Next, we'll explore comprehensive entity design guidelines.