Auto Mapper and Record Types - will they blend?

TL;DR: Yes, they will. Thank you for your time. 😄

Background

At Redgate, engineers enjoy a great benefit in that we get are expected to spend 10% of our time to improve on our craft. 

Having suffered this week with manually having to copy a bunch of properties from a domain object to its HTTP representation, I decided to spend this week's 10% time to see if I could answer the following questions:

  • Can use AutoMapper to simplify mapping between two types? 
  • Does it also allow me to map between a regular type and a record type?
  • What about the other way around?
  • Can I use this to reduce the maintenance burden in my product?

Mapping between two types [automapper fundamentals]

To started and get a basic mapping done, created a .NET 5 ASP.NET Web API project, added the below package references and configured automapper:

<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.0" />
services.AddAutoMapper(config =>
{
config.CreateMap<
RedGate.SqlDataCatalog.Domain.Filters.Filter,
RedGate.SqlDataCatalog.Http.Model.Resources.SuggestionRules.Filter>();

});

With the above configuration in place, I could build a sample controller action that instantiated a domain object, mapped that to its HTTP resource representation and returned said representation:

[HttpGet]
public async Task<HttpResources.Filter> Get(
[FromServices] IMapper mapper)
{
#region initialize

string[] databaseNames = {"database1", "...", "databaseN"};
string[] instanceNames = {"instance1", "...", "instanceN"};
string[] tagIds = {"tag1", "...", "tagN"};
string[] columnNameSubstring = {"columnName1", "...", "columnNameN"};
const string compositeKeyFilter = "Include";
string[] databaseNamesSubstrings = {"database1", "...", "databaseN"};
const string emptyTablesFilter = "Exclude";
const string foreignKeyFilter = "Require";
const string identityConstraintFilter = "Include";
string[] instanceNamesSubstrings = {"instance1", "...", "instanceN"};
const string primaryKeyFilter = "Exclude";
const string taggedColumnsFilter = "Require";
string[] tableNamesWithSchemas = {"table1", "...", "tableN"};
string[] columnDataTypeFullNames = {"dataType1", "...", "dataTypeN"};
string[] tableNamesWithSchemasSubstrings = {"dbo.table1", "...", "dbo.tableN"};

#endregion

var domainObject = new RedGate.SqlDataCatalog.Domain.Filters.Filter(
databaseNames,
instanceNames,
tagIds,
columnNameSubstring,
compositeKeyFilter,
databaseNamesSubstrings,
emptyTablesFilter,
foreignKeyFilter,
identityConstraintFilter,
instanceNamesSubstrings,
primaryKeyFilter,
taggedColumnsFilter,
tableNamesWithSchemas,
columnDataTypeFullNames,
tableNamesWithSchemasSubstrings);

var resourceObject = mapper.Map<
RedGate.SqlDataCatalog.Http.Model.Resources.SuggestionRules.Filter>(
domainObject);

return resourceObject;
}

(For those of you new to my blog, imagine the domain object coming from a service provider ... )

So far, so good! Mapping is easy-peasy! Does it work the other way around too?

In order to run this example, I added a sample POST controller action that accepted a HTTP Filter representation, mapped it into a Domain object and then converted that domain object to a string representation (just to be able to visually verify that things look alright):

[HttpPost]
public async Task<ActionResult> Post(
[FromBody] HttpResources.Filter filter,
[FromServices] IMapper mapper)
{
var domainObject = mapper.Map<DomainModel.Filter>(filter);
var stringRepresentation = domainObject.ToString();
System.Diagnostics.Debug.WriteLine(stringRepresentation);
return Ok(stringRepresentation);
}

The string representation was generated with the help of the JsonSerializer in System.Text.Json:

public override string ToString()
{
return System.Text.Json.JsonSerializer.Serialize(this);
}

In order to map in the opposite direction, I needed to add a configuration in Startup.cs:

config.CreateMap<
RedGate.SqlDataCatalog.Http.Model.Resources.SuggestionRules.Filter,
RedGate.SqlDataCatalog.Domain.Filters.Filter>();

Now, filling in all of values in the sample post request reminded me of why I started this project to begin with! After some digging, I found that I could let my controller create an example payload in the SwaggerUI.

Following a guide, I added a package reference to Swashbuckle Filters:

<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="6.0.1" />

I then let my controller implement IExamplesProvider<HttpResources.Filter>:

