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.
What’s covered#
- Why domain entities should never cross the API boundary
- Purpose-specific DTOs (create, update, response) instead of one shared shape
- Mapping with AutoMapper, and where manual mapping is the better call
- How the service layer of the Contact Management Application puts DTOs to work
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.
Key takeaways#
- Never let a domain entity cross the API boundary; the first breaking refactor will cost more than all the DTO classes combined.
- One DTO per operation. Shared “god DTOs” accumulate nullable fields until nobody knows the real contract.
- Use AutoMapper for flat, name-matched mappings and hand-written mapping for anything conditional.
- Keep DTOs in feature folders under
Application/UseCases, owned by the use case that consumes them.
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.
