Create your own
Lesson illustration

Implementing Value-Based Equality for Domain Objects

Good to continue. In the previous lesson, you used __new__ to ensure that every RetryCount instance is valid at creation time. That type naturally behaves like an integer: two RetryCount(3) values already compare by numeric value.

Ordinary user-defined domain classes begin differently. Unless you define otherwise, Python considers two separately created instances unequal, even when their attributes look identical. This lesson defines what “the same job” means in the capstone and implements that decision through __eq__.

By the end, you will be able to make independently loaded Job objects compare equal when they represent the same domain entity, while preserving correct behavior with unrelated types and subclasses.


Equality is a domain decision, not an attribute-counting exercise

Python offers two importantly different comparisons:

  • is asks whether two references point to the same runtime object.
  • == asks whether two objects are equal according to their equality contract.

is is always about identity. It never calls __eq__.

first = object()
second = object()
alias = first

assert first is alias
assert first is not second

For a class with no custom equality, == also effectively behaves like identity comparison:

class PlainJob:
    pass


first = PlainJob()
second = PlainJob()

assert first is not second
assert first != second

That default is sensible because Python cannot infer your domain’s meaning of equality. Consider two Job objects obtained from separate database queries. They are distinct Python objects, but they can represent the same durable job record.

For the job-service capstone, use this definition:

Two Job instances are equal when they have the same job_id.

The mutable or descriptive fields of a job, such as its status and retry count, do not decide whether it is the same job. They describe its current state.

This makes equality useful in application code:

stored_job == job_reloaded_from_database

may be true even though the two variables refer to different objects.


Read the protocol rule first

Python’s data model specifies that == is implemented through the rich-comparison protocol and explains the role of the NotImplemented sentinel.

3. Data model — Python 3.14.2 documentation

Read the official Python documentation to establish the precise behavior of __eq__, especially when the other operand is of a different type.

In the object.__eq__ entry under the rich-comparison methods, begin at the paragraph following the operator-to-method correspondence list. Read the comparison protocol through the final paragraph of that entry. Focus on three points: NotImplemented is a return value rather than an exception, default equality is identity-based, and a subclass on the right side can receive priority in a mixed-type comparison.

The most useful operational model is:

  1. Python invokes an equality method for the operands.
  2. A method that does not know how to compare the pair returns NotImplemented.
  3. Python gives the other operand an opportunity to define the comparison.
  4. If neither side handles equality, == falls back to identity comparison.

The exact internal dispatch rules have a few subtleties around subclasses, but the design rule is simple: return NotImplemented when the other operand is outside your class’s equality contract.

NotImplemented is not the same as NotImplementedError:

  • NotImplemented is a singleton value returned by special methods.
  • NotImplementedError is an exception, commonly used for incomplete abstract methods.

This short video gives a useful concrete view of why returning False too early can prevent the other operand from participating.

"NotImplemented" is Awesome in Python

Watch “NotImplemented is Awesome in Python” by Indently for a compact demonstration of the sentinel’s role in custom comparisons.

Watch the distinction to separate the sentinel from the similarly named exception. Then watch the equality example and the fallback logic. Notice that NotImplemented means “this operand cannot decide,” not “the two objects are unequal.”


Implement equality for the Job entity

A job has several fields, but only one is its durable identity: job_id.

from uuid import UUID

from .retry_count import RetryCount


class Job:
    def __init__(
        self,
        job_id: UUID,
        owner_id: UUID,
        job_type: str,
        retries: RetryCount,
        status: str,
    ) -> None:
        self.job_id = job_id
        self.owner_id = owner_id
        self.job_type = job_type
        self.retries = retries
        self.status = status

    def __eq__(self, other: object) -> bool:
        if self is other:
            return True

        if type(other) is not type(self):
            return NotImplemented

        return self.job_id == other.job_id

There are four deliberate design choices here.

1. other is annotated as object

Equality can be attempted with absolutely any Python object:

job == "job-123"
job == None
job == 42

Therefore, other should begin as object, not Job. Your implementation must narrow it before accessing .job_id.

After this check:

if type(other) is not type(self):
    return NotImplemented

Python knows at runtime that other is the same concrete type as self, so accessing other.job_id is valid.

2. The identity fast path is correct but optional

if self is other:
    return True

An object must compare equal to itself. The later job_id comparison would also produce True, so this branch is primarily a fast path and an explicit statement of the equality law.

3. Exact-type comparison is intentional here

This lesson uses:

type(other) is not type(self)

That says equality is defined only between two instances of the same concrete Job class.

An alternative is:

if not isinstance(other, Job):
    return NotImplemented

That alternative is appropriate only if every future subclass of Job should share exactly the same equality contract. For example, if a PriorityJob adds a priority field that contributes to equality, isinstance() could create surprising or asymmetric behavior between Job and PriorityJob.

Use exact types by default when subclasses might later add meaning. Use isinstance() when shared equality across an intentional class family is genuinely part of the design.

4. Only semantic identity fields belong in equality

This line is the heart of the contract:

return self.job_id == other.job_id

Do not compare every attribute merely because it exists. In this entity model:

FieldIncluded in equality?Reason
job_idYesIdentifies the same durable job
owner_idNoAn attribute of the job, not its identity
job_typeNoDescribes requested work
retriesNoCan change during processing
statusNoCan change from queued to running to completed

This means two snapshots can be equal while differing in state:

from uuid import UUID

job_id = UUID(int=1)