[NonAction]
public HttpResources.Filter GetExamples()
{
string[] databaseNames = {"database1", "...", "databaseN"};
string[] instanceNames = {"instance1", "...", "instanceN"};
string[] tagIds = {"tag1", "...", "tagN"};
string[] columnNameSubstring = {"columnName1", "...", "columnNameN"};
const string compositeKeyFilter = "Include";
string[] databaseNamesSubstrings = {"database1", "...", "databaseN"};
const string emptyTablesFilter = "Exclude";
const string foreignKeyFilter = "Require";
const string identityConstraintFilter = "Include";
string[] instanceNamesSubstrings = {"instance1", "...", "instanceN"};
const string primaryKeyFilter = "Exclude";
const string taggedColumnsFilter = "Require";
string[] tableNamesWithSchemas = {"table1", "...", "tableN"};
string[] columnDataTypeFullNames = {"dataType1", "...", "dataTypeN"};
string[] tableNamesWithSchemasSubstrings = {"dbo.table1", "...", "dbo.tableN"};

var filter = new HttpResources.Filter(
databaseNames,
instanceNames,
tagIds,
columnNameSubstring,
compositeKeyFilter,
databaseNamesSubstrings,
emptyTablesFilter,
foreignKeyFilter,
identityConstraintFilter,
instanceNamesSubstrings,
primaryKeyFilter,
taggedColumnsFilter,
tableNamesWithSchemas,
columnDataTypeFullNames,
tableNamesWithSchemasSubstrings);

return filter;
}

... configured the Swagger generation to also take examples into consideration:

services.AddSwaggerGen(c =>
{
...
c.ExampleFilters();
});

... and added the assembly that hosted these examples (which was the main assembly in this sample project):

services.AddSwaggerExamplesFromAssemblyOf<HttpResources.Filter>();

OK! So we have verified that ... automapper works? No big surprise there!

Moving on to Record Types

First off, I created a record type that resembled my HttpResource.

public sealed record FilterRecord(
string[] DatabaseNames,
string[] InstanceNames,
string[] TagIds,
string[] ColumnNameSubstring,
string CompositeKeyFilter,
string[] DatabaseNamesSubstrings,
string EmptyTablesFilter,
string ForeignKeyFilter,
string IdentityConstraintFilter,
string[] InstanceNamesSubstrings,
string PrimaryKeyFilter,
string TaggedColumnsFilter,
string[] TableNamesWithSchemas,
string[] ColumnDataTypeFullNames,
string[] TableNamesWithSchemasSubstrings);

A lot less code than our hand-rolled type where we'd also implemented Equals and GetHashCode! 

Then, I added another mapping in Startup.cs:

config.CreateMap<
RedGate.SqlDataCatalog.Domain.Filters.Filter,
HttpResources.FilterRecord>();

... and updated my GET Action to map to, and return, FilterRecord instead of a Filter. It just worked!

Accepting a FilterRecord through the POST request similarly just worked: 

Add mapping

config.CreateMap<
HttpResources.FilterRecord,
RedGate.SqlDataCatalog.Domain.Filters.Filter>();

... and accept a FilterRecord in the POST action:

[HttpPost]
public async Task<ActionResult> Post(
[FromBody] HttpResources.FilterRecord filter,
[FromServices] IMapper mapper)
{
var domainObject = mapper.Map<DomainModel.Filter>(filter);
...

Oh, note how I injected IMapper directly in the method that used that dependency? That's a slick ASP.NET Core DI feature (it just works; dependency added by the initial call to AddAutoMapper).


Conclusion

Can I use this to simplify the maintenance of my product? I'd say so! Record Types allow us to write a lot less code for our data transfer / model objects. We have opted to maintain distinct object models in the various seams of our application (HTTP, Domain, Storage). Mapping between these representations will be greatly simplified with AutoMapper.


Resources

  • Class image: https://unsplash.com/photos/4V1dC_eoCwg
  • Record image: https://unsplash.com/photos/6AtQNsjMoJo
  • Getting started with AutoMapper: https://docs.automapper.org/en/latest/Getting-started.html
  • More on Record Types: https://www.thomasclaudiushuber.com/2020/09/01/c-9-0-records-work-with-immutable-data-classes/
  • https://github.com/mattfrear/Swashbuckle.AspNetCore.Filters

Comments

Popular posts from this blog

Unit testing your Azure functions - part 2: Queues and Blobs

Testing WCF services with user credentials and binary endpoints