Pattern catalog

From a Java construct to its BKIR

Every source-code construct has a canonical way to be represented in BKIR. On the left, idiomatic Java; on the right, the BKIR Units that capture its observable behavior. The examples are illustrative (trimmed of provenance/source) and follow the real corpus. Looking for the operators, reserved words and functions of the expression language? They are in the FEEL reference.

Data types TYPE_

Data classes, enums and sum types (a value that is one of several) become reusable canonical types. No Java types: the generator maps them to its target.

Java
class Item {
    String     sku;
    int        cantidad;
    BigDecimal precio;     // money
}

enum Estado { NUEVO, PAGADO }

// a passenger is either a name OR a username
sealed interface Pasajero
    permits PorNombre, PorUsuario {}
BKIR · type
kind: type
id: TYPE_ITEM
definition:
  record:
    fields:
      sku:      { type: String }
      cantidad: { type: Integer }
      precio:   { type: Money }      # collapses currency+units+nanos
---
kind: type
id: TYPE_ESTADO
definition:
  enum: { values: [NUEVO, PAGADO] }
---
kind: type
id: TYPE_PASAJERO
definition:
  alias:
    type: Union                      # sum type (xsd:choice / sealed)
    variants:
      nombre:  { type: Record, fields: { nombres: { type: String } } }
      usuario: { type: String }
Canonical system. Semantic scalars (Money, Integer, Date, UUID, ID…), composites (Record, List, Set, Map, Union) and references { ref: TYPE_… }. Nullability is explicit with optional: true (Java nullOptional / *T / nil depending on the target).

Conditions and guards COND_ OP_

An if that throws an exception is a named business condition, not a Java exception class. It is declared once (with its signature) and referenced from the flow.

Java
if (cuenta.getEstado() != Estado.ACTIVA) {
    throw new PlatformApiDataValidationException(
      "error.msg.savingsaccount.transaction"
        + ".account.is.not.active",
      "Transaction is not allowed. "
        + "Account is not active.");
}
BKIR · condition + flow
kind: condition
id: COND_CUENTA_NO_ACTIVA
meaning: "the account is not active"
inputs:                              # signature: the predicate uses only these names
  cuenta: { ref: TYPE_CUENTA_AHORRO }
detection:
  kind: predicate
  expr: 'cuenta.estado != "ACTIVA"'  # FEEL, portable
---
# in the operation:
flow:
  - decision: COND_CUENTA_NO_ACTIVA
    whenTrue: { to: OUT_CUENTA_NO_ACTIVA }
outputs:
  OUT_CUENTA_NO_ACTIVA:
    result: error
    code: "error.msg.savingsaccount.transaction.account.is.not.active"
    message: "Transaction is not allowed. Account is not active."
    guard: COND_CUENTA_NO_ACTIVA
Collapse the mechanism. The condition unifies every internal state other than ACTIVE (submitted, approved, closed…) because they produce the same output. The signature (inputs) makes it validable in isolation and portable across operations; the decision node satisfies it by name or with args.

Input validation constraints

Format/range restrictions (Bean Validation, regex, @Future) are declarative constraints on the type or the input. Their violation is an observable rejection at the boundary.

Java
record HandlingReport(
  @NotBlank @Size(min = 4)
  String trackingId,

  @Pattern(regexp = "[a-zA-Z]{2}[a-zA-Z2-9]{3}")
  String unLocode,

  @Future LocalDate deadline
) {}

// strictly positive amount
Validate.isTrue(monto.signum() > 0,
                "amount must be positive");
BKIR · input with constraints
input:
  trackingId:
    type: String
    constraints: { minLength: 4, message: "Tracking ID must be at least four characters." }
  unLocode:
    type: String
    constraints: { pattern: "[a-zA-Z]{2}[a-zA-Z2-9]{3}" }
  deadline:
    type: Date
    constraints: { future: true, message: "Deadline must be in the future." }
  pasajeros:
    type: List
    of: { ref: TYPE_PASAJERO }
    constraints: { minItems: 1, maxItems: 9 }   # also on collections
Constraint-vs-condition rule. If the original responds with the transport's generic validation error (a 400), it goes as constraints. If it gives a specific catalog output (its own code and message, a different status) —like Fineract's monto > 0, which has its validation.msg.*— it goes as condition + decision + output.

Computations and pure derivations FN_

A transformation without effects is a computation. If it is expressed in FEEL, it is portable; if it is an idiosyncratic algorithm, it escapes to foreign with a contract.

