Skip to main content

Data Transfer Objects (DTOs)

Key Concept

DTOs are simple objects that carry data between processes, creating clear boundaries between system layers and ensuring data integrity.

This document outlines the team standards and best practices for implementing Data Transfer Objects (DTOs) in our projects.

Introduction

Data Transfer Objects (DTOs) are simple objects that carry data between processes. They are essential for creating clear boundaries between system layers and ensuring data integrity throughout your application. This guide establishes our team's standards for implementing DTOs.

info

DTOs are distinct from higher-level concepts like Data Structures, Data Types, and Custom Types. Sometimes what is used as a DTO might actually be a Custom Type. This document focuses specifically on DTOs.

Core Principles

1. Constructor Requirement

Every DTO must have a constructor. A DTO without a constructor is meaningless as it cannot guarantee the integrity of its data.

// DTO with constructor
class UserDTO
{
public function __construct(
public readonly string $name,
public readonly ?string $email = null
) {}
}

Important: A DTO without a constructor cannot enforce data integrity or validation rules.

2. Mandatory Properties

Every DTO must have at least one mandatory property defined in its constructor. This ensures that when a data block is created, it contains the necessary data.

// DTO with mandatory properties
class ArticleDTO
{
public readonly string $title;
public readonly string $content;
public ?string $author = null;

public function __construct(
string $title,
string $content
) {
$this->title = $title;
$this->content = $content;
}
}

3. No Behaviors

DTOs should not contain business logic or behaviors. They are purely data carriers.

Key Concept

We must distinguish between "Business Logic" and "Construction Logic". DTOs should not contain business logic, but they can contain construction logic.

Difference Between Business Logic and Construction Logic

Business logic requires domain knowledge, business rules, or external services to make a decision or perform an operation.

Characteristics of Business Logic:

  • Requires external dependencies
  • Implements business rules
  • Changes system state
  • Connects to databases or other services
// DTO with business logic (anti-pattern)
class OrderDTO
{
public readonly float $amount;

public function __construct(float $amount)
{
$this->amount = $amount;
}

// Business logic should not be in DTO
public function applyDiscount(DiscountService $discountService, User $user): float
{
// Requires external service and domain knowledge
$discount = $discountService->getDiscountFor($user);
return $this->amount * (1 - $discount);
}
}

Golden Rule for Identification

Golden Rule

For executing a method in a DTO, does it need anything beyond the input parameters and the internal fields of the class itself?

  • No? It's probably construction logic or a simple helper. (✅ Allowed)
  • Yes? This is definitely business logic. (🛑 Prohibited)
// DTO without behavior
class OrderDTO
{
public function __construct(
public readonly float $amount
) {}
}

Remember: DTOs should be simple data structures without any business logic.

4. Constructor Parameter Organization

Organize constructor parameters with these guidelines:

  1. Required parameters first (no default values)
  2. Optional parameters last (with default values)
  3. Group related parameters together
  4. Limit total parameters - consider using nested DTOs if there are too many

Tip: Use PHP 8's constructor property promotion for cleaner code.

// ❌ Suboptimal: Mixing mandatory and optional properties in constructor
class ProductDTO
{
public function __construct(
public readonly string $name,
public readonly float $price,
public readonly ?string $description = null,
public readonly ?string $category = null,
public readonly ?array $tags = null
) {}
}

// ✅ Better: Only mandatory properties in constructor
class ProductDTO
{
public readonly ?string $description = null;
public readonly ?string $category = null;
public readonly ?array $tags = null;

public function __construct(
public readonly string $name,
public readonly float $price
) {}
}

Constructor Parameter Order

When organizing parameters in a constructor, follow this order:

  1. Required parameters without default values
  2. Nullable parameters without default values
  3. Parameters with default values
class UserProfileDTO 
{
public function __construct(
// 1. Required parameters without default values
public readonly string $userId,
public readonly string $username,

// 2. Nullable parameters without default values
public readonly ?string $email = null,
public readonly ?string $phone = null,

// 3. Parameters with default values
public readonly string $country = 'USA',
public readonly bool $isActive = true
) {}
}

5. No Getters and Setters

Anti-Pattern

DTOs don't need getters and setters since they don't have behaviors. Getters and setters are meaningful when you want to create and control behavior for a class.

// DTO with public readonly property
class ProductDTO
{
public function __construct(
public readonly string $name
) {}
}

Property Types

✅ Public Readonly Properties

  • Recommended for most cases
  • Provides immutability
  • Prevents accidental modification
  • Cleaner, more concise code

⚠️ Public Properties

  • Use only when mutability is required
  • Less safe than readonly properties
  • Can lead to unexpected behavior
  • Requires careful handling
Best Practice

Private properties in DTOs are effectively the same as public readonly properties if you add a getter. To avoid unnecessary code, use public readonly properties directly.

