Skip to content

New Entity Guide

This guide shows how to add a new entity to an existing module. If you need to create the module first, see the New Module Guide.

Slash Command

You can scaffold an entity automatically with the Claude Code command:

/add-entity BPM/BpmProduct

This guide explains the manual process.


What Gets Created

Adding one entity produces eight files across three sub-projects:

Sub-Project File Purpose
Entities {Entity}.java JPA entity
DTOs {Entity}EditDto.java Create/update DTO
DTOs {Entity}ListDto.java Table display DTO
DTOs {Entity}FilterDto.java Filter criteria DTO
Service {Entity}Repository.java JPA repository
Service {Entity}EditDtoConverter.java Entity-DTO mapping (3 methods)
Service {Entity}ListDtoConverter.java Entity-ListDto mapping (1 method)
Service {Entity}Specification.java Dynamic query builder
Service {Entity}Service.java Business logic + CRUD
Service {Entity}Controller.java REST endpoints

Plus updates to:

  • docs/database/ddl_scripts.md — New table DDL
  • s_ic{mod}_id table — New row for ID generation
  • ServiceErrorMsgCodes.java — Entity-specific error codes
  • messages.properties / messages_tr.properties — Error messages

Example: Adding BpmProduct to BPM

We will add a BpmProduct entity to the existing ICoSys-BPM module.


Step 1 — Entity

ICoSys-BPM-Entities-1.0/.../entity/BpmProduct.java
@Entity
@Table(name = "bpm_product", schema = "icbpm", catalog = "icbpm")
@TableGenerator(
    name = "BpmProduct_ID_GEN",
    schema = "icbpm", catalog = "icbpm",
    table = "s_icbpm_id",
    pkColumnName = "table_name",
    pkColumnValue = "bpm_product",
    valueColumnName = "unique_id",
    initialValue = 0, allocationSize = 1
)
@DynamicInsert @DynamicUpdate
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class BpmProduct extends AbstractICEntity6 implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.TABLE, generator = "BpmProduct_ID_GEN")
    @Column(name = "id", unique = true, nullable = false)
    @EqualsAndHashCode.Include
    private Long id;

    @Column(name = "product_code", nullable = false, length = 50)
    private String productCode;

    @Column(name = "product_name", nullable = false, length = 200)
    private String productName;

    @Column(name = "unit_price")
    private BigDecimal unitPrice;

    @Column(name = "status")
    private Boolean status;
}

Key points:

  • Use the existing module's schema and ID table (icbpm, s_icbpm_id)
  • Generator name must be unique across the module: {Entity}_ID_GEN
  • pkColumnValue matches the table name: bpm_product

Step 2 — DTOs

BpmProductEditDto.java
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class BpmProductEditDto extends ICEntity6Dto {
    private String productCode;
    private String productName;
    private BigDecimal unitPrice;
    private Boolean status;
}
BpmProductListDto.java
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class BpmProductListDto extends ICEntity6Dto {
    private String productCode;
    private String productName;
    private BigDecimal unitPrice;
    private Boolean status;
}
BpmProductFilterDto.java
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class BpmProductFilterDto extends ICEntity6FilterDto {
    private Boolean status;
}

Step 3 — Repository

BpmProductRepository.java
@Repository
public interface BpmProductRepository extends ICrudRepository<BpmProduct, Long> {

    boolean existsByCrProcess_IdAndProductCodeAndIdNot(
        Long crProcessId, String productCode, Long id);
}

Custom query methods follow Spring Data naming:

  • findBy{Field} — select by field
  • existsBy{Field} — existence check
  • countBy{Field} — count
  • Use CrProcess_Id (underscore) for nested property traversal

Step 4 — Converters

Edit Converter (3 Methods)

BpmProductEditDtoConverter.java
@Component
public class BpmProductEditDtoConverter
        implements IEditDtoConverter<BpmProduct, BpmProductEditDto> {

    /** Entity → DTO */
    @Override
    public BpmProductEditDto from(BpmProduct entity) {
        if (entity == null) return null;
        BpmProductEditDto dto = BpmProductEditDto.builder()
                .productCode(entity.getProductCode())
                .productName(entity.getProductName())
                .unitPrice(entity.getUnitPrice())
                .status(entity.getStatus())
                .build();
        dto.mapSuperFields(entity);   // id, crProcessId, version
        return dto;
    }

    /** DTO → New Entity (create) */
    @Override
    public BpmProduct to(BpmProductEditDto dto) {
        if (dto == null) return null;
        return BpmProduct.builder()
                .productCode(dto.getProductCode())
                .productName(dto.getProductName())
                .unitPrice(dto.getUnitPrice())
                .status(dto.getStatus())
                .build();
        // DO NOT set crProcess here — use beforeCreate() hook
    }

    /** DTO → Existing Entity (update) */
    @Override
    public BpmProduct to(BpmProductEditDto dto, BpmProduct entity) {
        if (dto == null || entity == null) return entity;
        if (dto.getVersion() != null) {
            entity.setVersion(dto.getVersion());  // Optimistic lock
        }
        entity.setProductCode(dto.getProductCode());
        entity.setProductName(dto.getProductName());
        entity.setUnitPrice(dto.getUnitPrice());
        entity.setStatus(dto.getStatus());
        return entity;
    }
}

