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.
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.
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
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
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
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
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
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.