First Module¶
This tutorial walks you through creating a complete backend module called HRM (Human Resource Management) with a single entity. By the end, you will have a working REST API with full CRUD operations.
Prerequisites
- Platform running locally — see Quick Start
- Familiarity with Spring Boot and JPA basics
What You Will Build¶
A new ICoSys-HRM module with:
- A
HrmEmployeeentity (name, email, department, hire date) - Full CRUD REST API (
GET,POST,PUT,DELETE, list with pagination) - Specification-based filtering and search
- DTO converters (Edit, List, Filter)
- Proper tenant isolation via
CrProcess
Final endpoint: http://localhost:8040/ichrm/services/api/employee
Module Structure¶
Every ICOSYS backend module follows this sub-project layout:
ICoSys-HRM/ # Parent POM (aggregator)
├── ICoSys-HRM-Types-1.0/ # Enums (no Spring dependency)
├── ICoSys-HRM-Entities-1.0/ # JPA Entities
│ └── docs/database/ddl_scripts.md
├── ICoSys-HRM-Entities-Dto-1.0/ # DTOs
└── ICoSys-HRM-Services-1.0/ # Spring Boot service
└── src/main/java/com/icom/icosys/hrm/service/
└── zapi/employee/
├── HrmEmployeeRepository.java
├── controller/HrmEmployeeController.java
├── service/HrmEmployeeService.java
├── converter/HrmEmployeeEditDtoConverter.java
├── converter/HrmEmployeeListDtoConverter.java
└── specification/HrmEmployeeSpecification.java
Step 1 — Entity¶
All entities extend AbstractICEntity6, which provides audit fields, CrProcess
(tenant), optimistic locking, and UUID cross-reference.
@Entity
@Table(name = "hrm_employee", schema = "ichrm", catalog = "ichrm")
@TableGenerator(
name = "HrmEmployee_ID_GEN",
schema = "ichrm", catalog = "ichrm",
table = "s_ichrm_id",
pkColumnName = "table_name",
pkColumnValue = "hrm_employee",
valueColumnName = "unique_id",
initialValue = 0, allocationSize = 1
)
@DynamicInsert @DynamicUpdate
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class HrmEmployee extends AbstractICEntity6 implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.TABLE, generator = "HrmEmployee_ID_GEN")
@Column(name = "id", unique = true, nullable = false)
@EqualsAndHashCode.Include
private Long id;
@Column(name = "first_name", nullable = false, length = 100)
private String firstName;
@Column(name = "last_name", nullable = false, length = 100)
private String lastName;
@Column(name = "email", length = 255)
private String email;
@Column(name = "department", length = 100)
private String department;
@Column(name = "hire_date")
private LocalDate hireDate;
}
Key Rules
- Use
@Builder— not@SuperBuilder. Set parent fields via setters. - Column name
entity_idis reserved bygetEntityId(). UserefEntityIdif needed. - Every entity must implement
Serializablewith an explicitserialVersionUID.
Step 2 — DTOs¶
Edit DTO (Create / Update)¶
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class HrmEmployeeEditDto extends ICEntity6Dto {
private String firstName;
private String lastName;
private String email;
private String department;
private LocalDate hireDate;
}
List DTO (Table Display)¶
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class HrmEmployeeListDto extends ICEntity6Dto {
private String firstName;
private String lastName;
private String email;
private String department;
private LocalDate hireDate;
}
Filter DTO (Search Criteria)¶
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class HrmEmployeeFilterDto extends ICEntity6FilterDto {
private String department;
}
DTO Inheritance
ICEntity6Dto provides id, crProcessId, and version.
ICEntity6FilterDto provides crProcessId.
Step 3 — Repository¶
@Repository
public interface HrmEmployeeRepository extends ICrudRepository<HrmEmployee, Long> {
boolean existsByCrProcess_IdAndEmailAndIdNot(Long crProcessId, String email, Long id);
}
The ICrudRepository interface extends Spring Data's JpaRepository and
JpaSpecificationExecutor, giving you pagination and Specification support
out of the box.
Step 4 — Converters¶
Edit Converter¶
Every converter implements three methods: from (entity to DTO), to (DTO to
new entity), and to (DTO to existing entity for updates).
@Component
public class HrmEmployeeEditDtoConverter
implements IEditDtoConverter<HrmEmployee, HrmEmployeeEditDto> {
/** Entity → DTO */
@Override
public HrmEmployeeEditDto from(HrmEmployee entity) {
if (entity == null) return null;
HrmEmployeeEditDto dto = HrmEmployeeEditDto.builder()
.firstName(entity.getFirstName())
.lastName(entity.getLastName())
.email(entity.getEmail())
.department(entity.getDepartment())
.hireDate(entity.getHireDate())
.build();
dto.mapSuperFields(entity); // Maps id, crProcessId, version
return dto;
}
/** DTO → New Entity (create) */
@Override
public HrmEmployee to(HrmEmployeeEditDto dto) {
if (dto == null) return null;
return HrmEmployee.builder()
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
.email(dto.getEmail())
.department(dto.getDepartment())
.hireDate(dto.getHireDate())
.build();
}
/** DTO → Existing Entity (update) */
@Override
public HrmEmployee to(HrmEmployeeEditDto dto, HrmEmployee entity) {
if (dto == null || entity == null) return entity;
if (dto.getVersion() != null) {
entity.setVersion(dto.getVersion());
}
entity.setFirstName(dto.getFirstName());
entity.setLastName(dto.getLastName());
entity.setEmail(dto.getEmail());
entity.setDepartment(dto.getDepartment());
entity.setHireDate(dto.getHireDate());
return entity;
}
}
List Converter¶
@Component
public class HrmEmployeeListDtoConverter
implements IListDtoConverter<HrmEmployee, HrmEmployeeListDto> {
@Override
public HrmEmployeeListDto from(HrmEmployee entity) {
if (entity == null) return null;
HrmEmployeeListDto dto = HrmEmployeeListDto.builder()
.firstName(entity.getFirstName())
.lastName(entity.getLastName())
.email(entity.getEmail())
.department(entity.getDepartment())
.hireDate(entity.getHireDate())
.build();
dto.mapSuperFields(entity);
return dto;
}
}
mapSuperFields
Always call dto.mapSuperFields(entity) in the from() method. This maps
id, crProcessId, and version from the entity's inherited fields.
Step 5 — Specification¶
Specifications build dynamic JPA WHERE clauses from filter DTOs.
@Component
public class HrmEmployeeSpecification {
private static final String FIELD_ID = "id";
private static final String FIELD_FIRST_NAME = "firstName";
private static final String FIELD_LAST_NAME = "lastName";
private static final String FIELD_EMAIL = "email";
private static final String FIELD_DEPARTMENT = "department";
private static final String REL_CR_PROCESS = "crProcess";
/**
* Builds a JPA Specification from the given filter and search term.
*/
public Specification<HrmEmployee> build(HrmEmployeeFilterDto filter,
String searchTerm) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// Tenant isolation — ALWAYS first
if (filter != null && filter.getCrProcessId() != null) {
predicates.add(cb.equal(
root.get(REL_CR_PROCESS).get(FIELD_ID),
filter.getCrProcessId()
));
}
// Filter predicates
if (filter != null && filter.getDepartment() != null) {
predicates.add(cb.equal(
root.get(FIELD_DEPARTMENT),
filter.getDepartment()
));
}
// Free-text search
if (StringUtils.hasText(searchTerm)) {
String term = "%" + searchTerm.toLowerCase() + "%";
predicates.add(cb.or(
cb.like(cb.lower(root.get(FIELD_FIRST_NAME)), term),
cb.like(cb.lower(root.get(FIELD_LAST_NAME)), term),
cb.like(cb.lower(root.get(FIELD_EMAIL)), term)
));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}
Tenant Isolation
The crProcess.id filter must be the first predicate. Without it, queries
return data across all tenants.
Step 6 — Service¶
The service extends AbstractCrudService which provides findById, create,
update, delete, findPaged, and getStats out of the box.
@Service
@Transactional
@Slf4j
public class HrmEmployeeService extends AbstractCrudService<
HrmEmployee, Long, HrmEmployeeEditDto,
HrmEmployeeListDto, HrmEmployeeFilterDto> {
private final HrmEmployeeRepository repository;
private final HrmEmployeeEditDtoConverter editConverter;
private final HrmEmployeeListDtoConverter listConverter;
private final HrmEmployeeSpecification specification;
public HrmEmployeeService(
PagingSupport pagingSupport,
HrmEmployeeRepository repository,
HrmEmployeeEditDtoConverter editConverter,
HrmEmployeeListDtoConverter listConverter,
HrmEmployeeSpecification specification) {
super(pagingSupport);
this.repository = repository;
this.editConverter = editConverter;
this.listConverter = listConverter;
this.specification = specification;
}
@Override
protected ICrudRepository<HrmEmployee, Long> getRepository() {
return repository;
}
@Override
protected IEditDtoConverter<HrmEmployee, HrmEmployeeEditDto> getEditConverter() {
return editConverter;
}
@Override
protected IListDtoConverter<HrmEmployee, HrmEmployeeListDto> getListConverter() {
return listConverter;
}
@Override
protected String getEntityName() { return "HrmEmployee"; }
@Override
protected Long extractId(HrmEmployee entity) { return entity.getId(); }
@Override
protected Specification<HrmEmployee> buildSpecification(
HrmEmployeeFilterDto filter, String searchTerm) {
return specification.build(filter, searchTerm);
}
/** Set tenant on create. */
@Override
protected void beforeCreate(HrmEmployee entity, HrmEmployeeEditDto dto) {
if (dto.getCrProcessId() != null) {
CrProcess crProcess = entityManager.getReference(
CrProcess.class, dto.getCrProcessId());
entity.setCrProcess(crProcess);
}
}
}
Lifecycle Hooks¶
Override these methods in your service to add custom logic:
| Hook | When | Example Use Case |
|---|---|---|
validateForCreate(dto) |
Before create conversion | Required field checks |
beforeCreate(entity, dto) |
After conversion, before save | Set CrProcess, defaults |
afterCreate(entity) |
After save | Audit log, notifications |
validateForUpdate(dto, id) |
Before update conversion | Uniqueness checks |
beforeUpdate(entity, dto) |
After fetch, before merge | Computed fields |
afterUpdate(entity) |
After save | Audit log |
validateDeletion(entity) |
Before delete | Business rule checks |
checkReferences(id) |
Before delete | FK reference checks |
Step 7 — Controller¶
Controllers are plain @RestController classes — no abstract base class.
@RestController
@RequestMapping("/api/employee")
@RequiredArgsConstructor
public class HrmEmployeeController {
private final HrmEmployeeService service;
@GetMapping("/{id}")
public ResponseEntity<HrmEmployeeEditDto> findById(@PathVariable Long id) {
return service.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<HrmEmployeeEditDto> create(
@RequestBody HrmEmployeeEditDto dto) {
return ResponseEntity.ok(service.create(dto));
}
@PutMapping("/{id}")
public ResponseEntity<HrmEmployeeEditDto> update(
@PathVariable Long id,
@RequestBody HrmEmployeeEditDto dto) {
return ResponseEntity.ok(service.update(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/list")
public ResponseEntity<PageResponse<HrmEmployeeListDto>> findPaged(
@RequestBody PageRequest<HrmEmployeeFilterDto> request) {
return ResponseEntity.ok(service.findPaged(request));
}
@GetMapping("/stats")
public ResponseEntity<EntityStatsDto> getStats() {
return ResponseEntity.ok(service.getStats());
}
}
Step 8 — Database¶
Create the schema and tables:
CREATE DATABASE IF NOT EXISTS ichrm
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE ichrm;
-- ID generator table
CREATE TABLE s_ichrm_id (
table_name VARCHAR(255) NOT NULL,
unique_id BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (table_name)
) ENGINE=InnoDB;
INSERT INTO s_ichrm_id (table_name, unique_id) VALUES ('hrm_employee', 0);
-- Employee table
CREATE TABLE hrm_employee (
id BIGINT NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255),
department VARCHAR(100),
hire_date DATE,
-- Inherited from AbstractICEntity6
cr_process_id BIGINT NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
xref_id VARCHAR(255),
creation_date DATETIME(6),
created_by VARCHAR(255),
last_update_date DATETIME(6),
last_updated_by VARCHAR(255),
PRIMARY KEY (id),
CONSTRAINT fk_hrm_employee_cr_process
FOREIGN KEY (cr_process_id) REFERENCES icglb2.cr_process(id)
) ENGINE=InnoDB;
Step 9 — Configuration¶
server.servlet.context-path=/ichrm/services
spring.application.name=ichrm-services
spring.jpa.hibernate.ddl-auto=validate
server.port=8040
spring.datasource.url=jdbc:mysql://localhost:3306/ichrm?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=${DB_USERNAME:root}
spring.datasource.password=${DB_PASSWORD}
logging.file.name=C:/server/ICOSYS/log/ichrm/ichrm-services.log
cors.allowed-origins=*
jwt.secret.key=${JWT_SECRET:dev-secret-change-in-production}
Step 10 — Test It¶
Start the service:
Create an Employee¶
curl -X POST http://localhost:8040/ichrm/services/api/employee \
-H "Content-Type: application/json" \
-d '{
"crProcessId": 1,
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"department": "Engineering",
"hireDate": "2025-03-15"
}'
List Employees (Paginated)¶
curl -X POST http://localhost:8040/ichrm/services/api/employee/list \
-H "Content-Type: application/json" \
-d '{
"page": 0,
"size": 10,
"filter": { "crProcessId": 1 },
"searchTerm": "jane"
}'
Get by ID¶
Module Checklist¶
Before marking a module complete, verify:
- [x] All entities extend
AbstractICEntity6 - [x] EditDtos extend
ICEntity6Dto, FilterDtos extendICEntity6FilterDto - [x] Converters use
dto.mapSuperFields(entity)infrom()method - [x] Service has
beforeCreate()hook setting CrProcess - [x] Specification filters by
crProcess.idas first predicate - [x] DDL scripts documented with all inherited columns
- [x] No
@SuperBuilder— using@Builder+ setter for parent fields - [x] No
entity_idcolumn name - [x]
@Transactionalonly on public methods - [x] English JavaDoc on all public methods
What's Next?¶
- CRUD Engine — Deep dive into AbstractCrudService and all lifecycle hooks
- New Module Guide — Full reference for the
/new-backend-modulescaffold command - New Entity Guide — Add more entities to an existing module