List Converter (1 Method)

BpmProductListDtoConverter.java
@Component
public class BpmProductListDtoConverter
        implements IListDtoConverter<BpmProduct, BpmProductListDto> {

    @Override
    public BpmProductListDto from(BpmProduct entity) {
        if (entity == null) return null;
        BpmProductListDto dto = BpmProductListDto.builder()
                .productCode(entity.getProductCode())
                .productName(entity.getProductName())
                .unitPrice(entity.getUnitPrice())
                .status(entity.getStatus())
                .build();
        dto.mapSuperFields(entity);
        return dto;
    }
}

Common Mistake

Forgetting dto.mapSuperFields(entity) causes id, crProcessId, and version to be null in the response. Always call it in from().


Step 5 — Specification

BpmProductSpecification.java
@Component
public class BpmProductSpecification {

    private static final String FIELD_ID = "id";
    private static final String FIELD_PRODUCT_CODE = "productCode";
    private static final String FIELD_PRODUCT_NAME = "productName";
    private static final String FIELD_STATUS = "status";
    private static final String REL_CR_PROCESS = "crProcess";

    /** Builds JPA Specification from filter and search term. */
    public Specification<BpmProduct> build(BpmProductFilterDto filter,
                                           String searchTerm) {
        return (root, query, cb) -> {
            List<Predicate> predicates = new ArrayList<>();

            // 1. Tenant isolation (ALWAYS first!)
            if (filter != null && filter.getCrProcessId() != null) {
                predicates.add(cb.equal(
                    root.get(REL_CR_PROCESS).get(FIELD_ID),
                    filter.getCrProcessId()));
            }

            // 2. Filter predicates
            if (filter != null && filter.getStatus() != null) {
                predicates.add(cb.equal(
                    root.get(FIELD_STATUS), filter.getStatus()));
            }

            // 3. Free-text search
            if (StringUtils.hasText(searchTerm)) {
                String term = "%" + searchTerm.toLowerCase() + "%";
                predicates.add(cb.or(
                    cb.like(cb.lower(root.get(FIELD_PRODUCT_CODE)), term),
                    cb.like(cb.lower(root.get(FIELD_PRODUCT_NAME)), term)
                ));
            }

            return cb.and(predicates.toArray(new Predicate[0]));
        };
    }
}

Rules:

  • crProcess.id predicate is always first — tenant isolation
  • Use private static final String constants — no magic strings
  • searchTerm uses LIKE with lower() for case-insensitive search

Step 6 — Service

BpmProductService.java
@Service
@Transactional
@Slf4j
public class BpmProductService extends AbstractCrudService<
        BpmProduct, Long, BpmProductEditDto,
        BpmProductListDto, BpmProductFilterDto> {

    private final BpmProductRepository repository;
    private final BpmProductEditDtoConverter editConverter;
    private final BpmProductListDtoConverter listConverter;
    private final BpmProductSpecification specification;

    public BpmProductService(
            PagingSupport pagingSupport,
            BpmProductRepository repository,
            BpmProductEditDtoConverter editConverter,
            BpmProductListDtoConverter listConverter,
            BpmProductSpecification specification) {
        super(pagingSupport);
        this.repository = repository;
        this.editConverter = editConverter;
        this.listConverter = listConverter;
        this.specification = specification;
    }

    // --- 5 abstract method implementations ---

    @Override
    protected ICrudRepository<BpmProduct, Long> getRepository() {
        return repository;
    }

    @Override
    protected IEditDtoConverter<BpmProduct, BpmProductEditDto> getEditConverter() {
        return editConverter;
    }

    @Override
    protected IListDtoConverter<BpmProduct, BpmProductListDto> getListConverter() {
        return listConverter;
    }

    @Override
    protected String getEntityName() { return "BpmProduct"; }

    @Override
    protected Long extractId(BpmProduct entity) { return entity.getId(); }

    @Override
    protected Specification<BpmProduct> buildSpecification(
            BpmProductFilterDto filter, String searchTerm) {
        return specification.build(filter, searchTerm);
    }

    // --- Lifecycle hooks ---

    @Override
    protected void beforeCreate(BpmProduct entity, BpmProductEditDto dto) {
        if (dto.getCrProcessId() != null) {
            CrProcess crProcess = entityManager.getReference(
                CrProcess.class, dto.getCrProcessId());
            entity.setCrProcess(crProcess);
        }
    }

    @Override
    protected void validateForCreate(BpmProductEditDto dto) {
        requireNonBlank(dto.getProductCode(), "productCode");
        requireNonBlank(dto.getProductName(), "productName");
    }

    @Override
    protected void validateForUpdate(BpmProductEditDto dto, Long id) {
        requireNonBlank(dto.getProductCode(), "productCode");
        requireNonBlank(dto.getProductName(), "productName");
        // Check uniqueness (exclude current record)
        if (repository.existsByCrProcess_IdAndProductCodeAndIdNot(
                dto.getCrProcessId(), dto.getProductCode(), id)) {
            throw new ServiceException(ServiceErrorMsgCodes.PRODUCT_CODE_EXISTS);
        }
    }
}

