How to Scaffold a Liferay Object Action Microservice in VS Code
A step-by-step walkthrough of building a Liferay Object Action Client Extension — the yaml, the paired OAuth application, the Express handler, the Dockerfile, and the LCP.json manifest. Manual approach first, then how CX Composer collapses the whole thing to ten seconds.
Liferay Object Actions let you hook a microservice into any lifecycle event on an Object definition — created, updated, deleted, or triggered by a workflow. This tutorial walks the full setup manually first (so you know what every file does), then shows how to collapse the whole thing to a single command with CX Composer.
##1. What an Object Action actually is
An Object in Liferay is a custom entity you define in the admin UI — think "Order", "Ticket", or "Approval Request". Every Object supports actions: reusable, event-driven side effects that fire when an entry is created, updated, deleted, or explicitly triggered by a user or a workflow. Liferay ships several built-in action types (send an email, run a Groovy script, call a webhook), but the most powerful one is the Object Action Client Extension — a Liferay Client Extension that registers an external microservice as an available action type.
When the action fires, Liferay POSTs a signed JWT to your microservice with the object entry payload. Your service does whatever it needs to do — call an external API, run some business logic, write to another system — and returns. The whole thing is asynchronous by default; Liferay does not wait on the response before continuing.
##2. Prerequisites
- Liferay DXP 2024.Q1 or newer (Client Extension format has been stable since 2023.Q4)
- A Liferay Workspace with the workspace Gradle plugin (for the
blade gw buildClientExtensionZiptask) - Blade CLI on your
PATH - Node.js 20+ (for the microservice runtime)
- An Object definition already created in Liferay (with at least one field, and its status set to "approved" so extensions can bind to it)
##3. The manual way (six files, ~15 minutes)
A working Object Action Client Extension needs six files at minimum:
client-extension.yaml— the manifest declaring the microservice and its paired OAuth appapp.ts(orapp.js) — the Express handler mounted on the resource pathutil/liferay-oauth2-resource-server.ts— JWT verification middlewarepackage.json— Node dependenciesDockerfile— how Liferay Cloud (or self-hosted Docker) builds the imageLCP.json— Liferay Cloud manifest with liveness/readiness probes and port
Start with the manifest. Create a folder for your extension and drop this in client-extension.yaml:
order-approver:
name: Order Approver
oAuth2ApplicationExternalReferenceCode: order-approver-oauth-agent
resourcePath: /object-actions/order-approver
type: objectAction
Three fields on the primary block do the work:
oAuth2ApplicationExternalReferenceCodepoints at the OAuth application Liferay uses to sign requests. It must match theexternalReferenceCodeof an OAuth entry declared elsewhere — either in this same yaml (recommended) or provisioned separately.resourcePathis the path Liferay appends to the OAuth application'shomePageURLwhen dispatching the call. Your Express handler must be mounted at exactly this path.- Where Liferay POSTs is not on this block — it's derived from the paired OAuth entry's
.serviceAddress+.serviceScheme(see below). This is what wires the manifest to a runnable service.
##4. Wire up the paired OAuth application
The Object Action needs an OAuth 2.0 client to authenticate with. The "user-agent" flow is right for object actions (as opposed to "headless-server", which is used for Batch / Site Initializer). Add a second entry to the same client-extension.yaml:
order-approver-oauth-agent:
.serviceAddress: localhost:3001
.serviceScheme: http
name: Order Approver OAuth Application User Agent
scopes:
- Liferay.Headless.Admin.Workflow.everything
type: oAuthApplicationUserAgent
Five fields, in the order every real Liferay sample uses. Two of them are worth understanding:
.serviceAddressand.serviceScheme— dot-prefixed keys are local-dev metadata read by the Liferay Workspace Gradle plugin. They tell the workspace where your microservice runs. Real samples always uselocalhost:<port>here; the workspace / Liferay Cloud remaps the address correctly at deploy time via config trees.- No
externalReferenceCodefield. When one isn't declared, Liferay defaults the ERC to the OAuth entry's yaml key — which is exactly what the primary block'soAuth2ApplicationExternalReferenceCodereferences. Adding an explicitexternalReferenceCodeis only necessary if you want to override that default.
##5. Write the Express handler with JWT verification
Now the actual service. Create package.json with the minimum dependencies Liferay's own liferay-sample-etc-node reference uses:
{
"name": "order-approver",
"version": "1.0.0",
"main": "dist/app.js",
"scripts": {
"build": "tsc",
"start": "node dist/app.js",
"dev": "tsx watch app.ts"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.0",
"jsonwebtoken": "^9.0.2",
"jwk-to-pem": "^2.0.5",
"node-fetch": "^3.3.2",
"winston": "^3.13.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.0",
"tsx": "^4.15.0",
"typescript": "^5.4.0"
}
}Then the Express app. This is the pattern Liferay recommends: JWT middleware in front of the handler, so requests without a valid signed token get 401'd before your business logic runs:
import express from 'express';
import cors from 'cors';
import { liferayJWT } from './util/liferay-oauth2-resource-server';
const app = express();
app.use(cors());
app.use(express.json());
// Health checks — must return 200 for Liferay Cloud probes to consider the
// container ready.
app.get('/ready', (_req, res) => res.status(200).send('ok'));
app.get('/live', (_req, res) => res.status(200).send('ok'));
// The Object Action itself. liferayJWT verifies the incoming token against
// Liferay's JWKS, so if execution reaches your handler, the request is
// authenticated.
app.post('/object-actions/order-approver', liferayJWT, async (req, res) => {
const { objectEntry } = req.body as { objectEntry: { properties: Record<string, unknown> } };
// Your business logic. Look at objectEntry.properties for the fields your
// Object defines. Whatever you return here does not go back to Liferay —
// actions are fire-and-forget on the Liferay side.
console.log('Order fired action:', objectEntry.properties);
res.status(200).json({ status: 'ok' });
});
const port = Number(process.env.PORT ?? 3001);
app.listen(port, () => console.log(`order-approver listening on ${port}`));The JWT middleware is the fiddly bit. It has to fetch Liferay's JWKS (JSON Web Key Set — the public keys Liferay signs tokens with), verify the token's signature, and check that the client_id in the token matches your OAuth application. Liferay's liferay-sample-etc-node has a full reference; the essential logic:
import type { RequestHandler } from 'express';
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
const JWKS_URL = `${process.env.LIFERAY_HOST}/o/oauth2/jwks`;
const EXPECTED_CLIENT_ID = 'order-approver-oauth-agent';
let cachedKeys: Map<string, string> = new Map();
async function refreshKeys(): Promise<void> {
const res = await fetch(JWKS_URL);
const { keys } = (await res.json()) as { keys: Array<jwt.JwtHeader & { kid: string }> };
cachedKeys = new Map(keys.map((k) => [k.kid, jwkToPem(k as never)]));
}
export const liferayJWT: RequestHandler = async (req, res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) return res.status(401).send('missing bearer');
const token = header.slice(7);
const decoded = jwt.decode(token, { complete: true });
if (!decoded) return res.status(401).send('malformed token');
const { kid } = decoded.header;
if (!kid || !cachedKeys.has(kid)) await refreshKeys();
const pem = cachedKeys.get(kid);
if (!pem) return res.status(401).send('unknown signing key');
try {
const payload = jwt.verify(token, pem) as { client_id?: string };
if (payload.client_id !== EXPECTED_CLIENT_ID) return res.status(403).send('wrong client');
next();
} catch {
res.status(401).send('invalid token');
}
};##6. Containerize with Dockerfile + LCP.json
Liferay Cloud (and every self-hosted setup that follows Liferay's conventions) expects a two-stage Dockerfile: TypeScript compile in a build stage, then a lean runtime image. Or use Liferay's node-runner base image which is smaller still:
FROM liferay/node-runner:latest
COPY . /app
CMD ["node", "app.js"]And the LCP.json — Liferay Cloud reads this to know which port to expose, and how to check the container is alive:
{
"id": "order-approver",
"type": "backend-service",
"port": 3001,
"healthCheck": {
"type": "http",
"path": "/ready"
},
"livenessProbe": {
"type": "http",
"path": "/live",
"initialDelaySeconds": 10
}
}##7. The CX Composer way (10 seconds)
Every file above — the yaml, the paired OAuth entry with matching naming, the Express app, the JWT verifier, the package.json with Liferay's exact dependency set, the Dockerfile, the LCP.json — is what CX Composer scaffolds from a five-prompt wizard:
- Cmd+Shift+P →
CX Composer: Scaffold New Project - Pick
Object Actionfrom the type list (28 CX types, filterable by name) - Type an ID:
order-approver - Type a resourcePath:
/object-actions/order-approver - Pick With code — TypeScript (Node 20)
Ten seconds later you have the exact structure above, verified against Liferay's liferay-sample-etc-node reference sample. The paired OAuth application is auto-declared in the same yaml with matching naming (order-approver-oauth-agent), so the ExternalReferenceCode cross-reference is guaranteed correct at scaffold time — the #1 cause of a 401 at runtime is designed out.
##8. Deploy and wire the action to your Object
Build and deploy:
# From the extension folder — CX Composer's per-extension Build command
# does this for you via right-click on the yaml.
blade gw buildClientExtensionZip
# Or manually, if you're outside a Liferay Workspace:
zip -r order-approver.zip client-extension.yaml app.ts package.json Dockerfile LCP.json util/Drop the resulting .zip into your Liferay Workspace's deploy/ folder, or upload it via System Settings → Client Extensions → Add. Once deployed, the OAuth application appears in OAuth 2 Administration and the Object Action becomes selectable in the Object's action dropdown.
To wire it up to actually fire: open the Object definition → the Actions tab → Add Action. Give it a name, pick the Trigger (typically "After entry is added" or "After entry is updated"), and under Action Type pick the entry matching the name you gave your extension (Order Approver in this example).
##9. Common failures and how to fix them
###Liferay returns 401 when the action fires
The JWT verification middleware is rejecting the token. Almost always one of three things: (a) the client_id in your OAuth entry doesn't match EXPECTED_CLIENT_ID in the middleware, (b) the JWKS URL is wrong (check LIFERAY_HOST), or (c) the OAuth application isn't provisioned yet — check OAuth 2 Administration in the admin UI.
###Container starts but the action doesn't fire
Liferay is probably not routing to your service. Check the url in the yaml — it needs to be resolvable from Liferay's network. On Liferay Cloud this is the service name (http://<service-id>:<port>/...); on self-hosted Docker Compose it's the compose service name; on Kubernetes it's the service's cluster-local DNS.
###Pod restart loop
Your health-check endpoint is returning non-200. Confirm /ready and /live both exist in your Express app and return 200 without needing authentication. If they're behind liferayJWT, the probes get 401'd and the container is killed.
###The action doesn't appear in the Object's dropdown
Check that (a) the Object's status is approved (draft Objects can't use extensions), (b) the extension deployed without errors (look in the Liferay logs for OAuth2ApplicationHomePageURLSchemeException or ObjectActionValidationException), and (c) your user has permission to edit Object Actions in that instance.
Scaffold this in 10 seconds with CX Composer.
VS Code extension for Liferay developers. Wizard-driven scaffolding for all 28 Client Extension types, IntelliSense on client-extension.yaml, five layered validators, and per-extension build via Blade.