Java
// expressible derivation
Money total = items.stream()
  .map(i -> i.precio.multiply(
            BigDecimal.valueOf(i.cantidad)))
  .reduce(Money.ZERO, Money::add);

// algorithm (Luhn + BIN type): NOT declarative
CardInfo info = cardValidator(numero)
                  .getCardDetails();
BKIR · FEEL and foreign
kind: computation
id: FN_TOTAL_PEDIDO
inputs: { items: { type: List, of: { ref: TYPE_ITEM } } }
output: { type: Money }
body:
  expr: "sum(for i in items return i.precio * i.cantidad)"
---
kind: computation
id: FN_VALIDAR_TARJETA
inputs: { numero: { type: String } }
output: { type: Record, fields: { valida: { type: Boolean }, tipo: { type: String } } }
body:
  foreign:                           # declared contract, per-target impl
    contract:
      post: [ "valida = false or tipo != null" ]
    impls: { node: "npm:simple-card-validator@1.1.0" }
---
# and it is called as a function from FEEL:
detection: { kind: predicate, expr: "not(FN_VALIDAR_TARJETA(numero).valida)" }
Fidelity layers. What fits in the expression language is regenerated; the idiosyncratic part is marked as foreign with a signature + pre/post, and its implementation lives in per-target files, referenced — never embedded in the generated code. What isn't captured formally is marked explicitly.

Database queries IO_ COND_

A read is an interaction with direction: read. "Not found" is modeled as an attempt condition (only observable on trying).

Java
SavingsAccount cuenta =
  repository.findById(cuentaId)
    .orElseThrow(() ->
      new SavingsAccountNotFoundException(cuentaId));
BKIR · read + attempt
kind: interaction
id: IO_CUENTA_AHORRO_GET
direction: read
target: "SavingsAccountDB.get"        # neutral resource descriptor
inputs: { cuentaId: { type: ID } }
output: { ref: TYPE_CUENTA_AHORRO }
raises: [ COND_CUENTA_NO_EXISTE ]
---
kind: condition
id: COND_CUENTA_NO_EXISTE
detection: { kind: attempt, onInteraction: IO_CUENTA_AHORRO_GET, signal: notFound }
---
# in the flow:
flow:
  - read: { bind: cuenta, use: IO_CUENTA_AHORRO_GET, args: { cuentaId: cuentaId } }
    onCondition: { COND_CUENTA_NO_EXISTE: { to: OUT_CUENTA_NO_EXISTE } }
The SQL is implementation. target is a neutral descriptor; the concrete SELECT is mapped to the target's idioms or escaped. The result is named with bind and becomes available in the flow's scope.

Writes and transactions IO_

Writes are interaction with direction: effect. The transaction boundary is observable behavior (partial state is seen afterward) and is declared explicitly.

Java
@Transactional
public void registrar(...) {
  var id = txRepo.save(
             new Tx(cuentaId, monto)).getId();
  if (cuenta.tieneContabilidad())
      contabilidad.asentar(cuentaId, monto);
}
// after commit, best-effort:
eventos.publish(new RetiroEvent(cuentaId));
BKIR · effect
flow:
  - effect:
      transactional: true             # atomic group (≡ guarantee: atomic)
      steps:
        - bind: transaccionId         # the id generated by the DB, nameable
          use: IO_RETIRO_REGISTRAR
          args: { cuentaId: cuentaId, monto: monto }
        - use: IO_ASIENTOS_CONTABLES
          args: { cuentaId: cuentaId, monto: monto }
          when: "cuenta.contabilidadHabilitada"   # conditional step
  - effect:
      guarantee: after-commit         # only if the previous tx commits
      steps:
        - use: IO_EVENTO_RETIRO_PUBLICAR
          args: { cuentaId: cuentaId }
Guarantees as data. transactional: trueguarantee: atomic; after-commit and best-effort cover what does not take part in the commit. bind captures an effect's result (a generated id) to use it in the payload; when conditions a step without breaking the boundary.

Calls to external services IO_ COND_

An outbound call (REST, gRPC, SOAP…) is an interaction: read if it returns data, effect if it mutates or publishes. Its failure is an attempt condition.

Java
ConvertResponse r = currencyClient.convert(
  ConvertRequest.newBuilder()
    .setFrom(usd).setToCode(moneda).build());