queued = Job(
    job_id=job_id,
    owner_id=UUID(int=10),
    job_type="thumbnail",
    retries=RetryCount(0),
    status="queued",
)

running = Job(
    job_id=job_id,
    owner_id=UUID(int=10),
    job_type="thumbnail",
    retries=RetryCount(1),
    status="running",
)

assert queued is not running
assert queued == running

That does not claim that the snapshots have identical data. It says they refer to the same domain entity. If the application needs to detect a state conflict, it should compare version numbers, timestamps, or individual state fields explicitly rather than overload entity equality to mean “identical snapshot.”


Why NotImplemented is better than False

Suppose you compare a Job to an unrelated object:

job == "not a job"

Your Job.__eq__ returns NotImplemented. Usually, the string’s comparison method will also decline the comparison, and Python will ultimately produce False.

So this remains true:

assert job != "not a job"

But the intermediate meaning is more accurate:

assert job.__eq__("not a job") is NotImplemented

Returning False would mean, “I understand this comparison and the values differ.” Returning NotImplemented means, “This class does not define equality with that type.”

That distinction matters when another type does know how to compare itself to a job. For example, an adapter object could intentionally represent a job reference from another system. Returning NotImplemented gives that adapter a chance to define a symmetric comparison rule.

You normally do not call __eq__ directly in application code. Use ==; direct calls are useful here only to observe the protocol’s sentinel value.


Preserve the basic laws of equality

A usable equality relation should satisfy a few practical laws:

  • Reflexive: every object equals itself.
  • Symmetric: if a == b, then b == a.
  • Transitive: if a == b and b == c, then a == c.
  • Stable in meaning: equality should not depend on incidental runtime details such as memory addresses or a mutable status field.

Comparing job_id meets these requirements because UUID equality already compares values consistently.

By contrast, this would be an unstable and misleading equality implementation:

def __eq__(self, other: object) -> bool:
    if not isinstance(other, Job):
        return NotImplemented

    return (
        self.job_id == other.job_id
        and self.status == other.status
        and self.retries == other.retries
    )

Now a job can become unequal to an independently loaded representation of itself simply because its processing state changed. That may be correct for an immutable snapshot value, but it is not correct for an entity whose identity persists across state transitions.

A useful design question is therefore:

Does this class model an entity that remains the same while its state changes, or an immutable value whose complete contents define it?

Job is an entity. The RetryCount from the previous lesson is a value type. Python uses the same __eq__ mechanism for both, but your domain model determines which fields matter.


Test the contract through observable behavior

Create focused tests for the decisions above. The helper keeps the tests centered on equality rather than constructor noise.

from uuid import UUID

from app.domain.job import Job
from app.domain.retry_count import RetryCount


def make_job(
    *,
    job_id: UUID,
    status: str = "queued",
    retries: int = 0,
) -> Job:
    return Job(
        job_id=job_id,
        owner_id=UUID(int=10),
        job_type="thumbnail",
        retries=RetryCount(retries),
        status=status,
    )

First, independently constructed jobs with the same durable identity should compare equal:

def test_jobs_with_the_same_id_compare_equal() -> None:
    first = make_job(job_id=UUID(int=1))
    reloaded = make_job(
        job_id=UUID(int=1),
        status="running",
        retries=1,
    )

    assert first is not reloaded
    assert first == reloaded
    assert not (first != reloaded)

The is not assertion is important: it proves this is value-based domain equality, not accidental aliasing of one object.

Next, distinct IDs must produce inequality:

def test_jobs_with_different_ids_compare_unequal() -> None:
    first = make_job(job_id=UUID(int=1))
    second = make_job(job_id=UUID(int=2))

    assert first != second

Finally, verify that the class declines comparisons it does not own:

def test_job_declines_equality_with_an_unrelated_type() -> None:
    job = make_job(job_id=UUID(int=1))

    assert job.__eq__("job-1") is NotImplemented
    assert job != "job-1"

A productive implementation pass is:

  1. Add __eq__ to Job.
  2. Add these tests.
  3. Run pytest.
  4. Temporarily change the final comparison to self.status == other.status.
  5. Confirm the “same ID” test fails.
  6. Restore comparison by job_id only.

That failure is valuable: it verifies that the test suite protects the domain definition rather than merely executing the method.


Equality changes the class’s hashability

There is one consequence to recognize but not solve yet: once a class defines __eq__, Python makes it unhashable unless you explicitly provide a compatible __hash__.

So, after adding this method, this should not be treated as valid application behavior yet:

jobs = {queued, running}

Python does this defensively. If equal objects could have different hashes, dictionary and set lookups would become unreliable. The next lesson will implement a hash that is consistent with Job equality and explain when a mutable domain entity should or should not be hashable.


Takeaways

  • is compares runtime identity; == invokes the equality protocol.
  • Default user-defined-object equality is identity-based.
  • A domain entity’s equality fields must be chosen from domain meaning, not from all available attributes.
  • For the capstone’s Job entity, job_id alone defines equality; status and retry count describe changing state.
  • Annotate other as object, narrow it before attribute access, and return NotImplemented for unsupported operand types.
  • Exact-type checks are a conservative default when subclasses may acquire different equality semantics.
  • Tests should establish equal independent instances, unequal different identities, and correct behavior with unrelated types.

Next, you will make Job safely usable in sets and as dictionary keys by implementing __hash__ so it remains consistent with this equality contract.

Can't find a good explanation? Sign up and we'll make it for you

Sign up