DEV Community

Kamen
Kamen

Posted on • Originally published at kamenivanov.substack.com

Mapping Strategies Without the Magic (Chapter 4)

At the end of the previous chapter, I teased that we were about to dive straight into the heavy machinery of DAO implementation - hooking up Spring Data JPA and Hibernate under the hood.

If you look at our original roadmap, concrete implementation was supposed to be right here. But after laying down our domain models, data contracts, and reading some of your feedback, I realized that we need to address an unspoken architectural trap first: data mapping and transformation.

Most teams blindly adopt automated tools - whether runtime reflection wrappers or compile-time generators like MapStruct - until an architectural mismatch or production incident breaks their service. Before we wire up our database infrastructure, let’s see why we chose to completely bypass mapping "magic" in favor of pure, explicit Java transformers.

And yes, some will say that writing explicit transformers is boilerplate - why write it manually when we can just use an annotation? The answer is simple: we’re not building a simple CRUD application that gets thrown to a support team and forgotten. We’re building an enterprise-ready microservice, built for deployment in Kubernetes, integrated with Kafka, Redis, and multi-tenant authorization layers - a product designed to be actively developed and maintained over years, not weeks.

This is where the real value shines: spending a little more time writing explicit transformers - as I call them, rather than standard Mappers. I call them transformers because they actively reshape data. A "mapping" usually implies just copying a field from ClassA to ClassB (whether with the same or a different name). We aren't doing that here because at an enterprise level, field names, types, and structural representations will diverge significantly between your database entities, domain models, and external DTOs.


The Architectural Trap

Across my 11 years in Enterprise Java, I've seen team after team reach for automated mappers to "save time." To understand why we banned them, we must separate them into two distinct categories - because they fail for entirely different technical reasons.

1. The Reflection Black Box (ModelMapper, Dozer, Spring BeanUtils)

Runtime reflection tools attempt to inspect fields dynamically during application execution.

  • The Production Tax: They rely on deep reflection APIs, bypassing Java's strong compile-time type safety.
  • The Debugging Wall: When a field type changes or a property is missing, the application crashes under load with a 50-line stack trace originating from the library's internal reflection routines—giving you zero visibility into which object or field caused the failure.
  • Performance Drag: Continuously resolving fields via reflection adds memory allocation and CPU overhead to every single request.

2. The Compile-Time Generation (MapStruct)

Unlike reflection tools, MapStruct generates standard Java code in target/generated-sources/ during compilation. It is inspectable, debuggable, and fast at runtime.

To be fair: for a simple, flat 1:1 DTO with no structural divergence, MapStruct works fine and gets out of your way. But the pain scales linearly with how much your layers diverge - and in real-world enterprise systems, domain aggregates, database entities, and REST DTOs diverge rapidly.

When structural divergence happens, MapStruct introduces distinct friction points:

  • IDE Refactoring Friction: MapStruct relies on string-based path configurations: @Mapping(source = "shippingDetails.address.street", target = "streetAddress"). IDE rename refactoring doesn't reach into these annotation strings the way it does for real Java field references. Even with dedicated MapStruct IDE plugins installed, you're relying on static inspection warnings after the fact, rather than a guaranteed, seamless edit-time rewrite across your codebase.
  • The Fallacy of 1:1 Matching: If your Domain Model (pure POJO/Record protecting business invariants), your JPA Entity (polluted by Hibernate requirements, proxies, and persistence states), and your REST DTO (flat JSON contract) look identical and map 1:1... you have a severe architectural issue. They should never match 1:1. The domain protects business rules. The entity fights the relational database. The DTO adapts to external API clients.
  • Framework Leakage into Domain Logic: The moment your domain uses composite hierarchies or custom value objects, MapStruct forces you to write custom @Named helper methods or embed raw Java strings inside annotation parameters: expression = "java(new Money(...))". Writing raw Java code inside annotation strings is a clear indicator that the abstraction has broken down.

Composite Hierarchy Reality Check: MapStruct vs. Pure Java

MapStruct tutorials always feature trivial 1:1 flat classes (UserDto to UserEntity). But real enterprise software uses rich composite value objects on the domain side while entities flatten them or restructure audit information. Let’s compare how MapStruct handles composite hierarchies versus how pure Java solves it safely.

The MapStruct Way (Annotation Spaghetti & Untyped String Expressions)

When field names mismatch, unit conversions are required, or composite objects need instantiation, MapStruct forces you into string-based paths and Java string expressions:

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

    @Mapping(source = "totalMoney.amount", target = "totalAmountCentimes", qualifiedByName = "amountToCentimes")
    @Mapping(source = "totalMoney.currency", target = "currencyCode")
    @Mapping(source = "shippingDetails.recipientName", target = "deliveryContact")
    @Mapping(source = "shippingDetails.address.street", target = "streetAddress")
    @Mapping(source = "shippingDetails.address.zipCode", target = "postalCode")
    OrderEntity toEntity(Order domain);

    @Named("amountToCentimes")
    default long amountToCentimes(BigDecimal amount) {
        if (amount == null) {
            return 0L;
        }
        return amount.multiply(BigDecimal.valueOf(100)).longValue();
    }

    @InheritInverseConfiguration
    @Mapping(
        target = "totalMoney",
        expression = "java(new Money(entity.getTotalAmountCentimes(), entity.getCurrencyCode()))"
    )
    Order toDomain(OrderEntity entity);
}
Enter fullscreen mode Exit fullscreen mode

