Skip to main content

DTOs and Mapping: What the API Sends and Receives – C# | Lesson 45

Published / updated

Introduction​

The entity classes from Lesson 44 mirror the database exactly, including columns no outside app should ever see: PasswordHash, CreatedBy, IsDeleted. If the Web API returned entities directly, every one of those would travel to every phone.

A DTO (data transfer object) is a class or record designed for one job: carrying exactly the data a client needs, in the shape it needs. Mapping is the code that copies values between entities and DTOs.

By the end of this lesson, you will be able to:

  • Explain why an API should send DTOs, not entities.
  • Write response DTOs and request DTOs as records.
  • Map between entities and DTOs with extension methods.
  • Flatten data from several entities into one DTO.
  • Decide between hand-written mapping and a mapping library.

Why this matters​

Exposing entities is one of the most common security mistakes in web APIs. A field added to a table for internal use quietly becomes part of the public API, and nobody notices until someone looks at the network traffic.

DTOs also protect the other direction. When a parent updates their address, the request should not be able to change their child's admission number or fee status. A request DTO that simply has no such fields makes that impossible.

The concept​

Example: entity versus DTO​

EntityDTO
MirrorsA database tableWhat one screen or API call needs
ContainsEvery column, audit fields, navigation linksOnly the fields the client should see or send
RulesCan protect its own data (Lesson 38)Usually none; it just carries data
Changes whenThe database changesThe API contract changes

Think of the school office. The full student file has everything: fee history, medical notes, staff remarks.

The student's ID card has a name, a photo, and an admission number. The card is a DTO: a deliberate, minimal copy for a specific use.

Example: three kinds of DTO​

KindDirectionSMS exampleNotes
Response DTOOut to the clientStudentDtoReadable values: "Male", not 77
Create requestIn from the clientCreateStudentRequestNo Id: the database creates it
Update requestIn from the clientUpdateStudentRequestOnly the fields that may change

Records (Lesson 43) suit DTOs well: one line declares the whole shape, and DTOs never need identity or behaviour. Value equality is a bonus in tests: two DTOs with the same values are equal, so a test can compare a whole expected result in one line.

Example: extension methods​

An extension method is a static method in a static class whose first parameter has this:

Example
public static StudentDto ToDto(this Student student) { /* ... */ }

It can then be called as if Student had the method: student.ToDto(). The Student class itself does not change and knows nothing about DTOs. Forget the using for the namespace, and the call fails with error CS1061: 'Student' does not contain a definition for 'ToDto'.

Apply it to the SMS​

Step 1: the DTOs​

A new Dtos folder in SchoolApp.Core holds the shapes, as the SMS reference names them:

src/SchoolApp.Core/Dtos/StudentDtos.cs
using SchoolApp.Core.Enums;

namespace SchoolApp.Core.Dtos;

// Lesson 45: DTOs (data transfer objects): the shapes the API sends and receives.
// Records: short to write, compared by value, and fixed once created.

// What a client sees about a student: no audit columns, no soft-delete flags.
public record StudentDto(
int Id,
string AdmissionNumber,
string FullName,
DateOnly DateOfBirth,
string Gender,
string Status);

// What a client sends to add a student. No Id: the database creates it.
public record CreateStudentRequest(
string AdmissionNumber,
string FirstName,
string LastName,
DateOnly DateOfBirth,
Gender Gender,
DateOnly AdmissionDate,
string? Address);

// What a client may change later. The admission number and date of birth are not here.
public record UpdateStudentRequest(
string FirstName,
string LastName,
string? Address,
StudentStatus Status);
src/SchoolApp.Core/Dtos/OtherDtos.cs
namespace SchoolApp.Core.Dtos;

// Lesson 45: a flattened view of an invoice: values from three entities in one simple shape.
public record FeeInvoiceSummaryDto(
string InvoiceNumber,
string StudentName,
decimal Total,
decimal Paid,
decimal Balance,
string Status);

// Lesson 45: a user as clients may see it. PasswordHash is deliberately missing.
public record UserDto(
int Id,
string UserName,
string Email,
bool IsActive);

Step 2: the mapping​

src/SchoolApp.Core/Mapping/MappingExtensions.cs
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Entities;

namespace SchoolApp.Core.Mapping;