Lifecycle Hook Reference

Hook When Common Use
validateForCreate(dto) Before conversion Required fields, format checks
beforeCreate(entity, dto) After conversion Set CrProcess, defaults
afterCreate(entity) After save Audit log, notifications
validateForUpdate(dto, id) Before fetch Uniqueness, status checks
beforeUpdate(entity, dto) After merge Computed fields
afterUpdate(entity) After save Audit log
validateDeletion(entity) Before delete Business rules
checkReferences(id) Before delete FK reference checks
beforeDelete(entity) After validation Cascade cleanup
afterDelete(entity) After delete External resource cleanup

Step 7 — Controller

BpmProductController.java
@RestController
@RequestMapping("/api/product")
@RequiredArgsConstructor
public class BpmProductController {

    private final BpmProductService service;

    @GetMapping("/{id}")
    public ResponseEntity<BpmProductEditDto> findById(@PathVariable Long id) {
        return service.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<BpmProductEditDto> create(
            @RequestBody BpmProductEditDto dto) {
        return ResponseEntity.ok(service.create(dto));
    }

    @PutMapping("/{id}")
    public ResponseEntity<BpmProductEditDto> update(
            @PathVariable Long id,
            @RequestBody BpmProductEditDto 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<BpmProductListDto>> findPaged(
            @RequestBody PageRequest<BpmProductFilterDto> request) {
        return ResponseEntity.ok(service.findPaged(request));
    }

    @GetMapping("/stats")
    public ResponseEntity<EntityStatsDto> getStats() {
        return ResponseEntity.ok(service.getStats());
    }
}

Step 8 — Database

Add the table DDL and ID generator row:

Add to docs/database/ddl_scripts.md
INSERT INTO s_icbpm_id (table_name, unique_id) VALUES ('bpm_product', 0);

CREATE TABLE bpm_product (
    id               BIGINT       NOT NULL,
    product_code     VARCHAR(50)  NOT NULL,
    product_name     VARCHAR(200) NOT NULL,
    unit_price       DECIMAL(19,4),
    status           BIT(1),
    -- 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_bpm_product_crp
        FOREIGN KEY (cr_process_id) REFERENCES icglb2.cr_process(id)
) ENGINE=InnoDB;

Step 9 — Error Codes

Add entity-specific codes:

ServiceErrorMsgCodes.java (add)
public static final String PRODUCT_CODE_EXISTS = "IC-BPM-2020";
messages.properties (add)
IC-BPM-2020=Product code already exists
messages_tr.properties (add)
IC-BPM-2020=Ürün kodu zaten mevcut

Step 10 — Verify

Build and run:

cd ICoSys-BPM
mvn clean install -DskipTests
cd ICoSys-BPM-Services-1.0
mvn spring-boot:run -Dspring-boot.run.profiles=dev

Test:

# Create
curl -X POST http://localhost:8020/icbpm/services/api/product \
  -H "Content-Type: application/json" \
  -d '{"crProcessId":1,"productCode":"PRD-001","productName":"Widget","unitPrice":29.99,"status":true}'

# List
curl -X POST http://localhost:8020/icbpm/services/api/product/list \
  -H "Content-Type: application/json" \
  -d '{"page":0,"size":10,"filter":{"crProcessId":1}}'

Entity Checklist

  • [ ] Entity extends AbstractICEntity6 with @Builder
  • [ ] EditDto extends ICEntity6Dto
  • [ ] FilterDto extends ICEntity6FilterDto
  • [ ] Converters call dto.mapSuperFields(entity) in from()
  • [ ] Edit converter sets version in update method
  • [ ] Service implements beforeCreate() setting CrProcess
  • [ ] Specification has crProcess.id as first predicate
  • [ ] No magic strings in Specification (use constants)
  • [ ] Controller has all 6 CRUD endpoints
  • [ ] DDL includes all 7 inherited columns
  • [ ] ID generator row inserted in s_ic{mod}_id
  • [ ] Error codes added to ServiceErrorMsgCodes
  • [ ] Messages in both .properties files (EN + TR)

What's Next?