From JSPs to React in a Single JAR: Dummy Factory 2.0 on DXP 2026

What it took to replace ~20 JSPs, unify every batch response behind one record, and prove it with a real DXP 2026 container per CI run

Yasuyuki Takeo
Yasuyuki Takeo
5 Minuutin Luku

Dummy Factory was born inside Liferay Support. Customers don't file tickets about empty sites. What lands in the queue is the site that just hit 50,000 users, the organization tree with 12 levels of nesting, the message board thread with a thousand replies. To reproduce any of that on an engineer's laptop, you need the data. Hand-crafting it was a full day's work.

The tool grew out of that gap: a Control Panel portlet that mass-produces users, sites, organizations, web content, documents, message boards, and more, so a support engineer can stand up a realistic reproduction in minutes instead of hours.

I left Liferay in December 2024, and the tool went quiet after that. What pulled me back a year later was how far AI-assisted development had come. The DXP 2026 port had been sitting on the backlog as a week of unglamorous work — chase down every deprecated API, rewrite the JSPs, wire up a real integration harness. That shape of work was suddenly tractable in a single afternoon with current-generation tooling. So I tried it. This release is what came out.

⚠️ Breaking change: 2.0 targets DXP 2026.Q1.3-LTS only. It will not activate on 7.4.x — keep using 2023.q4.9 there.


Why a ground-up rewrite?

DXP 2026 moved the Portlet API from javax.portlet to jakarta.portlet. That sentence hides a lot of work: every @Component annotation, every import, every bnd.bnd stanza, and a handful of service API signatures had to be revisited. Patching the old 7.4 bundle in place would have meant dragging along JSPs, a fractured response contract, and a test harness that never really existed. So 2.0 was a clean break.

Everything below is a pattern that came out of that work. Even if you never use Dummy Factory, they're the things I'd want to know before starting a DXP 2026 port of my own.


The jakarta.portlet migration is mostly mechanical. Three things aren't.

The bulk of the port is a find-and-replace. These three caught me out.

bnd.bnd must exclude javax.servlet. DXP 2026 no longer exports javax.servlet or javax.servlet.http from the OSGi runtime, so a module that imports them stays UNSATISFIED at activation. One line fixes it:

Import-Package: !javax.servlet,!javax.servlet.http,*

The JSP taglib URI stays on JCP. Java moves to jakarta.portlet, but the taglib URI in init.jsp must stay on http://xmlns.jcp.org/portlet_3_0 — that's what DXP 2026 advertises via Provide-Capability. Switching to jakarta.tags.portlet breaks resolution. This one trips up everyone.

GroupLocalService.addGroup grew to 18 arguments. DXP 2026 adds externalReferenceCode as the first parameter and typeSettings just before serviceContext. Pass null for both if you don't need them:

_groupLocalService.addGroup(
    null,                   // externalReferenceCode (auto-generated)
    userId, parentGroupId, className, classPK,
    liveGroupId, nameMap, descriptionMap, type,
    manualMembership, membershipRestriction, friendlyURL,
    site, inheritContent, active,
    null,                   // typeSettings (defaults)
    serviceContext);

The module also absorbs a few smaller DXP 2026 traps: CompanyService is on the JSON-WS blacklist (so /api/jsonws/company/* always returns 404), and MBThreadLocalService.getThreads uses an exact-match categoryId instead of walking the hierarchy. Full catalog in docs/details/api-liferay-dxp2026.md.


One JAR, one React app

The old UI was JSP-per-entity. The new UI is a single React bundle under src/main/resources/META-INF/resources/js/, built by a custom esbuild script and served as static assets by the OSGi HTTP Whiteboard. All of it ships in the same bundle JAR. No separate web module. No @liferay/npm-bundler on the critical path.

The tradeoff showed up in i18n. Statically served ESM bundles bypass Liferay's server-side LanguageUtil.process() replacement, so Liferay.Language.get('key') calls are not rewritten at serve time and Liferay.Language._cache starts empty. The fix lives in view.jsp:

<%
ResourceBundle resourceBundle = portletConfig.getResourceBundle(locale);
JSONObject languageKeys = JSONFactoryUtil.createJSONObject();
Enumeration<String> enumeration = resourceBundle.getKeys();
while (enumeration.hasMoreElements()) {
    String key = enumeration.nextElement();
    languageKeys.put(key, resourceBundle.getString(key));
}
%>
<script>
    Object.assign(Liferay.Language._cache, <%= languageKeys.toJSONString() %>);
</script>

Use portletConfig.getResourceBundle(locale), not LanguageUtil.get(Locale, key). The latter only sees portal-global bundles and silently misses module-specific keys.


One record replaced every per-entity response

In 1.x, every entity type defined its own response shape. UserCreator returned {users: [...]}. OrganizationCreator returned {organizations: [...]}. The frontend shipped per-entity parsers. Every new entity re-invented the wire format. 2.0 collapses all of that into one record:

public record BatchResult<T>(
    boolean success,
    int count,
    int requested,
    int skipped,
    List<T> items,
    String error
) { ... }

The canonical constructor enforces four invariants that closed a lot of latent bugs. requested has to be positive. count and skipped cannot be negative. success == false requires a non-blank error — no silent failures. And success == true requires count == requested. Partial batches are failures, not partial successes. Requesting 10 and producing 3 comes back as {success: false, count: 3, requested: 10, ...}.

The JSON key for the array is always items, regardless of entity type. One parser handles everything:

JSONObject json = ResourceCommandUtil.toJson(result, item -> item.toJSONObject());

Every *ResourceCommand delegates to PortletJsonCommandTemplate.serveJsonWithProgress(...), which centralizes request parsing, ProgressManager lifecycle, and Throwable → error-response routing. Each Creator's per-entity work runs through BatchTransaction.run(...), so the Propagation.REQUIRED + rollback-on-Exception config lives in exactly one place and a mid-loop failure commits the entities already created.

Copy the pattern: one record, one helper, one transactional wrapper. Your Creators become ~30-line lambdas.


Reject user input. Sanitize everything else.

This is a rule more than a pattern, and it killed an entire class of silent UX bugs.

User-supplied strings — baseName from the Control Panel form, say — are rejected at the Creator boundary using /^[a-z0-9._-]+$/. A user typing 山田 gets an error. They do not silently get users named 1, 2, 3.

External-generated data — Datafaker output, RNGs, third-party API responses — is sanitized through ScreenNameSanitizer. The caller can't control the content, so rejecting would just turn faker regressions into flaky tests.

The two strategies never mix. Picking one per boundary and sticking to it was the single biggest quality-of-life improvement in 2.0.


The tests run against a real DXP 2026 container

2.0 ships a top-level integration-test/ module — deliberately outside modules/ — with 20+ *FunctionalSpec classes running against the real liferay/dxp:2026.q1.3-lts Docker image. Spock 2.4 drives them. Testcontainers 2.0.4 owns the container. Playwright 1.59.0 shows up only when DOM rendering is the actual subject under test; everything else goes through JSON-WS.

That last call matters. BaseLiferaySpec.jsonwsGet(...) with Basic Auth is faster, deterministic, and independent of Control Panel rendering state. If your test is asking "did the entity actually land in the database?", drop the UI navigation and hit /api/jsonws/... directly.

One trap worth naming. ResultAlert emits the same data-testid whether the result is success or failure — the class flips between .alert-success and .alert-danger. Waiting on the testId alone passes even when the server returned an error. AND the class onto the selector:

page.locator('[data-testid="organization-result"].alert-success').waitFor(
    new Locator.WaitForOptions().setTimeout(15_000)
)

Workflow JSON fixtures live under integration-test/src/test/resources/workflow-samples/ and are consumed by both the Spock specs (classpath) and a Vitest parity test on the TS side. TS and Groovy can't drift.


Try it

Drop the prebuilt JAR into $LIFERAY_HOME/deploy/:

cp liferay-dummy-factory.jar $LIFERAY_HOME/deploy/

The portlet lives under Control Panel → Configuration → Liferay Dummy Factory.

Source, release notes, and ADRs: https://github.com/yasuflatland-lf/liferay-dummy-factory.


Found a bug? Want a new entity type?

2.0 is a big surface-area change, and there are almost certainly rough edges I haven't hit yet. If something doesn't work, or you'd like to see a new entity type, data source, or workflow sample, open an issue:

For bug reports, include your DXP version, the GoGo lb dummy.factory output, the request payload, and the full JSON response. The {success, count, requested, skipped, items, error?} shape makes triage quick. Pull requests are welcome — CLAUDE.md and .claude/rules/ document the conventions the project holds itself to.


What to take away

Five things to steal for your own DXP 2026 modules.

  1. Exclude javax.servlet from Import-Package. Every DXP 2026 module needs this.
  2. Keep the JSP taglib URI on the JCP namespace. jakarta.tags.portlet breaks resolution.
  3. Unify your batch response behind a record with constructor-enforced invariants. It kills an entire category of frontend bugs.
  4. Decide reject-vs-sanitize at each input boundary. Document it. Mixing the two creates silent UX bugs.
  5. Prefer JSON-WS over UI navigation for test post-conditions. Faster, deterministic, independent of Control Panel rendering.

Full migration rationale: docs/ADR/adr-0008-dxp-2026-migration.md. BatchResult<T> rationale: docs/ADR/adr-0009-unified-batch-result.md.

Sivun kommentit

Related Assets...

Tuloksia Ei Löytynyt

More Blog Entries...