Skip to content

santunioni/purge-mapstruct

Repository files navigation

PurgeMapstruct

An OpenRewrite recipe that inlines MapStruct mapper implementations into plain Java code, giving you full ownership of your mapping logic.

New to OpenRewrite? OpenRewrite is an automated refactoring engine for Java (and other languages). It works by parsing your source files into a lossless syntax tree, applying recipes that modify that tree, and writing the changes back — preserving formatting and comments. Before diving into this README, skim the OpenRewrite introduction and the plugin setup for Gradle or Maven. The recipe catalog is also worth bookmarking.


Philosophy

MapStruct is terrible. It's greatest feature is its greatest flaw: it auto-maps your objects by matching field names. When it can't match, you either get a silently unmapped field (null in production) or you write the mapping logic as annotation attributes.

The result is a codebase where mappings are invisible, bugs are silent, and understanding what actually happens requires reading both the interface and the generated implementation — a file that lives in build/, is never committed, and disappears on a clean build. That is not easy to maintain; it is obscurity. MapStruct optimises for writing less code, not for reading or maintaining it. Simple field-matching is manageable; but once a mapping requires logic, you are writing Java expressions inside annotation strings, referencing methods by name in a context the LSP cannot reach. You need too many go-to-definition into generated code to understand mapping logic because the code lacks cohesion.

This recipe removes MapStruct from your project by replacing every @Mapper interface with its generated implementation, plus a series of mechanical refactorings to make the generated code better readable. From then the code will be yours to read and improve.

"Software should be designed for ease of reading, not ease of writing." — John Ousterhout, A Philosophy of Software Design


How to use

Step 1: install the OpenRewrite plugin

Gradle — add to your root build.gradle:

plugins {
    id "org.openrewrite.rewrite" version "latest.release"
}

Find the latest version on the Gradle Plugin Portal.

Maven — add to your root pom.xml:

<plugin>
    <groupId>org.openrewrite.maven</groupId>
    <artifactId>rewrite-maven-plugin</artifactId>
    <version>LATEST</version>
</plugin>

Find the latest version on Maven Central.

Step 2: redirect generated sources out of build/

The OpenRewrite plugin deliberately excludes build/ (or target/) from scanning. MapStruct writes its *Impl classes there by default, so you must redirect annotation processor output into src/ first.

Gradle (build.gradle):

tasks.withType(JavaCompile).configureEach {
    options.generatedSourceOutputDirectory = file("$projectDir/src/generated/java")
}

sourceSets {
    main {
        java.srcDirs += "$projectDir/src/generated/java"
    }
}

In a multi-module project, wrap the above in subprojects { } in the root build.gradle.

Maven (pom.xml):

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <generatedSourcesDirectory>${project.basedir}/src/generated/java</generatedSourcesDirectory>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.codehaus.mojo</groupId>
            <artifactId>build-helper-maven-plugin</artifactId>
            <executions>
                <execution>
                    <id>add-generated-sources</id>
                    <phase>generate-sources</phase>
                    <goals>
                        <goal>add-source</goal>
                    </goals>
                    <configuration>
                        <sources>
                            <source>${project.basedir}/src/generated/java</source>
                        </sources>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Step 3: normalise formatting first (Spotless)

Run your formatter across the whole codebase before the recipe. This keeps the inlining diff clean: when you run the formatter again after the recipe, only the files the recipe touched will show up as changed — everything else is already formatted and stays untouched.

Gradle:

plugins {
    id "com.diffplug.spotless" version "latest.release"
}

spotless {
    java {
        googleJavaFormat() // or palantirJavaFormat(), eclipse(), etc.
        removeUnusedImports()
        trimTrailingWhitespace()
        endWithNewline()
    }
}

Run with ./gradlew spotlessApply. See Spotless Gradle docs.

Maven:

<plugin>
    <groupId>com.diffplug.spotless</groupId>
    <artifactId>spotless-maven-plugin</artifactId>
    <version>LATEST</version>
    <configuration>
        <java>
            <googleJavaFormat/>
            <removeUnusedImports/>
            <trimTrailingWhitespace/>
            <endWithNewline/>
        </java>
    </configuration>
</plugin>

Run with ./mvnw spotless:apply. See Spotless Maven docs.

Step 4: run the recipe

Use io.github.santunioni.recipes.PurgeMapstruct. It inlines every @Mapper into plain Java and applies cleanup ( unused imports, redundant parens, lambda simplification, formatting) — but only to the files it changes, keeping the diff small.

Gradle (build.gradle):

dependencies {
    rewrite "io.github.santunioni:purge-mapstruct:latest.release"
}

rewrite {
    activeRecipe("io.github.santunioni.recipes.PurgeMapstruct")
}

Maven (pom.xml, inside the rewrite-maven-plugin configuration):

<configuration>
    <activeRecipes>
        <recipe>io.github.santunioni.recipes.PurgeMapstruct</recipe>
    </activeRecipes>
</configuration>
<dependencies>
<dependency>
    <groupId>io.github.santunioni</groupId>
    <artifactId>purge-mapstruct</artifactId>
    <version>LATEST</version>
</dependency>
</dependencies>

Compile first so MapStruct generates the *Impl files, run the recipe, then verify:

# Gradle
./gradlew compileJava compileTestJava \
  && ./gradlew rewriteRun \
  && ./gradlew compileJava compileTestJava test

# Maven
./mvnw compile test-compile \
  && ./mvnw rewrite:run \
  && ./mvnw compile test-compile test

Note on Mockito @Spy: If your tests spy on mapper fields using when(myMapper.someMethod(...)).thenReturn(...), those stubs will break after inlining because the mapper is now a concrete class. The recipe automatically rewrites them to doReturn(...).when(myMapper).someMethod(...), which is the correct pattern for concrete-class spies. Pro tip for the future: don't mock your own code in tests, use it.

After the recipe runs, apply the formatter again — now only the inlined mapper files will be reformatted:

./gradlew spotlessApply   # or ./mvnw spotless:apply

Unsupported features

In the TODO file I am registering usages of MapStruct that the recipe currently doesn't support. I intend to cover them in future releases. But in the meantime, you can use the recipe if it is possible for you to remove those complex usages from your codebase manually as a pre-work.


Feedback

I iterated on this recipe until it successfully purged MapStruct from two large production codebases. But I don't know your patterns. Maybe you are doing something I didn't cover.

If the recipe fails, produces broken code, or leaves something behind — open an issue on GitHub with a minimal reproducer, and I'll look into it. You are also invited to contribute a PR, as long as we discuss the reasoning behind the change first in an issue.


Further reading

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors