How to Write Clean DTO & Entity Mappers in Java (with Spring Boot)



This content originally appeared on DEV Community and was authored by Gianfranco Coppola

How to Write Clean DTO & Entity Mappers in Java (with Spring Boot)

Introduction

When building a Spring Boot application, one of the most common patterns you’ll encounter is mapping between Entities and DTOs (Data Transfer Objects).

But why should you separate them? Why not just expose your JPA entities directly through your API?

The short answer: maintainability, security, and performance.

  • Maintainability: DTOs decouple your API layer from the database schema, giving you flexibility to change one without breaking the other.

  • Security: You can control exactly what data is exposed to the client, avoiding accidental leaks of sensitive fields.

  • Performance: DTOs can be optimized to include only the required fields, reducing payload size and unnecessary joins.

In this article, we’ll explore:

  • What DTOs are and why you need them

  • How to map Entities to DTOs (and vice versa) cleanly

  • Different approaches: manual mapping, MapStruct, and ModelMapper

  • Best practices for keeping your code clean and maintainable

What is a DTO and Why Use It?

A DTO (Data Transfer Object) is a plain object used to transfer data between processes, layers, or services—typically between your API and the client.

Entity vs DTO

  • Entity: Represents a database table. Usually annotated with @Entity and tied to JPA/Hibernate.

  • DTO: Represents the data structure you expose in your API response or accept in requests. It is not managed by JPA.

Common Problems Without DTO

If you expose your Entity directly:

  • Tight coupling: Any change in your database model breaks your API.

  • Sensitive data exposure: Internal fields like IDs or relationships may leak.

  • Lazy loading issues: Serializing JPA entities directly can trigger unwanted queries or LazyInitializationException.

DTOs solve these problems by acting as a safe and controlled data layer.

Our Example Domain: Category & Product

We’ll use the following Entities:

@Entity
@Table(name = "categories")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    @Column(nullable = false, unique = true)
    String name;

    @OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
    List<Product> products;
}
@Entity
@Table(name = "products")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    @Column(nullable = false)
    String name;

    @Column(nullable = false)
    Double price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id", nullable = false)
    Category category;
}

And the following DTOs:

  • CategoryRequest (for creating categories)
  • ProductRequest (for creating products)
  • ProductResponse (for returning product data)

Example:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class ProductResponse {
    Long id;
    String category;
    String name;
    Double price;
}

Approaches to Mapping Entities and DTOs

1. Manual Mapping

The most basic approach: write the conversion logic by hand.

Example for Product:

public class ProductMapper {

    public static ProductResponse toResponse(Product product) {
        return ProductResponse.builder()
                .id(product.getId())
                .name(product.getName())
                .price(product.getPrice())
                .category(product.getCategory().getName()) // Extract category name
                .build();
    }

    public static Product toEntity(ProductRequest request, Category category) {
        return Product.builder()
                .name(request.getName())
                .price(request.getPrice())
                .category(category)
                .build();
    }
}

Pros: Full control, no dependencies

Cons: Boilerplate, hard to maintain at scale

2. Automatic Mapping with Libraries

MapStruct

MapStruct is a compile-time code generator for Java bean mappings. It uses annotations to define the mapping between source and target objects, and then generates the implementation at build time.

This means:

  • No reflection at runtime → faster than libraries like ModelMapper
  • Compile-time type checking → if a field mapping is missing or incorrect, you get a compiler error
  • No runtime surprises → everything is generated as plain Java code

How does MapStruct work?

When you define an interface and annotate it with @Mapper, MapStruct creates a class that implements it during compilation.

Example:

@Mapper(componentModel = "spring")
public interface ProductMapper {

    @Mapping(source = "category.name", target = "category")
    ProductResponse toResponse(Product product);

    @Mapping(source = "categoryId", target = "category.id")
    Product toEntity(ProductRequest request);
}

Breaking Down the Mapping

  • Product → ProductResponse

    • idid (same name → automatically mapped)
    • namename (same name → automatically mapped)
    • priceprice (same name → automatically mapped)
    • product.category.namecategory (custom mapping because the field names differ)
  • ProductRequest → Product

    • namename (automatic)
    • priceprice (automatic)
    • categoryIdcategory.id (nested property mapping)

Generated Code Example (by MapStruct)

After compilation, MapStruct generates an implementation like this:

`@Component  public  class  ProductMapperImpl  implements  ProductMapper { @Override  public ProductResponse toResponse(Product product) { if (product == null) { return  null;
        }

        ProductResponse.ProductResponseBuilder  productResponse  = ProductResponse.builder();
        productResponse.id(product.getId());
        productResponse.name(product.getName());
        productResponse.price(product.getPrice()); if (product.getCategory() != null) {
            productResponse.category(product.getCategory().getName());
        } return productResponse.build();
    } @Override  public Product toEntity(ProductRequest request) { if (request == null) { return  null;
        }

        Product.ProductBuilder  product  = Product.builder();
        product.name(request.getName());
        product.price(request.getPrice()); if (request.getCategoryId() != null) { Category  category  =  new  Category();
            category.setId(request.getCategoryId());
            product.category(category);
        } return product.build();
    }
}` 

✔ Notice how MapStruct takes care of null checks and builder patterns automatically.

Pros and cons of MapStruct

  • Pros:
    • Compile-time safety
    • High performance
    • Clear, explicit mappings
  • Cons:
    • Requires additional annotation processing setup

ModelMapper

Uses reflection at runtime:

@Configuration
public class MapperConfig {
    @Bean
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }
}

@Service
public class ProductService {

    private final ModelMapper modelMapper;

    public ProductService(ModelMapper modelMapper) {
        this.modelMapper = modelMapper;
    }

    public ProductResponse toResponse(Product product) {
        return modelMapper.map(product, ProductResponse.class);
    }
}

With nested fields (like category.name), you need extra configuration for ModelMapper.

Best Practices for Clean Mapping

  1. Keep mappers simple: No business logic inside.
  2. Place mappers logically: Usually in mapper or inside the related module/package.
  3. Choose the right approach:
    • Manual mapping: Small projects, simple models.
    • MapStruct: Most production apps (fast, safe).
    • ModelMapper: Quick prototypes or POCs.
  4. Avoid over-fetching: Map only the fields required by your API.
  5. Benchmark performance: For large-scale apps, MapStruct > Manual > ModelMapper in most cases.

Conclusion

Separating Entities and DTOs is a fundamental best practice in modern Spring Boot applications. It makes your code cleaner, more secure, and easier to maintain.
When it comes to mapping:

  • Manual mapping for small apps
  • MapStruct for most production scenarios
  • ModelMapper for quick prototypes

Want more? Check out the GitHub repository for more examples and full implementations and my Pro Starter Kit on Gumroad.

✅ What’s your favorite mapping strategy? Comment below and share your experience!


This content originally appeared on DEV Community and was authored by Gianfranco Coppola