DTOs and Mapping: What the API Sends and Receives – C# | Lesson 45
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
| Entity | DTO | |
|---|---|---|
| Mirrors | A database table | What one screen or API call needs |
| Contains | Every column, audit fields, navigation links | Only the fields the client should see or send |
| Rules | Can protect its own data (Lesson 38) | Usually none; it just carries data |
| Changes when | The database changes | The 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
| Kind | Direction | SMS example | Notes |
|---|---|---|---|
| Response DTO | Out to the client | StudentDto | Readable values: "Male", not 77 |
| Create request | In from the client | CreateStudentRequest | No Id: the database creates it |
| Update request | In from the client | UpdateStudentRequest | Only 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:
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:
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);
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
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
// 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));
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
| Step | Mapping | What it protects |
|---|---|---|
| Create | request.ToEntity() | The Student constructor still checks the names |
| Read | student.ToDto() | Enums become readable text; internal fields stay inside |
| Update | update.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 write | One method per mapping | Configuration, much less code |
| When a property is renamed | The build fails, so you notice | May silently map nothing |
| Debugging | Step into ordinary code | Harder to see what happened |
| Best for | Small and medium systems; learning | Very 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
- Returning entities from the API. Internal fields and secrets leak. Fix: always return DTOs.
- 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. - Accepting fields that should not change. Fix: leave them out of the update request.
- Forgetting the
usingfor the mapping namespace. CS1061 saysToDtodoes not exist. Fix: addusing SchoolApp.Core.Mapping;. - Putting business rules in mapping code. Mapping should copy, not decide. Fix: keep rules in entities and services.
In real 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
Userentity leaked 14 fields, includingpasswordHash;UserDtosends 4. - Response DTOs go out; create and update requests come in, with only allowed fields.
- Extension methods add
ToDto()andToEntity()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:
{"id":3,"isbn":"9780143039655","title":"Malgudi Days","author":"R. K. Narayan","category":"Fiction"}
Show the solution
// 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
Why should an API return DTOs instead of entities?
Show answer
Answer: B. Entities can expose internal fields such as a password hash A DTO contains only what the client should see.
Why does CreateStudentRequest have no Id?
Show answer
Answer: B. The database creates the Id IDENTITY columns are generated by SQL Server.
What makes student.ToDto() callable when Student has no ToDto method?
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.
Why can UpdateStudentRequest not change the admission number?
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.
What does flattening mean?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.