DTOs in Python. Methods of implementation

DTOs in Python. Methods of implementation

The main goal DTO is to simplify communication between application layers, especially when transferring data through various front-end interfaces such as web services, REST APIs, message brokers, or other remote interaction mechanisms. On the way to exchange information with other systems, it is important to minimize overhead such as redundant serialization/deserialization, and to ensure a clear data structure representing a specific contract between sender and receiver.

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

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

A DTO based on a Python class

Consider an example DTO based on a Python class. Let’s say 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 the methods of the DTO class to instantiate the class and load the data into the dictionary, as well as the validation method. Next, 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 a very simplified example. Thus, any functionality can be implemented. The only downside is that you need to describe everything by hand, and even using inheritance there will be a lot of code.

NamedTuple

Another way to create a DTO in Python is to use NamedTuple.

NamedTuple is a class from the Python standard library (as of Python 3.6) that is an immutable tuple with access to properties by name. This is a typed and more readable version of the class namedtuple from the module collections.

We can create a DTO based on NamedTuplecontaining 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 unloading the object into the dictionary and creating an object from the 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 the definition is more compact and readable out of the box. It is also unchanged, which gives more safety at work. Only those arguments that are defined in the method can be passed to the input _asdict to convert to a dictionary.

More details 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 does TypedDict a good choice for creating DTOs when you want 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 the 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 details here.

dataclass

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

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

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 UserDTOunload them into 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 declarator frozen=True. There is a method asdict for uploading to the dictionary. Additionally, validation methods can be implemented. Default values ​​can be used. In general, more compact than just classes and more functional than the previously considered options.

More details here.

Attr

Another way to create a DTO is with a module Attr. Works the same as and dataclassin addition, is an ancestor dataclass, but at the same time it is 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 we have defined a class to describe a DTO with attributes using a decorator first_name and last_nameat the same time, the default value and validation were immediately determined.

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

Thus, the module attr provides more powerful and flexible tools for defining DTO classes such as validation, default values, conversions. A DTO object can be made immutable using the decorator attribute frozen=True. Can also be initialized via a decorator define.

More details here.

Pydantic

Library Pydantic is a data definition and data conversion tool in Python that uses type annotations to define data schema and converts data from JSON to Python objects. Pydantic used for convenient work with data from web requests, configuration files, databases, and other places where you need to check and transform data. 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

Here we defined the model UserDTO c basic validation for string length and maximum age. It was also determined that the data for the attribute last_name will come through the parameter lastName. In the same way, for example, he provided 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 is a whole combination of possibilities. It is used by default in FastAPI for data schema definition and validation. Makes it easy to serialize and deserialize objects into JSON using built-in methods. Has more readable tooltips at runtime.

More details here.

Conclusion

In this article, I’ve run through the options for implementing DTOs in Python from simple to more complex. As a result, 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. Do you plan to use validation or conversion or rather simple type annotations.

I hope this article helps those who are looking for suitable ways to implement DTOs in Python.

Related posts