Lab 03 — API Compatibility

Phase: 04 — ASF Governance and Release Engineering
Difficulty: ⭐⭐⭐⭐☆ | Estimated Time: 2–3 hours
Primary Artifact: Multi-module Maven project producing a japicmp compatibility report
Verify: mvn install from parent directory fails (japicmp detects binary incompatibilities)


Goal

A library maintainer has just released DataProcessor v2 with breaking changes, and you need to catch them before shipping. You will:

  1. Build and install a v1 and v2 API module to your local Maven repository
  2. Run japicmp (Java API Compatibility Checker) to compare the two
  3. Interpret the compatibility report and classify each change
  4. Fill out the compatibility matrix below

This is exactly the process Apache libraries like Commons Lang, Jackson, and Spark run before every release.


Project Structure

lab-03-api-compatibility/
  pom.xml                               ← parent: declares api-v1, api-v2, compat-check modules
  api-v1/
    pom.xml
    src/main/java/org/example/compat/
      DataProcessor.java                ← v1 stable API
      BatchConfig.java                  ← v1 mutable config
  api-v2/
    pom.xml
    src/main/java/org/example/compat/
      DataProcessor.java                ← v2 with BREAKING changes
      BatchConfig.java                  ← v2 immutable (BREAKING constructor change)
  compat-check/
    pom.xml                             ← uses japicmp-maven-plugin to compare v1 vs v2

Steps

Step 1 — Inspect the v1 API

Read api-v1/src/main/java/org/example/compat/DataProcessor.java and BatchConfig.java.

Before looking at v2, write down:

  • The method signatures of DataProcessor
  • The constructor signature of BatchConfig
  • Whether BatchConfig is mutable or immutable

This is what your library's users have compiled their code against.

Step 2 — Read the v2 Changes

Read the v2 source files. Annotate each change in the table below (before running japicmp):

Method / Constructorv1 Signaturev2 SignatureYour prediction: Breaking?
DataProcessor.process
DataProcessor.setConfig
DataProcessor.withConfig
DataProcessor.isConfigured
BatchConfig(...)
BatchConfig.setStrict

Step 3 — Run japicmp

# From the lab-03-api-compatibility/ directory
mvn install

The build will intentionally fail — japicmp is configured with breakBuildOnBinaryIncompatibleModifications=true. Read the build output carefully.

Find the japicmp HTML report:

# The report is in the compat-check module's target directory
open compat-check/target/japicmp/japicmp.html     # macOS
xdg-open compat-check/target/japicmp/japicmp.html # Linux
# Or read the XML
cat compat-check/target/japicmp/japicmp.xml

Step 4 — Fill Out the Compatibility Matrix

After running japicmp, complete this table:

Changejapicmp ClassificationBinary Incompatible?Source Incompatible?Caller Impact
process(String) removedNoSuchMethodError at runtime
process(String, boolean) added
setConfig(BatchConfig) removed
withConfig(BatchConfig) added
isConfigured() added
BatchConfig(int) removed
BatchConfig(int, boolean) added
BatchConfig.setStrict removed

Classifications you will see in the report:

  • METHOD_REMOVED — binary and source incompatible
  • METHOD_ADDED — non-breaking (cannot break existing callers)
  • METHOD_NOW_ABSTRACT — binary incompatible
  • CONSTRUCTOR_REMOVED — binary and source incompatible

Step 5 — Fix the Version Number

The breaking changes between v1 and v2 require a major version bump under semantic versioning.

  1. Open api-v2/pom.xml and change <version>1.0.0</version> to <version>2.0.0</version>
  2. Open compat-check/pom.xml and update the newVersion dependency from 1.0.0 to 2.0.0
  3. Run mvn install again — the build still fails (binary incompatibilities are there regardless of version)
  4. Add <breakBuildOnBinaryIncompatibleModifications>false</breakBuildOnBinaryIncompatibleModifications> to compat-check/pom.xml and run again

Discuss: Why does setting breakBuildOnBinaryIncompatibleModifications=false not make the problem go away? What does it just change?

Step 6 — Write a Migration Guide

In migration-guide.md (create it in this directory), write a short migration guide from DataProcessor v1 to v2. For each breaking change:

  • State what code breaks
  • Show the before/after code snippet for the caller
  • State the minimum Maven dependency change needed

Verification

# Run from lab-03-api-compatibility/
mvn install 2>&1 | grep -E "BINARY_INCOMPATIBLE|BUILD FAILURE|japicmp"