Notice what happened: to handle a simple composite conversion, we ended up writing untyped Java inside an annotation string parameter (expression = "java(...)") and configuring string property paths that don't refactor cleanly with standard IDE tools.

The Pure Java Transformer Way (Zero Reflection, 100% Control)

In our multi-module architecture, we stripped out both runtime reflection libraries and compile-time code generators. We replaced them with simple, fully deterministic contracts written in vanilla Java. Here are the root interfaces that dictate the contracts for data transformation:

public interface Transformer<Input, Output> {  

    /**
     * Creates a new instance of an Output type, with transformed data from the input object.
     * @param input the input object  
     * @return the newly created Output object  
     */    
    Output createOutput(Input input);  

    /**
     * Copies the data from input to output.     
     * @param input  the input object  
     * @param output the output object  
     */    
    default void copyToOutput(Input input, Output output) {  
    }  
}
Enter fullscreen mode Exit fullscreen mode

And our transformer contract for reverse transformations (used when models require bi-directional mapping across boundaries):

public interface BiTransformer<Input, Output> extends Transformer<Input, Output> {  

    /**
     * Creates a new instance of an Input type, with transformed data from the output object. 
     * @param output the output object  
     * @return the newly created Input object  
     */
    Input createInput(Output output);  

    /**
     * Copies the data from output to input. 
     * @param output the output object 
     * @param input  the input object  
     */
    default void copyToInput(Output output, Input input) {  

    }  
}
Enter fullscreen mode Exit fullscreen mode

Leveraging Hierarchy: Polymorphic Auditing Without Annotation Duplication

To see how this works beyond abstract interfaces, look at our foundational audit layer. Just as we have domain-level base models, our infrastructure layer features shared concepts like creation and auditing (AbstractCreatableEntity in dao-impl and AbstractCreatable<UUID> in domain).

Instead of writing repetitive @Mapping annotations across dozens of child mappers, our transformer hierarchy follows the class hierarchy:

public abstract class AbstractCreatableTransformer<
    S extends AbstractCreatableEntity,
    D extends AbstractCreatable<UUID>
> extends AbstractBiTransformer<S, D> {

    @Override
    public void copyToInput(D output, S input) {  
        input.setId(output.getId());  
        input.setCreatedAt(output.getCreatedAt());
    }

    @Override
    public void copyToOutput(S input, D output) {  
        output.setId(input.getId());  
        output.setCreatedAt(input.getCreatedAt());
    }
}
Enter fullscreen mode Exit fullscreen mode

Every concrete implementation extends AbstractCreatableTransformer and gains out-of-the-box, strongly-typed transformation of parent audit fields without a single line of duplicated configuration or annotation processing.


The Honest Trade-Off: What We Give Up

I don't claim pure Java transformers come at zero cost. There is no free lunch in software architecture:

  • More Physical Files: You write and maintain explicit .java transformer classes for every boundary mapping rather than single-line interface annotations.
  • Initial Writing Time: Setting up a direct transformer takes a minute longer than dropping an @Mapper annotation on a brand-new entity.
  • Onboarding Learning Curve: New hires accustomed to MapStruct or ModelMapper have to learn your module hierarchy (Transformer vs. BiTransformer) before they write their first transformer.

We deliberately pay this price. We trade a few seconds of initial writing time for 100% compile-time predictability, zero annotation processor build friction, seamless IDE refactoring, and instant local debugging under pressure.


What we win for the price we pay

  • 0% Reflection Magic: The compiler knows everything. Every assignment is an explicit, strongly-typed method call (get / set / constructor / builder).
  • Guaranteed Refactoring Safety: Rename any field or record component in your domain model, and your IDE renames it across all transformer classes natively.
  • No Java Strings in Annotations: You write real Java code in Java files—not inside expression = "java(...)" annotation parameter strings.
  • JIT-Optimized Direct Assignments: The JVM easily unrolls and inlines straightforward Java method calls directly into native machine code. There is simply no faster execution path than raw Java.
  • Full Debugging Clarity: Place a breakpoint anywhere inside your transformer, hit F7, and inspect your real in-memory objects step-by-step.
  • Zero Annotation Processor Conflicts: No extra build-phase steps, no processor ordering headaches between tools, and zero build plugin friction.

What's Next?

Before we open up the dao-impl module and start building concrete JPA implementations, we have one more compile-time trap to tear down. In Chapter 5: The Lombok Illusion, we will see why we removed Lombok from our builds and how modern Java features make AST bytecode mutation obsolete.

Codebase & Architecture Blueprint

The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 4, use the following link:

Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.

◀️ Read Chapter 3: Anatomy of the Data Access Contract
▶️ Read Chapter 5: The Lombok Illusion (Coming soon)

Join the Masterclass Journey

This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, subscribe to my Substack Newsletter here!

Top comments (0)