Skip to content

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 HrmEmployee entity (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.

ICoSys-HRM-Entities-1.0/.../entity/HrmEmployee.java
@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 @Buildernot @SuperBuilder. Set parent fields via setters.
  • Column name entity_id is reserved by getEntityId(). Use refEntityId if needed.
  • Every entity must implement Serializable with an explicit serialVersionUID.

Step 2 — DTOs

Edit DTO (Create / Update)

ICoSys-HRM-Entities-Dto-1.0/.../dto/HrmEmployeeEditDto.java
@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)

ICoSys-HRM-Entities-Dto-1.0/.../dto/HrmEmployeeListDto.java
@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)

ICoSys-HRM-Entities-Dto-1.0/.../dto/HrmEmployeeFilterDto.java
@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

HrmEmployeeRepository.java
@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).

HrmEmployeeEditDtoConverter.java
@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

HrmEmployeeListDtoConverter.java
@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.

HrmEmployeeSpecification.java
@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.

HrmEmployeeService.java
@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.

HrmEmployeeController.java
@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:

DDL Script
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

application.properties
server.servlet.context-path=/ichrm/services
spring.application.name=ichrm-services
spring.jpa.hibernate.ddl-auto=validate
application-dev.properties
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:

cd ICoSys-HRM/ICoSys-HRM-Services-1.0
mvn spring-boot:run -Dspring-boot.run.profiles=dev

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

curl http://localhost:8040/ichrm/services/api/employee/1

Module Checklist

Before marking a module complete, verify:

  • [x] All entities extend AbstractICEntity6
  • [x] EditDtos extend ICEntity6Dto, FilterDtos extend ICEntity6FilterDto
  • [x] Converters use dto.mapSuperFields(entity) in from() method
  • [x] Service has beforeCreate() hook setting CrProcess
  • [x] Specification filters by crProcess.id as first predicate
  • [x] DDL scripts documented with all inherited columns
  • [x] No @SuperBuilder — using @Builder + setter for parent fields
  • [x] No entity_id column name
  • [x] @Transactional only 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-module scaffold command
  • New Entity Guide — Add more entities to an existing module