A DTO is an object designed to transport data between layers or across an API.
When we start developing APIs with Entity Framework, the temptation is strong. You have your Product class that maps to the database, and you think: “Why would I create another identical class? I’ll just return the Product directly in the controller and be done faster.”
Exposing your database entities directly to the client causes security risks, performance problems, and coupling. It’s convenient for five minutes, and then it turns into a massive technical debt.
The solution to all of this has three letters: DTO (Data Transfer Object).
The Problem with Exposing Entities
Suppose we have this entity in our database for managing users:
public class User
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
// Sensitive data
public string PasswordHash { get; set; }
public bool IsAdmin { get; set; }
public DateTime CreatedAt { get; set; }
}If in your controller you do this:
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
var user = _dbContext.Users.Find(id);
return Ok(user); // ❌ BAD: Returning the entire entity
}We are creating several problems:
Information Leakage
When serializing the User class to JSON, you are sending all public fields. Your response may include PasswordHash. Even if it’s a hash, it should never leave the server.
Over-posting (mass assignment)
This one is even worse. Let’s consider the update method:
[HttpPut]
public IActionResult Update([FromBody] User user) // ❌ Receives the entity
{
_dbContext.Users.Update(user);
_dbContext.SaveChanges();
return Ok();
}A malicious user could send this JSON:
{
"id": 1,
"username": "hacker",
"isAdmin": true
}Because the Model Binder automatically fills in the IsAdmin property and you save the object as is, the hacker just became an administrator.
Circular References
In Entity Framework, entities often have navigation relationships (e.g., Category has Products, and Product has Category). If you try to serialize this graph without configuring it, System.Text.Json will detect the possible cycle and throw an exception.
The Solution: Data Transfer Objects
A DTO is a simple class. It has no business logic, does not connect to the database, and does not represent a table. It is just a box for transporting data from one place to another.
We create a specific class for what we want to show the world:
// What the world sees
public class UserDto
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
// NO TRACE of Password or IsAdmin
}And now, in our controller, we perform the mapping:
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
// 1. Retrieve the Entity from the Database
var entity = _dbContext.Users.Find(id);
if (entity == null) return NotFound();
// 2. Map to DTO (Project)
var dto = new UserDto
{
Id = entity.Id,
Username = entity.Username,
Email = entity.Email
};
// 3. Return the DTO
return Ok(dto);
}Input and Output DTOs
A good practice is to have different DTOs for receiving data (Input) and for sending it (Output), as the requirements are usually different.
Example: User Creation
To create a user, we do need to receive the password (in plain text, so we can hash it), but we don’t need to receive the ID (it’s auto-generated) or the creation date.
public class CreateUserDto
{
[Required]
public string Username { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
[Required]
public string Password { get; set; } // Here we do ask for it
}Notice how using DTOs allows us to have granular control over what data enters and leaves for each operation.
The Power of record in C#
Since C# 9, we have records, which are convenient for defining DTOs due to their concise syntax and value-based equality.
We can define a DTO in a single line:
public record ProductDto(int Id, string Name, decimal Price);This generates a type with a constructor, init properties, and equality comparers. It’s a useful option, although a regular class can also be a perfectly valid DTO.
Manual vs. Automatic Mapping
You might be thinking: “Do I have to copy property by property every time? That’s very tedious and error-prone.”
You are right. There are two approaches:
-
Manual Mapping: Write
dto.Name = entity.Name.- Advantage: Explicit, fast to execute, and easy to debug.
- Disadvantage: Repetitive code.
-
Automatic Mapping (Mapster): Libraries that automatically copy properties based on them having the same name.
In small or performance-critical applications, manual mapping is better. In large applications, we will use libraries.