If you've worked with Liferay DXP, you already know that building custom data-driven portlets involves a lot of boilerplate — entity models, persistence layers, service interfaces, SQL scripts. Service Builder exists to eliminate exactly that. In this guide, we'll walk through everything you need to know to use Service Builder effectively in Liferay DXP 7.4, from project setup to production best practices.
What Is Service Builder?
Service Builder is Liferay's proprietary code generation framework. You describe your data model in a single XML file (service.xml), and Service Builder generates a fully functional, layered Java application stack — models, persistence, services, and SQL DDL — all wired into Liferay's OSGi and Declarative Services (DS) framework.
The layers it generates automatically include:
- Model Layer — Entity POJOs and their implementations
- Persistence Layer — Hibernate-based DAOs with auto-generated finder methods
- Service Layer — Local service interfaces, remote service interfaces, and base implementations
- Util Classes —
ServiceUtilandClpSerializerhelper classes - SQL Scripts — DDL scripts for MySQL, Oracle, PostgreSQL, and more
In Liferay 7.4, all generated code is deployed as OSGi bundles and integrates directly with Liferay's Declarative Services component framework.
When Should You Use Service Builder?
Service Builder is the right tool when your custom module needs:
- Custom database tables managed by Liferay's persistence layer
- Auto-generated finder methods for complex queries
- Entity caching via Liferay's built-in cache infrastructure
- Permission checking integration through
resourcedModel - Workflow support for custom entities
- Export/import and Staging support
The Two-Module Architecture
One of the first things to understand about Service Builder is that it always generates code across two OSGi modules:
| Module | Package Pattern | Contents |
|---|---|---|
*-api | com.example.service.api | Interfaces, Model interfaces, ServiceUtil, constants |
*-service | com.example.service.impl | Implementation classes, persistence, SQL scripts |
This separation enforces clean API boundaries and allows other modules to depend only on the *-api module without pulling in implementation details.
Setting Up Your Project
Creating the Module
Use the Blade CLI to scaffold both modules in one command:
blade create -t service-builder -p com.example.service -c Employee my-moduleThis generates my-module-api and my-module-service automatically.
Building and Deploying
# Regenerate Service Builder code
./gradlew :modules:my-module:my-module-service:buildService
# Deploy both modules
./gradlew :modules:my-module:my-module-api:deploy
./gradlew :modules:my-module:my-module-service:deploy
# Full clean rebuild and deploy
./gradlew clean buildService deployProject Structure
my-module/
my-module-api/
src/main/java/
com/example/model/ ← Model interfaces (DO NOT EDIT)
com/example/service/ ← Service interfaces (DO NOT EDIT)
my-module-service/
src/main/java/
com/example/model/impl/ ← EmployeeImpl.java (SAFE TO EDIT)
com/example/service/impl/← LocalServiceImpl, ServiceImpl (SAFE)
service.xml ← The master descriptor
build.gradle
bnd.bndKey Gradle Configuration
groovy
// my-module-service/build.gradle
dependencies {
api project(":modules:my-module:my-module-api")
compileOnly group: "com.liferay.portal", name: "release.portal.api"
}
buildService {
apiDir = "../my-module-api/src/main/java"
}The bnd.bnd File
Bundle-Name: My Module Service
Bundle-SymbolicName: com.example.service
Bundle-Version: 1.0.0
Liferay-Require-SchemaVersion: 1.0.0
Liferay-Service: trueThe Liferay-Service: true flag is critical — it tells Liferay to register generated services like EmployeeLocalService and EmployeePersistence into the OSGi service registry at deploy time. Without it, your services simply won't be available.
The service.xml Descriptor
The service.xml file is the single source of truth for Service Builder. Everything — entities, columns, finders, ordering, relationships — is defined here.
Root Element
xml
<?xml version="1.0"?>
<!DOCTYPE service-builder PUBLIC
"-//Liferay//DTD Service Builder 7.4.0//EN"
"http://www.liferay.com/dtd/liferay-service-builder_7_4_0.dtd">
<service-builder
dependency-injector="ds"
package-path="com.example.service">
<author>Your Name</author>
<namespace>EX</namespace>
</service-builder>The namespace attribute prefixes all SQL table names — so your Employee entity becomes EX_Employee in the database.
Defining an Entity
xml
<entity
name="Employee"
local-service="true"
remote-service="true"
uuid="true">| Attribute | Description |
|---|---|
local-service | Generates EmployeeLocalService interface and implementation |
remote-service | Generates permission-checked remote service layer |
uuid | Adds uuid column for Staged model / Publications support |
Column Types
<column name="employeeId" primary="true" type="long"/>
<column name="groupId" type="long"/>
<column name="companyId" type="long"/>
<column name="userId" type="long"/>
<column name="createDate" type="Date"/>
<column name="modifiedDate" type="Date"/>
<column name="firstName" type="String"/>
<column name="email" type="String"/>
<column name="department" type="String"/>
<column name="salary" type="double"/>
<column name="active" type="boolean"/>Defining Finders
Finders are automatically turned into findByXxx and countByXxx methods on the persistence interface:
xml
<finder name="GroupId" return-type="Collection">
<finder-column name="groupId"/>
</finder>
<finder name="Email" return-type="Employee" unique="true">
<finder-column name="email"/>
</finder>
<finder name="Department_Active" return-type="Collection">
<finder-column name="department"/>
<finder-column name="active" comparator="="/>
</finder>Setting unique="true" generates a single-entity finder that throws NoSuchEmployeeException if not found.
Understanding Generated Code
What's Safe to Edit vs. What Isn't
Customizing EmployeeImpl
Add transient model-level methods here — these are never overwritten:java
public class EmployeeImpl extends EmployeeBaseImpl {
public String getFullName() {
return getFirstName() + " " + getLastName();
}
public boolean isSenior() {
long diff = System.currentTimeMillis() - getHireDate().getTime();
return diff >
}
}Implementing Business Logic
CRUD in EmployeeLocalServiceImpl
@Component(
property = "model.class.name=com.example.service.model.Employee",
service = AopService.class
)
public class EmployeeLocalServiceImpl extends EmployeeLocalServiceBaseImpl {
public Employee addEmployee(
long userId, long groupId,
String firstName, String lastName,
String email, String department,
ServiceContext serviceContext) throws PortalException {
User user = userLocalService.getUser(userId);
long employeeId = counterLocalService.increment();
Employee employee = employeePersistence.create(employeeId);
// Audit fields
employee.setGroupId(groupId);
employee.setCompanyId(serviceContext.getCompanyId());
employee.setUserId(userId);
employee.setUserName(user.getFullName());
employee.setCreateDate(serviceContext.getCreateDate(new Date()));
employee.setModifiedDate(serviceContext.getModifiedDate(new Date()));
// Business fields
employee.setFirstName(firstName);
employee.setLastName(lastName);
employee.setEmail(email);
employee.setDepartment(department);
employee.setStatus(WorkflowConstants.STATUS_APPROVED);
employee.setActive(true);
employeePersistence.update(employee);
// Resource permissions
resourceLocalService.addResources(
serviceContext.getCompanyId(), groupId, userId,
Employee.class.getName(), employeeId,
false, true, true);
return employee;
}
}Using Generated Finders
public List<Employee> getEmployeesByGroupId(long groupId) {
return employeePersistence.findByGroupId(groupId);
}
public List<Employee> getActiveEmployeesByDepartment(
String department, boolean active) {
return employeePersistence.findByDepartment_Active(department, active);
}
public Employee getEmployeeByEmail(String email)
throws NoSuchEmployeeException {
return employeePersistence.findByEmail(email);
}Dynamic Queries for Complex Searches
When generated finders aren't enough, use DynamicQuery:
public List<Employee> searchEmployees(
long companyId, String keywords, int start, int end) {
DynamicQuery query = dynamicQuery();
if (Validator.isNotNull(keywords)) {
String like = "%" + keywords + "%";
Disjunction dis = RestrictionsFactoryUtil.disjunction();
dis.add(RestrictionsFactoryUtil.ilike("firstName", like));
dis.add(RestrictionsFactoryUtil.ilike("lastName", like));
dis.add(RestrictionsFactoryUtil.ilike("email", like));
query.add(dis);
}
query.add(PropertyFactoryUtil.forName("companyId").eq(companyId));
query.addOrder(OrderFactoryUtil.asc("lastName"));
return dynamicQuery(query, start, end);
}Remote Service & Permission Checks
The EmployeeServiceImpl layer wraps the local service with permission checks. This is what gets exposed via JSONWS and REST:
public class EmployeeServiceImpl extends EmployeeServiceBaseImpl {
public Employee addEmployee(
long groupId, String firstName, String lastName,
String email, String department,
ServiceContext serviceContext) throws PortalException {
EmployeePermission.check(
getPermissionChecker(), groupId,
EmployeeActionKeys.ADD_EMPLOYEE);
return employeeLocalService.addEmployee(
getUserId(), groupId, firstName, lastName,
email, department, serviceContext);
}
}Custom SQL for Complex Native Queries
For queries that go beyond what finders or DynamicQuery can handle, use Liferay's Custom SQL support.
Place your SQL in src/main/resources/META-INF/custom-sql/default.xml:
<custom-sql>
<sql id="com.example.service.persistence.EmployeeFinder.findByKeyword">
<![CDATA[
SELECT e.* FROM EX_Employee e
WHERE e.companyId = ?
AND (LOWER(e.firstName) LIKE LOWER(?)
OR LOWER(e.lastName) LIKE LOWER(?)
OR LOWER(e.email) LIKE LOWER(?))
ORDER BY e.lastName ASC
]]>
</sql>
</custom-sql>Then implement a EmployeeFinderImpl class that loads and executes this SQL using CustomSQLUtil.get().
Database Schema Upgrades
When you add or modify columns in service.xml, you need an UpgradeProcess to migrate existing data:
public class UpgradeDepartment extends UpgradeProcess {
@Override
protected void doUpgrade() throws Exception {
if (!hasColumn("EX_Employee", "departmentCode")) {
runSQL("ALTER TABLE EX_Employee ADD departmentCode VARCHAR(20)");
}
runSQL("UPDATE EX_Employee SET departmentCode = " +
"UPPER(SUBSTRING(department, 1, 3))");
}
}Register it with the upgrade framework:
@Component(service = UpgradeStepRegistrator.class)
public class ServiceUpgrade implements UpgradeStepRegistrator {
@Override
public void register(Registry registry) {
registry.register("0.0.1", "1.0.0", new UpgradeDepartment());
}
}Always update Liferay-Require-SchemaVersion in bnd.bnd to match the target version. Without this, upgrade processes will never be triggered.
Consuming Generated Services
From OSGi Components (Recommended)
@Component(service = EmployeeManager.class)
public class EmployeeManager {
@Reference
private EmployeeLocalService _employeeLocalService;
public void processEmployee(long employeeId) throws PortalException {
Employee emp = _employeeLocalService.getEmployee(employeeId);
// business logic
}
}From Portlet Classes
public class EmployeePortlet extends MVCPortlet {
@Reference
private EmployeeLocalService _employeeLocalService;
@Override
public void processAction(ActionRequest request, ActionResponse response)
throws IOException, PortletException {
try {
ServiceContext ctx = ServiceContextFactory.getInstance(
Employee.class.getName(), request);
_employeeLocalService.addEmployee(
ctx.getUserId(), ctx.getScopeGroupId(),
"John", "Doe", "john@example.com", "Engineering", ctx);
} catch (PortalException pe) {
SessionErrors.add(request, pe.getClass());
}
}
}Use
@Referencefor OSGi-safe dependency injection. Never use@Autowired— Spring annotations don't work in OSGi DS components.
Top 10 Best Practices
Common Errors and How to Fix Them
| Error | Resolution |
|---|---|
buildService fails with "Cannot resolve entity" | Check namespace conflicts; ensure all referenced entities are defined in service.xml |
NoSuchEmployeeException on startup | Schema version mismatch; run portal upgrade or check Liferay-Require-SchemaVersion |
UnsatisfiedDependencyException for @Reference | API module not exported in bnd.bnd; add Export-Package: com.example.service.api.* |
| Table already exists on deploy | Service Builder attempts DDL creation on deploy; safe to ignore if schema is already correct |
| Finder method not generated | Column in <finder> must exist in same entity; run buildService again after fix |
StackOverflowError in circular calls | Do not inject EmployeeLocalService inside EmployeeLocalServiceImpl; use persistence directly |
Quick Reference: Key Generated Methods
| Method | Description |
|---|---|
addEmployee(Employee) | Persist new entity (low-level) |
getEmployee(long id) | Fetch by PK; throws exception if not found |
fetchEmployee(long id) | Fetch by PK; returns null if not found |
updateEmployee(Employee) | Persist changes to existing entity |
deleteEmployee(long id) | Remove by primary key |
getEmployees(int start, int end) | Paginated list of all records |
getEmployeesCount() | Total count of all records |
dynamicQuery(DynamicQuery) | Execute a DynamicQuery |
createEmployee(long id) | Create unsaved instance (not yet persisted) |
Wrapping Up
Service Builder remains one of the most productive tools in the Liferay DXP 7.4 developer toolkit. Once you understand its patterns — the two-module split, what to edit and what not to, how finders and upgrades work — it dramatically reduces the time spent on persistence boilerplate and lets you focus on actual business logic.
The key things to always keep in mind:
service.xmlis your single source of truth — define everything there- Run
buildServiceafter everyservice.xmlchange - Only
*Impl.javafiles are safe to edit - Remote service always wraps local service with permission checks
- Schema changes need an
UpgradeProcessand a version bump
Happy coding with Liferay!
Comments
Post a Comment