// Public readonly property
class CustomerDTO
{
public function __construct(
public readonly string $name
) {}
}

Readonly Properties

Best Practice: Whenever possible, make DTO properties readonly. This creates a more robust structure and reduces bugs, as the data cannot be manipulated along the way.

note

With readonly properties, you can confidently pass your DTO through multiple layers without worrying about accidental data modification.

Validation

Validation vs. Behavior

Validations in the constructor that don't depend on external factors (like database or third-party services) are considered part of the DTO's nature, not behavior.

class EmailDTO 
{
public readonly string $address;

public function __construct(string $address)
{
if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}

$this->address = $address;
}
}

Practical Examples of Construction Logic and Business Logic

Important Note

Correctly distinguishing between construction logic (allowed) and business logic (prohibited) in DTOs is one of the most important skills in layered architecture design.

Examples of Construction Logic (Allowed in DTOs)

class ProductDTO
{
public readonly string $name;
public readonly float $price;
public readonly string $formattedPrice;
public readonly array $categories;

private function __construct(
string $name,
float $price,
string $formattedPrice,
array $categories
) {
$this->name = $name;
$this->price = $price;
$this->formattedPrice = $formattedPrice;
$this->categories = $categories;
}

// ✅ Construction logic: only uses input data
public static function fromEntity(Product $product): self
{
return new self(
$product->getName(),
$product->getPrice(),
number_format($product->getPrice(), 2) . ' USD',
array_map(fn($cat) => $cat->getName(), $product->getCategories())
);
}

// ✅ Construction logic: converts DTO to array
public function toArray(): array
{
return [
'name' => $this->name,
'price' => $this->price,
'formatted_price' => $this->formattedPrice,
'categories' => $this->categories,
];
}
}

Examples of Business Logic (Prohibited in DTOs)

// ❌ Anti-pattern: DTO with external service dependency
class OrderDTO
{
public function __construct(
public readonly string $orderId,
public readonly float $amount,
public readonly array $items
) {}

// ❌ Business logic: requires external services
public function calculateFinalPrice(TaxService $taxService, DiscountService $discountService): float
{
$taxRate = $taxService->getTaxRateForOrder($this);
$discount = $discountService->getApplicableDiscount($this->orderId);

return $this->amount * (1 + $taxRate) * (1 - $discount);
}
}

Decision Flowchart

Constructor Parameter Order

When organizing parameters in a constructor, follow this order:

  1. Required parameters without default values
  2. Nullable parameters without default values
  3. Parameters with default values
class PersonDTO 
{
public readonly string $name;
public readonly int $age;
public readonly ?string $email;
public readonly ?string $phone;
public readonly string $country;
public readonly bool $isActive;

public function __construct(
// 1. Required parameters without default values
string $name,
int $age,

// 2. Nullable parameters without default values
?string $email = null,
?string $phone = null,

// 3. Parameters with default values
string $country = 'USA',
bool $isActive = true
) {
$this->name = $name;
$this->age = $age;
$this->email = $email;
$this->phone = $phone;
$this->country = $country;
$this->isActive = $isActive;
}
}

Real-World Example

class AddressDTO
{
public readonly string $street;
public readonly string $city;
public readonly string $zipCode;
public readonly ?string $state;
public readonly string $country;

public function __construct(
string $street,
string $city,
string $zipCode,
?string $state = null,
string $country = 'USA'
) {
$this->street = $street;
$this->city = $city;
$this->zipCode = $zipCode;
$this->state = $state;
$this->country = $country;
}
}

Summary

Key DTO Guidelines

  1. Every DTO must have a constructor
  2. Every DTO must have at least one mandatory property
  3. DTOs should not contain business logic or behaviors
  4. Construction logic (like static factory methods) is allowed in DTOs
  5. Use public readonly properties instead of private properties with getters
  6. Prefer readonly properties whenever possible
  7. Include validations in the constructor for data integrity
  8. Follow the recommended parameter order in constructors
Golden Rule for Identifying Allowed and Prohibited Behavior

To determine whether a method in a DTO is allowed or not, ask yourself:

Does this method need anything beyond the input parameters and the internal state of the class itself to do its job?

  • No? It's probably construction logic or a simple helper. (✅ Allowed)
  • Yes? This is definitely business logic. (🛑 Prohibited)

Difference Between Business Logic and Construction Logic

🛑 Business Logic (Prohibited)

  • Requires external dependencies
  • Implements business rules
  • Changes system state
  • Connects to databases or other services

✅ Construction Logic (Allowed)

  • No external dependencies
  • Works only with input data and internal fields
  • Purpose is data transformation, not business rules
  • Examples: static factory methods, array conversion

Actionable Rules:

  • Review your DTOs during code reviews to ensure they follow these standards
  • Apply the golden rule to determine if a method belongs in a DTO
  • Consider using a static factory method pattern for DTO construction
  • Use static analysis tools to enforce these rules automatically :::