Skip to content

Runtime Extensions

Philip Helger edited this page Jul 20, 2026 · 3 revisions

Runtime Extensions

Since 0.10.4

phoss-ap can be extended at runtime with your own code — custom SPI implementations (notification handlers, verifiers, document forwarders, ...) and additional JDBC drivers — without rebuilding the application jar. You compile a small, thin jar of your own classes and drop it into an extension directory that the AP adds to its classpath on startup.

Requires phoss-ap 0.10.4 or later. The mechanism relies on the Spring Boot PropertiesLauncher layout and the LOADER_PATH environment variable, both of which were introduced in 0.10.4. On earlier versions the application jar uses the plain JarLauncher and ignores LOADER_PATH; extensions must instead be compiled into a custom build.

Table of Contents

How it works

The phoss-ap-webapp Spring Boot jar is repackaged with the ZIP layout, which selects Spring Boot's PropertiesLauncher instead of the default JarLauncher. PropertiesLauncher appends every location listed in the LOADER_PATH environment variable (or the -Dloader.path system property) to the runtime classpath. Directories are scanned recursively for *.jar files.

The official Docker image sets:

ENV LOADER_PATH=/ext

and creates /ext as a plain directory. Any jar placed in /ext therefore joins the same classpath as the application. Because all phoss-ap extension points are plain java.util.ServiceLoader lookups, an extension jar that carries the right META-INF/services entries is discovered automatically — there is no further wiring, configuration flag, or restart hook.

+------------------------------+
|   phoss-ap-webapp.jar        |   BOOT-INF/lib  (application + shipped deps)
|   (PropertiesLauncher)       |
+--------------+---------------+
               |  LOADER_PATH=/ext
               v
+------------------------------+
|   /ext/my-extension.jar      |   your SPI implementations + META-INF/services
|   /ext/some-jdbc-driver.jar  |   optional extra JDBC driver
+------------------------------+

Available extension points

All of the following are java.util.ServiceLoader SPIs defined in the phoss-ap-api module (package com.helger.phoss.ap.api.spi). Implement one or more of them:

SPI interface Purpose Cardinality
IAPLifecycleEventSPI Receive success/lifecycle callbacks — document received / forwarded / sent, reporting success, MLS correlation, scheduler cycle completions. Many
IAPNotificationHandlerSPI Receive failure callbacks — verification rejections, duplicate rejection, permanent send/forward failures, unexpected exceptions. Many
IInboundDocumentVerifierSPI Optional verification of an inbound document. All registered verifiers must pass for the document to be accepted. Many (all must pass)
IOutboundDocumentVerifierSPI Optional verification of an outbound document before sending. All registered verifiers must pass. Many (all must pass)
IPeppolReceiverCheckSPI Decide whether this AP services a given receiver participant. If not, the inbound AS4 message is rejected before database storage. Many
IDocumentForwarderProviderSPI Provide a deployment-specific document forwarder (e.g. a message queue). The AP selects one provider by matching its getID() against the configured forwarding.spi.id value. One selected by ID

The built-in modules phoss-ap-otel (IAPLifecycleEventSPI + IAPNotificationHandlerSPI), phoss-ap-sentry (IAPNotificationHandlerSPI) and phoss-ap-validation (I*DocumentVerifierSPI) are themselves implementations of these SPIs and can be used as references.

Writing an extension

Create a normal Maven jar module. The single rule that matters is dependency scope: everything the running AP already ships must be provided, so it is not bundled into your jar. The result is a thin jar containing only your own classes — exactly what belongs in /ext.

<dependencies>
  <!-- Compile against the SPI interfaces, but do NOT bundle them: the AP provides them. -->
  <dependency>
    <groupId>com.helger.phoss.ap</groupId>
    <artifactId>phoss-ap-api</artifactId>
    <scope>provided</scope>
  </dependency>
  <!-- Bundle ONLY dependencies that the AP does not already ship. -->
</dependencies>

Apply provided to every shared dependency (phoss-ap-*, phase4, ph-commons, slf4j, the AWS SDK, ...) and bundle only what is genuinely unique to your extension. This is what keeps the flat classpath free of version conflicts. Because the phoss-ap parent POM is a Maven BOM, you can import it to inherit aligned versions for the provided dependencies.

A minimal logging-only implementation of IAPLifecycleEventSPI looks like this:

package com.example.myext;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.helger.annotation.style.IsSPIImplementation;
import com.helger.phoss.ap.api.spi.IAPLifecycleEventSPI;
// ... interface method parameter imports omitted for brevity

@IsSPIImplementation
public final class MyLifecycleEventSPI implements IAPLifecycleEventSPI
{
  private static final Logger LOGGER = LoggerFactory.getLogger (MyLifecycleEventSPI.class);

  public void onOutboundDocumentSent (final String sTransactionID,
                                      final String sSbdhInstanceID,
                                      final java.time.Duration aSendingDuration,
                                      final int nAttempts)
  {
    LOGGER.info ("Document " + sSbdhInstanceID + " sent after " + nAttempts + " attempt(s)");
    // ... your real integration goes here (message queue, webhook, custom store, ...)
  }

