Skip to main content

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)

Figure 6.1 Chapter 6 Solution Architecture


Companion Code
  • 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
tip

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:

Figure 6.2 Models as an external contract

  • The client passes an id to the GetPlatformById endpoint
  • The controller uses AppDbContext to find a Platform model in the database with that id
  • Assuming at match a Platform model is passed back to AppDbContext
  • The controller passes back a representation of the Platform model to the client

We've seen this working, and it's all good, however a question:

"What happens if we want to change the Platform model? 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:

Figure 6.3 Introducing 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:

Figure 6.4 Create use-case with DTOs

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.cs
    • PlatformReadDto.cs
    • PlatformUpdateDto.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:

  • PlatformCreateDto and PlatformUpdateDto are 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 not classes) 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 Platform model 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 PlatformReadDtos as opposed to a collection of Platform models.
  • 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 PlatformReadDto as opposed to a Platform model.
  • Continues to attempt to find a Platform model based on the supplied id

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 PlatformReadDto as opposed to a Platform model.
  • Now expects a PlatformCreateDto as input as opposed to a Platform model.
  • 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.PlatformName property to a newly created Platform model
  • Manually maps the newly created Platform model (+ the database generated id) back to a PlatformReadDto

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 PlatformUpdateDto as input as opposed to a Platform model.
  • Manually maps the values from the PlatformUpdateDto to the retrieved Platform model

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.http should 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
tip

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 CreatedAt property 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);
info

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.