Appearance
Build a tenant extension service (L5)
What you're doing
Every guide before this one builds something that runs inside the shared ERP — a plugin's Java code loads into the same JVM as every other tenant's. An L5 extension is different: it is your own separate process, running in your own container, that the ERP core calls over plain HTTP whenever your tenant has one installed and enabled for a given extension point. The core never loads your code, never shares your JVM, and keeps working exactly as before if your service is down, slow, or was never installed at all.
Use this when a plugin isn't enough — you need to run code the platform can't vet and load in-process (a proprietary calculation, a call to your own internal systems, dependencies that would never be approved into the shared runtime).
| Artifact | What it is | Where it runs |
|---|---|---|
| A regular plugin (every other guide) | Java/TS code, installed into the shared ERP | In-process, shared JVM |
| An L5 extension service (this guide) | Your own Spring Boot app | Your own process/container |
The complete example
Copy the real, runnable skeleton at extensions/archetype/ — don't hand-write one. The fastest way:
bash
erp extension create-service acme-payroll
cd extensions/acme-payroll-extension-service
mvn spring-boot:runThis scaffolds a small Spring Boot app with exactly one endpoint:
java
@RestController
@RequestMapping("/extension-api")
public class ExtensionApiController {
@PostMapping("/{extensionPointCode}")
public ResponseEntity<Map<String, Object>> invoke(
@PathVariable String extensionPointCode,
@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestBody(required = false) Map<String, Object> request) {
if (!serviceAuthTokenVerifier.verify(authorization)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "invalid token"));
}
// request is a FLAT map: your payload fields + tenantId/applicationId/
// extensionId/actor/correlationId merged in — see the file's own doc comment.
Map<String, Object> response = new LinkedHashMap<>();
// ... your logic here, keyed on extensionPointCode ...
return ResponseEntity.ok(response); // fields to merge into the caller's record
}
}The wire contract, exactly
The ERP core's ExtensionRouter calls your service like this — match it exactly, there is no other shape:
- Request:
POST {your-endpoint}/extension-api/{extensionPointCode}— a FLAT JSON body (your business fields, plustenantId/applicationId/extensionId/actor/correlationIdalways present and always trustworthy — never read a tenant id from anywhere else). Headers carryAuthorization: Bearer <token>(see Security below),X-Tenant-Id,X-Correlation-Id. - Response: any 2xx with a FLAT JSON body = "extended" — return only the field(s) you want merged into the record.
{}is a valid, successful "no changes" response. - Timeout: 2 seconds. The core wraps every call in a circuit breaker — a slow or broken service degrades to "not extended," it never breaks the tenant's request.
- Today the ERP fires exactly ONE extension point:
EntityRecordBeforeCreate— before a new Entity Engine record is inserted, for ANY tenant-designed entity in ANY application.
Security
Every call from the core carries a short-lived (30s) HMAC-SHA256 signed bearer token. Set the SAME secret on both sides:
bash
# ERP core (env on the erp-suite/engine-api process)
ERP_EXTENSION_SERVICE_AUTH_SECRET=<your real shared secret>
# your extension service
ERP_EXTENSION_SERVICE_AUTH_SECRET=<the SAME shared secret>Never ship the dev-only-... default to production. This is a shared secret, not certificate-based mTLS — see the archetype's own README "Security model" section for exactly what's real vs. deliberately deferred.
Ground yourself first
bash
erp extension create-service --help
erp platform describe RemoteExtensionPointRegister and enable it
Your service does nothing until it's installed AND enabled for a tenant — until then, the core never calls it at all.
bash
erp tenant plugin install <tenantId> acme-payroll --runtime service \
--endpoint https://<your-service-host>:8480 --application HCM
erp tenant plugin enable <tenantId> acme-payroll --application HCMHow to verify it worked
bash
curl -s -X POST localhost:8480/extension-api/EntityRecordBeforeCreate \
-H 'content-type: application/json' \
-H 'authorization: Bearer <token minted with your shared secret>' \
-d '{"tenantId":42,"applicationId":"HCM","extensionId":"EntityRecordBeforeCreate","actor":"system","correlationId":"demo","employeeId":100234}'json
{}Then create a record in that tenant's app that goes through the Entity Engine's create path — your service's logs should show the call arriving.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Service never gets called | Binding not ENABLED, or you're on the wrong applicationId | erp tenant plugin list <tenantId> to check status |
401 on every call | Shared secret mismatch | Set the exact same ERP_EXTENSION_SERVICE_AUTH_SECRET on both sides |
| Core logs "security violation" | Your response's tenantId field doesn't match the request | Don't echo tenantId back at all unless it's unchanged |
| Change never takes effect for up to ~45s | Binding resolution is cached | Expected — bounded staleness, not a bug |
| Deployed extension can't reach anything else in the cluster | NetworkPolicy default-denies egress | Correct by design — add one narrow rule if you truly need an external call |
What to read next
extensions/archetype/README.md— full security model, Docker/Kubernetes deployment- Publish and upgrade a plugin — for the regular (in-process) plugin path this guide is the alternative to