Implementing AutoMapper for DTO Mapping with Audit Details
How the Contact Management Application uses AutoMapper in Clean Architecture to map DTOs to entities and populate audit fields in one place.
Early versions of the Contact Management Application had the same four lines at the end of every create and update method: set CreatedBy, set CreatedOn (or their Updated* twins), then save. Copy-pasted audit code gets forgotten just often enough to make the audit columns untrustworthy, and an audit column you can’t trust is worse than none. Moving that logic into AutoMapper profiles fixed it in one place.
I’ll say up front that AutoMapper is a contested choice, and the critics have a point: convention-based mapping hides errors until runtime. My rule is narrow. For flat DTO-to-entity mappings plus cross-cutting touches like audit fields, it earns its place; the moment a mapping needs a business decision, I write that mapping by hand.
1. What is AutoMapper?
AutoMapper automates data transformations between objects. It is especially useful in Clean Architecture, where decoupling between layers is a key principle. In the Contact Management Application, AutoMapper:
-
Maps data between DTOs and domain entities in both directions.
-
Keeps domain logic independent of API-specific requirements.
-
Populates audit fields like CreatedBy and UpdatedOn during create and update operations, without service code touching them.
2. Setting Up AutoMapper in the Application
2.1 Install AutoMapper
To begin, install AutoMapper in your project via NuGet:
Install-Package AutoMapper.Extensions.Microsoft.DependencyInjection2.2 Configure AutoMapper Profiles
Mapping profiles define transformation rules. The application implements a BaseMappingProfile to hold shared functionality, including the assignment of audit fields:
public abstract class BaseMappingProfile : Profile{ private IHttpContextAccessor _httpContextAccessor;
protected BaseMappingProfile(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; }
protected void SetAuditFields<TSource, TEntity>(TSource dto, TEntity entity, bool isCreate = true) where TEntity : BaseEntity { Guid userId = GetCurrentUserId(); var currentTime = DateTimeOffset.UtcNow;
if (isCreate) { entity.CreatedOn = currentTime; entity.CreatedBy = userId; } else { entity.UpdatedOn = currentTime; entity.UpdatedBy = userId; } }
private Guid GetCurrentUserId() { var userIdClaim = _httpContextAccessor?.HttpContext?.User?.FindFirst("Id"); return userIdClaim != null ? Guid.Parse(userIdClaim.Value) : Guid.Empty; }}The user identity comes from the JWT claims via IHttpContextAccessor, so “who changed this row” is answered by the token, not by whatever the caller chose to send.
ContactPersonMappingProfile demonstrates mappings specific to the ContactPerson entity:
public class ContactPersonPofile : BaseMappingProfile{ public ContactPersonPofile(IHttpContextAccessor httpContextAccessor) : base(httpContextAccessor) { CreateMap<ContactPerson, ContactPersonResponse>(); CreateMap<CreateContactPerson, ContactPerson>() .AfterMap((src, dest) => SetAuditFields(src, dest)); // Audit fields on creation CreateMap<UpdateContactPerson, ContactPerson>() .AfterMap((src, dest) => SetAuditFields(src, dest, false)); // Audit fields on update }}Key highlights:
-
The profile covers create, update, and response mappings for one entity in one place.
-
AfterMaphooks populate the audit fields for every request type, so no service method can forget them.
2.3 Registering AutoMapper in Startup.cs
To register AutoMapper in the API Layer, add the following line to the Startup.cs file in the ConfigureServices method:
services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());Assembly scanning picks up every profile, so a new entity’s mappings work as soon as its profile class exists.
3. Implementing a Reusable Service with AutoMapper
The GenericService provides reusable CRUD functionality, using AutoMapper for object transformations:
public class GenericService<TEntity, TResponse, TCreate, TUpdate> : IGenericService<TEntity, TResponse, TCreate, TUpdate> where TEntity : BaseEntity where TResponse : class where TCreate : class where TUpdate : class{ private readonly IGenericRepository<TEntity> _repository; private readonly IMapper _mapper; private readonly IUnitOfWork _unitOfWork;
public GenericService(IGenericRepository<TEntity> repository, IMapper mapper, IUnitOfWork unitOfWork) { _repository = repository; _mapper = mapper; _unitOfWork = unitOfWork; }
public async Task<TResponse> Add(TCreate createDto) { using var transaction = _unitOfWork.BeginTransaction(); try { var entity = _mapper.Map<TEntity>(createDto); var createdEntity = await _repository.Add(entity, transaction); await _unitOfWork.CommitAsync(); return _mapper.Map<TResponse>(createdEntity); } catch { await _unitOfWork.RollbackAsync(); throw; } }
public async Task<TResponse> Update(TUpdate updateDto) { var entity = _mapper.Map<TEntity>(updateDto); var updatedEntity = await _repository.Update(entity); return _mapper.Map<TResponse>(updatedEntity); }
public async Task<bool> Delete(Guid id) { return await _repository.Delete(id); }
public async Task<TResponse> FindByID(Guid id) { var entity = await _repository.FindByID(id); return _mapper.Map<TResponse>(entity); }
public async Task<IEnumerable<TResponse>> FindAll() { var entities = await _repository.FindAll(); return _mapper.Map<IEnumerable<TResponse>>(entities); }}The ContactPersonService extends GenericService for domain-specific operations:
public class ContactPersonService : GenericService<ContactPerson, ContactPersonResponse, CreateContactPerson, UpdateContactPerson>, IContactPersonService{ public ContactPersonService(IGenericRepository<ContactPerson> repository, IMapper mapper, IUnitOfWork unitOfWork) : base(repository, mapper, unitOfWork) { }
// Additional methods specific to ContactPerson can go here}In Clean Architecture, DTOs live in the API Layer to exchange data without revealing the underlying domain models. Below is how the data flows from a CreateContactPerson DTO to the ContactPerson domain entity.
3.1 Example of CreateContactPersonDto
The DTO used when creating a new contact person:
public class CreateContactPerson{ public string FirstName { get; set; } public string LastName { get; set; } public string Email { get; set; } public long Mobile { get; set; }}3.2 Example of ContactPerson Entity
The domain entity that represents a contact:
public class ContactPerson : BaseEntity{ public string FirstName { get; set; } public string LastName { get; set; } public string Email { get; set; } public long Mobile { get; set; }}The ContactPerson entity inherits from the BaseEntity, which contains common properties like CreatedBy, CreatedOn, UpdatedBy, and UpdatedOn. Notice the DTO has no audit properties at all: clients can’t set them, and services never touch them.
4. Why route audit fields through the mapping layer?
The BaseMappingProfile shown in section 2.2 is the single owner of audit-field logic. That buys three concrete things:
-
Consistency: every create and update passes through the same
SetAuditFieldscode path, so the columns are populated the same way on every table. -
Separation of concerns: the service layer handles business logic; the mapping layer owns the bookkeeping.
-
Less boilerplate: no service or repository method sets audit fields manually, so none can get it wrong.
The honest caveat: audit stamping in AfterMap is invisible when you read a service method. A new team member will wonder where CreatedBy comes from, so document the convention where people will find it, in the base profile and the onboarding notes.
Comments
Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.