Best Practices for Creating and Using DTOs in the API
Best practices for designing DTOs in Clean Architecture: purpose-specific DTOs, mapping strategies, and better API performance and maintainability.
Serializing domain entities straight to the client looks like a shortcut until the first schema change. Rename one property on the entity and every consumer of the API breaks with it, and you find out from their bug reports. DTOs are the cheap insurance against that: the entity can change shape freely because the contract the client sees is a separate class you control. This post covers the DTO conventions I use in the Contact Management Application and why each one exists.
1. What is a DTO?
A Data Transfer Object (DTO) is a simple object used to carry data between processes, primarily between layers in an application. DTOs hold no business logic; they exist only to move data. They are used to:
-
Isolate the domain model from external concerns like the presentation layer (UI or API).
-
Control the data exposure, so only the fields a client needs are visible to it.
-
Carry transformed data between layers of the application (e.g., from domain models to API responses).
2. Why DTOs Matter in Clean Architecture
Clean architecture keeps domain entities isolated from the presentation layer. Exposing them directly to the API or front end causes three concrete problems:
-
Security risks: Domain entities carry fields the client has no business seeing (password hashes, audit columns, internal flags).
-
Tight coupling: Once a client depends on the entity’s shape, refactoring the domain means renegotiating every consumer.
-
Payload noise: Most operations need a subset of the entity, so full entities waste bandwidth and leak structure.
With DTOs in between, the domain logic stays decoupled from external systems, only safe and relevant data crosses the boundary, and the core keeps a clean separation from external dependencies.
3. Best Practices for Creating and Organizing DTOs
3.1 Separate DTOs for Each Operation
Create separate DTOs for different operations instead of one class serving create, update, and read. For the contact feature that means CreateContactPersonDto, UpdateContactPersonDto, and ContactPersonResponseDto. A single shared DTO always grows nullable fields that only apply to some operations, and callers stop knowing which ones matter.
Example from the Contact Management Application:
public class CreateContactPersonDto{ public string FirstName { get; set; } public string LastName { get; set; } public string Email { get; set; } public string PhoneNumber { get; set; }}
public class UpdateContactPersonDto{ public Guid Id { get; set; } public string Email { get; set; } public string PhoneNumber { get; set; }}
public class ContactPersonResponseDto{ public Guid Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string Email { get; set; } public string PhoneNumber { get; set; }}In this example:
-
CreateContactPersonDto contains the fields required to create a new contact, and nothing else.
-
UpdateContactPersonDto allows only the fields that may change on update.
-
ContactPersonResponseDto is the shape returned to the client.
3.2 Use AutoMapper for DTO Transformation
In the Contact Management Application, AutoMapper maps DTOs to domain entities and back, which removes a pile of manual property assignments:
public class ContactPersonProfile : Profile{ public ContactPersonProfile() { CreateMap<CreateContactPersonDto, ContactPerson>(); CreateMap<UpdateContactPersonDto, ContactPerson>(); CreateMap<ContactPerson, ContactPersonResponseDto>(); }}Here, AutoMapper maps:
-
CreateContactPersonDto to ContactPerson when creating a new contact.
-
UpdateContactPersonDto to ContactPerson when updating an existing contact.
-
ContactPerson to ContactPersonResponseDto when returning a contact’s data in API responses.
AutoMapper earns its place here because these mappings are flat, name-matched property copies. The moment a mapping needs conditional logic or computed fields, I write it by hand instead: a misconfigured profile fails at runtime, and debugging it costs more than the boilerplate AutoMapper saved.
3.3 Organize DTOs by Feature or Operation
Keep DTOs in folders grouped by feature, next to the use case that owns them, rather than in one flat Dtos folder.
Example folder structure:
/Application /UseCases /ContactPerson CreateContactPersonDto.cs UpdateContactPersonDto.cs ContactPersonResponseDto.csThis structure separates DTOs by feature, so navigating the codebase follows the same lines as the domain.
4. Implementation of DTOs in the Contact Management Application
In the Contact Management Application, DTOs handle every request and response in the API layer. The following example shows how they flow through a typical service:
4.1 Service Layer Using DTOs
public class ContactPersonService : IContactPersonService{ private readonly IGenericRepository<ContactPerson> _repository; private readonly IMapper _mapper;
public ContactPersonService(IGenericRepository<ContactPerson> repository, IMapper mapper) { _repository = repository; _mapper = mapper; }
public async Task<ContactPersonResponseDto> Add(CreateContactPersonDto createContactPersonDto) { var contactPerson = _mapper.Map<ContactPerson>(createContactPersonDto); var createdContact = await _repository.Add(contactPerson); return _mapper.Map<ContactPersonResponseDto>(createdContact); }
public async Task<ContactPersonResponseDto> Update(UpdateContactPersonDto updateContactPersonDto) { var contactPerson = _mapper.Map<ContactPerson>(updateContactPersonDto); var updatedContact = await _repository.Update(contactPerson); return _mapper.Map<ContactPersonResponseDto>(updatedContact); }
public async Task<IEnumerable<ContactPersonResponseDto>> GetAll() { var contacts = await _repository.FindAll(); return _mapper.Map<IEnumerable<ContactPersonResponseDto>>(contacts); }
public async Task<ContactPersonResponseDto> GetById(Guid id) { var contact = await _repository.FindByID(id); return _mapper.Map<ContactPersonResponseDto>(contact); }
public async Task<bool> Delete(Guid id) { return await _repository.Delete(id); }}In this implementation:
-
CreateContactPersonDto and UpdateContactPersonDto carry the incoming requests to create and update contacts.
-
ContactPersonResponseDto is what goes back to the client; the entity itself never leaves the service.
5. What DTOs Actually Buy You
- Decoupling: the API talks to the world in DTOs, so domain model changes don’t ripple out to consumers.
- Exposure control: sensitive entity fields stay out of responses by construction, not by remembering to strip them.
- Smaller payloads: each operation serializes only the fields it needs instead of the whole entity.
- Simpler tests: business logic tests work against DTO shapes without dragging in serialization concerns.
What’s next
Browse the Contact Management Application on GitHub and trace one request end to end, from CreateContactPersonDto at the controller to the response DTO going back out. The next article in the series covers error handling and exception management in the API.
Comments
Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.