Data Transfer Objects (DTOs)#

Starlite includes a DTOFactory class that allows you to create DTOs from pydantic models, dataclasses, typing.TypedDict, and any other class supported via plugins.

An instance of the factory must first be created, optionally passing plugins to it as a kwarg. It can then be used to create a DTO by calling the instance like a function. Additionally, it can exclude (drop) attributes, remap field names and field types, and add new fields.

The created DTO can be used for data parsing, validation and OpenAPI schema generation like a regularly declared pydantic model.

Attention

Although the value generated is a pydantic factory, because it is being generated programmatically, it’s currently impossible to extend editor auto-complete for the DTO properties - it will be typed as DTO[T], with T being a generic argument representing the original model used to create the DTO.

Note

MyPy doesn’t support using types defined using Type[] as a type, and MyPy will regard these as invalid types. There is currently no way to circumvent this (not even with a plugin) except using a # type: ignore comment.

The DTOFactory class supports plugins, for example, this is how it could be used with an SQLAlchemy declarative class using the SQLAlchemyPlugin:

Declaring a DTO#
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base

from starlite import DTOFactory
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin

dto_factory = DTOFactory(plugins=[SQLAlchemyPlugin()])

Base = declarative_base()


class Company(Base):  # pyright: ignore
    __tablename__ = "company"
    id: Mapped[int] = Column(Integer, primary_key=True)  # pyright: ignore
    name: Mapped[str] = Column(String)  # pyright: ignore
    worth: Mapped[float] = Column(Float)  # pyright: ignore


CompanyDTO = dto_factory("CompanyDTO", Company)

The created CompanyDTO is equal to this pydantic model declaration:

from pydantic import BaseModel


class CompanyDTO(BaseModel):
    id: int
    name: str
    worth: float

You can now use it in route handler functions as you would any other pydantic model. The one caveat though is lack of editor completion and mypy support - this requires the implementation of a mypy plugin, which is planned for the future.

Excluding Fields#

You can exclude any field in the original model class from the DTO:

Excluding fields#
from pydantic import BaseModel

from starlite import DTOFactory


class MyClass(BaseModel):
    first: int
    second: int


dto_factory = DTOFactory()

MyClassDTO = dto_factory("MyClassDTO", MyClass, exclude=["first"])

The generated MyClassDTO is equal to this model declaration:

from pydantic import BaseModel


class MyClassDTO(BaseModel):
    second: int

Remapping Fields#

You can remap fields in two ways:

  1. you can switch change their keys:

Remapping fields#
from pydantic import BaseModel

from starlite import DTOFactory


class MyClass(BaseModel):
    first: int
    second: int


dto_factory = DTOFactory()

MyClassDTO = dto_factory("MyClassDTO", MyClass, field_mapping={"first": "third"})

The generated MyClassDTO is equal to this model declaration:

from pydantic import BaseModel


class MyClassDTO(BaseModel):
    second: int
    third: int

You can remap name and type. To do this use a tuple instead of a string for the object value:

Remapping fields with types#
from pydantic import BaseModel

from starlite import DTOFactory


class MyClass(BaseModel):
    first: int
    second: int


dto_factory = DTOFactory()

MyClassDTO = dto_factory("MyClassDTO", MyClass, field_mapping={"first": "third", "second": ("fourth", float)})

The generated MyClassDTO is equal to this model declaration:

from pydantic import BaseModel


class MyClassDTO(BaseModel):
    third: int
    fourth: float

Add New Fields#

You add fields that do not exist in the original model by passing in a field_definitions dictionary. This dictionary should have field names as keys, and a tuple following the format supported by the pydantic create_model helper:

  • For required fields use a tuple of type + ellipsis, for example (str, ...).

  • For optional fields use a tuple of type + None , for example (str, None)

  • To set a default value use a tuple of type + default value, for example (str, "Hello World")

Add new fields#
from pydantic import BaseModel

from starlite import DTOFactory


class MyClass(BaseModel):
    first: int
    second: int


dto_factory = DTOFactory()

MyClassDTO = dto_factory("MyClassDTO", MyClass, field_definitions={"third": (str, ...)})

The generated MyClassDTO is equal to this model declaration:

from pydantic import BaseModel


class MyClassDTO(BaseModel):
    first: int
    second: int
    third: str

DTO Methods#

DTO.from_model_instance#

Once you create a DTO class you can use its class method from_model_instance to create an instance from an existing instance of the model from which the DTO was generated:

DTO.from_model_instance()#
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base

from starlite import DTOFactory
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin

dto_factory = DTOFactory(plugins=[SQLAlchemyPlugin()])

Base = declarative_base()


class Company(Base):  # pyright: ignore
    __tablename__ = "company"

    id: Mapped[int] = Column(Integer, primary_key=True)  # pyright: ignore
    name: Mapped[str] = Column(String)  # pyright: ignore
    worth: Mapped[float] = Column(Float)  # pyright: ignore


CompanyDTO = dto_factory("CompanyDTO", Company)

company_instance = Company(id=1, name="My Firm", worth=1000000.0)

dto_instance = CompanyDTO.from_model_instance(company_instance)

In the above, dto_instance is a validated pydantic model instance.

DTO.to_model_instance#

When you have an instance of a DTO model, you can convert it into a model instance using the to_model_instance method:

DTO.to_model_instance()#
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base

from starlite import DTOFactory, post
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin

dto_factory = DTOFactory(plugins=[SQLAlchemyPlugin()])

Base = declarative_base()


class Company(Base):  # pyright: ignore
    __tablename__ = "company"

    id: Mapped[int] = Column(Integer, primary_key=True)  # pyright: ignore
    name: Mapped[str] = Column(String)  # pyright: ignore
    worth: Mapped[float] = Column(Float)  # pyright: ignore


CompanyDTO = dto_factory("CompanyDTO", Company)


@post()
def create_company(data: CompanyDTO) -> Company:  # type: ignore
    return data.to_model_instance()  # type: ignore

In the above company_instance is an instance of the SQLAlchemy declarative class Company. It is correctly typed as Company because the DTO class uses generic to store this data.

Attention

If you exclude keys or add additional fields, you should make sure this does not cause an error when trying to generate a model class from a dto instance. For example, if you exclude required fields from a pydantic model and try to create an instance from a dto that doesn’t have these, a validation error will be raised.

Automatic Conversion on Response#

When you use a DTO as a return type in a route handler, if the returned data is a model or a dict, it will be converted to the DTO automatically:

DTO automatic conversion#
from typing import List

from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base

from starlite import DTOFactory, HTTPException, Starlite, get
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin
from starlite.status_codes import HTTP_404_NOT_FOUND

sqlalchemy_plugin = SQLAlchemyPlugin()
dto_factory = DTOFactory(plugins=[sqlalchemy_plugin])

Base = declarative_base()


class Company(Base):  # pyright: ignore
    __tablename__ = "company"

    id: Mapped[int] = Column(Integer, primary_key=True)  # pyright: ignore
    name: Mapped[str] = Column(String)  # pyright: ignore
    worth: Mapped[float] = Column(Float)  # pyright: ignore
    secret: Mapped[str] = Column(String)  # pyright: ignore


ReadCompanyDTO = dto_factory("CompanyDTO", Company, exclude=["secret"])

companies: List[Company] = [
    Company(id=1, name="My Firm", worth=1000000.0, secret="secret"),
    Company(id=2, name="My New Firm", worth=1000.0, secret="abc123"),
]


@get("/{company_id: int}")
def get_company(company_id: int) -> ReadCompanyDTO:  # type: ignore
    try:
        return companies[company_id - 1]
    except IndexError:
        raise HTTPException(
            detail="Company not found",
            status_code=HTTP_404_NOT_FOUND,
        )


@get()
def get_companies() -> List[ReadCompanyDTO]:  # type: ignore
    return companies


app = Starlite(
    route_handlers=[get_company, get_companies],
    plugins=[sqlalchemy_plugin],
)
DTO automatic conversion#
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base

from starlite import DTOFactory, HTTPException, Starlite, get
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin
from starlite.status_codes import HTTP_404_NOT_FOUND

sqlalchemy_plugin = SQLAlchemyPlugin()
dto_factory = DTOFactory(plugins=[sqlalchemy_plugin])

Base = declarative_base()


class Company(Base):  # pyright: ignore
    __tablename__ = "company"

    id: Mapped[int] = Column(Integer, primary_key=True)  # pyright: ignore
    name: Mapped[str] = Column(String)  # pyright: ignore
    worth: Mapped[float] = Column(Float)  # pyright: ignore
    secret: Mapped[str] = Column(String)  # pyright: ignore


ReadCompanyDTO = dto_factory("CompanyDTO", Company, exclude=["secret"])

companies: list[Company] = [
    Company(id=1, name="My Firm", worth=1000000.0, secret="secret"),
    Company(id=2, name="My New Firm", worth=1000.0, secret="abc123"),
]


@get("/{company_id: int}")
def get_company(company_id: int) -> ReadCompanyDTO:  # type: ignore
    try:
        return companies[company_id - 1]
    except IndexError:
        raise HTTPException(
            detail="Company not found",
            status_code=HTTP_404_NOT_FOUND,
        )


@get()
def get_companies() -> list[ReadCompanyDTO]:  # type: ignore
    return companies


app = Starlite(
    route_handlers=[get_company, get_companies],
    plugins=[sqlalchemy_plugin],
)

In the above, when requesting route of a company, the secret attribute will not be included in the response. And it also works when returning a list of companies.

Creating partial DTOs#

Sometimes you may only need to partially modify a resource. In these cases, DTOs can be wrapped with Partial.

from pydantic import BaseModel
from starlite.types.partial import Partial


class CompanyDTO(BaseModel):
    id: int
    name: str
    worth: float


PartialCompanyDTO = Partial[CompanyDTO]

The created PartialCompanyDTO is equivalent to the following declaration:

from typing import Optional
from pydantic import BaseModel


class PartialCompanyDTO(BaseModel):
    id: Optional[int]
    name: Optional[str]
    worth: Optional[float]
from pydantic import BaseModel


class PartialCompanyDTO(BaseModel):
    id: int | None
    name: str | None
    worth: float | None

Partial can also be used inline when creating routes.

from pydantic import UUID4, BaseModel
from starlite.controller import Controller
from starlite.handlers import patch
from starlite.types.partial import Partial


class UserOrder(BaseModel):
    order_id: UUID4
    order_item_id: UUID4
    notes: str


class UserOrderController(Controller):
    path = "/user"

    @patch(path="/{order_id:uuid}")
    async def update_user_order(
        self, order_id: UUID4, data: Partial[UserOrder]
    ) -> UserOrder:
        ...