paint-brush
DTO in Python: Ways of implementationby@kirillshershen
10,707 reads
10,707 reads

DTO in Python: Ways of implementation

by Kirill ShershenAugust 7th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

The primary goal of a DTO is to simplify communication between different layers of an application, particularly when transmitting data through various boundary interfaces such as web services, REST APIs, message brokers, or other mechanisms of remote interaction. When exchanging information with other systems, it is crucial to minimize unnecessary costs, such as redundant serialization/deserialization, and to ensure a clear data structure that represents a specific contract between the sender and receiver.
featured image - DTO in Python: Ways of implementation
Kirill Shershen HackerNoon profile picture

The primary goal of a DTO is to simplify communication between different layers of an application, particularly when transmitting data through various boundary interfaces such as web services, REST APIs, message brokers, or other mechanisms of remote interaction.


When exchanging information with other systems, it is crucial to minimize unnecessary costs, such as redundant serialization/deserialization, and to ensure a clear data structure that represents a specific contract between the sender and receiver.


In this article, I intend to explore the capabilities that Python offers for implementing DTOs. Starting from built-in tools and extending to specialized libraries. Among the core functionalities, I want to emphasize type and data validation, object creation, and conversion to dictionaries.

DTO Based on a Python Class

Let's consider an example of a DTO based on a Python class. Let's assume we have a user model that includes a first name and a last name:

class UserDTO:
   def __init__(self, **kwargs):
       self.first_name = kwargs.get("first_name")
       self.last_name = kwargs.get("last_name")
       self.validate_lastname()


   def validate_lastname(self):
       if len(self.last_name) <= 2:
           raise ValueError("last_name length must be more then 2")


   def to_dict(self):
       return self.__dict__


   @classmethod
   def from_dict(cls, dict_obj):
       return cls(**dict_obj)


We have implemented methods in the DTO class for creating an instance of the class, converting data to a dictionary, and adding validation logic. Now, let's see how this can be used:

>>> user_dto = UserDTO.from_dict({'first_name': 'John', 'last_name': 'Doe'})
>>> user_dto.to_dict()
{'first_name': 'John', 'last_name': 'Doe'}

>>> user_dto = UserDTO.from_dict({'first_name': 'John', 'last_name': 'Do'})
ValueError: last_name length must be more then 2


This is a highly simplified example. This approach allows you to implement various functionalities. The only downside is that you need to define everything manually, and even with inheritance, there might be a significant amount of code.

NamedTuple

Another way to create a DTO in Python is by using NamedTuple.


NamedTuple is a class from the Python standard library (introduced in Python 3.6) representing an immutable tuple with named properties. It is a typed and more readable version of the namedtuple class from the collections module.


We can create a DTO based on NamedTuple, containing the first name and last name of a user from the example using classes:

from typing import NamedTuple

class UserDTO(NamedTuple):
    first_name: str
    last_name: str


Now, we can create UserDTO objects as follows, as well as convert the object to a dictionary and create an object from a dictionary:

>>> user_dto = UserDTO(**{'first_name': 'John', 'last_name': 'Doe'})
>>> user_dto.first_name
'John'
>>> user_dto
UserDTO(first_name='John', last_name='Doe'})
>>> user_dto._asdict()
{'first_name': 'John', 'last_name': 'Doe'}
>>> user_dto.first_name = 'Bill'
AttributeError: can't set attribute


There is no built-in type and data validation, but it provides a more compact definition and a readable format. It is also immutable, which provides more security during operation. Only the defined arguments can be passed as input, and there is a _asdict method for converting it to a dictionary.


For more details, see here.

TypedDict

Another option for creating DTO objects in Python is to use TypedDict, which has been introduced in the language starting from version 3.8. This data type allows you to create dictionaries with a fixed set of keys and annotations for value types.


This approach makes TypedDict a good choice for creating DTO objects when you need to use a dictionary with a specific set of keys.


To create an object, you need to import the TypedDict data type from the typing module. Let's create a TypedDict for the user model:

from typing import TypedDict

class UserDTO(TypedDict):
   first_name: str
   last_name: str


In this example, we define the UserDTO class, which is a subclass of TypedDict. We can create a UserDTO object and populate it with data:

>>> user_dto = UserDTO(**{first_name: 'John', last_name: 'Doe'})
>>> user_dto
{first_name: 'John', last_name: 'Doe'}
>>> type(user_dto)
<class 'dict'>

We can use it to define dictionaries with a fixed set of keys and annotations for value types. This makes the code more readable and predictable. Additionally, TypedDict provides the ability to use dictionary methods such as keys() and values(), which can be useful in certain cases.


For more details, see here.

Dataclass