Expected to see lines like:

[WARNING] BINARY INCOMPATIBLE: ...DataProcessor.process(java.lang.String) has been removed
[WARNING] BINARY INCOMPATIBLE: ...DataProcessor.setConfig(...) has been removed
[INFO] BUILD FAILURE

Key Insight

japicmp gives you a machine-readable, auditable diff of your public API. Running it in CI means:

  1. Developers get immediate feedback when they accidentally break binary compatibility
  2. PMC members can attach the japicmp report to the [VOTE] email as evidence that compatibility was checked
  3. Users can look at the report in a release's GitHub release page to understand exactly what changed

Apache projects that do this well: Apache HttpComponents, Apache Commons Collections, Apache Kafka.


Source Files

pom.xml (parent)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!--
    Parent POM for the API compatibility lab.

    Module build order (determined by Maven dependency resolution):
      1. api-v1     — builds and installs data-processor-v1:1.0.0 to local repo
      2. api-v2     — builds and installs data-processor-v2:1.0.0 to local repo
      3. compat-check — depends on both, runs japicmp to compare them

    Run from this directory:
      mvn install

    Expected result: BUILD FAILURE — japicmp detects binary incompatibilities
    between api-v1 and api-v2 and breaks the build as configured.
  -->

    <groupId>org.example.compat</groupId>
    <artifactId>api-compat-lab</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <name>API Compatibility Lab — Parent</name>

    <modules>
        <module>api-v1</module>
        <module>api-v2</module>
        <module>compat-check</module>
    </modules>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

</project>

api-v1/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.example.compat</groupId>
        <artifactId>api-compat-lab</artifactId>
        <version>1.0.0</version>
    </parent>

    <!--
    data-processor-v1 — the stable, released API.

    This is what library users have compiled their code against.
    japicmp will use this JAR as the "old version" baseline.
  -->
    <artifactId>data-processor-v1</artifactId>
    <version>1.0.0</version>
    <name>Data Processor v1</name>

</project>

api-v1DataProcessor.java

package org.example.compat;

/**
 * Processes a stream of string records according to a {@link BatchConfig}.
 *
 * <p>
 * <strong>This is the v1 stable API.</strong> This is what user
 *  have compiled
 * their application code against. Any incompatible change to this class in v2
 * is 
 * a binary compatibility regression.
 *
 * >Example usage (caller code compiled against v1):
 * 
 * @code
 * hConfig config = new BatchConfig(100);
 * config.setStrict(true);
 * 
 * 
 * Processor processor = new DataProcessor();
 *   processor.setConfig(config);
 *   processor.process("record-001");
 *   S
 * ystem.out.println(processor.getResult());
 * }</pre>
 * 
 * 
pu c class DataProcessor {
 * 

    private BatchConfig config;
    pr
 * ivate String lastResult;
 
 * 
   ublic DataProcessor() {
   
 
    /**
   * Process a single input record.
   *
     * <p>This method signature — {@code process(String)} — is what callers have compiled
     * against. Removing it or changing its parameter list in v2 will produce
     * {@code NoSuchMethodError} at runtime for any code compiled against v1.
     *
     * @param input the record to process; must not be null
     */
    public void process(String input) {
        if (input == null) {
            throw new IllegalArgumentException("input must not be null");
        }
        // Simulate processing — in a real library this would do meaningful work.
        boolean strict = (config != null && config.isStrict());
        this.lastResult = "processed[strict=" + strict + "]: " + input;
    }

    /* 
     * Returns the result of the last {@link #process(String)} call.
     *
     * @return the last processed result, or {@code null} if {@link #process(String)}
     *         has not been called yet
     */
    public
     *  String getResult() {
        return lastResult;
    }

    /**
     * Configure this processor.
     *               
     *
     * <p>This method must be called before {@link #process(String)} if the caller
     * wants non-default batch behaviour.
     *
     * @param config the configuration to apply; may be null to reset to defaults
     */
    public void setConfig(BatchConfig config) {
        this.config = config;
    }

    /**
     * Returns the current configuration, or {@code null} if none has been set.
     */
    public BatchConfig getConfig() {
        return config;
    }
     *         
}
 
    // 
     * 
     * 
     * 
     * 
     * 

api-v1BatchConfig.java

package org.example.compat;

