Skip to main content

Implementing AutoMapper for DTO Mapping with Audit Details

Implementing AutoMapper for DTO Mapping with Audit Details
Table of Contents

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.

What you’ll build
#

  1. Mapping between DTOs (Data Transfer Objects) and domain entities, in both directions.

  2. A base mapping profile that populates audit fields automatically.

  3. A reusable generic service that keeps CRUD implementations clean.

This article is part of the Clean Architecture series built around the Contact Management Application (source on GitHub). Start with the introduction to the project structure for the full series index.

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.DependencyInjection

2.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.

  • AfterMap hooks 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.

Key point: AutoMapper fails at runtime, not compile time. Add a unit test that calls MapperConfiguration.AssertConfigurationIsValid() so a renamed property breaks the build instead of production.

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 SetAuditFields code 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.

The bottom line
#

  • Audit fields set in one AfterMap hook beat the same four lines copy-pasted across services; the copy-paste version fails silently the day someone forgets it.
  • Take the user identity from JWT claims through IHttpContextAccessor, never from the request payload.
  • Keep DTOs free of audit properties so the API contract can’t lie about who changed what.
  • AutoMapper is for boring mappings. Validate the configuration in a test, and hand-write any mapping that embeds a business rule.

Next in the series: input validation in the Application Layer with FluentValidation. To see the mapping profiles in context first, clone the Contact Management Application and follow a create request from controller to database.

Related

Best Practices for Creating and Using DTOs in the API

··6 mins
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.

Handling Authorization and Role-Based Access Control (RBAC)

··19 mins
Introduction # Static role checks ([Authorize(Roles = "Admin")]) fall apart the first time someone asks you to add a permission without a redeploy. Once roles and permissions have to change at runtime, hard-coded role attributes become a liability. The Contact Management Application takes a different route: a dynamic policy provider that builds authorization policies from the database at request time, covering both the backend API and the Angular frontend, wired into JWT authentication without breaking the separation of concerns Clean Architecture expects.

Validating Inputs with FluentValidation

··6 mins
Introduction # Validation is the one part of a request I refuse to trust anywhere but the edge. Domain objects assume they are already valid; rejecting a malformed email or a missing name belongs at the API boundary, before that data reaches a service or a repository.