Object Mapping in ConnectSoft Microservice Template¶
Purpose & Overview¶
Object Mapping is the process of transforming objects from one representation to another, enabling clean separation between layers in the microservice architecture. In the ConnectSoft Microservice Template, AutoMapper is used to automatically map between ServiceModel DTOs (Data Transfer Objects) and DomainModel Input/Output models, eliminating boilerplate mapping code and ensuring consistent transformations across the application.
Object mapping provides:
- Layer Isolation: ServiceModel DTOs remain separate from DomainModel, preventing internal structure leakage
- Protocol Independence: Same DTOs can be used across REST, gRPC, CoreWCF, GraphQL, and Azure Functions
- Automatic Transformation: Convention-based mapping reduces manual mapping code
- Type Safety: Compile-time checking of mapping configurations
- Maintainability: Centralized mapping profiles make changes easier
- Testability: Mapping configurations are validated via unit tests
Object Mapping Philosophy
Object mapping is the bridge between layers in Clean Architecture. By using AutoMapper, we eliminate repetitive mapping code while maintaining strict boundaries between ServiceModel (API contracts) and DomainModel (business logic). This ensures that API contracts can evolve independently from domain models, and that internal domain structure never leaks to external clients.
Architecture Overview¶
Mapping Flow in Clean Architecture¶
External Clients
↓
Service Model Layer (ServiceModel)
├── Request DTOs (CreateMicroserviceAggregateRootRequest)
├── Response DTOs (CreateMicroserviceAggregateRootResponse)
└── DTOs (MicroserviceAggregateRootDto)
↓ (AutoMapper)
Domain Model Layer (DomainModel)
├── Input Models (CreateMicroserviceAggregateRootInput)
├── Output Models (FeatureAUseCaseAOutput)
└── Processors/Retrievers
↓
Entity Model Layer (EntityModel)
└── Domain Entities (IMicroserviceAggregateRoot)
Mapping Directions¶
| Direction | Source | Target | Purpose |
|---|---|---|---|
| Request → Input | CreateMicroserviceAggregateRootRequest |
CreateMicroserviceAggregateRootInput |
Transform API request to domain input |
| Entity → DTO | IMicroserviceAggregateRoot |
MicroserviceAggregateRootDto |
Transform domain entity to API response |
| Output → Response | FeatureAUseCaseAOutput |
FeatureAUseCaseAResponse |
Transform domain output to API response |
AutoMapper Configuration¶
Service Registration¶
AutoMapper is registered via extension method:
Registration Implementation¶
// AutoMapperExtensions.cs
internal static void AddAutoMapper(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Create mapper configuration
var mapperConfig = new MapperConfiguration(mc =>
{
#if UseGrpc || UseRestApi || UseCoreWCF || UseGraphQL || UseServiceFabric || UseAzureFunction
mc.AddProfile<MicroserviceServiceModelMappingProfile>();
#endif
});
// Create mapper instance
IMapper mapper = mapperConfig.CreateMapper();
// Register as singleton
services.AddSingleton(mapper);
services.ActivateSingleton<IMapper>();
}
Key Points:
- MapperConfiguration: Created once at startup
- IMapper: Registered as singleton (thread-safe)
- Profiles: Conditionally included based on service model selection
- Validation: Configuration is validated at startup (via unit tests)
Mapping Profiles¶
Profile Structure¶
Mapping profiles inherit from Profile and define mappings in the constructor:
// MicroserviceServiceModelMappingProfile.cs
namespace ConnectSoft.MicroserviceTemplate.ApplicationModel.Mappings
{
using AutoMapper;
using ConnectSoft.MicroserviceTemplate.DomainModel;
using ConnectSoft.MicroserviceTemplate.EntityModel;
using ConnectSoft.MicroserviceTemplate.ServiceModel;
public class MicroserviceServiceModelMappingProfile : Profile
{
public MicroserviceServiceModelMappingProfile()
{
// Domain Entity → DTO
this.CreateMap<IMicroserviceAggregateRoot, MicroserviceAggregateRootDto>();
// Request → Input
this.CreateMap<GetMicroserviceAggregateRootDetailsRequest, GetMicroserviceAggregateRootDetailsInput>();
this.CreateMap<CreateMicroserviceAggregateRootRequest, CreateMicroserviceAggregateRootInput>();
this.CreateMap<DeleteMicroserviceAggregateRootRequest, DeleteMicroserviceAggregateRootInput>();
// Output → Response
this.CreateMap<FeatureAUseCaseARequest, FeatureAUseCaseAInput>();
this.CreateMap<FeatureAUseCaseAOutput, FeatureAUseCaseAResponse>();
}
}
}
Profile Location¶
Profiles are located in: ConnectSoft.MicroserviceTemplate.ApplicationModel/Mappings/
Naming Convention: MicroserviceServiceModelMappingProfile (Profile suffix)
Convention-Based Mapping¶
Automatic Property Mapping¶
AutoMapper automatically maps properties with matching names:
// Source
public class CreateMicroserviceAggregateRootRequest
{
public Guid ObjectId { get; set; }
}
// Target
public class CreateMicroserviceAggregateRootInput
{
public Guid ObjectId { get; set; }
}
// Mapping (automatic - no configuration needed)
this.CreateMap<CreateMicroserviceAggregateRootRequest, CreateMicroserviceAggregateRootInput>();
Convention Rules: - Name Matching: Properties with same name are mapped automatically - Type Matching: Types must be compatible (or have implicit conversion) - Case Insensitive: Property names are matched case-insensitively - Nested Properties: Nested objects are mapped recursively
Supported Type Conversions¶
AutoMapper automatically handles:
| Source Type | Target Type | Conversion |
|---|---|---|
string |
int |
Parsing (if valid) |
int |
string |
.ToString() |
Guid |
string |
.ToString() |
DateTime |
DateTimeOffset |
Implicit conversion |
List<T> |
IEnumerable<T> |
Direct assignment |
T? |
T |
Nullable to non-nullable (if not null) |
Custom Mappings¶
Property Name Mapping¶
When property names differ, use ForMember:
this.CreateMap<SourceClass, TargetClass>()
.ForMember(dest => dest.TargetProperty, opt => opt.MapFrom(src => src.SourceProperty));
Example:
// Source
public class UserRequest
{
public string UserName { get; set; }
}
// Target
public class UserInput
{
public string Name { get; set; }
}
// Custom mapping
this.CreateMap<UserRequest, UserInput>()
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.UserName));
Ignoring Properties¶
Ignore properties that shouldn't be mapped:
this.CreateMap<SourceClass, TargetClass>()
.ForMember(dest => dest.InternalProperty, opt => opt.Ignore());
Example:
// Source
public class UserRequest
{
public string UserName { get; set; }
public string InternalSecret { get; set; }
}
// Target
public class UserInput
{
public string UserName { get; set; }
// No InternalSecret property
}
// Ignore internal property
this.CreateMap<UserRequest, UserInput>()
.ForMember(dest => dest.InternalSecret, opt => opt.Ignore());
Value Transformations¶
Transform values during mapping:
this.CreateMap<SourceClass, TargetClass>()
.ForMember(dest => dest.UppercaseName, opt => opt.MapFrom(src => src.Name.ToUpperInvariant()));
Example:
// Source
public class UserRequest
{
public string Email { get; set; }
}
// Target
public class UserInput
{
public string NormalizedEmail { get; set; }
}
// Transform during mapping
this.CreateMap<UserRequest, UserInput>()
.ForMember(dest => dest.NormalizedEmail, opt => opt.MapFrom(src => src.Email.ToLowerInvariant()));
Conditional Mapping¶
Map properties conditionally:
this.CreateMap<SourceClass, TargetClass>()
.ForMember(dest => dest.Status, opt => opt.MapFrom(src =>
src.IsActive ? "Active" : "Inactive"));
Nested Object Mapping¶
AutoMapper automatically maps nested objects if types are mapped:
// Source
public class OrderRequest
{
public AddressRequest ShippingAddress { get; set; }
}
public class AddressRequest
{
public string Street { get; set; }
public string City { get; set; }
}
// Target
public class OrderInput
{
public AddressInput ShippingAddress { get; set; }
}
public class AddressInput
{
public string Street { get; set; }
public string City { get; set; }
}
// Mappings
this.CreateMap<AddressRequest, AddressInput>();
this.CreateMap<OrderRequest, OrderInput>();
// ShippingAddress is automatically mapped
Collection Mapping¶
Collections are automatically mapped:
// Source
public class OrderRequest
{
public List<OrderItemRequest> Items { get; set; }
}
// Target
public class OrderInput
{
public IReadOnlyList<OrderItemInput> Items { get; set; }
}
// Mappings
this.CreateMap<OrderItemRequest, OrderItemInput>();
this.CreateMap<OrderRequest, OrderInput>();
// Items collection is automatically mapped
AfterMap Actions¶
Perform additional operations after mapping:
this.CreateMap<SourceClass, TargetClass>()
.AfterMap((src, dest) =>
{
dest.CalculatedProperty = src.Value1 + src.Value2;
});
Example:
this.CreateMap<OrderRequest, OrderInput>()
.AfterMap((src, dest) =>
{
dest.TotalAmount = src.Items.Sum(i => i.Price * i.Quantity);
dest.CreatedAt = DateTimeOffset.UtcNow;
});
Usage Patterns¶
Basic Mapping¶
In Controllers:
// REST API Controller
public class MicroserviceAggregateRootsServiceController : ControllerBase
{
private readonly IMapper mapper;
private readonly IMicroserviceAggregateRootsProcessor processor;
public MicroserviceAggregateRootsServiceController(
IMapper mapper,
IMicroserviceAggregateRootsProcessor processor)
{
this.mapper = mapper;
this.processor = processor;
}
[HttpPost("MicroserviceAggregateRoots/")]
public async Task<CreateMicroserviceAggregateRootResponse> CreateMicroserviceAggregateRoot(
[FromBody] CreateMicroserviceAggregateRootRequest request,
CancellationToken token = default)
{
// Map Request → Input
var input = this.mapper.Map<CreateMicroserviceAggregateRootRequest, CreateMicroserviceAggregateRootInput>(request);
// Execute domain logic
var entity = await this.processor.CreateMicroserviceAggregateRoot(input, token);
// Map Entity → DTO
var dto = this.mapper.Map<IMicroserviceAggregateRoot, MicroserviceAggregateRootDto>(entity);
// Create response
return new CreateMicroserviceAggregateRootResponse
{
CreatedMicroserviceAggregateRoot = dto
};
}
}
Generic Mapping Syntax¶
AutoMapper supports two mapping syntaxes:
// ✅ Preferred - Explicit type parameters
var input = this.mapper.Map<CreateMicroserviceAggregateRootRequest, CreateMicroserviceAggregateRootInput>(request);
// ✅ Alternative - Type inference
var input = this.mapper.Map<CreateMicroserviceAggregateRootInput>(request);
Recommendation: Use explicit type parameters for clarity and compile-time safety.
Mapping to Existing Instances¶
Update existing objects:
var existingInput = new CreateMicroserviceAggregateRootInput();
this.mapper.Map(request, existingInput);
// existingInput is updated with values from request
Mapping Collections¶
// Map list
var inputList = this.mapper.Map<List<CreateMicroserviceAggregateRootRequest>, List<CreateMicroserviceAggregateRootInput>>(requestList);
// Map enumerable
var inputEnumerable = this.mapper.Map<IEnumerable<CreateMicroserviceAggregateRootRequest>, IEnumerable<CreateMicroserviceAggregateRootInput>>(requestEnumerable);
Complete Request-Response Flow¶
Creating an Aggregate (Example)¶
flowchart TD
Client[Client sends Request] --> Controller[Controller receives Request DTO]
Controller --> Validate[Validate Request]
Validate --> MapToInput[Map Request → Input]
MapToInput --> Processor[Processor executes]
Processor --> Entity[Returns Domain Entity]
Entity --> MapToDto[Map Entity → DTO]
MapToDto --> Response[Create Response]
Response --> Client
Step-by-Step Flow:
- Client Request:
CreateMicroserviceAggregateRootRequestarrives at controller - Validation: Request is validated (DataAnnotations, FluentValidation)
- Map to Input:
mapper.Map<CreateMicroserviceAggregateRootRequest, CreateMicroserviceAggregateRootInput>(request) - Domain Processing: Processor executes business logic with
CreateMicroserviceAggregateRootInput - Entity Return: Processor returns
IMicroserviceAggregateRoot - Map to DTO:
mapper.Map<IMicroserviceAggregateRoot, MicroserviceAggregateRootDto>(entity) - Response Creation: Response DTO created with mapped DTO
- Client Response:
CreateMicroserviceAggregateRootResponsereturned
Code Example¶
[HttpPost("MicroserviceAggregateRoots/")]
public async Task<CreateMicroserviceAggregateRootResponse> CreateMicroserviceAggregateRoot(
[FromBody] CreateMicroserviceAggregateRootRequest request,
CancellationToken token = default)
{
// Step 1: Map Request → Input
var input = this.mapper.Map<CreateMicroserviceAggregateRootRequest, CreateMicroserviceAggregateRootInput>(request);
// Step 2: Execute domain logic
var entity = await this.processor.CreateMicroserviceAggregateRoot(input, token);
// Step 3: Map Entity → DTO
var dto = this.mapper.Map<IMicroserviceAggregateRoot, MicroserviceAggregateRootDto>(entity);
// Step 4: Create response
return new CreateMicroserviceAggregateRootResponse
{
CreatedMicroserviceAggregateRoot = dto
};
}
Testing¶
Mapping Configuration Validation¶
Validate mapping configurations via unit tests:
// MicroserviceServiceModelMappingProfileUnitTests.cs
[TestClass]
public class MicroserviceServiceModelMappingProfileUnitTests
{
private MapperConfiguration? mapperConfiguration;
[TestInitialize]
public void InitializeMapperConfiguration()
{
this.mapperConfiguration = new MapperConfiguration(cfg =>
{
#if UseGrpc || UseRestApi || UseCoreWCF || UseGraphQL || UseServiceFabric || UseAzureFunction
cfg.AddProfile<MicroserviceServiceModelMappingProfile>();
#endif
});
}
[TestMethod]
public void ValidateMicroserviceServiceModelMappingProfileMappings()
{
// Assert
Assert.IsNotNull(this.mapperConfiguration);
// Validates all mappings are configured correctly
this.mapperConfiguration.AssertConfigurationIsValid();
}
}
Benefits: - Startup Validation: Catches mapping errors at test time, not runtime - Complete Coverage: Validates all mappings in the profile - Missing Properties: Detects unmapped properties - Type Mismatches: Detects incompatible type mappings
Mapping Execution Tests¶
Test actual mapping behavior:
[TestMethod]
public void Map_RequestToInput_ShouldMapCorrectly()
{
// Arrange
var mapper = this.mapperConfiguration.CreateMapper();
var request = new CreateMicroserviceAggregateRootRequest
{
ObjectId = Guid.NewGuid()
};
// Act
var input = mapper.Map<CreateMicroserviceAggregateRootRequest, CreateMicroserviceAggregateRootInput>(request);
// Assert
Assert.AreEqual(request.ObjectId, input.ObjectId);
}
[TestMethod]
public void Map_EntityToDto_ShouldMapCorrectly()
{
// Arrange
var mapper = this.mapperConfiguration.CreateMapper();
var entity = new MicroserviceAggregateRootEntity
{
ObjectId = Guid.NewGuid(),
SomeValue = "Test"
};
// Act
var dto = mapper.Map<IMicroserviceAggregateRoot, MicroserviceAggregateRootDto>(entity);
// Assert
Assert.AreEqual(entity.ObjectId, dto.ObjectId);
}
Best Practices¶
Do's¶
-
Use Convention-Based Mapping When Possible
-
Keep Mappings Simple
-
Validate Mapping Configuration
-
Use Explicit Type Parameters
-
Group Related Mappings
-
Document Complex Mappings
Don'ts¶
-
Don't Put Business Logic in Mappings
// ❌ BAD - Business logic in mapping this.CreateMap<OrderRequest, OrderInput>() .AfterMap((src, dest) => { if (dest.TotalAmount > 1000) { dest.ApprovalRequired = true; // Business logic ❌ } }); // ✅ GOOD - Business logic in domain layer // Mapping stays simple this.CreateMap<OrderRequest, OrderInput>(); // Domain processor handles approval logic -
Don't Skip Mapping Validation
-
Don't Expose Domain Entities Directly
// ❌ BAD - Domain entity exposed public async Task<IMicroserviceAggregateRoot> GetEntity(Guid id) { return await repository.GetByIdAsync(id); } // ✅ GOOD - Map to DTO public async Task<MicroserviceAggregateRootDto> GetEntity(Guid id) { var entity = await repository.GetByIdAsync(id); return mapper.Map<IMicroserviceAggregateRoot, MicroserviceAggregateRootDto>(entity); } -
Don't Create Circular Mappings
-
Don't Map Sensitive Data
// ❌ BAD - Sensitive data in DTO public class UserDto { public string Password { get; set; } // Never! public string ApiKey { get; set; } // Never! } // ✅ GOOD - Map only safe properties this.CreateMap<UserEntity, UserDto>() .ForMember(dest => dest.Password, opt => opt.Ignore()) .ForMember(dest => dest.ApiKey, opt => opt.Ignore());
Common Mapping Patterns¶
Pattern 1: Simple Property Mapping¶
When source and target properties have the same name:
// Source
public class CreateUserRequest
{
public string UserName { get; set; }
public string Email { get; set; }
}
// Target
public class CreateUserInput
{
public string UserName { get; set; }
public string Email { get; set; }
}
// Mapping (automatic)
this.CreateMap<CreateUserRequest, CreateUserInput>();
Pattern 2: Property Name Transformation¶
When property names differ:
// Source
public class CreateOrderRequest
{
public Guid CustomerId { get; set; }
}
// Target
public class CreateOrderInput
{
public Guid OrderCustomerId { get; set; }
}
// Custom mapping
this.CreateMap<CreateOrderRequest, CreateOrderInput>()
.ForMember(dest => dest.OrderCustomerId, opt => opt.MapFrom(src => src.CustomerId));
Pattern 3: Computed Properties¶
When target property is computed:
// Source
public class OrderRequest
{
public List<OrderItemRequest> Items { get; set; }
}
// Target
public class OrderInput
{
public int ItemCount { get; set; }
}
// Computed mapping
this.CreateMap<OrderRequest, OrderInput>()
.ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count));
Pattern 4: Conditional Mapping¶
When mapping depends on source values:
// Source
public class UserRequest
{
public bool IsActive { get; set; }
}
// Target
public class UserInput
{
public string Status { get; set; }
}
// Conditional mapping
this.CreateMap<UserRequest, UserInput>()
.ForMember(dest => dest.Status, opt => opt.MapFrom(src =>
src.IsActive ? "Active" : "Inactive"));
Pattern 5: Nested Object Mapping¶
When objects contain nested objects:
// Source
public class OrderRequest
{
public AddressRequest ShippingAddress { get; set; }
}
public class AddressRequest
{
public string Street { get; set; }
public string City { get; set; }
}
// Target
public class OrderInput
{
public AddressInput ShippingAddress { get; set; }
}
public class AddressInput
{
public string Street { get; set; }
public string City { get; set; }
}
// Mappings
this.CreateMap<AddressRequest, AddressInput>();
this.CreateMap<OrderRequest, OrderInput>();
// ShippingAddress is automatically mapped
Troubleshooting¶
Issue: Mapping Configuration Invalid¶
Symptoms: AutoMapperConfigurationException during startup or test execution.
Solutions:
1. Check Property Names: Ensure all required properties are mapped
2. Verify Type Compatibility: Check source and target types are compatible
3. Review Ignored Properties: Ensure ignored properties are intentionally excluded
4. Check Nested Mappings: Verify nested object mappings are configured
5. Run Validation Test: mapperConfiguration.AssertConfigurationIsValid() will show specific errors
Example Error:
Solution: Add mapping or ignore the property:
Issue: Null Reference Exceptions¶
Symptoms: NullReferenceException during mapping.
Solutions: 1. Check Nullable Properties: Ensure nullable properties are handled 2. Use Nullable Reference Types: Enable nullable reference types for better null safety 3. Add Null Checks: Use conditional mapping for nullable properties
Example:
this.CreateMap<Source, Target>()
.ForMember(dest => dest.OptionalProperty, opt => opt.MapFrom(src =>
src.OptionalProperty ?? "DefaultValue"));
Issue: Property Not Mapping¶
Symptoms: Property value is null or default after mapping.
Solutions: 1. Check Property Names: Verify property names match exactly 2. Check Property Types: Ensure types are compatible 3. Verify Mapping Configuration: Check if property is explicitly ignored 4. Review Custom Mapping: Check if custom mapping is overriding automatic mapping
Example:
// Property not mapping - check if ignored
this.CreateMap<Source, Target>()
.ForMember(dest => dest.PropertyName, opt => opt.MapFrom(src => src.PropertyName));
Issue: Performance Issues¶
Symptoms: Mapping operations are slow.
Solutions:
1. Compile Mappings: Use CreateMapper() instead of dynamic mapping
2. Avoid Complex Transformations: Move complex logic to domain layer
3. Cache Mapper Instance: Ensure IMapper is singleton (already configured)
4. Profile Mappings: Use profiling to identify slow mappings
Summary¶
Object mapping in the ConnectSoft Microservice Template provides:
- ✅ Layer Isolation: Clean separation between ServiceModel and DomainModel
- ✅ Automatic Mapping: Convention-based mapping reduces boilerplate code
- ✅ Type Safety: Compile-time checking of mapping configurations
- ✅ Protocol Independence: Same mappings work across REST, gRPC, CoreWCF, GraphQL
- ✅ Testability: Mapping configurations are validated via unit tests
- ✅ Maintainability: Centralized mapping profiles make changes easier
- ✅ Flexibility: Custom mappings for complex scenarios
By following these patterns, teams can:
- Maintain Clean Boundaries: Keep API contracts separate from domain models
- Reduce Boilerplate: Eliminate repetitive mapping code
- Ensure Consistency: Centralized mapping ensures consistent transformations
- Test Thoroughly: Validate mapping configurations prevent runtime errors
- Evolve Independently: API contracts can change without affecting domain models
Object mapping is the glue that connects layers in Clean Architecture—enabling clean separation while providing seamless data transformation between API boundaries and domain logic.