Navegação e Gateways de Decisão
A classe io.kikwiflow.navigation.Navigator é o componente responsável por responder uma única pergunta depois
que um nó termina de executar: qual é o próximo nó (ou nós)? Toda a lógica de roteamento do motor —
gateways exclusivos, fan-out/fan-in paralelo, casamento de error handlers — está concentrada nesta classe,
através do método determineNextContinuation.
AnswerProvider: decisões como código Java testável
Um gateway exclusivo (EXCLUSIVE_GATEWAY) precisa resolver uma resposta (uma String) para escolher qual
aresta de saída seguir. O Kikwiflow oferece três estratégias, configuradas pelo campo providerType do gateway:
public enum AnswerProviderType {
BEAN,
VARIABLE
}
providerType: BEAN
A resposta vem da execução de um bean Spring que implementa AnswerProvider:
@FunctionalInterface
public interface AnswerProvider {
String resolve(EvaluationContext context); // null é permitido
}
{
"type": "EXCLUSIVE_GATEWAY",
"providerType": "BEAN",
"providerBean": "customerRiskStrategy",
"outgoing": [
{ "targetNodeId": "emitir-parecer-external-task", "isDefault": true },
{ "targetNodeId": "solicitar-dados-task", "handlesNull": true },
{ "targetNodeId": "avaliar-fraude", "expectedAnswer": "FRAUDE" }
]
}
@Component("customerRiskStrategy")
public class CustomerRiskStrategy implements AnswerProvider {
@Override
public String resolve(EvaluationContext context) {
return context.getVariableValue("riskScore")
.filter(Objects::nonNull)
.map(riskScore -> (Double) riskScore < 50 ? "FRAUDE" : "APROVADO")
.orElse(null);
}
}
EvaluationContext é deliberadamente mais enxuto que ExecutionContext (usado em TaskHandler) — é
somente-leitura, expondo apenas getProcessInstanceId(), getVariableValue(String) e getVariables().
Essa assimetria é intencional: uma decisão não deve ter efeitos colaterais sobre o estado do processo. Isso é
imposto na assinatura da interface, não apenas por convenção — o que a torna trivialmente testável com um mock
de mapa de variáveis, sem qualquer dependência do motor.
providerType: VARIABLE
Quando a resposta já está calculada (por exemplo, definida por um TaskHandler anterior ou por uma tarefa
externa que injeta o resultado de uma decisão humana), não é preciso escrever nenhum bean — o gateway lê
diretamente uma variável de processo:
{
"type": "EXCLUSIVE_GATEWAY",
"providerType": "VARIABLE",
"providerVariable": "acaoResultadoAnaliseFraude",
"outgoing": [
{ "targetNodeId": "finished", "expectedAnswer": "FINALIZAR" },
{ "targetNodeId": "CALCULATE_CUSTOMER_RISK_ST", "expectedAnswer": "RECALCULAR" }
]
}
Internamente, Navigator.resolveAnswer chama variables.get(gateway.providerVariable()) e converte o valor
para String via toString(). Se a variável não existir ou for null, a resposta resolvida é null (mesma
semântica de handlesNull do modo BEAN).
:::warning Erros de configuração são detectados no deploy
O DeployValidator (kikwi-core/validation) valida, no momento do deploy, que todo gateway BEAN aponte
para um bean AnswerProvider de fato registrado no Spring, e que todo gateway VARIABLE tenha
providerVariable preenchido. Um processo mal configurado nunca chega a ser implantado — ele falha com
InvalidProcessDefinitionException antes de qualquer instância poder ser iniciada, evitando que o erro só seja
descoberto em runtime, no meio de uma execução de produção.
:::
Regras de casamento de arestas (findMatchingFlow)
Depois que uma resposta é resolvida, o Navigator escolhe a aresta de saída seguindo esta ordem de prioridade:
- Resposta nula → procura a (única) aresta marcada
handlesNull: true. Se nenhuma existir, lançaIllegalStateException— uma respostanullsem rota de tratamento é um erro de execução, não é silenciosamente ignorada. - Resposta não-nula → procura a primeira aresta cujo
expectedAnswerseja igual (via.equals()) à resposta. - Nenhum match → recorre à aresta marcada
isDefault: true. Se nenhuma existir, lançaIllegalStateException.
private SequenceFlowDefinition findMatchingFlow(ExclusiveGatewayDefinition gateway, String answer) {
if (answer == null) {
return outgoingFlows.stream().filter(SequenceFlowDefinition::handlesNull).findFirst()
.orElseThrow(() -> new IllegalStateException(/* ... */));
}
return outgoingFlows.stream().filter(sf -> answer.equals(sf.expectedAnswer())).findFirst()
.orElseGet(() -> outgoingFlows.stream().filter(SequenceFlowDefinition::isDefault).findFirst()
.orElseThrow(() -> new IllegalStateException(/* ... */)));
}
:::tip Boa prática de modelagem
Sempre declare uma aresta isDefault: true em gateways cuja resposta possa assumir valores não previstos
explicitamente (ex.: um AnswerProvider que evolui com o tempo), e uma aresta handlesNull: true sempre que o
provider puder retornar null legitimamente. O DeployValidator também rejeita definições com mais de uma
aresta isDefault no mesmo gateway.
:::
Fan-out: PARALLEL_GATEWAY
Um gateway paralelo não avalia nenhuma condição — ele instrui o motor a seguir todas as arestas de saída
simultaneamente, cada uma como um ramo (branch) independente identificado por um branchId gerado (UUID):
if ("PARALLEL_GATEWAY".equals(completedNode.type())) {
List<FlowNodeDefinition> nextNodes = /* todos os targets de outgoing */;
String targetJoinId = ((ParallelGatewayDefinition) completedNode).targetJoinId();
FlowNodeDefinition targetJoinNode = processDefinition.flowNodes().get(targetJoinId);
return new Continuation(nextNodes, true, null, null, targetJoinNode);
}
Note que a continuação retornada é sempre isAsynchronous = true — um split paralelo sempre cruza uma
fronteira transacional, mesmo que nenhum dos nós filhos tenha commitBefore: true explicitamente. Os detalhes
de como os ramos são materializados como ExecutableTasks com pendingBranchIds estão em
Execução Síncrona e Assíncrona.
Fan-in: JOIN_GATEWAY
Um join não decide nada — ele apenas segue sua única aresta de saída assim que todos os ramos pendentes
tiverem sido concluídos (mecanismo de contagem coberto na próxima página). Se o join não tiver saída
(outgoing vazio), o Navigator retorna null, sinalizando fim daquele ramo de execução.
Casamento de error handlers de borda
Navigator.findMatchingErrorHandler é usado pelo ProcessExecutionManager quando um TaskHandler lança uma
ProcessErrorException — ele inspeciona os boundaryEventIds do nó que falhou, filtra os que são
ErrorHandlerDefinition, e escolhe o primeiro cujo errorCode seja null (curinga, casa qualquer erro) ou
igual ao código lançado. Detalhes completos em
Tratamento de Erros de Negócio.
Observabilidade de decisões
Quando kikwiflow.stats.enabled=true ou kikwiflow.outbox.events-enabled=true, toda resolução de um gateway
exclusivo emite um evento GATEWAY_ANSWER_RESOLVED (GatewayAnswerResolved) contendo providerType,
providerBean/providerVariable, a resposta resolvida e o id da aresta escolhida — útil para auditoria de
decisões de negócio sem precisar instrumentar manualmente cada AnswerProvider. Ver
Eventos e Observabilidade.