Java

Usage

First, add the annotation and processor dependencies to your favorite build-tool. See below for Maven, Gradle or Mill.

Mark your domain-primitive with the @LazyValue annotation and the processor will figure out the rest.

Quantity Record
@LazyValue()
public record Quantity(int value) {

    public Quantity {
        if(value <= 0){
            throw new IllegalArgumentException("Quantity must be greater than 0");
        }
    }
}
ISBN Object with factory method
@LazyValue
public final class Isbn {

    private final String value;

    private Isbn(String value){
        Objects.requireNonNull(value);
        this.value = value;
    }

    public String value(){
        return value;
    }

    // will be used by the annotation processor (factory methods have higher precedence)
    public static Isbn parse(String value) throws IllegalArgumentException {
        Objects.requireNonNull(value, "ISBN cannot be null");

        String cleanValue = value.replaceAll("[-\\s]", "");

        if (cleanValue.length() == 10) {
            validateIsbn10(cleanValue);
        } else if (cleanValue.length() == 13) {
            validateIsbn13(cleanValue);
        } else {
            throw new IllegalArgumentException("Invalid ISBN length. Must be 10 or 13 digits (excluding hyphens)");
        }

        return new Isbn(value);
    }

    // ... validation, equals, hashCode, toString
Generated Mapper definition
@Mapper(
    unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface LazyvalMapper {
    default int mapQuantityToWrappedType(Quantity type) {
        return type.value();
    }

    default Quantity mapQuantity(int value) {
        return new Quantity(value);
    }

    default String mapIsbnToWrappedType(Isbn type) {
        return type == null ? null : type.value();
    }

    default Isbn mapIsbn(String value) {
        return value == null ? null : Isbn.parse(value);
    }
}

This Mapper can now be used in other mappers or included in a default config. See Mapstructs docu on shared configurations

@MapperConfig(
        uses = LazyvalMapper.class
)
public interface DefaultMapperConfig {
}
Generated JPA AttributeConverter definition
@Converter(
    autoApply = true
)
public class QuantityAttributeConverter implements AttributeConverter<Quantity, Integer> { // 1.
    public Integer convertToDatabaseColumn(Quantity type) {
        return type.value();
    }

    public Quantity convertToEntityAttribute(Integer dbValue) {
        return new Quantity(dbValue);
    }
}

@Converter(
    autoApply = true
)
public class IsbnAttributeConverter implements AttributeConverter<Isbn, String> {
    public String convertToDatabaseColumn(Isbn type) {
        return type == null ? null : type.value();
    }

    public Isbn convertToEntityAttribute(String dbValue) {
        return dbValue == null ? null : Isbn.parse(dbValue);
    }
}
  1. where needed, wrapped primitives are boxed

These converters will be automatically picked up by your persistence provider.

Lazyval will only generate Classes for which the dependencies are available on the classpath. In case neither Mapstruct, nor JPA is available, a Warning message will be logged in the compilation.

Setup

Maven

pom.xml
    <properties>
        <version.lazyval>0.1.0</version.lazyval>
        <version.mapstruct>1.6.3</version.mapstruct>
        <version.jakarta-ee>11.0.0</version.jakarta-ee>
    </properties>

    <dependencies>
        <dependency>
            <groupId>de.qualityminds.lazyval</groupId>
            <artifactId>lazyval</artifactId>
            <version>${version.lazyval}</version>
            <optional>true</optional> <!-- 1. -->
        </dependency>
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${version.mapstruct}</version>
        </dependency>
        <dependency>
            <groupId>jakarta.platform</groupId>
            <artifactId>jakarta.jakartaee-api</artifactId>
            <version>${version.jakarta-ee}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
                <configuration>
                    <release>17</release>
                    <proc>full</proc>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>de.qualityminds.lazyval</groupId>
                            <artifactId>lazyval-processor</artifactId>
                            <version>${version.lazyval}</version>
                        </path>
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>${version.mapstruct}</version>
                        </path>
                    </annotationProcessorPaths>
                    <compilerArgs>
                        <arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
                        <arg>-Alazyval.jpa.generatedPackage=test.boundary.persistence</arg>
                        <arg>-Alazyval.mapstruct.generatedPackage=test</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
        </plugins>
    </build>
  1. mark the dependency as optional to avoid runtime-dependency

Gradle

build.gradle.kts
val versionLazyval = project.findProperty("version.lazyval") as String? ?: "0.1.0"
dependencies {
    compileOnly("de.qualityminds.lazyval:lazyval:$versionLazyval")
    implementation("org.mapstruct:mapstruct:1.6.3")
    compileOnly("jakarta.platform:jakarta.jakartaee-api:11.0.0")

    // Annotation processors
    annotationProcessor("de.qualityminds.lazyval:lazyval-processor:$versionLazyval")
    annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3")
}

tasks.withType<JavaCompile>().configureEach {
    options.compilerArgs.apply{
        add("-Amapstruct.unmappedTargetPolicy=ERROR")
        add("-Alazyval.jpa.generatedPackage=test.boundary.persistence")
        add("-Alazyval.mapstruct.generatedPackage=test")
    }
}

Mill

build.mill
  override def mvnDeps: T[Seq[Dep]] = Seq(
    mvn"org.mapstruct:mapstruct:${Version.mapstruct}",
  )

  override def compileMvnDeps: T[Seq[Dep]] = Seq(
    mvn"de.qualityminds.lazyval:lazyval:${Version.lazyval}",
    mvn"jakarta.persistence:jakarta.persistence-api:${Version.jpa}"
  )

  override def annotationProcessorsMvnDeps = Seq(
    mvn"de.qualityminds.lazyval:lazyval-processor:${Version.lazyval}",
    mvn"org.mapstruct:mapstruct-processor:${Version.mapstruct}"
  )

  def javacOptions = super.javacOptions() ++ Seq(
    "-Amapstruct.unmappedTargetPolicy=ERROR",
    "-Alazyval.jpa.generatedPackage=test.boundary.persistence",
    "-Alazyval.mapstruct.generatedPackage=test",
  )