Backend Development 25 min read

Design and Implementation of a Configurable Approval Workflow System

This article explains how to design a flexible approval workflow with serial, parallel, conditional, and mixed processes, defines the required database tables, shows UI mock‑ups, and provides Java Spring code for handling approval actions, including auto‑pass, delegation, and rule‑based field visibility.

Top Architect
Top Architect
Top Architect
Design and Implementation of a Configurable Approval Workflow System

1 Process Analysis

Describes four types of approval processes: serial (one node after another), parallel (multiple reviewers at the same node, e.g., countersignature or joint sign), conditional (different paths based on rules such as amount thresholds), and mixed (combination of the above).

Approval Actions

Approve : move to the next node, optional reason.

Reject : send back to initiator, previous node, or a specific earlier node.

Save : keep current data.

Transfer : hand over to another approver.

Add Sign : temporarily add reviewers during approval.

CC : copy other users who can view the process.

Execution Actions

Apply : create a new approval instance, optionally allow revocation and edit permissions.

Approve : mark current step as passed; if it is the last step, finish the workflow.

Return : move back one step.

Reject : terminate the workflow.

Withdraw : cancel when the next approver has not acted yet.

2 UI Preview

Images illustrate the form layout and button placement for the approval UI.

3 Table Design

Approval Configuration Table

