DTOs in Python. Implementation methods

main goal DTO is to simplify communication between application layers, especially when passing data through various edge interfaces such as web services, REST APIs, message brokers, or other remoting mechanisms. On the way to exchanging information with other systems, it is important to minimize unnecessary overhead, such as redundant serialization/deserialization, and to provide a clear data structure that represents a specific contract between sender and receiver.

In this article, I want to look at what options Python has for implementing DTOs. Starting from built-in tools, ending with special libraries.

From the main functionality, I want to highlight the validation of types and data, the creation of an object and uploading to a dictionary.

Python class based DTO

Let’s look at an example of a DTO based on a Python class. Let’s imagine that we have a user model that contains a first and 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 of the DTO class for creating an instance of the class and uploading data to the dictionary, as well as a validation method. Let’s see how it 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 the most simplified example. In this way, any functionality can be implemented. The only negative is that you need to describe everything by hand, and even using inheritance there will be a lot of code.

Named Tuple

Another way to create a DTO in Python is using Named Tuple.

Named Tuple is a class from the Python standard library (since Python 3.6) that is an immutable tuple with property-by-name access. This is a typed and more readable version of the class namedtuple from module collections.

We can create a DTO based on Named Tuplecontaining the first and last name of the user from the example using classes:

from typing import NamedTuple

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

Now we can create objects UserDTO as follows, as well as uploading an object to a dictionary and creating 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 validation of types and data. But out of the box, a more compact definition and a more readable look. It is also immutable, which gives more security during operation. Only those arguments that are defined can be passed to the input, there is a method _asdict to convert to a dictionary.

More here.

TypedDict

Another option for creating DTO objects in Python is to use TypedDict, which has been added to the language since version 3.8. This data type allows you to create dictionaries with a fixed set of keys and value type annotations. This approach makes TypedDict a good choice for creating DTOs when you need to use a dictionary with a specific set of keys.

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

from typing import TypedDict

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

In this example, we define a class UserDTOwhich is a subclass TypedDict. We can create an object UserDTO and fill 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 value type annotations. This makes the code more readable and predictable. In addition, TypedDict provides the ability to use dictionary methods such as keys() and values(), which can be useful in some cases.

More here.

dataclass

dataclass is a decorator that provides an easy way to create classes to store data. dataclass uses type annotations to define fields and then generates all the methods needed to create and use objects of that class.

To create a DTO with dataclass need to add a decorator dataclass and define fields with type annotations. For example, we can create a DTO for the user model with dataclass in the following way:

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 objects UserDTOupload 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 need to pass an argument to the decarator frozen=True. There is a method asdict to upload to the dictionary. Additionally, you can implement validation methods. You can use the default values. In general, more compact than just classes and more functional than the previously discussed options.

More here.

Attr

Another way to create a DTO is with a module Attr. Works exactly the same as dataclassis also an ancestor dataclass, but at the same time more functional, and the description is more compact. 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 a decorator, we defined a class for describing a DTO with attributes first_name And last_namewhile immediately defining 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'>...

So the module attr provides more powerful and flexible tools for defining DTO classes, such as validation, defaults, conversions. A DTO object can also be made immutable with a decorator attribute frozen=True. It can also be initialized through a decorator define.

More here.

Pydantic

Library Pydantic is a Python data definition and data conversion tool that uses type annotations to define the data schema and converts data from JSON to Python objects. Pydantic is used to conveniently work with web request data, configuration files, databases, and other places where you need to validate and transform data. Can be installed with 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

Here we have defined the model UserDTO with basic validation for string length and maximum age. We also determined that the data for the attribute last_name will come through the parameter lastName. Also, for example, I gave a description of a custom minimum age validator.

>>> 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 it’s a whole bunch of possibilities. It is used by default in FastAPI for schema and validation definitions. Simplifies the serialization and deserialization of objects to JSON format using built-in methods. Has more readable runtime tooltips.

More here.

Conclusion

In this article, I’ve gone through Python’s DTO implementations from simple to more complex. Which one to choose for implementation on your project depends on many factors. What version of Python is on the project and is it possible to install new dependencies. Whether it is planned to use validation or conversion, or a simple type annotation will suffice.

I hope this article will help those who are looking for suitable ways to implement DTO in Python.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *