Getting Started with the Plugin SDK
This page walks you through building a custom Transformer end to end. By the end, you will have a working component that you can upload to the platform and use in any adapter pipeline. Once you have a Transformer running, the Components section covers each of the other component types (Processor, Condition, ErrorProcessor, Connector) with the same structure.
Prerequisites
- Docker
- Access to the Conncentric container images via your container registry (see Prerequisites)
No local Java or Gradle installation is required. The SDK image provides Java 25, Gradle, and the Conncentric SDK in a single container.
The SDK image
The SDK (conncentric/sdk) is a Docker image that contains everything needed to compile, test, and package custom plugins. It includes the Conncentric SDK JAR, Java 25, and Gradle. The SDK is provided to your build automatically (compileOnly at compile time, on the test classpath at test time), so your plugin projects never declare it as a dependency and the SDK JAR is never bundled into your plugin.
The builder image version is tied to the platform version. CONNCENTRIC_VERSION must equal the SDK/runtime version your target cluster runs. Building against a different major, or a newer minor than the runtime, is rejected at upload and import on every path, because a plugin compiled against a mismatched SDK crashes at load with NoSuchMethodError. Building against an older minor is accepted; newer runtimes aim to keep older plugins working.
The starter template ships a Makefile that wraps the image. Set the coordinates once, then use the targets:
export CONNCENTRIC_REGISTRY=<your-registry>
export CONNCENTRIC_VERSION=<your-cluster's-runtime-SDK-version>
make build # compile plugins -> plugins/*/build/libs/*.jar (no tests, no bundle)
make test # build + unit tests + validate iac/definitions against the canonical model
make bundle # package a deployable bundle.zip
make shell # interactive shell in the builder image
make help # list targets
Each target wraps the equivalent docker run. The valid entrypoint commands are build, test, bundle, and shell; there is no other subcommand.
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> build
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> test
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> bundle
The --user flag matches the host UID and GID so the SDK image can write to the bind-mounted workspace (Gradle creates build/ and cache directories there). It is required on Linux CI runners and harmless to keep elsewhere.
Recommended repository structure
The fastest way to start is to extract the plugin starter template distributed alongside the platform release. The template is a working starter repo with a Transformer example, the expected directory layout, and a build configuration that the SDK image accepts out of the box.
unzip plugin-starter.zip
mv plugin-starter conncentric-plugins
cd conncentric-plugins && git init
Then build it as-is to confirm the toolchain works:
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> test
If you prefer to construct the layout yourself, this is what the template produces and what the SDK builder image expects:
conncentric-plugins/
plugins/
my-custom-processor/ # One directory per plugin project
src/main/java/...
src/test/java/...
build.gradle.kts
gradle.properties
my-custom-transformer/
src/main/java/...
build.gradle.kts
gradle.properties
iac/
definitions/ # Adapter definitions exported from the Portal (JSON).
# that the installer substitutes at apply time.
artifacts/ # Reference files (data dictionaries, schemas)
.gitignore
.gitignore and Gradle configuration
The starter template ships a working .gitignore, build.gradle.kts, and gradle.properties for the example plugin. Copy and rename plugins/example-transformer/ for each new plugin you add. The build configuration is plain Gradle: a java plugin on a Java 25 toolchain, a com.gradleup.shadow plugin for the fat JAR, PF4J for annotation processing, JUnit and Mockito for tests, and a shadowJar manifest block that carries the attributes PF4J needs to load the plugin.
dependencies {
// The Conncentric SDK is provided by the builder image (compileOnly at build
// time, on the test classpath for tests). Do not declare it; it is never
// bundled into your plugin JAR.
// PF4J drives extension discovery. The annotationProcessor writes
// META-INF/extensions.idx, the index the platform scans at load time. Without
// it your plugin loads but contributes no components.
compileOnly("org.pf4j:pf4j:3.10.0")
annotationProcessor("org.pf4j:pf4j:3.10.0")
testImplementation("org.junit.jupiter:junit-jupiter:5.11.1")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.1.0-M1")
// Mockito must be 5.18 or newer: earlier versions reject the class-file
// version javac emits under a Java 25 toolchain and fail at mock().
testImplementation("org.mockito:mockito-core:5.18.0")
}
tasks.shadowJar {
archiveClassifier.set("")
mergeServiceFiles()
manifest {
attributes(
"Plugin-Id" to (project.findProperty("pluginId") ?: "example-transformer"),
"Plugin-Version" to (project.findProperty("pluginVersion") ?: "1.0.0"),
"Plugin-Provider" to (project.findProperty("pluginProvider") ?: "Your Organization"),
// Plugin-Class points at the org.pf4j.Plugin lifecycle class;
// Plugin-Base-Package scopes PF4J's classloader to your package.
// Both are required for extension discovery to find your entry point.
"Plugin-Class" to "com.example.myplugin.ExampleLifecycle",
"Plugin-Base-Package" to "com.example.myplugin",
"System-Id" to "integration-adapter"
)
}
}
The Conncentric SDK and PF4J are provided by the builder image, so they are declared compileOnly and never shipped inside your JAR. At runtime the platform's classpath provides them.
Local development
A developer cloning the repo for the first time:
git clone <your-repo>/conncentric-plugins.git
cd conncentric-plugins
# Build all plugins
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> build
# Run tests
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> test
# Package a deployment bundle (builds, runs tests, zips definitions, plugins, artifacts)
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> bundle
The bundle command outputs bundle.zip in the workspace root, ready to upload and deploy.
Interactive use
For interactive work (debugging a Gradle task, running a single test, exploring the build), open a shell inside the SDK image instead of a one-shot build or test invocation:
# Start an interactive shell with your workspace mounted
docker run -it --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> shell
Inside the container, run any command you would normally run on the host:
./gradlew build
./gradlew test --tests MyPluginTest
gradle dependencies
Or attach to a long-running container started elsewhere:
docker exec -it <container-name> bash
Edit your source files in any editor or IDE on the host. The workspace is bind-mounted, so host edits are immediately visible inside the container; compile, test, and packaging commands always run inside the SDK image. There is nothing to install on the host: no Java, no Gradle, no SDK JAR. The platform does not prescribe an IDE; any editor that can open the workspace folder works.
CI/CD
The builder image is the same tool CI uses. See CI/CD Integration for the full pipeline. The core steps:
- name: Build and package
run: |
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
${{ vars.CONNCENTRIC_REGISTRY }}/conncentric/sdk:${{ vars.CONNCENTRIC_VERSION }} \
bundle
One command: build all plugins, run tests, package the bundle. The CONNCENTRIC_REGISTRY and CONNCENTRIC_VERSION variables are set in your CI environment configuration.
Third-party dependencies
Plugins run inside the platform's process, and for any library the platform itself ships, the platform's copy always wins at runtime. Two cases:
| Your library | How to depend on it |
|---|---|
| The platform does not ship it (a venue client, a parser) | Bundle it normally; it rides in your plugin JAR. |
| The platform ships it (Jackson, slf4j) | Relocate it in your shadowJar block, for example relocate("com.fasterxml.jackson", "com.acme.myplugin.jackson"). Relocation gives you your own copy at your own version, completely isolated from the platform's in both directions. |
Bundling a platform-shipped library without relocating it does nothing: the platform's copy shadows yours, and a call into an API it does not have fails at runtime. Declaring it compileOnly at the platform release's version and riding the platform's copy also works, but it ties your plugin to the platform's version choices; prefer relocation unless you have a reason not to.
When the platform is upgraded
When your team upgrades Conncentric:
- Update the builder image tag to match the new platform version.
- Rebuild your plugins: the builder image contains the updated SDK.
- Package and deploy the updated bundle.
Custom plugins built against a previous SDK version normally keep working. Where a release changes the contract, the migration note that ships with it says so, and the installer's API version gate refuses an outright major mismatch rather than letting it fail at runtime.
You are now ready to add your custom components.
Your first component: a Transformer
A Transformer changes the message format (payload type, encoding, structure). It is the simplest component type to start with: no side effects, no failure handling, no protocol I/O. Once you have a working Transformer, the Components section walks the same pattern through every other type.
Create src/main/java/com/example/myplugin/UpperCaseTransformer.java:
package com.example.myplugin;
import com.connamara.sdk.v1.adapter.components.Transformer;
import com.connamara.sdk.v1.common.component.ManifestResource;
import com.connamara.sdk.v1.common.message.DerivedMessage;
import com.connamara.sdk.v1.common.message.Message;
import java.util.Map;
@ManifestResource("""
{
"id": "uppercase-transformer",
"pluginId": "example-transformer",
"functionalType": "uppercase-transformer",
"displayName": "Uppercase Transformer",
"category": "TRANSFORMER",
"configuration": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Uppercase Settings",
"properties": {}
}
}
""")
public class UpperCaseTransformer implements Transformer {
public UpperCaseTransformer(Map<String, Object> config) {
// No configuration needed for this transformer.
}
@Override
public Message<?> transform(Message<?> message) {
Object payload = message.getPayload();
if (payload instanceof String text) {
return DerivedMessage.derive(message, text.toUpperCase(), Map.of());
}
return message;
}
}
Three rules every component follows, illustrated above:
@ManifestResourceis required. The Portal renders this component's configuration form from the annotation's JSON Schema. A component without@ManifestResourcedoes not appear in the Portal. ThepluginIdvalue must match thepluginIdingradle.properties. See Component Manifest Schema for the full specification.- Constructor signature. Every component receives its configuration as
Map<String, Object>populated from the Portal form. Read fields from the map; do not query external configuration sources. - Messages are immutable. Use
DerivedMessage.derive(message, payload, additionalHeaders)to produce a modified message. The derived wrapper references the source rather than copying it.
Register the component
Writing the component class is not enough on its own. The platform never scans your JAR for component classes directly. It discovers a single plugin entry point and asks that entry point for its components. Registration is the step that connects your UpperCaseTransformer to that entry point, and it is the step most often missed: a JAR that compiles and loads but skips this step contributes zero components, and any route that references it fails at runtime with No plugin found that handles component type: <type>.
Registration has three parts, all present in the starter template.
1. The plugin entry point
Create one class per plugin annotated with @org.pf4j.Extension and implementing com.connamara.sdk.v1.adapter.AdapterPlugin. This class owns a SimpleComponentRegistry mapping each component type id to its class, and a createComponent switch that instantiates the class for a given type id.
package com.example.myplugin;
import com.connamara.sdk.v1.adapter.AdapterPlugin;
import com.connamara.sdk.v1.adapter.components.AdapterComponent;
import com.connamara.sdk.v1.adapter.components.SimpleComponentRegistry;
import com.connamara.sdk.v1.common.component.ComponentMetadata;
import org.pf4j.Extension;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Extension
public class ExamplePlugin implements AdapterPlugin {
// Map every component type id this plugin contributes to its class.
// Each key MUST equal the "id" declared in that component's @ManifestResource.
// Add a row here for every new component.
private static final SimpleComponentRegistry registry = new SimpleComponentRegistry(Map.ofEntries(
Map.entry("uppercase-transformer", UpperCaseTransformer.class)
));
@Override public String getId() { return "example-transformer"; }
@Override public String getDisplayName() { return "Example Plugin"; }
@Override
public Optional<ComponentMetadata> getComponentMetadata(String componentId) {
return registry.getMetadata(componentId);
}
@Override
public AdapterComponent createComponent(String type, Map<String, Object> configuration) {
// The platform calls this method to instantiate a component. It does NOT
// construct your class itself. Add a case for every new component.
AdapterComponent component = switch (type) {
case "uppercase-transformer" -> new UpperCaseTransformer(configuration);
default -> null;
};
if (component != null) {
registry.trackInstance((String) configuration.get("instanceId"), component);
}
return component;
}
@Override
public List<AdapterComponent> getComponents() {
return List.copyOf(registry.getTrackedInstances().values());
}
}
The registry Map.entry(...) and the matching createComponent case are the load-bearing step. Adding a new component means adding one row to each. The type id string ("uppercase-transformer") must match the id in the component's @ManifestResource; that id is the key the platform dispatches on.
2. The lifecycle class
Add a second class that extends org.pf4j.Plugin. It has no behavior of its own, but PF4J requires it before it will hand the platform any extensions from your JAR.
package com.example.myplugin;
import org.pf4j.Plugin;
public class ExampleLifecycle extends Plugin {
public ExampleLifecycle() {}
}
3. The manifest attributes
The shadowJar manifest block in build.gradle.kts (shown above) points PF4J at the lifecycle class and your package: Plugin-Class names the org.pf4j.Plugin subclass, Plugin-Base-Package names the package your plugin classes live in, and System-Id is integration-adapter. Without both Plugin-Class and Plugin-Base-Package, PF4J resolves the plugin but returns an empty extension list, so the platform never registers your entry point and routes fail with No plugin found that handles component type: <type>.
The annotationProcessor("org.pf4j:pf4j:3.10.0") line writes META-INF/extensions.idx at compile time, the index that lists your @Extension class. This is why the processor is a required dependency, not an optional one.
plugin-components.json is informational onlyThe starter template carries src/main/resources/plugin-components.json. The runtime ignores it. Registration flows entirely through the @Extension AdapterPlugin above. The file is kept as harmless human-readable documentation of which components a plugin contributes; deleting it changes nothing.
Test the component
Tests follow standard JUnit 5. Mock the SDK interfaces (Message, MessageHeaders) to verify behavior in isolation.
package com.example.myplugin;
import com.connamara.sdk.v1.common.message.Message;
import com.connamara.sdk.v1.common.message.MessageHeaders;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class UpperCaseTransformerTest {
@Test
@DisplayName("GIVEN a lowercase string payload WHEN transformed THEN payload is uppercase")
void transform_uppercasesString() {
UpperCaseTransformer transformer = new UpperCaseTransformer(Map.of());
Message<String> message = mock(Message.class);
when(message.getPayload()).thenReturn("hello world");
when(message.getHeaders()).thenReturn(new MessageHeaders(Collections.emptyMap()));
Message<?> result = transformer.transform(message);
assertEquals("HELLO WORLD", result.getPayload());
}
@Test
@DisplayName("GIVEN a non-string payload WHEN transformed THEN payload is unchanged")
void transform_nonString_passesThrough() {
UpperCaseTransformer transformer = new UpperCaseTransformer(Map.of());
Message<Integer> message = mock(Message.class);
when(message.getPayload()).thenReturn(42);
when(message.getHeaders()).thenReturn(new MessageHeaders(Collections.emptyMap()));
Message<?> result = transformer.transform(message);
assertEquals(42, result.getPayload());
}
}
The same test pattern (mock Message, exercise the component's method, assert on the result) is reused on every per-type page in Components.
Build and deploy
Build inside the SDK image. Everything runs in the container; there is nothing to install on the host.
make build
# or, without the Makefile:
docker run --user "$(id -u):$(id -g)" \
-v $(pwd):/workspace \
<your-registry>/conncentric/sdk:<version> build
build produces a shadow (fat) JAR per plugin at plugins/<plugin-name>/build/libs/<plugin-name>.jar. For the starter plugin that is plugins/example-transformer/build/libs/example-transformer.jar. Deploy it using one of two methods:
- Upload via the Portal. Go to Settings > Plugins and upload the JAR. Your new components appear in the Pipeline Designer immediately.
- Package a custom bundle for automated deployment. Run
make bundle(ordocker run ... bundle) to producebundle.zipin the workspace root, host it at a URL your cluster can reach, set that URL in your Helm values'installer.customBundleUrls, and runhelm upgrade. See Custom Bundles & Extensibility for the full workflow.
Next steps
You have a Transformer compiling, tested, and ready to deploy. To build other component types:
| Component | Page |
|---|---|
| Processor (per-message business logic, enrichment, validation, drop) | Processor |
| Condition (route filter or predicate) | Condition |
| ErrorProcessor (failure handling, dead-letter, retry decisions) | ErrorProcessor |
| Connector (custom protocol I/O) | Connector |
Each page mirrors the structure above: a complete example, the rules specific to that type, a test pattern, and when-to-use guidance. Refer to Component Manifest Schema once you want to refine the Portal form (layout, conditional fields, advanced sections).