Simplifying Data Conversion: A Testable Approach for Entity Models and DTOs

Gilles TOURREAUGilles TOURREAU
5 min read

In many applications, converting data from one format to another is a common task. For instance, you might need to convert a business entity object into JSON or vice versa. Many developers use extension methods like ToJson() or ToModel() for this conversion, which act on the JSON DTO object model or the entity.

For example, if we have the following business object:

public class Person
{
    public Person(int id, string firstName, string lastName)
    {
        this.Id = id;
        this.FirstName = firstName;
        this.LastName = lastName;
    }

    public int Id { get; }

    public string FirstName { get; }

    public string LastName { get; }
}

And the JSON DTO version used to expose it as a Web API:

public class PersonJson
{
    public PersonJson(int id, string firstName, string lastName)
    {
        this.Id = id;
        this.FirstName = firstName;
        this.LastName = lastName;
    }

    [JsonPropertyName("id")]
    public int Id { get; }

    [JsonPropertyName("firstName")]
    public string FirstName { get; }

    [JsonPropertyName("lastName")]
    public string LastName { get; }
}

The extension method that allows converting from Person to PersonJson could look like this:

public static class PersonJsonExtensions
{
    public static PersonJson ToJson(this Person person)
    {
        return new PersonJson(person.Id, person.FirstName, person.LastName);
    }
}

And the usage inside an ASP.NET Core controller is straightforward:

[ApiController]
[Route("persons")]
public class PersonController : ControllerBase
{
    private readonly IPersonManager manager;

    public PersonController(IPersonManager manager)
    {
        this.manager = manager;
    }

    [HttpGet("byId/{id}")]
    public PersonJson GetById(int id)
    {
        var person = this.manager.GetByID(id);
        var personJson = person.ToJson();

        return personJson;
    }

    [HttpGet("byName/{name}")]
    public PersonJson GetByName(string name)
    {
        var person = this.manager.GetByName(name);
        var personJson = person.ToJson();

        return personJson;
    }
}

However, this approach can complicate unit testing. In this article, I'll explain an alternative method using an interface to decouple the code, which makes testing easier.

The problem with extension methods

While this approach works, the problem arises during unit testing. You need to test both the code that retrieves the business entity (the GetById() or GetByName() methods) and the code that converts it to JSON. This makes your tests more complex and harder to maintain.

For example, to test our PersonController, we would write the following unit tests:

public class PersonControllerTest
{
    [Fact]
    public void GetById()
    {
        // Arrange
        var person = new Person(1, "John", "DOE");

        var manager = new Mock<IPersonManager>(MockBehavior.Strict);
        manager.Setup(m => m.GetByID(1234))
            .Returns(person);

        var controller = new PersonController(manager.Object);

        // Act
        var json = controller.GetById(1234);

        json.FirstName.Should().Be("John");
        json.Id.Should().Be(1);
        json.LastName.Should().Be("DOE");

        // Assert
        manager.VerifyAll();
    }

    [Fact]
    public void GetByName()
    {
        // Arrange
        var person = new Person(1, "John", "DOE");

        var manager = new Mock<IPersonManager>(MockBehavior.Strict);
        manager.Setup(m => m.GetByName("Someone"))
            .Returns(person);

        var controller = new PersonController(manager.Object);

        // Act
        var json = controller.GetByName("Someone");

        // Assert
        json.FirstName.Should().Be("John");
        json.Id.Should().Be(1);
        json.LastName.Should().Be("DOE");

        manager.VerifyAll();
    }
}

As you can see, in these unit tests, for each use of the ToJson() extension method, we have to test the mapping of each property. These unit tests are quite simple, but imagine a controller that makes many different calls to the ToJson() extension method, especially if the Person/PersonJson objects have many properties.

The solution: Create an external mapper

To simplify testing, we can move the conversion logic into a separate class by introducing a simple interface called IPersonJsonMapper. Here's how:

  1. Define the interface:

     public interface IPersonJsonMapper
     {
         PersonJson ToJson(Person person);
     }
    
  2. Implement the interface:

     public class PersonJsonMapper : IPersonJsonMapper
     {
         public PersonJson ToJson(Person person)
         {
             return new PersonJson(person.Id, person.FirstName, person.LastName);
         }
     }
    
  3. Use the interface in our controller:

     [ApiController]
     [Route("persons")]
     public class PersonController : ControllerBase
     {
         private readonly IPersonManager manager;
    
         private readonly IPersonJsonMapper jsonMapper;
    
         public PersonController(IPersonManager manager, IPersonJsonMapper jsonMapper)
         {
             this.manager = manager;
             this.jsonMapper = jsonMapper;
         }
    
         [HttpGet("byId/{id}")]
         public PersonJson GetById(int id)
         {
             var person = this.manager.GetByID(id);
             var personJson = this.jsonMapper.ToJson(person);
    
             return personJson;
         }
    
         [HttpGet("byName/{name}")]
         public PersonJson GetByName(string name)
         {
             var person = this.manager.GetByName(name);
             var personJson = this.jsonMapper.ToJson(person);
    
             return personJson;
         }
     }
    
  4. Register the mapper as service in the Program.cs file:

