Good point, the three-arg getMessage variant doesn't expose such a distinction anymore. We may have to revisit this, doing the empty String adaptation at the caller's level instead.
Generally speaking, you could consider usung getMessage(code, args, locale which throws a NoSuchMessageException in the undefined case. This is definitely the clearer contract for such a purpose, not affected by useCodeAsDefaultMessage and the like either.
I briefly though about using the throwing implementation instead, but it isn't ideal in my case. I'm using the AbstractMessageSource in a chain of property locators. If one locator doesn't return a result (i.e. returns null, I move on to the next. It is the expected case, that a single locator doesn't contain all application codes and try/catching isn't the best way to express this.
For 5.0.2, I've revised MessageSource towards returning null for a null default message again. This also includes a revision of 5.0.1's fix for #20596, now streamlined towards a null check like it originally was in 4.x.
Higher-level accessors such as MessageSourceAccessor.getMessage or RequestContext.getMessage declare their default message argument as non-null to begin with, so also consistently return a non-null message outcome. Internal null-to-empty-String adaptation happens at that level now, not within AbstractMessageSource anymore, which keeps our @Nullable checks and our corresponding Kotlin callers happy. This should be a good enough compromise going forward.