The basic pattern I’ve found useful is to put all my custom validation in clean() and then simply call full_clean() (which calls clean() and a few other methods) from inside save(), e.g.:
class BaseModel(models.Model):
def clean(self, *args, **kwargs):
# add custom validation here
super().clean(*args, **kwargs)
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
This isn’t done by default, as explained here, because it interferes with certain features, but those aren’t a problem for my application.