Dataclass is a decorator that provides a simple way to create classes for storing data. Dataclass uses type annotations to define fields and then generates all the necessary methods for creating and using objects of that class.


To create a DTO using dataclass, you need to add the dataclass decorator and define fields with type annotations. For example, we can create a DTO for a user model using dataclass like this:

from dataclasses import asdict, dataclass

@dataclass
class UserDTO:
   first_name: str
   last_name: str = ''


   def __post_init__(self):
       self.validate_lastname()


   def validate_lastname(self):
       if len(self.last_name) <= 2:
           raise ValueError("last_name length must be more then 2")


Now, we can easily create UserDTO objects, convert them to dictionaries, and create new objects based on dictionaries:

>>> user_dto = UserDTO(**{'first_name': 'John', 'last_name': 'Doe'})
>>> user_dto
UserDTO(first_name='John', last_name='Doe')
>>> asdict(user_dto)
{'first_name': 'John', 'last_name': 'Doe'}
>>> user_dto = UserDTO(**{'first_name': 'John', 'last_name': 'Do'})
ValueError: last_name length must be more then 2


To create an immutable object, you can pass the argument frozen=True to the decorator. There's an asdict method for converting to a dictionary. Additionally, you can implement validation methods and use default values.


In general, dataclasses are more compact than plain classes and more functional than the previously discussed options.


For more details, see here.

Attr

Another way to create DTOs is by using the attr module. It works similarly to dataclass but is more functional and provides a more compact description. This library can be installed using the command pip install attrs.

import attr


@attr.s
class UserDTO:
   first_name: str = attr.ib(default="John", validator=attr.validators.instance_of(str))
   last_name: str = attr.ib(default="Doe", validator=attr.validators.instance_of(str))


Here, using the decorator, we define a DTO class with attributes first_name and last_name, while also setting default values and validation.

>>> user_dto = UserDTO(**{'first_name': 'John', 'last_name': 'Doe'})
>>> user_dto
UserDTO(first_name='John', last_name='Doe')
>>> user_dto = UserDTO()
>>> user_dto
UserDTO(first_name='John', last_name='Doe')
>>> user_dto = UserDTO(**{'first_name': 1, 'last_name': 'Doe'})
TypeError: ("'first_name' must be <class 'str'>...


In this way, the attrs module offers powerful and flexible tools for defining DTO classes, such as validation, default values, and transformations. The DTO object can also be made immutable using the decorator attribute frozen=True. It can also be initialized through the define decorator.


For more details, see here.

Pydantic

Pуdantic library is a tool for data validation and data conversion in Python. It utilizes type annotations to define data schemas and converts JSON data into Python objects.


Pydantic is used for the convenient handling of data from web requests, configuration files, databases, and other scenarios where data validation and conversion are required.


It can be installed using the command pip install pydantic.

from pydantic import BaseModel, Field, field_validator


class UserDTO(BaseModel):
   first_name: str
   last_name: str = Field(min_length=2, alias="lastName")
   age: int = Field(lt=100, description="Age must be a positive integer")
   
   @field_validator("age")
   def validate_age(cls, value):
       if value < 18:
           raise ValueError("Age must be at least 18")
       return value


In this example, we've defined a UserDTO model with basic validations for string length and maximum age. We've also specified that data for the last_name attribute will come through the parameter lastName.


Additionally, as an example, a custom validator for minimum age is demonstrated.

>>> user_dto = UserDTO(**{'first_name': 'John', 'lastName': 'Doe', 'age': 31})
>>> user_dto
UserDTO(first_name='John', last_name='Doe', age=31)

>>> user_dto.model_dump()
{'first_name': 'John', 'last_name': 'Doe', 'age': 31}

>>> user_dto.model_dump_json()
'{"first_name":"John","last_name":"Doe","age":31}'


>>> user_dto = UserDTO(**{'first_name': 'John', 'lastName': 'D', 'age': 3})
pydantic_core._pydantic_core.ValidationError: 2 validation errors for UserDTO
lastName
    String should have at least 2 characters [type=string_too_short, input_value='D', input_type=str]
age
    Value error, Age must be at least 18 [type=value_error, input_value=3, input_type=int]


Pydantic is a versatile tool. It is the default data schema and validation tool used in FastAPI. It simplifies the serialization and deserialization of objects to JSON format using built-in methods and provides more readable runtime hints.


For more details, see here.

Conclusion

In this article, I covered different ways of implementing DTOs in Python, starting from simple approaches to more complex ones. The choice of which method to use for your project depends on various factors.


This includes the Python version in your project and whether you have the ability to install new dependencies. It also depends on whether you plan to use validation, conversion, or if simple type annotations are sufficient.


I hope this article will help those who are seeking suitable methods for implementing DTOs in Python for their projects.