// any gRPC error from the callee propagates…
BKIR · remote interaction
kind: interaction
id: IO_MONEDA_CONVERTIR
direction: read
target: "grpc:hipstershop.CurrencyService/Convert"
inputs: { monto: { type: Money }, moneda: { type: String } }
output: { type: Money }
raises: [ COND_CONVERSION_FALLIDA ]
---
kind: condition
id: COND_CONVERSION_FALLIDA
detection: { kind: attempt, onInteraction: IO_MONEDA_CONVERTIR, signal: callFailed }
Error collapse. If the caller turns every internal cause from the callee into a single code (e.g. Internal), that is one output: the guiding principle in action. The exact transport code (including a non-standard one preserved on purpose) is annotated in binding.responses.

Iteration (loops) forEach

A for/forEach that repeats reads or effects per element is a forEach node. Faithful semantics: N invocations in order (batching is a different modeling decision).

Java
List<OrderItem> ordenItems = new ArrayList<>();
for (CartItem item : carrito) {
  Product p = catalog.getProduct(item.productId);
  Money precio = currency.convert(p.priceUsd, moneda);
  ordenItems.add(new OrderItem(item, precio));
}
BKIR · forEach (map)
flow:
  - forEach:
      in: itemsCarrito
      as: item                       # item variable (only in the sub-flow)
      bind: itemsOrden               # accumulates the result (map semantics)
      yield: itemOrden
      do:
        - read: { bind: producto, use: IO_PRODUCTO_GET, args: { productoId: item.productoId } }
          onCondition: { COND_PRODUCTO_NO_DISPONIBLE: { to: OUT_FALLO_CATALOGO } }
        - read: { bind: precio, use: IO_MONEDA_CONVERTIR, args: { monto: producto.precioUsd, moneda: moneda } }
        - compute: { bind: itemOrden, expr: "{ item: item, costoUnitario: precio }" }
Java · per-item effect in a transaction
@Transactional
void reservar(List<Pasajero> pax, Vuelo v) {
  for (Pasajero p : pax)
    if (p.esFrecuente())
      millas.acreditar(p.usuario(), v.millas());
}
BKIR · forEach inside effect
- effect:
    transactional: true
    steps:
      - forEach:
          in: pasajeros
          as: pasajero
          do:
            - use: IO_MILLAS_ACREDITAR
              args: { usuario: pasajero.usuarioViajeroFrecuente, millas: vuelo.millas }
              when: "pasajero.usuarioViajeroFrecuente != null"
Item scope. The as variable lives only inside the sub-flow; inner binds do not leak outside (except bind, the accumulated result). forEach exists in the flow and as a step inside an effect, for per-element effects within the same atomic boundary.

Error handling and outputs outputs

The set of distinct results an operation can return lives in the outputs catalog, canonicalized by observable output. The transport code is mapped separately.

Java
// success → DTO; each exception → code + HTTP
return ResponseEntity.ok(new Result(txId));
// ↑ vs ↓
catch (InsufficientBalance e) {
  // 403, "error.msg…insufficient", e.getMessage()
}
catch (ClientNotFound e) {
  // 404, "Customer " + id + " does not exist"
}
BKIR · outputs + binding
outputs:
  OUT_RETIRO_OK:
    result: success
    payload: { type: Record, fields: { transaccionId: { type: ID } } }
  OUT_SALDO_INSUFICIENTE:
    result: error
    code: "error.msg.savingsaccount.insufficient.account.balance"
    message: "Insufficient account balance."
    guard: COND_SALDO_INSUFICIENTE
  OUT_CLIENTE_NO_EXISTE:
    result: error
    message:
      template: "Customer {clienteId} does not exist"   # template with FEEL args
      args: { clienteId: clienteId }
bindings:
  - protocol: rest
    method: POST
    path: "/v1/savingsaccounts/{cuentaId}/transactions"
    responses:
      OUT_RETIRO_OK:          { status: 200 }
      OUT_SALDO_INSUFICIENTE: { status: 403 }
      OUT_CLIENTE_NO_EXISTE:  { status: 404 }
Semantic result. success / error / accepted (async acceptance). code is the symbolic contract; message is plain or a template; the transport status lives in binding.responses. What is pre-checkable is predicate; what is only seen on trying (service down, unique violation) is attempt.

Call composition invoke

Calling another method/operation that also has its own output catalog is an invoke node, with onOutcome to map the callee's outputs.

Java
// charging the card is itself another operation
ChargeResponse cobro =
  paymentClient.charge(total, tarjeta);