// Lesson 45: mapping between entities and DTOs, written by hand as extension methods.
// "this Student student" makes ToDto callable as student.ToDto().
public static class MappingExtensions
{
public static StudentDto ToDto(this Student student)
{
return new StudentDto(
student.Id,
student.AdmissionNumber,
student.FullName,
student.DateOfBirth,
student.Gender.ToString(),
student.Status.ToString());
}

public static Student ToEntity(this CreateStudentRequest request)
{
// The Student constructor still checks the names (Lesson 38).
return new Student(request.AdmissionNumber, request.FirstName, request.LastName,
request.DateOfBirth, request.Gender)
{
AdmissionDate = request.AdmissionDate,
Address = request.Address
};
}

// Updates an existing entity: only the fields the request is allowed to change.
public static void ApplyTo(this UpdateStudentRequest request, Student student)
{
student.FirstName = request.FirstName;
student.LastName = request.LastName;
student.Address = request.Address;
student.Status = request.Status;
}

// Flattening: the student's name comes from Enrollment.Student, two links away.
public static FeeInvoiceSummaryDto ToSummaryDto(this FeeInvoice invoice)
{
string studentName = invoice.Enrollment?.Student?.FullName ?? "(student not loaded)";
return new FeeInvoiceSummaryDto(
invoice.InvoiceNumber,
studentName,
invoice.TotalAmount - invoice.DiscountAmount,
invoice.AmountPaid,
invoice.Balance,
invoice.Status.ToString());
}

public static UserDto ToDto(this User user) => new UserDto(user.Id, user.UserName, user.Email, user.IsActive);
}

Step 3: the program​

Show src/SchoolApp.Console/Program.cs
src/SchoolApp.Console/Program.cs
// Lesson 45: DTOs and mapping.
using System.Text.Json;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Enums;
using SchoolApp.Core.Mapping;

JsonSerializerOptions json = JsonSerializerOptions.Web; // camelCase names, as web APIs use

// 1. Why DTOs: an entity sent as JSON exposes everything, including the password hash.
User rajeshUser = new User
{
Id = 5, UserName = "rajesh.sharma", Email = "rajesh.sharma@nexcodingschool.example",
PasswordHash = "NOT-SET", GuardianId = 1
};
Console.WriteLine("Entity as JSON:");
Console.WriteLine(JsonSerializer.Serialize(rajeshUser, json));
Console.WriteLine("DTO as JSON:");
Console.WriteLine(JsonSerializer.Serialize(rajeshUser.ToDto(), json));

// 2. Create: request -> entity. The entity's own rules still apply.
CreateStudentRequest request = new CreateStudentRequest(
"ADM-2026-10001", "Ishaan", "Verma", new DateOnly(2016, 2, 14), Gender.Male, new DateOnly(2026, 6, 1), null);
Student ishaan = request.ToEntity();
ishaan.Id = 10013;
Console.WriteLine($"Created: {ishaan.Describe()}, status {ishaan.Status}");

// 3. Read: entity -> DTO. Enums become readable text.
StudentDto dto = ishaan.ToDto();
Console.WriteLine(JsonSerializer.Serialize(dto, json));

// 4. Update: only the allowed fields change.
UpdateStudentRequest update = new UpdateStudentRequest("Ishaan", "Verma", "12 Garden Lane", StudentStatus.Active);
update.ApplyTo(ishaan);
Console.WriteLine($"After update: address {ishaan.Address}; admission number still {ishaan.AdmissionNumber}");

// 5. Flattening: one simple shape built from FeeInvoice, Enrollment, and Student.
Student diya = new Student("ADM-2017-0002", "Diya", "Patel", new DateOnly(2011, 8, 23), Gender.Female) { Id = 2 };
Enrollment diyaEnrollment = new Enrollment { Id = 14, StudentId = 2, AcademicYearId = 2, SectionId = 19, RollNumber = 2, Student = diya };
FeeInvoice invoice = new FeeInvoice("INV-2026-000002", 45500m) { Id = 2, EnrollmentId = 14, Enrollment = diyaEnrollment };
invoice.RecordPayment(20000m);
Console.WriteLine(JsonSerializer.Serialize(invoice.ToSummaryDto(), json));
Output
Entity as JSON:
{"id":5,"userName":"rajesh.sharma","email":"rajesh.sharma@nexcodingschool.example","passwordHash":"NOT-SET","teacherId":null,"guardianId":1,"isActive":true,"lastLoginAt":null,"isDeleted":false,"deletedAt":null,"createdAt":"0001-01-01T00:00:00","createdBy":null,"updatedAt":null,"updatedBy":null}
DTO as JSON:
{"id":5,"userName":"rajesh.sharma","email":"rajesh.sharma@nexcodingschool.example","isActive":true}
Created: Ishaan Verma (student ADM-2026-10001), status Active
{"id":10013,"admissionNumber":"ADM-2026-10001","fullName":"Ishaan Verma","dateOfBirth":"2016-02-14","gender":"Male","status":"Active"}
After update: address 12 Garden Lane; admission number still ADM-2026-10001
{"invoiceNumber":"INV-2026-000002","studentName":"Diya Patel","total":45500,"paid":20000,"balance":25500,"status":"PartiallyPaid"}

