Compile-time mapping does not have to stop at Java properties.
Java object mapping is usually expressed in terms of properties:
@Mapping(target = "displayName", source = "name")
UserDto toDto(User user);
That model works well when both sides are ordinary Java objects.
But structured data is often broader than a pair of Java types. A value may live several levels deep. The source may be a Map or a JSON-like object. The target may itself be nested. A value may need to be assembled from several locations.
SJF4J approaches these cases as structural mapping.
Instead of limiting mapping endpoints to Java properties, @CompiledMapper can work with locations inside a structured object graph:
@Mapping(
source = "$.customer.address.city",
target = "$.shipping.destination.city"
)
OrderView map(Order source);
The mapper still generates direct Java code at compile time.
The difference is the abstraction it compiles.
The structural model behind it
SJF4J is built around a common structural model called the Object-Based Node Tree (OBNT).
OBNT represents structured data using native Java objects. A POJO, Map, or JsonObject can represent a JSON object; a List, array, or JsonArray can represent a JSON array; ordinary Java scalar values represent JSON values.
The objects remain ordinary Java objects. SJF4J does not require them to be converted into an intermediate JSON tree before structural operations can be applied.
The same OBNT model is shared across SJF4J's navigation, binding, patching, validation, and mapping APIs.
@CompiledMapper builds compile-time transformation on top of that model. A mapping therefore operates on structure first; the concrete Java representation is simply how that structure happens to be stored.
Start with ordinary object mapping
The simplest mapper remains familiar.
public class User {
private String name;
private int age;
// getters and setters
}
public class UserDto {
private String name;
private int age;
// getters and setters
}
Declare the mapper:
@CompiledMapper
public interface UserMapper {
UserDto toDto(User user);
}
Then obtain the generated implementation:
UserMapper mapper = CompiledNodes.instanceOf(UserMapper.class);
UserDto dto = mapper.toDto(user);
Compatible members with matching names are mapped automatically.
When names differ, describe only the difference:
@CompiledMapper
public interface UserMapper {
@Mapping(target = "displayName", source = "name")
UserDto toDto(User user);
}
The annotation processor generates the implementation during compilation, so runtime mapping does not need reflective property discovery.
Map nested values directly
Consider a source with nested data:
{
"name": "Alice",
"profile": {
"address": {
"city": "Seattle",
"country": "US"
}
}
}
The target may only need city.
With @CompiledMapper, the mapping can describe the actual structural location:
@CompiledMapper
public interface UserMapper {
@Mapping(
target = "city",
source = "$.profile.address.city"
)
UserDto toDto(User source);
}
The source can also be expressed as JSON Pointer:
@CompiledMapper
public interface UserMapper {
@Mapping(
target = "country",
source = "/profile/address/country"
)
UserDto toDto(User source);
}
There is no need to flatten the input model first or introduce another intermediate DTO just to expose the nested value.
Targets can be paths too
Structural mapping works in both directions.
A target can itself be nested:
@CompiledMapper
public interface UserMapper {
@Mapping(
target = "$.profile.displayName",
source = "name"
)
UserView toView(User source);
}
Both sides can be structural locations:
@CompiledMapper
public interface OrderMapper {
@Mapping(
source = "$.customer.address.city",
target = "$.shipping.destination.city"
)
OrderView map(Order source);
}
At this point, the mapping declaration is no longer just copying one property to another. It describes a transformation between locations in a structured object graph.
Maps and JSON-like objects use the same model
A mapper does not require every source to be a dedicated Java class.
For example:
@CompiledMapper
public interface UserMapper {
UserDto toDto(Map<String, Object> source);
}
The reverse direction works as well:
@CompiledMapper
public interface UserMapper {
Map<String, Object> toMap(User source);
}
SJF4J's JSON-like objects participate in the same OBNT model.
The point is not that every combination needs a special mapper. POJOs, maps, and JSON-like objects are simply different representations of structured data handled by the same mapping model.
Compute values from multiple sources
Some target values naturally depend on more than one source location.
@CompiledMapper
public interface UserMapper {
@Mapping(
target = "fullName",
sources = {"firstName", "lastName"},
compute = "(first, last) -> first + \" \" + last"
)
UserDto toDto(User source);
}
The computation is processed during annotation processing and emitted into the generated Java implementation.
There is no runtime expression interpreter in the mapping path.
Sources can also be nested paths:
@CompiledMapper
public interface UserMapper {
@Mapping(
target = "fullName",
sources = {
"$.profile.firstName",
"$.profile.lastName"
},
compute = "this::join"
)
UserDto toDto(User source);
default String join(String first, String last) {
return first + " " + last;
}
}
This lets the mapper describe both where values come from and how they are combined, while non-trivial logic remains ordinary Java code.
Structural sources beyond object graphs
The same compile-time approach also applies to data that does not naturally look like a Java object graph.
For JDBC results, SJF4J provides @CompiledJdbcMapper:
@CompiledJdbcMapper
interface UserJdbcMapper {
@Mapping(target = "name", source = "full_name")
User user(ResultSet resultSet);
}
The annotation processor generates the ResultSet access and target assignments ahead of time.
JDBC uses a dedicated mapper because a ResultSet has its own access semantics, but the design remains consistent: describe the transformation and generate direct Java code at compile time.
Compile-time generation
Add SJF4J and its annotation processor:
implementation("org.sjf4j:sjf4j:{version}")
annotationProcessor("org.sjf4j:sjf4j-processor:{version}")
For an interface such as:
@CompiledMapper
public interface UserMapper {
@Mapping(target = "displayName", source = "name")
UserDto toDto(User source);
}
SJF4J generates ordinary Java code corresponding to the mapping.
The generated implementation can directly read known members, navigate known structural paths, perform configured conversions, and assign target values.
The mapping decisions are made during compilation rather than repeatedly resolved at runtime.
Performance
@CompiledMapper is included in the independent
Java Object Mapper Benchmark,
which compares a range of Java object mapping frameworks on conventional object-to-object workloads.
The benchmark covers the familiar Java object mapping case, while SJF4J's mapping model also extends to structural paths and other representations.
See the independent
Java Object Mapper Benchmark
for the full comparison.
Structural mapping
SJF4J mapping is not primarily about adding more source and target types to a bean mapper.
It is built on OBNT, the same structural model used across the framework. Properties, nested paths, maps, JSON-like objects, and other structured sources can participate in the same compile-time transformation model.
That is what SJF4J means by structural mapping.
Tags: java json mapping performance
- Documentation: https://sjf4j.org/docs/mapping
- GitHub: https://github.com/sjf4j-projects/sjf4j

Top comments (0)