Domain Entities in Python
In my previous post, I've 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 (...)
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 behaviours (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 oursevles just to the data types provided by the ORM.
Let consider an example: we are in the process of developing SeaBNB, and Airbnb clone. We have 2 types of users in the system: Guests and Hosts. Any one who registers can become a guest by renting a place, and can become a host by add his place for rent (and set the rate for the place). For sake of simplicity, let's assume the following:
- anyone can register and add owned places, nobody verifies if place actually actually exists etc.
- before place can be listed for rental, place owner must set the pricing for a place
- after the pricing is determined, place can be listed by a owner
- a guest can book the place for a given timespan, at a rate resulting from a pricing algoritm
- a system will prevent guests from over-booking the place at the same timespan
- a confirmation emails will be sent to guest and host when a place is booked.
We clearly need an entity to model the Place - an ID will be required to identify the place, both hosts and geusts will interact with it (publish, rent, edit, etc.). How the class should look like? I'm sure you already have a mental picture of a data model in your head, but resist a temptation to add attributes to a Place class right from the get-go. Start thinking in terms of behaviours: how are we going to interact with this class? what public interface should it expose?
Let's imaginge 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. How about booking a place by a guest?
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 behaviours that 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 it's own Place class with different set of attributes needed for performing certain actions.
Now when we know the behavior of our class, let's move on to the actual implmentation. 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 all the business role invariants are met. Also we are using UUID as entity indentifier.
In the context of listing module, a 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 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 to 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 following:
@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, a 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...