Introduction
In modern web applications, APIs are the backbone of data exchange between the backend and the frontend. However, sending entire database entities directly as API responses often leads to:
A common solution in ASP.NET Core applications is to use DTOs (Data Transfer Objects) combined with AutoMapper. This approach allows developers to:
Return only the required fields
Map complex entities to simpler shapes
Reduce bandwidth consumption
Maintain separation of concerns
In this article, we will demonstrate a complete workflow with ASP.NET Core APIs, AutoMapper, and Angular clients, ensuring production-ready implementation.
1. Understanding DTOs
DTO (Data Transfer Object) is a simple object designed to transfer data between layers, especially between backend and frontend.
Benefits of using DTOs:
Minimized API payloads – Send only the fields required by the client.
Encapsulation – Hide internal fields like passwords or audit logs.
Decoupling – Backend can change entity structure without breaking the API contract.
Improved performance – Smaller JSON payloads reduce latency and bandwidth.
Example
Suppose you have a User entity:
public class User
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string PasswordHash { get; set; }
public DateTime CreatedAt { get; set; }
public string Role { get; set; }
}
You don’t want to send PasswordHash or internal timestamps to the frontend. Instead, define a DTO:
public class UserDto
{
public int Id { get; set; }
public string FullName { get; set; }
public string Email { get; set; }
}
2. Introducing AutoMapper
AutoMapper is a library for object-to-object mapping. It automatically maps properties from a source object to a target object, reducing boilerplate code.
Installation
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection
Configuration
Create a Mapping Profile:
using AutoMapper;
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<User, UserDto>()
.ForMember(dest => dest.FullName,
opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}"));
}
}
Register AutoMapper in Program.cs:
builder.Services.AddAutoMapper(typeof(Program));
3. Using DTOs in ASP.NET Core Controllers
Example: User API
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _context;
private readonly IMapper _mapper;
public UsersController(AppDbContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
[HttpGet]
public async Task<IActionResult> GetUsers()
{
var users = await _context.Users.ToListAsync();
// Map entities to DTOs
var userDtos = _mapper.Map<List<UserDto>>(users);
return Ok(userDtos);
}
}
Key Points
The frontend receives only Id, FullName, and Email.
PasswordHash and internal fields are never exposed.
Mapping is automatic and maintainable even with nested objects.
4. Mapping Nested Objects
Many real-world entities have relationships. AutoMapper can handle nested properties.
Example: Product and Category
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public Category Category { get; set; }
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
DTO
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; }
public string CategoryName { get; set; }
}
Mapping Profile
CreateMap<Product, ProductDto>()
.ForMember(dest => dest.CategoryName, opt => opt.MapFrom(src => src.Category.Name));
Now, the API will send a flat, lightweight object instead of embedding the entire Category.
5. Reducing API Response Size With Select Queries
Instead of fetching full entities and then mapping, project directly to DTOs using LINQ. This is more efficient for large datasets.
[HttpGet]
public async Task<IActionResult> GetProducts()
{
var products = await _context.Products
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
CategoryName = p.Category.Name
})
.ToListAsync();
return Ok(products);
}
Why this matters
Only the required columns are selected.
Database load and network transfer are reduced.
Ideal for large tables or nested objects.
6. Pagination, Filtering, and Sorting
Reducing response size is not only about selecting fields. For large datasets, implement:
Pagination
Filtering
Sorting
Example
[HttpGet]
public async Task<IActionResult> GetProducts([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var query = _context.Products.AsQueryable();
// Example filter
query = query.Where(p => p.Price > 100);
// Sorting
query = query.OrderBy(p => p.Name);
var total = await query.CountAsync();
var products = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
CategoryName = p.Category.Name
})
.ToListAsync();
return Ok(new { Total = total, Data = products });
}
7. Angular Integration
On the Angular side, define interfaces that match DTOs.
export interface ProductDto {
id: number;
name: string;
categoryName: string;
}
Service Example
@Injectable({ providedIn: 'root' })
export class ProductService {
constructor(private http: HttpClient) {}
getProducts(page: number, pageSize: number): Observable<{ total: number; data: ProductDto[] }> {
return this.http.get<{ total: number; data: ProductDto[] }>(`/api/products?page=${page}&pageSize=${pageSize}`);
}
}
Component Example
@Component({
selector: 'app-product-list',
templateUrl: './product-list.component.html'
})
export class ProductListComponent implements OnInit {
products: ProductDto[] = [];
total = 0;
page = 1;
pageSize = 20;
constructor(private productService: ProductService) {}
ngOnInit() {
this.loadProducts();
}
loadProducts() {
this.productService.getProducts(this.page, this.pageSize)
.subscribe(res => {
this.products = res.data;
this.total = res.total;
});
}
}
8. Benefits of Combining DTOs, AutoMapper, and Select Projections
| Technique | Benefits |
|---|
| DTOs | Limits data exposure, reduces payload size |
| AutoMapper | Simplifies mapping, reduces boilerplate |
LINQ .Select() projection | Fetch only required columns, improves performance |
| Pagination & Filtering | Limits records returned, reduces API load |
Tip: Use all these techniques together in production for maximum efficiency.
9. Best Practices
Keep DTOs lean: Include only necessary fields.
Use projection for large datasets: Avoid fetching full entities if only a few fields are needed.
Map nested objects carefully: AutoMapper can flatten complex relationships.
Avoid sending sensitive data: Never include passwords, tokens, or internal IDs.
Version your APIs: Avoid breaking frontend when entities change.
Test payload sizes: Measure network payload to confirm optimization.
Document DTOs: Use Swagger/OpenAPI to describe the API contract.
10. Advanced Scenarios
10.1 Multiple DTOs for Different Clients
You can define multiple DTOs for different use cases:
Mapping profiles
CreateMap<User, UserSummaryDto>()
.ForMember(d => d.FullName, opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}"));
CreateMap<User, UserDetailDto>()
.ForMember(d => d.FullName, opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}"))
.ForMember(d => d.Role, opt => opt.MapFrom(src => src.Role));
10.2 Conditional Mapping
Use AutoMapper conditions to map only when necessary:
CreateMap<User, UserDto>()
.ForMember(dest => dest.Email, opt => opt.Condition(src => !string.IsNullOrEmpty(src.Email)));
10.3 Projection to IQueryable
For very large datasets:
var userDtos = await _context.Users
.ProjectTo<UserDto>(_mapper.ConfigurationProvider)
.ToListAsync();
Conclusion
Reducing API response size is essential for modern web applications. Using DTOs, AutoMapper, and selective projections ensures:
Lightweight responses
Improved performance
Secure data exposure
Maintainable code
Angular clients benefit from receiving exactly the data they need, enabling smooth dashboards, tables, and charts without unnecessary payloads.
By combining DTOs, AutoMapper, LINQ projection, and pagination, developers can build scalable, production-ready APIs.