Data Transfer Objects (DTOs)
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.
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.
- ❌ Incorrect
- ✅ Correct
// DTO without constructor
class UserDTO
{
public string $name;
public ?string $email;
}
// 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.
- ✅ Correct
- ❌ Incorrect
// 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;
}
}
// DTO with all optional properties
class ArticleDTO
{
public function __construct(
public ?string $title = null,
public ?string $content = null
) {}
}
3. No Behaviors
DTOs should not contain business logic or behaviors. They are purely data carriers.
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 (Prohibited)
- ✅ Construction Logic (Allowed)
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);
}
}
Construction logic helps the DTO to be created from another data source (typically an Entity) or vice versa. This logic has no external dependencies and only works with input data and its internal state.
Characteristics of Construction Logic:
- No external dependencies
- Works only with input data and internal fields
- Its purpose is data transformation, not executing business rules
// DTO with construction logic (appropriate pattern)
class UserDTO
{
public readonly string $fullName;
public readonly string $email;
private function __construct(string $fullName, string $email)
{
$this->fullName = $fullName;
$this->email = $email;
}
// Static factory method -> This is construction logic
public static function fromEntity(User $user): self
{
// Self-contained logic, no external services or database calls
$fullName = $user->getFirstName() . ' ' . $user->getLastName();
return new self($fullName, $user->getEmail());
}
}
Golden Rule for Identification
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)
- ❌ Incorrect
- ✅ Correct
// DTO with business logic
class OrderDTO
{
public readonly float $amount;
public function __construct(float $amount)
{
$this->amount = $amount;
}
// Business logic should not be in DTO
public function calculateTax(): float
{
return $this->amount * 0.2;
}
}
// 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:
- Required parameters first (no default values)
- Optional parameters last (with default values)
- Group related parameters together
- 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:
- Required parameters without default values
- Nullable parameters without default values
- 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
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.
- ❌ Unnecessary
- ✅ Better
// DTO with getters and setters
class ProductDTO
{
private string $name;
public function __construct(string $name)
{
$this->name = $name;
}
// Unnecessary getter
public function getName(): string
{
return $this->name;
}
// Unnecessary setter
public function setName(string $name): void
{
$this->name = $name;
}
}
// 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
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.
- ❌ Unnecessary
- ✅ Better
// Private property with getter
class CustomerDTO
{
private string $name;
public function __construct(string $name)
{
$this->name = $name;
}
public function getName(): string
{
return $this->name;
}
}
// 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.
With readonly properties, you can confidently pass your DTO through multiple layers without worrying about accidental data modification.
Validation
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.
- Basic Validation
- Advanced Validation
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;
}
}
class UserDTO
{
public readonly string $username;
public readonly string $email;
public readonly int $age;
public function __construct(string $username, string $email, int $age)
{
// Username validation
if (strlen($username) < 3 || strlen($username) > 20) {
throw new InvalidArgumentException(
'Username must be between 3 and 20 characters'
);
}
// Email validation
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}
// Age validation
if ($age < 18 || $age > 120) {
throw new InvalidArgumentException('Age must be between 18 and 120');
}
$this->username = $username;
$this->email = $email;
$this->age = $age;
}
}
Practical Examples of Construction Logic and Business Logic
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)
- Factory Methods
- Simple Helper Methods
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,
];
}
}
class AddressDTO
{
public function __construct(
public readonly string $street,
public readonly string $city,
public readonly string $zipCode,
public readonly string $country
) {}
// ✅ Construction logic: only uses internal data
public function getFullAddress(): string
{
return "$this->street, $this->city, $this->country, $this->zipCode";
}
// ✅ Construction logic: simple transformation without external dependencies
public function isInternational(string $userCountry): bool
{
return $this->country !== $userCountry;
}
}
Examples of Business Logic (Prohibited in DTOs)
- External Dependencies
- Database Access
- System State Change
// ❌ 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);
}
}
// ❌ Anti-pattern: DTO with database access
class UserDTO
{
public function __construct(
public readonly int $userId,
public readonly string $username,
public readonly string $email
) {}
// ❌ Business logic: direct database access
public function getUserOrders(Database $db): array
{
return $db->query(
"SELECT * FROM orders WHERE user_id = ?",
[$this->userId]
)->fetchAll();
}
}
// ❌ Anti-pattern: DTO with system state change capability
class PaymentDTO
{
public function __construct(
public readonly string $transactionId,
public readonly float $amount,
public readonly string $status
) {}
// ❌ Business logic: changes system state
public function processPayment(PaymentGateway $gateway): bool
{
if ($this->status === 'pending') {
return $gateway->processTransaction($this->transactionId);
}
return false;
}
}
Decision Flowchart
Constructor Parameter Order
When organizing parameters in a constructor, follow this order:
- Required parameters without default values
- Nullable parameters without default values
- 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
- Basic DTO
- Advanced DTO with Validation
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;
}
}
class UserRegistrationDTO
{
public readonly string $username;
public readonly string $email;
public readonly string $password;
public readonly ?string $referralCode;
public readonly bool $acceptTerms;
public function __construct(
string $username,
string $email,
string $password,
?string $referralCode = null,
bool $acceptTerms = false
) {
// Validate username
if (strlen($username) < 3) {
throw new InvalidArgumentException('Username must be at least 3 characters');
}
// Validate email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}
// Validate password
if (strlen($password) < 8) {
throw new InvalidArgumentException('Password must be at least 8 characters');
}
// Validate terms acceptance
if (!$acceptTerms) {
throw new InvalidArgumentException('Terms must be accepted');
}
$this->username = $username;
$this->email = $email;
$this->password = $password;
$this->referralCode = $referralCode;
$this->acceptTerms = $acceptTerms;
}
}
Summary
Key DTO Guidelines
- Every DTO must have a constructor
- Every DTO must have at least one mandatory property
- DTOs should not contain business logic or behaviors
- Construction logic (like static factory methods) is allowed in DTOs
- Use public readonly properties instead of private properties with getters
- Prefer readonly properties whenever possible
- Include validations in the constructor for data integrity
- Follow the recommended parameter order in constructors
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 :::