CREATE TABLE `approval_config` (
  `approval_config_id` varchar(255) NOT NULL COMMENT 'snowflake id',
  `corp_id` varchar(100) DEFAULT NULL COMMENT 'company id',
  `node_name` varchar(100) DEFAULT NULL COMMENT 'node name',
  `form_id` varchar(50) DEFAULT NULL COMMENT 'form id',
  `approval_auto_pass` tinyint(1) DEFAULT '0' COMMENT 'auto‑pass when approvers are the same',
  `current_node_approver_type` tinyint DEFAULT NULL COMMENT '1: initiator, 2: role, 3: user, 4: form user',
  `current_node_approver` varchar(255) DEFAULT NULL COMMENT 'role ids or user ids',
  `care_of` tinyint(1) DEFAULT NULL COMMENT 'allow transfer',
  `approval_type` tinyint DEFAULT NULL COMMENT '1: countersign, 2: joint sign',
  `approval_node` int DEFAULT NULL COMMENT 'node order',
  `carbon_copy_recipients_type` tinyint DEFAULT '1' COMMENT 'CC type',
  `carbon_copy_recipients` varchar(255) DEFAULT NULL COMMENT 'CC user ids',
  `create_time` datetime DEFAULT NULL COMMENT 'creation time',
  `group_id` varchar(255) DEFAULT NULL COMMENT 'group identifier',
  `visible_fields` json DEFAULT NULL COMMENT 'field visibility rules',
  PRIMARY KEY (`approval_config_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='approval process node configuration';

Approval Instance Table

CREATE TABLE `approval_info` (
  `approval_info_id` varchar(255) NOT NULL COMMENT 'snowflake id',
  `corp_id` varchar(50) NOT NULL COMMENT 'company id',
  `approval_config_group_id` varchar(255) DEFAULT NULL COMMENT 'config group id',
  `form_id` varchar(50) DEFAULT NULL COMMENT 'form id',
  `founder` varchar(255) NOT NULL COMMENT 'initiator',
  `launch_time` datetime NOT NULL COMMENT 'start time',
  `end_state` tinyint(1) DEFAULT '0' COMMENT 'is finished',
  `end_time` datetime DEFAULT NULL COMMENT 'finish time',
  `object_id` varchar(255) DEFAULT NULL COMMENT 'related data id',
  `process_progress` int DEFAULT '0' COMMENT 'current node index',
  `record_node_history` varchar(255) DEFAULT NULL COMMENT 'comma‑separated node history',
  PRIMARY KEY (`approval_info_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='approval instance';

Approval Record Table

CREATE TABLE `approval_record` (
  `approval_record_id` varchar(50) NOT NULL COMMENT 'snowflake id',
  `corp_id` varchar(50) NOT NULL COMMENT 'company id',
  `approval_info_id` varchar(50) NOT NULL COMMENT 'link to approval_info',
  `form_id` varchar(50) NOT NULL COMMENT 'form id',
  `approver_user_id` varchar(50) NOT NULL COMMENT 'approver or transferee',
  `approver_time` datetime NOT NULL COMMENT 'action time',
  `approver_opinions` varchar(255) DEFAULT NULL COMMENT 'comments',
  `approver_result` tinyint NOT NULL COMMENT '1:agree,2:reject,3:transfer',
  `transferee` varchar(50) DEFAULT NULL COMMENT 'new approver when transferred',
  `accessory_url` varchar(255) DEFAULT NULL COMMENT 'attachments',
  `approver_node` tinyint NOT NULL COMMENT 'node index',
  `approver_count` tinyint DEFAULT '1' COMMENT 'times this node was processed',
  PRIMARY KEY (`approval_record_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='approval record';

CC Record Table

CREATE TABLE `approval_carbon_copy` (
  `carbon_copy_id` varchar(255) NOT NULL COMMENT 'snowflake id',
  `corp_id` varchar(255) NOT NULL COMMENT 'company id',
  `form_id` varchar(255) NOT NULL COMMENT 'form id',
  `approval_info_id` varchar(255) NOT NULL COMMENT 'linked approval',
  `user_id` varchar(255) NOT NULL COMMENT 'CC recipient',
  `create_time` datetime NOT NULL COMMENT 'CC time',
  `read` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'read flag',
  `approval_node` tinyint NOT NULL COMMENT 'node where CC occurs',
  PRIMARY KEY (`carbon_copy_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='approval CC records';

4 Workflow Logic

1. Configure each node in approval_config with approvers, CC users, visibility, editability, and required fields.

2. When a new business record is created, read the configuration and insert a row into approval_info to start the process.

3. At each node, the system dynamically determines the approvers based on the configuration (role‑based, specific users, or the initiator).

5 Core Java Code

Approval Button Handler

@Transactional(rollbackFor = Exception.class)
public ResultUtils approval(String token, ApprovalDTO param) {
    String corpId = TokenUtils.getCorpId(token);
    String userId = TokenUtils.getUserId(token);
    LocalDateTime time = LocalDateTime.now();
    // fetch current approval instance
    ApprovalInfo info = approvalInfoMapper.selectOne(new QueryWrapper
()
        .eq("corp_id", corpId)
        .eq("object_id", param.getObjectId())
        .eq("form_id", param.getFormId())
        .select("approval_info_id", "process_progress", "record_node_history", "end_state", "approval_config_group_id", "founder"));
    Assert.isFalse(info.getEndState(), ErrorMsg.msg18);
    Integer currentNode = info.getProcessProgress();
    // load node configurations
    List
configs = approvalConfigMapper.selectList(new QueryWrapper
()
        .eq("corp_id", corpId)
        .eq("group_id", info.getApprovalConfigGroupId())
        .eq("form_id", param.getFormId()));
    Map
configMap = configs.stream()
        .collect(Collectors.toMap(ApprovalConfig::getApprovalNode, c -> c));
    // ... (validation, delegation, record insertion, auto‑pass handling)
    // approve or reject logic delegated to helper methods
    if (param.getType() == 1) {
        approvalAgreeing(currentNode, info, configMap, param, userId, corpId, time, param.getObjectId());
    } else if (param.getType() == 2) {
        approvalRefuse(info, configs, param, userId, corpId, count, time);
    }
    return ResultUtils.success();
}

Reject Helper

private void approvalRefuse(ApprovalInfo info, List
configs, ApprovalDTO param,
    String userId, String corpId, int count, LocalDateTime time) {
    ApprovalRecord record = new ApprovalRecord();
    record.setApprovalRecordId(SnowFlakeUtil.getDefaultSnowFlakeId() + "");
    record.setCorpId(corpId);
    record.setApprovalInfoId(info.getApprovalInfoId());
    record.setFormId(param.getFormId());
    record.setApproverUserId(userId);
    record.setApproverTime(time);
    record.setApproverOpinions(param.getMessage());
    record.setApproverResult(param.getType());
    if (ObjectUtil.isNotEmpty(param.getAccessoryUrl())) {
        record.setAccessoryUrl(JSONUtil.toJsonStr(param.getAccessoryUrl()));
    }
    record.setApproverNode(info.getProcessProgress().byteValue());
    record.setApproverCount(new Byte(count + ""));
    approvalRecordMapper.insertSelective(record);
    // rollback to previous node if needed
    info.setProcessProgress(info.getProcessProgress() - 1);
    info.setRecordNodeHistory(info.getRecordNodeHistory() + "," + info.getProcessProgress());
    approvalInfoMapper.updateByPrimaryKeySelective(info);
    // if rolled back to first node, reset data state
    if (info.getProcessProgress() == 0) {
        String formCode = fromConfigMapper.findFormCode(corpId, param.getFormId());
        commonMapper.updateDataState(formCode, 0, param.getObjectId(), corpId);
    }
}

Approve Helper (Countersign & Auto‑Pass)

private void approvalAgreeing(Integer currentNode, ApprovalInfo info,
    Map
configMap, ApprovalDTO param, String userId,
    String corpId, LocalDateTime time, String objectId) {
    // create approval record
    ApprovalRecord rec = new ApprovalRecord();
    rec.setApprovalRecordId(SnowFlakeUtil.getDefaultSnowFlakeId() + "");
    rec.setCorpId(corpId);
    rec.setApprovalInfoId(info.getApprovalInfoId());
    rec.setFormId(param.getFormId());
    rec.setApproverUserId(userId);
    rec.setApproverTime(time);
    rec.setApproverOpinions(param.getMessage());
    rec.setApproverResult(param.getType());
    if (ObjectUtil.isNotEmpty(param.getAccessoryUrl())) {
        rec.setAccessoryUrl(JSONUtil.toJsonStr(param.getAccessoryUrl()));
    }
    rec.setApproverNode(currentNode.byteValue());
    rec.setApproverCount(new Byte(count + ""));
    approvalRecordMapper.insertSelective(rec);
    // move to next node
    int nextNode = currentNode + 1;
    info.setProcessProgress(nextNode);
    info.setRecordNodeHistory(info.getRecordNodeHistory() + "," + nextNode);
    // if last node, finish workflow and set data state to active
    if (configMap.size() - 1 == nextNode) {
        info.setEndTime(time);
        info.setEndState(true);
        approvalInfoMapper.updateByPrimaryKeySelective(info);
        String formCode = fromConfigMapper.findFormCode(corpId, param.getFormId());
        commonMapper.updateDataState(formCode, 2, objectId, corpId);
        return;
    }
    approvalInfoMapper.updateByPrimaryKeySelective(info);
    // check next node rules (auto‑pass, field writability, etc.)
    ApprovalConfig nextConfig = configMap.get(nextNode);
    List
rules = JSONUtil.toList(JSONUtil.parseArray(nextConfig.getVisibleFields()), RuleVO.class);
    for (RuleVO r : rules) {
        if (r.getKx()) {
            return; // writable field, stop auto‑pass
        }
    }
    if (nextConfig.getApprovalAutoPass()) {
        // verify current user matches next node approvers, then recurse
        // (role‑based or user‑based checks omitted for brevity)
        approvalAgreeing(nextNode, info, configMap, param, userId, corpId, time, objectId);
    }
}

6 Permission Merging Logic

When the final node contains multiple custom permission groups, the mergePowerGroup method combines their visible_fields JSON definitions, giving precedence to any true flag (visible, writable, required) from any group.

private List
mergePowerGroup(List
roleIds, List
dbPowerGroup) {
    List
result = new ArrayList<>();
    for (PowerGroup power : dbPowerGroup) {
        List
dbRoleIds = JSONUtil.toList(power.getRoleId().toString(), Integer.class);
        List
intersect = new ArrayList<>(roleIds);
        intersect.retainAll(dbRoleIds);
        if (!intersect.isEmpty()) {
            List
rule = JSONUtil.toList(JSONUtil.parseArray(power.getVisibleFields()), RuleVO.class);
            // merge logic that keeps true flags from any group
            // (implementation omitted for brevity)
            result = rule; // simplified for illustration
        }
    }
    return result;
}

The article concludes with a call for discussion, community links, and promotional material unrelated to the technical content.

Javabackend developmentSpringDatabase DesignApproval Workflowrole-based access
Top Architect
Written by

Top Architect

Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.