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 null → Optional /
*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: true ≡
guarantee: 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.