/**
 * Configuration for a {@link DataProcessor}.
 *
 * <p><strong>This is the v1 stable API.</strong> The class is mutable: after construction
 * the caller can call {@link #setStrict(boolean)} to toggle strict mode.
 *
 * <p>Example usage (caller code compiled against v1):
 * <pre>{@code
 *   BatchConfig config = new BatchConfig(100);   // single-arg constructor
 *   config.setStrict(true);                       // mutable setter
 * }</pre>
 */
public class BatchConfig {

    private final int batchSize;
    private boolean strict;

    /**
     * Creates a BatchConfig with the given batch size.
     *
     * <p>This is the only constructor in v1. Any caller that instantiates
     * {@code BatchConfig} via this signature ({@code new BatchConfig(int)}) will get
     * {@code NoSuchMethodError} at runtime if v2 removes this constructor.
     *
     * @param batchSize number of records to process per batch; must be positive
     */
    public BatchConfig(int batchSize) {
        if (batchSize <= 0) {
            throw new IllegalArgumentException("batchSize must be positive, was: " + batchSize);
        }
        this.batchSize = batchSize;
        this.strict = false; // default: lenient
    }

    /**
     * Returns the batch size.
     */
    public int getBatchSize() {
        return batchSize;
    }

    /**
     * Returns whether strict mode is enabled.
     */
    public boolean isStrict() {
        return strict;
    }

    /**
     * Enables or disables strict mode.
     *
     * <p>This mutable setter is removed in v2 (the class becomes immutable).
     * Any caller that calls this method will get {@code NoSuchMethodError} at runtime.
     *
     * @param strict {@code true} to throw on any parse error; {@code false} to skip invalid records
     */
    public void setStrict(boolean strict) {
        this.strict = strict;
    }
}

api-v2/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.example.compat</groupId>
        <artifactId>api-compat-lab</artifactId>
        <version>1.0.0</version>
    </parent>

    <!--
    data-processor-v2 — the new version with BREAKING changes.

    This JAR is intentionally binary-incompatible with data-processor-v1.
    japicmp will use this as the "new version" and report all regressions.

    In a real project, if this were a minor release (1.1.0 instead of 2.0.0),
    these changes would be blocked by the japicmp build failure you are about
    to trigger. That is exactly the point: catch regressions before they ship.
  -->
    <artifactId>data-processor-v2</artifactId>
    <version>1.0.0</version>
    <name>Data Processor v2</name>

</project>

api-v2DataProcessor.java

package org.example.compat;

/**
 * Processes a stream of string records according to a {@link BatchConfig}.
 *
 * <p>
 * <strong>This is the v2 API — it contains intentional BREAKING
 * changes.</strong>
 *
 * <p>
 * Breaking changes introduced in this version:
 * <ol>
 * <li>{@code process(String)} REMOVED — replaced by
 * {@code process(String, boolean)}.
 * Any code compiled against v1 calling {@code process(String)} will get
 * {@code NoSuchMethodError} at runtime.</li>
 * <li>{@code setConfig(BatchConfig)} REMOVED — replaced by fluent
 * {@code withConfig}.
 * Any code compiled against v1 calling {@code setConfig} will get
 * {@code NoSuchMethodError} at runtime.</li>
 * </ol>
 *
 * <p>
 * Non-breaking additions in this version:
 * <ul>
 * <li>{@code isConfigured()} added — new method, cannot break existing
 * callers.</li>
 * <li>{@code withConfig(BatchConfig)} added — new method, cannot break existing
 * callers.</li>
 * </ul>
 *
 * <p>
 * Correct migration path for v1 callers:
 * 
 * <pre>{@code
 * // v1 caller (breaks at runtime against v2)
 * processor.setConfig(config);
 * processor.process("record-001");
 *
 * // v2 caller (recompile required)
 * processor.withConfig(config).process("record-001", false);
 * }</pre>
 */
public class DataProcessor {

    private BatchConfig config;
    private String lastResult;

    public DataProcessor() {
    }

    // =========================================================================
    // BREAKING CHANGE 1: process(String) removed
    //
    // The v1 method was: public void process(String input)
    // That method no longer exists. Callers compiled against v1 will get:
    // java.lang.NoSuchMethodError: DataProcessor.process(Ljava/lang/String;)V
    // =========================================================================

