Reducing Complexity in Django Models With Value Objects | by Michał Godkowicz | Jan, 2023 - Exotic Digital Access
  • Kangundo Road, Nairobi, Kenya
  • support@exoticdigitalaccess.co.ke
  • Opening Time : 07 AM - 10 PM
Reducing Complexity in Django Models With Value Objects | by Michał Godkowicz | Jan, 2023

Reducing Complexity in Django Models With Value Objects | by Michał Godkowicz | Jan, 2023

Reducing Complexity in Django Models With Value Objects | by Michał Godkowicz | Jan, 2023
Photo by elnaz asadi on Unsplash

Martin Fowler defines value objects in the following way:

A small simple object, like money or a date range, whose equality isn’t based on identity.

Ok, but what does it mean?

The important thing about Value Objects is that they are equal to each other if they have the same information, not just if they are the same object in memory. So, two value objects that represent the same date or the same amount of money would be considered equal. Additionally, value objects are declared as immutable, meaning their values cannot be changed after they are created, which further ensures the consistency of data in an application.

Using value objects helps avoid the issue of “primitive obsession,” which occurs when we use basic data types like integers or strings in place of more meaningful data structures. For instance, using a string to represent an email address. By using value objects, we can create more meaningful, self-contained representations of data.

The concept of Value Objects originates from Domain-Driven Design (DDD) and is utilized along with entities and aggregates to model domain models. In this article, I will demonstrate the usage of Value Objects with raw Django models. This can serve as a useful first step towards better architecture or simply as a refactoring measure that helps keep models concise and less complex.

To illustrate the use of value objects in Django models, let’s look at a common scenario: describing the start and end dates of an event. A basic implementation of this might look like the following:

class Appointment(models.Model):
start = models.DateTimeField(null=True)
end = models.DateTimeField(null=True)

However, as the complexity of the model increases, this simple implementation may become insufficient.

For example, you may need to check if one appointment overlaps another, or determine if it starts before or after another event.

To handle these scenarios, we can use a custom value object to describe the date range of an appointment.

@dataclass(frozen=True) (1)
class DateRange:
start: datetime
end: datetime

def __post_init__(self) -> None: (2)
if self.start >= self.end:
raise ValueError("Can not end before starting.")

def __str__(self) -> str:
return f"DateRange({self.start} - {self.end})"

def __add__(self, other: timedelta) -> "DateRange": (3)
if isinstance(other, timedelta):
return DateRange(self.start + other, self.end + other)

raise TypeError()

def hour_later(self) -> "DateRange": (4)
return self + timedelta(hours=1)

@property
def duration(self) -> timedelta: (5)
return self.end - self.start

def overlaps(self, date_range: "DateRange") -> bool: (5)
return (self.start <= date_range.end
and self.end >= date_range.start)

def starts_before(self, date_range: "DateRange") -> bool: (5)
return self.start < date_range.start

def range(self, step: timedelta) -> Iterator[datetime]: (5)
current_datetime = self.start
while current_datetime <= self.end:
yield current_datetime
current_datetime = current_datetime + step

  1. We use the dataclass decorator for convenience, which helps us with type declarations and provides immutability with the frozen=True flag.
  2. Basic validation to ensure that the model is always in a valid state.
  3. Adding two date ranges doesn’t make sense, so we override the + operator to allow rescheduling. Since our class is immutable, we must return a new object.
  4. As a more verbose alternative, we can utilize previously declared methods to create functions that use “business language,” making the code easier to understand.
  5. Common utilities could be hidden behind a simple interface.

To integrate the DateRange value object with the Appointment model, we need to add the following code:

class Appointment(models.Model):
_start = models.DateTimeField(null=True) (1)
_end = models.DateTimeField(null=True)

objects = CustomManager()

@property
def date_range(self) -> DateRange: (2)
return DateRange(self._start, self._end)

@date_range.setter
def date_range(self, value: DateRange) -> None: (3)
self._start = value.start
self._end = value.end

@classmethod
def new_in_date_range(cls, date_range: DateRange): (4)
return cls(_start=date_range.start, _end=date_range.end)

  1. We use an underscore prefix (e.g. _start) to indicate that this is a private field, meaning it should not be used directly.
  2. Getter property returns the DateRange value object.
  3. Allows assigning a DateRange object to an Appointment.
  4. The new_in_date_range method is a factory method that creates new Appointment objects with a DateRange value object.

To support querying for start/end dates, we can use the CustomManager and CustomQuerySet classes:

class CustomManager(models.Manager):
def get_queryset(self):
return CustomQuerySet(self.model, using=self._db)

def in_range(self, date_range: DateRange):
return self.get_queryset().filter(
_start__gt=date_range.start,
_end__lt=date_range.end
)

class CustomQuerySet(models.QuerySet):

def filter(self, *args, **kwargs):
date_range = kwargs.get("date_range")

if date_range and isinstance(date_range, DateRange):
kwargs["_start"] = date_range.start
kwargs["_end"] = date_range.end

return super().filter(*args, **kwargs)

Value objects can enforce simple rules, such as preventing the creation of a DateRange that ends before it starts.


Source link

Leave a Reply