  // ... implement the remaining interface methods
}

Registering the SPI implementation

Register each implementation the same way phoss-ap's own modules do — a plain text file under src/main/resources, named after the fully-qualified interface name, listing your implementation class(es), one per line:

src/main/resources/
  META-INF/services/
    com.helger.phoss.ap.api.spi.IAPLifecycleEventSPI     -> com.example.myext.MyLifecycleEventSPI
    com.helger.phoss.ap.api.spi.IAPNotificationHandlerSPI -> com.example.myext.MyNotificationHandlerSPI

The @IsSPIImplementation annotation is a documentation/validation marker; the META-INF/services file is what ServiceLoader actually reads.

Loading it — plain JVM / shell script

Point LOADER_PATH at a directory containing your extension jar(s) and launch the application jar normally. This works because the 0.10.4+ jar uses PropertiesLauncher.

#!/bin/bash
set -euo pipefail

# 1. Build the thin extension jar
mvn -pl my-extension -am package

# 2. Stage it into a local extension directory
mkdir -p ./ext
cp my-extension/target/my-extension-*.jar ./ext/

# 3. Run the AP with the extension directory on the classpath
LOADER_PATH=./ext java -jar phoss-ap-webapp-0.10.4.jar

LOADER_PATH accepts a comma-separated list, so multiple directories/jars are possible, e.g. LOADER_PATH=./ext,./drivers.

Loading it — Docker bind mount

The official image already sets LOADER_PATH=/ext, so you only need to bind-mount a host directory that contains your jar(s) at /ext:

mkdir -p ./ext
cp my-extension/target/my-extension-*.jar ./ext/

docker run --rm -p 8080:8080 \
  -v "$(pwd)/ext:/ext" \
  -e PHOSSAP_JDBC_URL=jdbc:postgresql://host.docker.internal:5432/phoss-ap \
  -e PHOSSAP_JDBC_USER=peppol \
  -e PHOSSAP_JDBC_PASSWORD=peppol \
  phelger/phoss-ap

On Apple Silicon use the phelger/phoss-ap-arm64 image instead.

Loading it — Docker baked into an image

To ship a self-contained image, layer your extension on top of a phoss-ap image and copy the jar into /ext:

ARG PHOSS_AP_IMAGE=phelger/phoss-ap:latest
FROM ${PHOSS_AP_IMAGE}

# Keep the image self-contained even on older bases
ENV LOADER_PATH=/ext

COPY target/my-extension-*.jar /ext/my-extension.jar
mvn -pl my-extension -am package
docker build -t phoss-ap-with-my-ext .
docker run --rm -p 8080:8080 \
  -e PHOSSAP_JDBC_URL=jdbc:postgresql://host.docker.internal:5432/phoss-ap \
  -e PHOSSAP_JDBC_USER=peppol \
  -e PHOSSAP_JDBC_PASSWORD=peppol \
  phoss-ap-with-my-ext

/ext is a plain directory (not a Docker VOLUME), so the COPY persists into the image and is overridden cleanly if you later bind-mount a different /ext.

Confirming it is working

An SPI implementation is instantiated the first time the AP dispatches to it (and, if you log from the constructor, at load time). Add a log line to prove the wiring, e.g.:

[my-ext] MyLifecycleEventSPI was loaded via SPI
[my-ext] Document 1234-... sent after 1 attempt(s)

If you see your log lines as documents flow through the AP, the extension is being loaded and invoked. If you do not, check that:

  1. You are running 0.10.4 or later (earlier jars ignore LOADER_PATH).
  2. LOADER_PATH points at the directory that actually contains the jar.
  3. The META-INF/services/<interface> file exists in the jar and names the correct class.
  4. The jar is thin — it must not re-bundle phoss-ap-api or other shipped classes.

For deeper diagnosis set -Dloader.debug=true (or _LOADER_DEBUG=true as an environment variable) to have PropertiesLauncher print the classpath entries it adds.

Custom JDBC drivers

The same directory is the supported way to add a JDBC driver that is not shipped by default (the official image bundles only the PostgreSQL driver). Drop the driver jar into /ext (or your LOADER_PATH directory) alongside any driver-specific Flyway module and configure the database via the standard properties. This keeps proprietary or heavyweight drivers out of the base image while still making them available at runtime. See Configuration Properties for the database settings.

Reference module: phoss-ap-extension-demo

The repository ships a complete, runnable example in the phoss-ap-extension-demo module. It contains two logging-only SPI implementations (DemoLifecycleEventSPI and DemoNotificationHandlerSPI), their META-INF/services registrations, an example Dockerfile, and two helper scripts:

  • assemble.sh — builds the thin jar and, optionally, a Docker image with the extension baked in (it automatically selects the -arm64 image name and base on macOS).
  • run-docker-ph.sh — runs that image locally (also selecting the right image on macOS).

Copy that module as the starting point for your own extension. See also Maven Module Structure for how it fits into the build, and OpenTelemetry Integration for a production example of the lifecycle/notification SPIs in action.

Clone this wiki locally