builder.Services.AddSingleton<IPersonJsonMapper, PersonJsonMapper>();

The code of the controller remains as simple as when using the ToJson() extension method, but now the unit test becomes easier:

public class PersonControllerTest
{
    [Fact]
    public void GetById()
    {
        // Arrange
        var person = new Person(default, default, default);
        var personJson = new PersonJson(default, default, default);

        var manager = new Mock<IPersonManager>(MockBehavior.Strict);
        manager.Setup(m => m.GetByID(1234))
            .Returns(person);

        var mapper = new Mock<IPersonJsonMapper>(MockBehavior.Strict);
        mapper.Setup(m => m.ToJson(person))
            .Returns(personJson);

        var controller = new PersonController(manager.Object, mapper.Object);

        // Act
        var json = controller.GetById(1234);

        // Assert
        json.Should().BeSameAs(personJson);

        manager.VerifyAll();
        mapper.VerifyAll();
    }

    [Fact]
    public void GetByName()
    {
        // Arrange
        var person = new Person(default, default, default);
        var personJson = new PersonJson(default, default, default);

        var manager = new Mock<IPersonManager>(MockBehavior.Strict);
        manager.Setup(m => m.GetByName("Someone"))
            .Returns(person);

        var mapper = new Mock<IPersonJsonMapper>(MockBehavior.Strict);
        mapper.Setup(m => m.ToJson(person))
            .Returns(personJson);

        var controller = new PersonController(manager.Object, mapper.Object);

        // Act
        var json = controller.GetByName("Someone");

        // Assert
        json.Should().BeSameAs(personJson);

        manager.VerifyAll();
        mapper.VerifyAll();
    }
}

As you can see, now we don't need to assert the content of the PersonJson object because it's not the controller's responsibility. Also, I intentionally instantiate the Person and PersonJson objects with default values, since the code being tested will not use them. Of course, we could use a library like AutoFixture to easily instantiate these objects.

var person = new Person(default, default, default);
var personJson = new PersonJson(default, default, default);

Unit test the mapper

Even though we don't need to assert the content of the PersonJson instance in the controllers, we should still assert the conversion from a Person instance to a PersonJson by adding a unit test for the PersonJsonMapper class:

public class PersonJsonMappingTest
{
    [Fact]
    public void ToJson()
    {
        // Arrange
        var person = new Person(1234, "John", "DOE");

        var mapper = new PersonJsonMapper();

        // Act
        var json = mapper.ToJson(person);

        // Assert
        json.FirstName.Should().Be("John");
        json.Id.Should().Be(1234);
        json.LastName.Should().Be("DOE");
    }
}

Change the implementation of the mapper

By delegating the conversion to a dedicated class, we can also easily change the implementation using another mapper library, such as AutoMapper.

Using this approach allows you to change the library or the mapping logic easily without altering the code in the controller.

Conclusion

Delegating data conversion to another class makes your code more testable and maintainable. This approach can be applied at various levels, whether in controllers to convert XML/JSON to entities or in the data access layer for converting Entity Framework entities to business entities. By using this approach, you also respect the "Single Responsibility" principle of the S.O.L.I.D. principles.

A complete example of the source of this article is available within one of my public GitHub projects: GillesTourreau/GillesTourreau.ModelMapper: Example project to delegate the conversion of entity model to JSON in other class (github.com).

0
Subscribe to my newsletter

Read articles from Gilles TOURREAU directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Gilles TOURREAU
Gilles TOURREAU

I'm a French seasoned architect and developer specializing in Microsoft technologies such as .NET and Azure. As a freelance professional, I've lent my expertise to diverse sectors including Energy, Banking, Insurance, Software Publishing, Transportation, and Industry. With over 25 years of dedicated experience in Microsoft technologies, I've established three software publishing companies, initially on-premises and later transitioning to SaaS models. My journey has been driven by a passion for innovation and a commitment to delivering top-notch solutions in the ever-evolving tech landscape. Humorously attributing my baldness to solving countless tech puzzles. Passionate about travel, having worked extensively abroad in Indonesia, Hong Kong, Singapore, and Australia. Philippines holds my heart, magnetized by its heavenly beaches. My English might not be top-notch, but it’s enough to bicker with my Filipina partner! 😁