6. Data Transfer Objects
About this chapter
In this chapter we'll introduce Data Transfer Objects (DTOs) to our app, this includes:
- Creating DTOs for Read, Create and Update operations
- Retrofitting the Platforms controller to use DTOs
Learning outcomes:
- Understand why we have introduced DTOs to the API
- Understand how to use DTOs with controller actions
- Understand how to manually map between domain models and DTOs
Architecture Checkpoint
In reference to our solution architecture we'll be making code changes to the highlighted components in this chapter:
- DTOs (partially complete)

- The code for this section can be found here on GitHub
- The complete finished code can be found here on GitHub
Feature branch
Ensure that main is current, then create a feature branch called: chapter_6_dtos, and check it out:
git branch chapter_6_dtos
git checkout chapter_6_dtos
If you can't remember the full workflow, refer back to Chapter 5
What are DTOs
Before we look at DTO's, lets' look at an example of how the API works now (without DTOs) when retrieving a Platform by its id:

- The client passes an
idto theGetPlatformByIdendpoint - The controller uses
AppDbContextto find aPlatformmodel in the database with thatid - Assuming at match a
Platformmodel is passed back toAppDbContext - The controller passes back a representation of the
Platformmodel to the client
We've seen this working, and it's all good, however a question:
"What happens if we want to change the
Platformmodel? E.g. add a new Property that we only use internally, for example:RetailCost"
Answer: That property would leak out to our API Consumers when Platform models we passed back. With this design, we have tied the internal representations of our data to the external contract.
You do not want to do this.
This example covers a data retrieval use-case. The same principles hold true for data creation. What if we added some new mandatory property to the Platform model? For example you may want to add CreatedAt timestamp.
In this case you would not want API consumers providing this value, but by expecting a representation of the Platform model as input to the CreatePlatform endpoint that's what you're doing.
Moreover, if the API consumer does not provide this value (e.g. they have not updated their code since you introduced the new property), calls to CreatePlatform that previously worked, now would not.
By tying the internal representations of our data to the external contract - we have amongst other issues, introduced a breaking change.
Introducing DTOs
Let's take a look at the same example as before, this time employing DTOs:

