Domain Entities in Python

In the previous post, we discussed the implementation of Value Objects. This time let's focus on a different kind of Domain Objects - Entities. The distinction is pretty intuitive:

Entities are usually big things like Customer, Ship, Rental Agreement. Values [value objects] are usually little things like Date, Money, Database Query (...) Martin Fowler

So basically any object that has an ID, runs through time, needs to be persisted, and could be referenced by other objects is a natural fit for an Entity.

When designing an Entity, don't think about its attributes and database representation (which may seem the most natural approach). Think in terms of behaviors (which will determine the public interface for the entity), and try to implement them as pure Python classes (or dataclasses) - we don't want to have any coupling between the database and our entity. There are plenty of data structures in Python: enums, ordered dicts, lists, sequences, generators, etc. and we don't want to limit ourselves just to the data types provided by the ORM.

Let's consider an example: we are in the process of developing SeaBNB, an Airbnb clone. We have 2 types of users in the system: Guests and Hosts. Anyone who registers can become a guest by renting a place and can become a host by adding his place for rent (and setting the rate for the place). For sake of simplicity, let's assume the following:

  1. anyone can register and add owned places, nobody verifies if the place exists, etc.
  2. before a place can be listed for rent, a place owner must set the pricing for a place
  3. after the pricing is determined, a place can be listed by an owner
  4. a guest can book the place for a given timespan, at a rate resulting from a pricing algorithm
  5. a system will prevent guests from over-booking the place at the same timespan
  6. confirmation emails will be sent to a guest and a host when a place is booked.

Clearly, we need an entity to model the Place - an ID will be required to identify the place, as both hosts and guests will interact with it (publish, rent, edit, etc.). So how the class should look like? I'm sure you already have a mental picture of a data model in your head, but resist the temptation to add attributes to a Place class right from the get-go. Start thinking in terms of behaviors: how are we going to interact with this class? what public interface should it expose?

Let's imagine how we could interact with a Place:

my_place = Place(...)
pricing = StandardPricing(
  rate_per_night=Price(50, 'EUR'),
  minimum_stay=Days(7),
  cleaning_fee=Price(10, 'EUR')
)
my_place.set_pricing(pricing)
my_place.list_for_rental()

That should be enough to list a place and make it available to guests. What about guest booking?

tonny = Guest(name="Tonny the tourist")
place = get_place_that_tonny_is_interested_in()
place.confirm_availability(arrival_date=..., departure_date=...)
place.book(guest=tony, arrival_date=..., departure_date=...)

No surprise - a completely different set of behaviors than in the previous code snippet. Clearly, the place is being used in 2 different contexts here: a listing context and a booking context. Therefore, we could have 2 different modules: a listing module and a booking module. Each of the modules would implement its own Place class with a different set of attributes needed for performing certain actions.

Now that we know the behavior of our class, let's move on to the actual implementation. Let's start with a base class for entities.

@dataclass
class Entity(DomainObject):
  """A base class for all entities"""
  id: uuid.UUID = field(default_factory=lambda: globals()['Entity'].next_id(), kw_only=True)

  @classmethod
  def next_id(cls) -> uuid.uuid4:
    """Generates new UUID"""
    return uuid.uuid4()

  def check_rule(self, rule: BusinessRule):
    """Checks if a business rule is valid, if not an exception is raised""" 
    if rule.is_broken():
      raise BusinessRuleBrokenException(rule)

One interesting thing here is a check_rule method, that validates if all the business role invariants are met. Also, we are using UUID as an entity identifier.

In the context of listing module, we can define the following place entity:

@dataclass
class Place(Entity):
  name: str
  pricing: PlacePricing | None = None
  is_listed: bool = False

  def set_pricing(self, pricing: PlacePricing):
    self.pricing = pricing

  def list_for_rental(self):
    self.check_rule(PlaceMustHaveAName(name=self.name))
    self.check_rule(RatePerNightMustBeGreaterThanZero(rate_per_night=self.pricing.rate_per_night))
    self.check_rule(CleaningFeeMustBeNonNegative(cleaning_fee=self.pricing.cleaning_fee))
    self.is_listed = True

It makes sense to implement PlacePricing as a value object, at least at this point. Maybe in the future, if we would like to have something more complicated (i.e. pricings with history, or the ones that could be scheduled to change) we could evolve it into the entity, but let's keep things simple for now.

As you can see, in list_for_rental we are checking a number of rules before a place is listed for rent. These rules are defined as follows:

@dataclass
class RatePerNightMustBeGreaterThanZero(BusinessRule):
  rate_per_night: Price

  def is_broken(self):
    return self.rate_per_night.amount <= 0


@dataclass
class CleaningFeeMustBeNonNegative(BusinessRule):
  cleaning_fee: Price

  def is_broken(self):
    return self.cleaning_fee.amount < 0


@dataclass
class PlaceMustHaveAName(BusinessRule):
  name: str

  def is_broken(self):
    return not self.name

Simple and easy as stealing candy from a baby.

In the context of a booking module, we can define the following place entity:

@dataclass
class Place(Entity):
  name: str
  bookings: List[Bookings] = []

  def book(self, guest_id: GuestId, date_from, date_to, total_price: Price):
    new_booking = Booking(guest_id, date_from: date, date_to: date, total_price)
    for booking in self.bookings:
      self.check_rule(BookingsDoesNotOverlap(new_booking, booking))
    self.bookings.append(new_booking)

To be continued...