Appearance
Build a plugin with custom React + Java code
What you're doing
Every other guide in these docs builds a plugin out of JSON: entities, pages, data services, workflows. That covers the large majority of real screens. This guide is for the remaining case — a screen that needs real component state, hooks, or business logic the block engine can't express, backed by real server-side logic the Entity / Query / Rule / Workflow / Job engines can't express either.
You'll use two escape hatches together, both real and already shipped:
- The custom-component escape hatch (frontend) — a genuinely arbitrary React component, mounted through
@erp/block-engine'sregisterExternal+@erp/block-adapter-mui'sregisterCustomRenderer, reading data through@erp/data's hooks or plainfetch. - A Java
Pluginextension (backend) — a real class implementing one ofengine-plugin-api's extension-point interfaces (RestContribution,DashboardWidgetContribution, and others — seeerp platform catalog), loaded in-process into the shared ERP via PF4J.
When to reach for this (and when not to)
Try these first, in order — see create-a-plugin.md's own "Java: only if you need it" section:
- JSON composition — entities, pages, data services/views, workflows, rules. No code at all. Covers most screens.
- A scheduled job config row — no Java, see the job guides.
- The compile-to-JSON Code Plugin path —
@erp/ui+definePlugin(), for declarative composition of existing blocks with no runtime hooks/state. - The custom-component escape hatch alone (frontend only) — real React, reading data through the generic query engine (
useERPQuery). No Java — see Add a custom block if this is all you need. - This guide — real React AND real Java, when the data or logic your component needs isn't something the generic engines can compute at all (a bespoke server-side calculation, a call to a library or resource only your Java code should touch).
Level 4 (this guide) vs Level 5 (a tenant extension service)
Both let you write real, unrestricted code. They differ in where it runs and what it's allowed to touch:
| Level 4 — a Java plugin (this guide) | Level 5 — a tenant extension service | |
|---|---|---|
| Where your code runs | In-process, loaded via PF4J into the SAME JVM as the ERP core and every other plugin | Your own separate process/container, called over plain HTTP |
scope | global — an ERP-vendor/marketplace-style plugin, installed once, available to any tenant that installs it | tenant — isolated to the one tenant that owns it |
| What it can do | Contribute a real extension point (RestContribution, DashboardWidgetContribution, ScheduledJobContribution, ...) — genuinely part of the platform's own request handling | Respond to ONE wire contract (POST /extension-api/{extensionPointCode}) the core calls with a timeout + circuit breaker |
| Trust bar | Vetted/loaded like core code — no sandbox, full JVM access, affects every tenant with it installed | The core never loads it; a slow/broken service just degrades to "not extended" |
| Use it when | You're building a plugin (JSON or code) that ANY tenant could install, same trust level as the platform's own modules | You need to run code the platform can't vet in-process — a proprietary calculation, a call to your own internal systems, dependencies that would never be approved into the shared runtime |
If you're not sure: a normal Java plugin (this guide) is the default for scope: global work. Reach for an L5 extension service only when the code genuinely must NOT run inside the shared ERP process — see that guide's own "What you're doing" for the full reasoning.
The complete example
Copy the real, runnable example at developer-docs/tutorial/code-plugin-example/ — don't hand-write one from scratch. It pairs:
- Java:
RiskScoreSummaryContribution— a realRestContribution(a live route,GET /api/v1/plugins/risk-score-widget/summary) that also implementsDashboardWidgetContribution, computing a "risk score" over the tenant's realorganization_tagrows with a plainJdbcTemplatequery — the exact same arithmetic the JSON tutorial's data lives inside, just run in Java instead of read through the generic query engine. - React:
RiskScoreSummaryPanel— realuseState/useEffect, calls that SAME Java route with a plainfetch(there's no generic@erp/datahook for an arbitrary plugin-owned route — only for entity reads/writes), renders the real returned score.
Build and verify it yourself
bash
# 1. Java compiles standalone (own pom.xml, never added to the platform's
# own Maven reactor — see the example's README for what a true
# third-party build (no platform source) looks like)
mvn -f developer-docs/tutorial/code-plugin-example/java/pom.xml clean package
# 2. React type-checks and tests clean (extends the existing
# erp-code-plugin-demo workspace package)
cd frontend/packages/erp-code-plugin-demo
pnpm typecheck && pnpm test
# 3. Package the .spk — auto-refreshes spk-assembly/lib/*.jar from the
# Java build above, validates the page JSON
node tools/spark-cli/spark.js package developer-docs/tutorial/code-plugin-example/spk-assembly
# 4. Validate the manifest against the real schema
node tools/erp-cli/erp.mjs schema validate \
developer-docs/tutorial/code-plugin-example/spk-assembly/plugin.json \
--schema plugin-manifestInstalling it on a live ERP and re-running the React test against the real route is the example's own README's last step — it needs a running ERP, which this guide doesn't assume you have yet.
The parts that differ from a pure-JSON plugin
plugin.json'smainClassis a real fully-qualified class name, notnull— seereference/plugin-manifest.md.spk-assembly/lib/carries your compiled jar (and only your own classes —engine-plugin-api/Spring dependencies areprovided, resolved from the host's own classloader at runtime, never bundled).- Your Java class implements one of
engine-plugin-api's extension-point interfaces (org.pf4j.ExtensionPointsubtypes), annotated@Extension.erp platform cataloglists every one that exists today. - Your React component is registered through
registerExternal+registerCustomRenderer, not composed from@erp/uiprimitives — see Add a custom block for that seam on its own (no Java), andconcepts/sdk-modes.mdfor the full custom-component boundary (what IS and ISN'T sandboxed). - Getting the bundle onto a live tenant is
erp plugin publish-frontend(uploadsdist/browser.js, stampsplugin.json'sfrontendBundle) — a separate step from.spkpackaging, run after install.
What this guide does NOT cover
- Writing your OWN new extension-point interface —
engine-plugin-api's existing interfaces are what you implement; adding a new one is platform work, not plugin work. - Wiring a
DashboardWidgetContributioninto Studio's actual Dashboard designer/renderer — real, shipped as an extension-point SHAPE, but the host-side rendering wire-up is a disclosed, not-yet-built platform gap (see that interface's own Javadoc). The example above still uses it to show the shape; theRestContributionroute is what's reachable end to end today. scope: tenant+ Java — that's Level 5, the tenant extension service guide, a different deployable entirely.