    /**
     * Process a single input record with an explicit strict-mode flag.
     *
     * <p>
     * BREAKING: replaces {@code process(String)} from v1.
     * The added {@code strict} parameter makes this a different method signature.
     * Code compiled against v1 cannot call this method without recompilation.
     *
     * @param input  the record to process; must not be null
     * @param strict if {@code true}, throw {@link IllegalStateException} on invalid
     *               input;
     *               if {@code false}, skip silently
     */
    public void process(String input, boolean strict) {
        if (input == null) {
            if (strict) {
                throw new IllegalStateException("strict mode: null input not allowed");
            }
            return; // lenient: skip silently
        }
        this.lastResult = "processed[strict=" + strict + "]: " + input;
    }

    /**
     * Returns the result of the last {@link #process(String, boolean)} call.
     *
     * @return the last processed result, or {@code null} if process has not been
     *         called
     */
    public String getResult() {
        return lastResult;
    }

    // =========================================================================
    // BREAKING CHANGE 2: setConfig(BatchConfig) removed
    //
    // The v1 method was: public void setConfig(BatchConfig config)
    // Callers compiled against v1 calling setConfig will get:
    // java.lang.NoSuchMethodError:
    // DataProcessor.setConfig(Lorg/example/compat/BatchConfig;)V
    // =========================================================================

    /**
     * Returns a new DataProcessor with the given configuration applied.
     *
     * <p>
     * Fluent builder pattern: replaces the v1 {@code setConfig(BatchConfig)} void
     * setter.
     * This is a non-breaking addition — existing callers who never called
     * {@code setConfig} are unaffected.
     *
     * <p>
     * Note: returns {@code this} for chaining convenience (this processor is
     * mutated).
     * A fully immutable design would return a new instance.
     *
     * @param config the configuration to apply
     * @return {@code this}, for method chaining
     */
    public DataProcessor withConfig(BatchConfig config) {
        this.config = config;
        return this;
    }

    /**
     * Returns the current configuration, or {@code null} if none has been set.
     */
    public BatchConfig getConfig() {
        return config;
    }

    // =========================================================================
    // NON-BREAKING ADDITION: isConfigured()
    //
    // New method — cannot break existing callers. Callers compiled against v1
    // simply cannot call this method (compile error if they try).
    // =========================================================================

    /**
     * Returns {@code true} if this processor has been configured via
     * {@link #withConfig}.
     */
    public boolean isConfigured() {
        return config != null;
    }
}

api-v2BatchConfig.java

package org.example.compat;

/**
 * Configuration for a {@link DataProcessor}.
 *
 * <p>
 * <strong>This is the v2 API — it contains intentional BREAKING
 * changes.</strong>
 *
 * <p>
 * Breaking changes introduced in this version:
 * <ol>
 * <li>{@code BatchConfig(int)} REMOVED — the single-argument constructor is
 * gone.
 * Any code calling {@code new BatchConfig(100)} will get
 * {@code NoSuchMethodError} at runtime.</li>
 * <li>{@code setStrict(boolean)} REMOVED — the class is now immutable.
 * Any code calling {@code config.setStrict(true)} will get
 * {@code NoSuchMethodError} at runtime.</li>
 * </ol>
 *
 * <p>
 * Correct migration path for v1 callers:
 * 
 * <pre>{@code
 * // v1 caller (breaks at runtime against v2)
 * BatchConfig config = new BatchConfig(100);
 * config.setStrict(true);
 *
 * // v2 caller (recompile required)
 * BatchConfig config = new BatchConfig(100, true);
 * }</pre>
 */
public class BatchConfig {

    private final int batchSize;
    private final boolean strict;

    // =========================================================================
    // BREAKING CHANGE 1: BatchConfig(int) removed
    //
    // v1 constructor: public BatchConfig(int batchSize)
    // v2 requires both batchSize AND strict at construction time.
    // Callers compiled against v1 calling new BatchConfig(100) will get:
    // java.lang.NoSuchMethodError: BatchConfig.<init>(I)V
    // =========================================================================

    /**
     * Creates an immutable BatchConfig.
     *
     * <p>
     * BREAKING: replaces {@code BatchConfig(int)} from v1.
     * The {@code strict} parameter is now required at construction time — the class
     * is immutable and the old {@link #setStrict(boolean)} setter is gone.
     *
     * @param batchSize number of records per batch; must be positive
     * @param strict    if {@code true}, throw on any parse error;
     *                  if {@code false}, skip invalid records silently
     */
    public BatchConfig(int batchSize, boolean strict) {
        if (batchSize <= 0) {
            throw new IllegalArgumentException("batchSize must be positive, was: " + batchSize);
        }
        this.batchSize = batchSize;
        this.strict = strict;
    }

    /**
     * Returns the batch size.
     */
    public int getBatchSize() {
        return batchSize;
    }