Example: what the entity would leak​

JsonSerializer turns an object into JSON, the format the Web API will send (Extra 3 showed the same format from SQL). JsonSerializerOptions.Web uses camelCase names, as most APIs do.

Rajesh's User entity serialized to 14 fields, including passwordHash, his guardian Id, and every audit column. In SchoolDb the hash is still the placeholder NOT-SET; once Module 12 adds real logins, it would be a real password hash. The DTO sends four fields, and that is all a client ever needs.

Example: create, read, update​

StepMappingWhat it protects
Createrequest.ToEntity()The Student constructor still checks the names
Readstudent.ToDto()Enums become readable text; internal fields stay inside
Updateupdate.ApplyTo(student)Only names, address, and status can change

After the update, Ishaan's admission number is unchanged, because UpdateStudentRequest has no such field. A client cannot send what the request type does not contain.

Example: flattening​

ToSummaryDto builds one simple shape from three entities: the invoice, its enrollment, and the enrollment's student. The client gets "studentName":"Diya Patel" directly, with the real balance of 25,500.

The ?. and ?? from Lesson 35 handle a missing link: if the student was not loaded, the DTO says so instead of crashing. A screen built on this DTO never needs to know how the tables are joined.

Flattening also keeps JSON small. Sending the whole invoice, enrollment, and student objects nested inside each other would repeat dozens of fields the screen never shows. On a slow mobile connection, those extra bytes are real seconds.

Example: over-posting​

Suppose the update endpoint accepted a whole Student entity. A client could add "status": "Graduated" or a different admission number to the request, and the server would save it. This attack is called over-posting, or mass assignment.

It needs no hacking skill: just an extra field in the JSON. Request DTOs stop it at the door. Fields that are not in UpdateStudentRequest cannot be read from the request, so they cannot be changed through it.

Example: DTOs are a contract​

Once mobile apps use the API, its JSON shape is a contract: apps on thousands of phones expect exactly those field names.

With DTOs, the database can change freely behind them. Rename a column, split a table, or add audit fields, and only the mapping changes; the DTO, and every phone, stays the same. With entities on the wire, every database change would be a breaking change for every app.

Example: where mapping lives​

In this course, DTOs and mapping live in SchoolApp.Core, next to the entities, so every project can use them. In Module 11, the Web API's controllers call ToDto() just before returning a response, and ToEntity() just after receiving a request.

That keeps the rule simple: entities inside the application, DTOs at its edges. Nothing that crosses the network is ever an entity.

Example: requests still need validation​

A request DTO limits which fields can arrive, but not their values. A CreateStudentRequest can still carry a blank name or a date of birth in the future.

Some checks already happen: ToEntity() calls the Student constructor, which refuses blank names. In Module 11, the API adds request validation, so bad values are rejected with a clear message before any entity is created.

Example: hand-written mapping or a library?​

Hand-written (this course)Mapping library, such as AutoMapper or Mapster
Code to writeOne method per mappingConfiguration, much less code
When a property is renamedThe build fails, so you noticeMay silently map nothing
DebuggingStep into ordinary codeHarder to see what happened
Best forSmall and medium systems; learningVery many similar mappings

Both are used in real projects. Hand-written mapping is explicit and checked by the compiler, which is why this course uses it. Whichever you choose, test the mapping once per DTO.

