5. Maybe Functions Considered Harmful
Date: 2024-04-13
Functions should do something, not maybe do something.
From Maybe Functions
Repository.get
is a particularly interesting function.
See, it is the place where the database is accessed to retrieve data. There are only two possible outcomes to get
:
- The filters specified apply to a row, and a domain object gets retrieved from the database.
- The filters do not apply, and the database sends no errors.
While the first is straightforward, most ORMs handled the second case in two ways: they either raise an error
(e.g., calling one()
on SQLAlchemy, or get()
on Django), or return None
(calling first()
both in SQLAlchemy
and Django).
In this project, Repository.get
never goes for the second option. The type system, enforced by typing.Protocol
,
will not accept a class acting as a Repository if that class's get
method can return None (only
get(id: UUID) -> PaymentMethod
is allowed, and PaymentMethod | None
is banned).
Functions that maybe return an object, but sometimes don't, are called Maybe Functions. Maybe Functions are prohibited, banned, verboten.
This is related to the Null Object Pattern, but from the perspective of functions.
Maybe functions are easier to call, because the outcome is tied to the validation. You can tell that something went wrong by looking at the result.
The problem is that you don't know what happened. The caller can only hope that everything went well, and it wasn't something nasty with the connection or anything like that. You can't decide what to do in the case of two error scenarios.
Should get be retried if connection timeout? Too bad, you only have a None
to work with, there's no way to tell if
it was a timeout!
If that isn't enough, consider this piece of code (from acquiring/domain/flow.py):
try:
payment_method = payment_method_repository.get(id=payment_method.id)
except domain.PaymentMethod.DoesNotExist:
# handle DoesNotExist
This code replaces payment_method
with the data stored in the database. As in the only thing that matters
is in the source of truth.
How would that look like if payment_method_repository.get
were a Maybe Function?
if (_payment_method := payment_method_repository.get(paymen_method_.id)) is None:
# Handle None
payment_method = _payment_method
Clever piece of code! The assignment in the former case hasn't happened when the error is raised, and so payment_method
is still holding the old information by the time I'm handling the error condition.
In the second case, the assignment has already happened. So, something bad went on, and if I wanted to replace the
payment_method
variable with the new info, the Maybe Function would have erased everything by the time I'm handling
the error. So, I have to assign the outcome of get
to a placeholder _payment_method
, and then replace later on
if and only if get
didn't return None
.
And the walrus, for goodness sake! What were you thinking Guido?
Not good.
This is lifting the maybeness up to the caller, and if functions were people, it would be very bad manners.
Maybe Functions. OK if you disagree here. But not in this codebase. Go play somewhere else. Maybe.