// its 3 errors are all handled the same here
BKIR · invoke + onOutcome
flow:
  - invoke: { bind: cobro, use: OP_COBRAR_TARJETA, args: { monto: total, tarjeta: tarjeta } }
    onOutcome:
      OUT_TARJETA_INVALIDA:         { to: OUT_FALLO_COBRO }   # declared collapse:
      OUT_TIPO_TARJETA_NO_ACEPTADO: { to: OUT_FALLO_COBRO }   # 3 callee outputs
      OUT_TARJETA_EXPIRADA:         { to: OUT_FALLO_COBRO }   # → 1 caller output
Semantic reuse. invoke says "this behavior is part of mine". The generator decides whether to inline or invoke. onOutcome maps the callee's OUT_ outputs to caller edges, including collapsing several into one.

Asynchronous messaging CHN_ IO_ trigger

Queues/events (JMS, Kafka…) are modeled with the channel kind: a Unit that gives the channel identity, its payload and its guarantees. The producer publishes; the consumer declares a trigger.

Java
// producer (within a transaction)
jms.send(handlingQueue, attempt);
// → 204 to the client: "accepted", not "done"

// consumer (MDB: a NEW transaction)
@MessageDriven
void onMessage(Message m) {
  service.register(parse(m));   // may fail → rollback
}
BKIR · channel + trigger
kind: channel
id: CHN_INTENTO_REGISTRO
payload: { type: Record, fields: { trackingId: { type: String } } }
delivery:
  guarantee: at-least-once
  onConsumerFailure: redeliver-then-dead-letter
  ttl: PT1S
---
kind: interaction                  # producer
id: IO_PUBLICAR_INTENTO
direction: effect
channel: CHN_INTENTO_REGISTRO      # publishes to the channel (instead of target)
inputs: { trackingId: { type: String } }
---
kind: operation                    # consumer
id: OP_REGISTRAR_EVENTO
trigger: { channel: CHN_INTENTO_REGISTRO }   # driven by the channel
# … an 'error' output ⇒ rollback governed by the channel policy
---
# and the synchronous boundary that enqueues:
outputs:
  OUT_REPORTE_ACEPTADO:
    result: accepted               # "enqueued", not "registered"
    continues: CHN_INTENTO_REGISTRO  # work continues on this channel
Navigable link. With the channel as a Unit, producer → channel → consumer is navigable and verifiable (every channel requires a producer — it catches dead queues). Inside a transactional effect, the atomicity persistence ⟺ emission is expressed. AsyncAPI is the natural projection.

Authentication and authorization security

That an operation requires an authenticated principal with a certain role is observable behavior (it produces a failure before running the flow). It is declared in the security block.

Java
@PreAuthorize("hasRole('FREQUENT_FLYER')")
public int getFrequentFlyerMileage() {
  var auth = SecurityContextHolder
               .getContext().getAuthentication();
  return repo.findByUsername(auth.getName())
             .getMiles();
}
BKIR · security
kind: operation
id: OP_OBTENER_MILLAS
security:
  authentication: required
  roles: [ FREQUENT_FLYER ]
  principal: principal             # available in the flow
  onFailure: { to: OUT_NO_AUTENTICADO }
input: {}                          # identity does not come in the payload
flow:
  - read: { bind: millas, use: IO_MILLAS_VIAJERO, args: { usuario: principal.usuario } }
  - outcome: OUT_MILLAS_OK
Identity from the environment. The requester does not come in the payload: it comes from the authenticated principal, which the model makes explicit. The concrete mechanism (WS-Security, JWT, session) is a binding detail.

Clock and randomness env

"Now" and generating random ids are ambient-environment dependencies. They are made explicit so equivalence tests can control them.

Java
LocalDate hoy = LocalDate.now();        // clock
if (fecha.isAfter(hoy)) { ... }

String id = UUID.randomUUID().toString(); // randomness
BKIR · env clock + read
kind: condition
id: COND_FECHA_FUTURA
inputs: { fecha: { type: Date } }
detection: { kind: predicate, expr: "fecha > today()" }
env: [ clock ]                     # required if today()/now() is used
---
kind: interaction
id: IO_UUID_ALEATORIO
direction: read
target: "system.random.uuid"       # randomness = reading the environment (not pure)
output: { type: UUID }
Visible dependencies. today()/now() require declaring env: [clock] (to freeze the clock in golden tests). Generating a random id is an interaction read, never a computation —it is not pure, and two calls give different results.

Missing a construct? The corpus (corpus/FINDINGS.md) records the expressiveness limits found while modeling real projects.

← Back to home