Common mistakes​

  1. Returning entities from the API. Internal fields and secrets leak. Fix: always return DTOs.
  2. One DTO for everything. A create request ends up with an Id, and a response with a password. Fix: separate response, create, and update shapes.
  3. Accepting fields that should not change. Fix: leave them out of the update request.
  4. Forgetting the using for the mapping namespace. CS1061 says ToDto does not exist. Fix: add using SchoolApp.Core.Mapping;.
  5. Putting business rules in mapping code. Mapping should copy, not decide. Fix: keep rules in entities and services.

In real projects​

From 16 years of .NET projects

A security review of one school app found that the "my profile" endpoint returned the whole user entity, including the password hash and a field marking staff accounts. It had been like that for two years.

Nobody had meant to expose them. The endpoint returned User, and someone later added columns to Users. Every new column went straight to every phone.

We added a UserDto with four fields that afternoon. Since then, returning an entity from a controller fails our code review automatically.

Best practices​

  • Never send entities over the network; send DTOs.
  • Use separate DTOs for responses, create requests, and update requests.
  • Make DTOs records, and name them as the SMS reference does: StudentDto, CreateStudentRequest.
  • Keep mapping in one place, such as extension methods, and keep it free of business rules.
  • Convert enums to readable text in response DTOs.

Recap​

  • An entity mirrors the database; a DTO carries only what a client needs.
  • The User entity leaked 14 fields, including passwordHash; UserDto sends 4.
  • Response DTOs go out; create and update requests come in, with only allowed fields.
  • Extension methods add ToDto() and ToEntity() without changing the entity classes.
  • Flattening builds one simple DTO from several linked entities.
  • Hand-written mapping is explicit and compiler-checked; libraries trade that for less code.

Homework​

Library module task: Write a BookDto and a CreateBookRequest as records, and ToDto() and ToEntity() extension methods. Create Malgudi Days from a request, set an internal CreatedBy, and print the DTO as JSON. CreatedBy must not appear.

A correct result looks like:

Output
{"id":3,"isbn":"9780143039655","title":"Malgudi Days","author":"R. K. Narayan","category":"Fiction"}
Show the solution
library-assignment/045-library-dtos.cs
// Library homework, Lesson 45: a DTO and a create request for books, with mapping methods.
// Run with: dotnet run library-assignment/045-library-dtos.cs
// Single-file apps are built for native AOT by default, which turns off reflection-based JSON.
// This line turns AOT off, so JsonSerializer works as it does in a normal project.
#:property PublishAot=false
using System.Text.Json;

CreateBookRequest request = new CreateBookRequest("9780143039655", "Malgudi Days", "R. K. Narayan", "Fiction");
Book book = request.ToEntity();
book.Id = 3;
book.CreatedBy = 1; // an internal column: never shown to clients

Console.WriteLine(JsonSerializer.Serialize(book.ToDto(), JsonSerializerOptions.Web));

class Book
{
public int Id { get; set; }
public required string Isbn { get; set; }
public required string Title { get; set; }
public required string Author { get; set; }
public required string Category { get; set; }
public int? CreatedBy { get; set; }
public bool IsDeleted { get; set; }
}

record BookDto(int Id, string Isbn, string Title, string Author, string Category);

record CreateBookRequest(string Isbn, string Title, string Author, string Category);

static class BookMappings
{
public static BookDto ToDto(this Book book) => new BookDto(book.Id, book.Isbn, book.Title, book.Author, book.Category);

public static Book ToEntity(this CreateBookRequest request) => new Book
{
Isbn = request.Isbn,
Title = request.Title,
Author = request.Author,
Category = request.Category
};
}

Note the #:property PublishAot=false line. Single-file apps are prepared for native AOT by default: a way of compiling ahead of time that switches off reflection-based JSON.

Without the line, the program stops with "Reflection-based serialization has been disabled for this application". Normal projects, like SchoolApp.Console, are not affected. This behaviour was found while testing this homework, and it is exactly the kind of surprise a real project throws at you.

