Skip to main content

Command Palette

Search for a command to run...

Optional is not your domain

From null to Optional to explicit domain types in Java.

Updated
9 min readView as Markdown
Optional is not your domain

I've gone through several phases in the way I represent absence in Java.

At first, I returned null.

Then I discovered Optional, and started returning it everywhere.

It felt like progress, and it was.

But eventually I ran into a different problem.

I had an Optional.empty() in front of me, and I had no idea why it was empty.

That was the moment I started questioning whether Optional belonged in my domain at all.

This article isn't about why Optional is bad. It isn't.

It's about taking the idea behind Optional one step further:

If the absence of a value has meaning, model that meaning.


Stage 1 - null: "Good luck"

Let's take a simple example from a pet store.

We want to map some data into a Pet.

public Pet map(PetData data) {
    if (data == null) {
        return null;
    }

    if (data.name() == null) {
        return null;
    }

    return new Pet(data.name(), data.age());
}

We've all written code like this.

The method technically communicates something:

Sometimes I give you a Pet. Sometimes I don't.

The problem is that this information isn't part of the contract.

Pet pet = mapper.map(data);

pet.name(); // 💥

Nothing in the type system tells the caller that pet might not exist.

The responsibility for dealing with that possibility has effectively been pushed upstream, without giving the caller any help.

And there's an even bigger problem.

  • What does null mean?

  • Was data null?

  • Was the name missing?

  • Did validation fail?

  • Was there simply no pet to map?

We don't know.

null has collapsed all of those situations into the same thing.

It represents the absence of an object, but says nothing about the reason for that absence.


Stage 2 - Exceptions: "Something went wrong!"

We can improve this by refusing to silently return null.

public Pet map(PetData data) {
    if (data == null) {
        throw new MissingPetDataException();
    }

    if (data.name() == null) {
        throw new MissingPetNameException();
    }

    return new Pet(data.name(), data.age());
}

That's already better in one important way.

We haven't lost the reason anymore.

The caller can distinguish between different failures.

But now our caller starts looking like this:

try {
    Pet pet = mapper.map(data);
    process(pet);
} catch (MissingPetDataException e) {
    handleMissingData();
} catch (MissingPetNameException e) {
    handleMissingName();
}

Exceptions have their place.

If something exceptional happened - an invariant was violated, the database disappeared, an operation couldn't continue, throwing an exception can be exactly the right thing to do.

But there's an important distinction:

not every unsuccessful outcome is exceptional.

If incomplete pet data is an expected state in our system, then we're using exceptions to represent normal domain behaviour.

Our method signature still says:

Pet map(PetData data)

But its real contract is closer to:

Pet
OR missing data
OR missing name

The signature doesn't tell us that.

We have to know which exceptions might escape from the implementation.

We've made the failure louder.

We haven't necessarily made the domain clearer.


Stage 3 - Optional: "There might not be a Pet"

Then comes Optional.

public Optional<Pet> map(PetData data) {
    if (data == null) {
        return Optional.empty();
    }

    if (data.name() == null) {
        return Optional.empty();
    }

    return Optional.of(
        new Pet(data.name(), data.age())
    );
}

This feels much better.

And it is much better than returning null.

The contract now explicitly says:

Optional<Pet>

There might be a Pet.

There might not.

The caller cannot accidentally pretend otherwise.

We can also compose operations nicely:

mapper.map(data)
    .map(this::enrich)
    .filter(this::isValid)
    .ifPresent(this::process);

No null checks.

No accidental NullPointerException.

No try/catch controlling ordinary program flow.

Optional solved a real problem.

But it introduced a temptation.

Because Optional is convenient, we start using it to represent things it doesn't actually represent.

Consider our mapper again:

public Optional<Pet> map(PetData data) {
    if (data == null) {
        return Optional.empty();
    }

    if (data.name() == null) {
        return Optional.empty();
    }

    if (!isSupported(data.type())) {
        return Optional.empty();
    }

    return Optional.of(toPet(data));
}

Now imagine receiving:

Optional.empty()

Why is it empty?

We don't know.

Three completely different situations have been compressed into the same technical representation.

And this was the problem I eventually ran into in real code.

I had several gates in a mapping process. Any one of them could result in Optional.empty().

At the end of the pipeline, I knew that no object had been produced.

But I had lost why.

And that "why" was domain information.


Optional.empty() can sweep information under the rug

There's another subtle consequence.

Imagine this:

Optional<Pet> pet = mapper.map(data);

if (pet.isEmpty()) {
    return;
}

Simple.

But what decision did we just make?

Maybe the pet should be ignored because the input genuinely doesn't represent one.

Maybe the data is incomplete and should be retried later.

Maybe the pet type isn't supported and should generate a metric.

Maybe the input violates a business rule and should be reported.

Those are different behaviours.

Yet Optional.empty() encourages the caller to treat them as one.

Worse, the decision about those cases has effectively moved inside the mapper.

The mapper isn't merely mapping anymore.

It's validating.

It's interpreting domain rules.

And by returning empty, it's deciding that all those outcomes mean the same thing to the caller.

That's a lot of responsibility hidden behind a very innocent-looking return type.


Stage 4 - Model the outcome