    /**
     * Returns whether strict mode is enabled.
     */
    public boolean isStrict() {
        return strict;
    }

    // =========================================================================
    // BREAKING CHANGE 2: setStrict(boolean) removed
    //
    // v1 method: public void setStrict(boolean strict)
    // The class is now immutable — all fields are final and there are no setters.
    // Callers compiled against v1 calling setStrict will get:
    // java.lang.NoSuchMethodError: BatchConfig.setStrict(Z)V
    // =========================================================================
}

compat-check/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.example.compat</groupId>
        <artifactId>api-compat-lab</artifactId>
        <version>1.0.0</version>
    </parent>

    <!--
    compat-check — runs japicmp to compare data-processor-v1 against data-processor-v2.

    This module has no source code. Its only job is to declare the japicmp plugin
    execution that compares the two API JARs.

    How japicmp resolves artifacts:
      oldVersion: data-processor-v1:1.0.0  (must be installed in local Maven repo)
      newVersion: data-processor-v2:1.0.0  (must be installed in local Maven repo)

    Both are installed to the local repo when you run 'mvn install' from the parent,
    because Maven builds modules in dependency order and installs each one before
    moving to the next.

    breakBuildOnBinaryIncompatibleModifications=true:
      This setting causes the Maven build to FAIL if japicmp detects any binary
      incompatibility between the old and new versions. This is how CI enforces
      the compatibility contract: you cannot accidentally ship a breaking change
      without the build explicitly telling you.
  -->
    <artifactId>compat-check</artifactId>
    <version>1.0.0</version>
    <name>Compatibility Check (japicmp)</name>

    <!--
    Declare a dependency on data-processor-v2 so Maven builds the modules in order:
      api-v1 → api-v2 → compat-check
    Without this, Maven might try to build compat-check before the JARs exist.
  -->
    <dependencies>
        <dependency>
            <groupId>org.example.compat</groupId>
            <artifactId>data-processor-v2</artifactId>
            <version>1.0.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!--
        japicmp-maven-plugin: Java API Compatibility Checker.
        Version 0.20.0 is current as of 2024.

        Plugin documentation: https://siom79.github.io/japicmp/MavenPlugin.html

        Key configuration fields:
          oldVersion: the "baseline" — what users compiled their code against
          newVersion: the "candidate" — what you want to release
          breakBuildOnBinaryIncompatibleModifications: fail if binary compat broken
          onlyModified: only report things that changed (not the whole API)
          htmlOutputFile: where to write the HTML report

        The comparison runs during the 'verify' phase. When you run 'mvn install',
        the verify phase runs before install, so incompatibilities are caught before
        the artifact is installed to the local repo.
      -->
            <plugin>
                <groupId>com.github.siom79.japicmp</groupId>
                <artifactId>japicmp-maven-plugin</artifactId>
                <version>0.20.0</version>
                <executions>
                    <execution>
                        <id>check-api-compatibility</id>
                        <phase>verify</phase>
                        <goals>
                            <goal>cmp</goal>
                        </goals>
                        <configuration>
                            <oldVersion>
                                <dependency>
                                    <groupId>org.example.compat</groupId>
                                    <artifactId>data-processor-v1</artifactId>
                                    <version>1.0.0</version>
                                </dependency>
                            </oldVersion>
                            <newVersion>
                                <dependency>
                                    <groupId>org.example.compat</groupId>
                                    <artifactId>data-processor-v2</artifactId>
                                    <version>1.0.0</version>
                                </dependency>
                            </newVersion>
                            <parameter>
                                <!--
                  BREAK THE BUILD if any binary incompatibilities are found.
                  This is the key setting that makes japicmp a real CI gate
                  rather than just a reporting tool.

                  Lab step 5 asks you to change this to false and observe
                  what happens — the report is still generated, but the build
                  no longer fails. This is useful during a deliberate major
                  version bump where breaking changes are expected and documented.
                -->
                                <breakBuildOnBinaryIncompatibleModifications>true</breakBuildOnBinaryIncompatibleModifications>

                                <!--
                  Only report classes/methods that actually changed.
                  Without this, the report lists every class in the entire JAR.
                -->
                                <onlyModified>true</onlyModified>

                                <!--
                  Include package-private and protected members in the report.
                  For Apache libraries, public and protected APIs are the contract.
                  Package-private is internal.
                -->
                                <accessModifier>public</accessModifier>
                            </parameter>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>