Liferay gives you two real paths to build a custom headless API on top of it, and picking the wrong one usually doesn’t hurt until six months in – when a REST Builder project outgrows generated CRUD, or a hand-rolled JAX-RS service reinvents authentication and permission-checking that Liferay already gives you for free.
Quick guide: implementers should read the comparison table and the two code walkthroughs. Architects deciding on an approach for a new project can jump straight to “When to Use Which.”
The Two Paths
REST Builder
REST Builder is Liferay’s code-generation tool. You describe your API in two files — rest-config.yaml (Liferay-specific wiring) and rest-openapi.yaml (the actual OpenAPI schema) — then run a Gradle task. It generates the JAX-RS application, resource interfaces, DTOs, and a resource-implementation package for you to fill in, plus a matching GraphQL endpoint. Critically, it also wires into Liferay’s authentication pipeline, permission checking, and OAuth2 scopes automatically.
The catch: REST Builder assumes a Service Builder module underneath it for persistence and permissions, and it’s only available on Liferay PaaS and Self-Hosted – not SaaS, since it depends on deploying custom OSGi modules.
Hand-Rolled JAX-RS
This is just a plain JAX-RS resource class, registered as an OSGi component, the same way you’d write a REST endpoint outside Liferay. No generated scaffolding, no automatic pipeline integration, you wire authentication, permissions, and serialization yourself.
Comparison
| Factor | REST Builder | Hand-Rolled JAX-RS |
|---|---|---|
| Setup speed | Fast – schema-first, generates boilerplate | Slower – everything written by hand |
| Liferay auth/permissions | Wired in automatically | You implement it yourself |
| GraphQL support | Generated alongside REST for free | Not included – build separately |
| Flexibility | Best for CRUD over a defined schema | Full control over logic and response shape |
| Requires Service Builder | Yes, in practice | No |
| SaaS availability | Not available | Available |
| Upgrade safety | Regenerating overwrites hand edits outside marked sections | You own the whole lifecycle |
REST Builder in Practice
apiDir: "api"
apiPackagePath: "com.acme.headless.foo"
application:
baseURI: "foo"
clientDir: "client"
implDir: "impl"
The matching OpenAPI schema fragment (rest-openapi.yaml) defining one resource:
paths:
/foo/{fooId}:
get:
operationId: getFoo
parameters:
- name: fooId
in: path
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Foo"
After running the Gradle build task and deploying, the endpoint is live and discoverable through Liferay’s own Gogo Shell command:
jaxrs:check
curl -u 'test@liferay.com:learn' \
"http://localhost:8080/o/headless-foo/v1.0/foo/1" Hand-Rolled JAX-RS in Practice
The equivalent, written by hand, is a plain resource class registered as an OSGi component – no generated interfaces, no rest-config.yaml:
@Component(
property = {
"osgi.jaxrs.application.select=" +
"(osgi.jaxrs.name=Liferay.Foo)",
"osgi.jaxrs.resource=true"
},
service = FooResource.class
)
@Path("/foo")
public class FooResource {
@GET
@Path("/{fooId}")
public Response getFoo(@PathParam("fooId") long fooId) {
// your own permission check, lookup, and
// response mapping all belong here
return Response.ok(_fooService.getFoo(fooId)).build();
}
}
Notice what’s missing compared to the generated version: there’s no automatic permission check, no OAuth2 scope wiring, and no GraphQL counterpart — all of that is your responsibility to add if you need it.
When to Use Which
Use REST Builder when:
- The API is fundamentally CRUD over a Service Builder entity.
- You want Liferay’s permission model and OAuth2 scopes enforced automatically.
- You also want a GraphQL endpoint without building one separately.
- You’re on PaaS or Self-Hosted, not SaaS.
Use hand-rolled JAX-RS when:
- The endpoint doesn’t map cleanly to CRUD – orchestration across systems, non-standard aggregation, or a response shape that isn’t entity-based.
- You’re on Liferay SaaS, where REST Builder’s generated OSGi modules aren’t deployable.
- You need to bypass or heavily customize the default permission-checking flow for a specific integration.
The two aren’t mutually exclusive within one project: it’s common to use REST Builder for the standard entity endpoints and drop a hand-written JAX-RS resource alongside it for the one or two endpoints that don’t fit the generated model.
Common Mistakes
1. Editing generated code outside the marked sections
REST Builder regenerates its output on every build. Custom logic belongs in the impl module’s resource implementation, not in the generated interfaces – otherwise a rebuild silently discards it.
2. Reaching for REST Builder without a Service Builder module
REST Builder expects an entity model and persistence layer underneath it. Bolting it onto a non-entity, orchestration-style endpoint usually means fighting the generator rather than benefiting it – that’s a sign to use JAX-RS directly instead.
3. Forgetting the SaaS constraint
Planning a REST Builder-based architecture without confirming the deployment target is PaaS or Self-Hosted is one of the more expensive mistakes to discover late – verify this before committing to the approach.
Conclusion
Neither approach is universally “better” – REST Builder trades some flexibility for speed and built-in Liferay integration; hand-rolled JAX-RS trades setup speed for full control. The decision comes down to whether your endpoint is fundamentally CRUD over an entity Liferay already models well, and whether your deployment target even supports REST Builder’s custom OSGi modules in the first place.