Modern Java gives us another option.

Instead of describing the presence or absence of a value, we can describe the possible outcomes of the operation.

For example:

public sealed interface PetMappingResult
    permits MappedPet, MissingInformation, UnsupportedPetType {
}

And then:

public record MappedPet(Pet pet)
    implements PetMappingResult {
}

public record MissingInformation(Set<String> fields)
    implements PetMappingResult {
}

public record UnsupportedPetType(String type)
    implements PetMappingResult {
}

Our mapper can now return:

public PetMappingResult map(PetData data) {
    if (data.name() == null) {
        return new MissingInformation(Set.of("name"));
    }

    if (!isSupported(data.type())) {
        return new UnsupportedPetType(data.type());
    }

    return new MappedPet(toPet(data));
}

Look at the signature:

PetMappingResult map(PetData data)

We're no longer saying:

Maybe there's a Pet.

We're saying:

Mapping a Pet has a defined set of outcomes.

That's a very different contract.

And, more importantly, those outcomes belong to our domain.


Now the caller makes the decision

The mapper no longer needs to decide what MissingInformation means to the rest of the application.

It reports what happened.

The caller decides what to do with it.

switch (mapper.map(data)) {
    case MappedPet(var pet) ->
        process(pet);

    case MissingInformation(var fields) ->
        scheduleForRetry(fields);

    case UnsupportedPetType(var type) ->
        recordUnsupportedType(type);
}

There's no null.

There's no Optional.empty() hiding the reason.

There's no try/catch representing expected control flow.

And the code reads surprisingly close to the domain:

Mapped pet      -> process it
Missing data    -> retry it
Unsupported pet -> record it

The types carry the information.


And then the domain changes

This is where I think this approach becomes particularly interesting.

Software changes.

Imagine that six months later we discover another legitimate outcome:

public record TemporarilyUnavailable(String reason)
    implements PetMappingResult {
}

We add it to our sealed hierarchy:

public sealed interface PetMappingResult
    permits MappedPet,
            MissingInformation,
            UnsupportedPetType,
            TemporarilyUnavailable {
}

With Optional, perhaps we'd have done this:

return Optional.empty();

The existing callers would continue compiling.

Nothing would tell them that a new domain state now exists.

They would keep treating it exactly like every other absence.

With the sealed hierarchy and an exhaustive pattern-matching switch, something very useful happens.

The compiler complains.

Our existing code:

switch (result) {
    case MappedPet(var pet) -> process(pet);
    case MissingInformation(var fields) -> scheduleForRetry(fields);
    case UnsupportedPetType(var type) -> recordUnsupportedType(type);
}

is no longer exhaustive.

We have introduced a new state into the domain without defining how the application should handle it.

That's exactly the kind of problem I want the compiler to find.

The compiler has effectively become part of the domain modelling process.


So is returning Optional bad?

No.

Optional is a great Java abstraction.

I still use it.

A lot.

Inside an implementation, it can be an excellent tool.

Optional.ofNullable(rawPet)
    .map(this::normalize)
    .filter(this::isUsable)
    .map(this::convert);

It lets us express transformations elegantly without scattering null checks throughout the code.

And sometimes absence really is the complete semantic information.

Consider:

Optional<Pet> findById(PetId id);

Depending on the domain, that may be exactly the contract we want:

There either is a Pet with this ID, or there isn't.

There's no additional information to preserve.

In that situation, inventing a hierarchy of domain types would probably add ceremony without adding meaning.

The problem isn't Optional.

The problem is using Optional when empty actually represents several meaningful states.


Technical abstractions aren't automatically domain abstractions

This is the distinction I've gradually started making in my own code.

Optional belongs to Java.

Your domain belongs to your application.

Sometimes those abstractions happen to line up perfectly.

Sometimes they don't.

The fact that Java gives us:

Optional<Pet>

doesn't mean our domain contains the concept of an "optional pet".

Maybe it contains:

Pet mapped
Pet information incomplete
Pet type unsupported
Pet temporarily unavailable

Those are different concepts.

Compressing all of them into:

Optional.empty()

doesn't simplify the domain.

It removes information from it.


A progression, not a rule

I don't see these approaches as four competing camps.

For me, they represent a progression in how I thought about responsibility.

I started with:

Pet

and sometimes secretly returned null.

Then I moved to:

Optional<Pet>

and made absence explicit.

That was an improvement.

But eventually I realised that sometimes I was using Optional.empty() in exactly the same way I'd previously used null.

I'd made the absence type-safe.

I still hadn't explained it.

Today, when designing a return type, I try to ask a slightly different question.

Not:

Can this method return nothing?

But:

What are the meaningful outcomes of this operation?

Sometimes the answer genuinely is:

Optional<Pet>

And that's perfectly fine.

But when empty starts meaning missing information, unsupported, invalid, not ready yet, or something else happened...

I probably don't have an optional value anymore.

I have a domain model waiting to be written.

Development

Part 12 of 12

Unlock your potential with this 11-step guide focusing on skill development, while tackling the Dunning-Kruger Effect and Impostor Syndrome

Start from the beginning

Addressing the Dunning-Kruger Effect and Impostor Syndrome

Skill Development Process