Here you will see that we have a different external representation for the data being passed back to the API Client (PlatformReadDto). The internal model is still retrieved from the database, but this is not passed back directly, instead it is mapped to the DTO, which is passed back to the client.
The mapping activity takes care of what we want to go back to the consumer, (e.g. don't map internal properties). We have detached the internal representations of data from the external, meaning that we can change the internal representations of our data without having immediate knock on effects to the consumer, e.g. exposing data we don't want to.
For the creation use-case, we employ a PlatformCreateDto as well as a PlatformReadDto as shown below:

This means that we can shape the PlatformCreateDto to not require internal mandatory properties (e.g. CreatedAt) instead generating those ourself at the point of insertion to the Db.
Adding DTOs
In the root of the project:
- Create a folder called
Dtos - Into that folder create 3 files:
PlatformCreateDto.csPlatformReadDto.csPlatformUpdateDto.cs
Update the code in each of those files as follows:
PlatformCreateDto
using System.ComponentModel.DataAnnotations;
namespace CommandAPI.Dtos;
public record PlatformCreateDto(
[Required]
[MinLength(2)]
string PlatformName);
PlatformReadDto
namespace Run1API.Dtos;
public record PlatformReadDto(int Id, string PlatformName);
PlatformUpdateDto
using System.ComponentModel.DataAnnotations;
namespace CommandAPI.Dtos;
public record PlatformUpdateDto(
[Required]
[MinLength(2)]
string PlatformName);
Some points to note:
PlatformCreateDtoandPlatformUpdateDtoare identical. We could just use 1 DTO to represent both operations, but these may diverge in the future so we'll keep them separate.- In all cases we are using
records(and notclasses) to represent DTOs, this is a deliberate design choice. Records are ideal for DTOs because they provide immutability (data can't be changed after creation) and value-based equality (two DTOs with the same values are considered equal). Since DTOs are simple data containers meant to transfer data between layers, not hold business logic, records give us cleaner syntax with built-in benefits that align perfectly with how DTOs should behave. - At the moment there isn't a huge difference between the
Platformmodel and these DTos. That is almost immaterial from a design perspective, as it is still good practice to keep internal and external data representations separate. The value of employing DTOs is not measured by how different they are from the model, but by the underlying architectural benefits they bring, including:- Flexibility of future changes
- Explicit intent: "this is what we accept"
- Protection / property leakage - we have to think about what we send back to consumers
Refactor the controller
Adding DTOs to the project is of course just part of the picture, we need to update the Platforms Controller to make use of them.
We'll go through each endpoint in turn just to break it down a bit, but before we do that bring in the DTOs namespace at the top of the PlatformsController.cs file:
using CommandAPI.Dtos;
GetPlatforms
Update the GetPlatforms endpoint code to reflect the following:
[HttpGet]
public async Task<ActionResult<IEnumerable<PlatformReadDto>>> GetPlatforms()
{
var platforms = await _context.Platforms.ToListAsync();
// Manual mapping to DTOs
var platformDtos = platforms.Select(p => new PlatformReadDto(p.Id, p.PlatformName));
return Ok(platformDtos);
}
This code:
- Now returns a collection of
PlatformReadDtosas opposed to a collection ofPlatformmodels. - Continues retrieves any models from the DB, but these get (manually) mapped to a collection of DTOs.
The existing test in platforms.http should continue to work, and indeed return the same payload.
GetPlatformById
Update the GetPlatformById endpoint code to reflect the following:
[HttpGet("{id}", Name = "GetPlatformById")]
public async Task<ActionResult<PlatformReadDto>> GetPlatformById(int id)
{
var platform = await _context.Platforms.FirstOrDefaultAsync(p => p.Id == id);
if (platform == null)
return NotFound();
// Manual mapping to DTO
var platformDto = new PlatformReadDto(platform.Id, platform.PlatformName);
return Ok(platformDto);
}
This code:
- Now returns a
PlatformReadDtoas opposed to aPlatformmodel. - Continues to attempt to find a
Platformmodel based on the suppliedid
The existing test in platforms.http should continue to work, and indeed return the same payload.
CreatePlatform
Update the CreatePlatform endpoint code to reflect the following:
[HttpPost]
public async Task<ActionResult<PlatformReadDto>> CreatePlatform(PlatformCreateDto platformCreateDto)
{
// Manual mapping from DTO to entity
var platform = new Platform
{
PlatformName = platformCreateDto.PlatformName
};
await _context.Platforms.AddAsync(platform);
await _context.SaveChangesAsync();
// Manual mapping to DTO for response
var platformReadDto = new PlatformReadDto(platform.Id, platform.PlatformName);
return CreatedAtRoute(nameof(GetPlatformById), new { Id = platform.Id }, platformReadDto);
}
This code:
- Now returns a
PlatformReadDtoas opposed to aPlatformmodel. - Now expects a
PlatformCreateDtoas input as opposed to aPlatformmodel. - Removes our previous null check - this happens automatically with .NET model binding - we'll discuss an enhanced approach to validation in Chapter 14
- Manually maps the
PlatformCreateDto.PlatformNameproperty to a newly createdPlatformmodel - Manually maps the newly created
Platformmodel (+ the database generatedid) back to aPlatformReadDto
The existing test in platforms.http should continue to work, and indeed return the same payload.
UpdatePlatform
Update the UpdatePlatform endpoint code to reflect the following:
[HttpPut("{id}")]
public async Task<ActionResult> UpdatePlatform(int id, PlatformUpdateDto platformUpdateDto)
{
var platformFromContext = await _context.Platforms.FirstOrDefaultAsync(p => p.Id == id);
if (platformFromContext == null)
{
return NotFound();
}
// Manual mapping from DTO to entity
platformFromContext.PlatformName = platformUpdateDto.PlatformName;
await _context.SaveChangesAsync();
return NoContent();
}
This code:
- Now expects a
PlatformUpdateDtoas input as opposed to aPlatformmodel. - Manually maps the values from the
PlatformUpdateDtoto the retrievedPlatformmodel
The existing test in platforms.http should continue to work, and indeed return the same payload.
DeletePlatform
The DeletePlatform does not need to be refactored as we neither pass an object nor return one.
Refactor observations
- The existing tests in
platforms.httpshould all continue to work as before - We are manually mapping between models and DTOs and vice-versa. For a models / DTOs of this size that is fine. However if we look to increase the complexity of our domain, we may want to look at auto-mapping (I uses that term generically). We cover this topic in Chapter 10
- We've not tested out the concept of the model and the DTO having different properties, they are both identical in that regard. We'll look at this next.
Diverging the model
To round out the conversation on DTOs, let's add a new property: CreatedAt to the Platform model. This will be:
- Mandatory at the model level
- Generated automatically in the backend (not supplied by the client)
- Passed back as a read property via DTO
Update Platform
Update the Platform model with the additional property:
using System.ComponentModel.DataAnnotations;
namespace CommandAPI.Models;
public class Platform
{
[Key]
public int Id { get; set; }
[Required]
public required string PlatformName { get; set; }
[Required]
public DateTime CreatedAt { get; set; }
}
Generate a new migration
Adding this new property to a model that has an associated DbSet in AppDbContext means that we can migrate that property down to the DB, at a command prompt type:
dotnet ef migrations add AddCreatedAt
dotnet ef database update
You can use DBeaver to check that the new column has been added to the DB
Update the DTOs
In this design we:
- Expect the
CreatedAtproperty to be returned with read operations - Do not expect it to be provided with Create or Update operations
To that end, update the PlatformsReadDto:
namespace CommandAPI.Dtos;
public record PlatformReadDto(int Id, string PlatformName, DateTime CreatedAt);
Setting CreatedAt
We need to set the value of CreatedAt, we'll do this by overriding the SaveChangesAsync method on the DbContext base class. To do so update AppDbContext as follows:
using CommandAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace CommandAPI.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{ }
public DbSet<Platform> Platforms { get; set; }
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var entries = ChangeTracker.Entries()
.Where(e => e.Entity is Platform && e.State == EntityState.Added);
foreach (var entry in entries)
{
((Platform)entry.Entity).CreatedAt = DateTime.UtcNow;
}
return base.SaveChangesAsync(cancellationToken);
}
}
This approach is:
- Centralized: All Platform creation goes through one place
- Automatic: No need to remember to set it manually
Adjusting manual mapping
As we have specified that PlatformsReadDto has a mandatory new property of CreatedAt, the manual mappings in PlatformReadDtos will now be failing. We need to update these in each impacted endpoint in the Platforms controller:
For GetPlatforms, update the manual mapping assignments as follows:
// Manual mapping to DTOs
var platformDtos = platforms.Select(p => new PlatformReadDto(p.Id, p.PlatformName, p.CreatedAt));
For GetPlatformById, update the manual mapping assignments as follows:
// Manual mapping to DTO
var platformDto = new PlatformReadDto(platform.Id, platform.PlatformName, platform.CreatedAt);
For CreatePlatform, update the manual mapping assignments as follows:
// Manual mapping to DTO for response
var platformReadDto = new PlatformReadDto(platform.Id, platform.PlatformName, platform.CreatedAt);
You should start to see the issues with manually mapping properties in this way...
Test
Using the existing requests in platforms.http you should be able to test that this new functionality works.
The only difference you should see is in the return payloads for Platforms:
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Mon, 12 Jan 2026 10:38:59 GMT
Server: Kestrel
Transfer-Encoding: chunked
{
"id": 4,
"platformName": "Podman",
"createdAt": "2026-01-12T10:38:54.56966Z"
}
Version Control
With the code complete, and our requests exercised - it's time to commit our code. A summary of those steps can be found below, for a more detailed over view refer to Chapter 5
- Save all files
git add .git commit -m "update project to use dtos"git push(will fail - copy suggestion)git push --set-upstream origin chapter_6_dtos- Move to GitHub and complete the PR process through to merging
- Back at a command prompt:
git checkout main git pull
Conclusion
DTO's enable us to separate internal and external data representations, or to put it another way, you should not expose internal models to your API consumers.
With the addition of the CreatedAt property on the platform model, we started to diverge these internal and external representations. While we included this property on PlatformReadDto we did not need to, and could have kept it removed from public view entirely.