Quiz​

  1. Why should an API return DTOs instead of entities?

    1. DTOs are faster to create
    2. Entities can expose internal fields such as a password hash
    3. Entities cannot be serialized
    4. DTOs are required by C#
    Show answer

    Answer: B. Entities can expose internal fields such as a password hash A DTO contains only what the client should see.

  2. Why does CreateStudentRequest have no Id?

    1. Ids are secret
    2. The database creates the Id
    3. Records cannot have Ids
    4. It was forgotten
    Show answer

    Answer: B. The database creates the Id IDENTITY columns are generated by SQL Server.

  3. What makes student.ToDto() callable when Student has no ToDto method?

    1. Inheritance
    2. An extension method with this Student as its first parameter
    3. A partial class
    4. Reflection
    Show answer

    Answer: B. An extension method with this Student as its first parameter Extension methods live in a static class and need its namespace in a using.

  4. Why can UpdateStudentRequest not change the admission number?

    1. The setter is private
    2. The request has no admission number field at all
    3. SQL blocks it
    4. The mapping throws
    Show answer

    Answer: B. The request has no admission number field at all A client cannot send a field that the request type does not contain.

  5. What does flattening mean?

    1. Removing nulls
    2. Building one simple DTO from values in several linked entities
    3. Compressing JSON
    4. Sorting properties
    Show answer

    Answer: B. Building one simple DTO from values in several linked entities FeeInvoiceSummaryDto takes the student's name from Enrollment.Student.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: OOP interview questions.

Fresher (0-2 yrs)​

F What is a DTO? concept

A data transfer object: a simple class or record that carries exactly the data one API call or screen needs, such as StudentDto. It has no business rules and no database-only fields.

See the example · Permalink

F Why should an API not return entity classes directly? concept

Entities mirror the database and expose everything: password hashes, audit columns, soft-delete flags, and any column added later. DTOs send only what the client needs, so internal data cannot leak and database changes do not break clients.

See the example · Permalink

F What is an extension method? concept

A static method in a static class whose first parameter has this, such as ToDto(this Student student). It can be called like an instance method, student.ToDto(), without changing the original class. Its namespace must be imported with using.

See the example · Permalink

Mid (3-5 yrs)​

M Why use separate DTOs for responses, create requests, and update requests? design

Each direction needs different fields: a response has an Id and readable values; a create request has no Id; an update request contains only fields that may change. One shared DTO would accept or expose fields it should not.

See the example · Permalink

M What is over-posting (mass assignment), and how do DTOs prevent it? concept

A client adds extra fields to a request, such as a status or role, and the server saves them because it bound the request to an entity. Request DTOs contain only the allowed fields, so anything else in the JSON is ignored.

See the example · Permalink

M What is flattening in mapping? concept

Building one simple DTO from values spread across linked entities, such as an invoice summary that includes the student's name from Enrollment.Student. Clients get a small, ready-to-show shape without knowing how tables are joined.

See the example · Permalink

Senior (6-9 yrs)​

S A security review finds the profile endpoint returns the password hash. How do you fix it and prevent it across the codebase? scenario

Introduce a DTO with only the needed fields and map the entity to it before returning. Check all other endpoints for returned entities, add a review rule or analyzer that forbids entities in controller responses, and consider rotating credentials if hashes were exposed.

How to answer: Cover the immediate fix, the audit of similar endpoints, prevention (rules, tests that inspect response shapes), and the incident response, such as assessing exposure.

See the example · Permalink

S Hand-written mapping or a mapping library such as AutoMapper? design

Hand-written mapping is explicit, compiler-checked, and easy to debug; renamed properties break the build. Libraries reduce repetitive code for many similar mappings but can silently skip properties. Choose per project size, and test mappings either way.

How to answer: Weigh explicitness and safety against less code. Mention configuration validation in libraries and source-generated mappers as a middle ground.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How do DTOs help an API evolve without breaking mobile apps? design

The DTO is the public contract; the database and entities can change behind it while mapping adapts. Breaking changes to the DTO are made deliberately, with versioning, so apps already on phones keep working.

How to answer: Explain contract stability, additive versus breaking changes, and API versioning strategies, with an example such as renaming a database column without changing the JSON.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-45-dtos-mapping (code repository coming soon)
  • Files: src/SchoolApp.Core/Dtos/StudentDtos.cs, src/SchoolApp.Core/Dtos/OtherDtos.cs, src/SchoolApp.Core/Mapping/MappingExtensions.cs, src/SchoolApp.Console/Program.cs, library-assignment/045-library-dtos.cs
  • Video: coming soon

What's next​

Module 7 ends with five principles that tie everything together: SOLID. You will review the SchoolApp code against each one, and prepare the interfaces that Module 9 builds on. Lesson 46: SOLID principles

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.