OCL Constraints

The Object Constraint Language (OCL) is the OMG-standard expression language for adding invariants, pre-conditions, and post-conditions to UML models. kUML implements the full OCL 2.4/2.5 expression language — collection iterators, let/if, type operations, association navigation, and more — for invariants, pre/post-conditions, and state-machine guards. A short list of deliberately out-of-scope features remains; see Deliberately not supported.

Invariants on classifiers

Add constraint(name, body) inside any classifier:

classOf(name = "Order") {
    attribute(name = "total", type = "BigDecimal")
    attribute(name = "discount", type = "BigDecimal")

    constraint(name = "NonNegativeTotal",
        body = "self.total >= 0")
    constraint(name = "DiscountWithinLimits",
        body = "self.discount >= 0 and self.discount <= self.total")
}

The body is plain OCL. self always refers to the surrounding classifier instance.

Validation

kuml validate order.kuml.kts

The CLI parses every constraint, evaluates it, and exits with code:

  • 0 if every constraint holds.

  • 1 if any constraint is violated. Each violation prints the constraint name, the classifier it sits on, and the OCL expression.

  • non-zero if a constraint fails to parse (syntax error).

In a Gradle build, the kumlValidate task runs the same check and fails the build if failOnValidationViolations is true (the default).

Supported OCL language (v0.23.0)

Category Supported

Boolean operators

and, or, not, implies (xor expressible via (a or b) and not (a and b))

Comparison

=, <>, <, , >, >=

Arithmetic

+, -, *, / (always real division), unary -

Literals

Integer (42), Real (3.14), String ('hello'), Boolean (true, false), null

Navigation

self.attribute, self.assocEnd.role, cross-metamodel (UML, BPMN, SysML 2)

Operation calls

self.attr.toUpper(), self.name.substring(1, 3) — zero- and N-arg calls

String standard library

size, concat, substring (1-based), toUpper, toLower, indexOf (1-based), isEmpty, notEmpty, at

Integer/Real standard library

abs, floor, round (half-up), max, min, mod, div

Collection ops

size, isEmpty, notEmpty, includes, excludes, count, including, excluding, union, intersection, first, last, asSet, asSequence, sum, closure

Iterators

forAll, exists, select, reject, collect, any, one, isUnique, sortedBy, iterate

Conditionals

if then else endif, let …​ in …​

Type operations

oclIsTypeOf, oclIsKindOf, oclAsType, oclIsUndefined, oclIsInvalid

Pre-state

expr@pre inside post: constraint bodies (snapshot-based; no-op without an explicit pre-state, since operation bodies are not executed — see Deliberately not supported)

State-machine guards

Transition guards on state machines use the same OCL expression language, with two additional implicit variables: event (the triggering event with its payload as a Map<String, Any?>) and the model instance binding.

stateMachine(name = "Cart") {
    initial("empty")
    state("active") {
        // Read attribute from current variables map
        constraint(name = "PositiveItemCount", body = "self.itemCount > 0")
    }
    state("checked_out") { final() }

    transition(from = "active", to = "checked_out",
        trigger = "checkout",
        guard = "event.totalCents > 0 and self.itemCount > 0")
}

The runtime parses the guard at evaluation time, navigates event.totalCents against the event’s payload map, and self.itemCount against the machine’s variables map. Both are populated by the simulator’s event stream — see simulate.

Guard expressions (on state machine transitions, activity/BPMN decision and gateway edges alike) additionally accept the C-like operators !, !=, &&, || alongside the OCL keywords not, <>, and, or from the table above — !allow and not allow are both valid and equivalent for a bare identifier. Both spellings bind identically in longer expressions too: ! and not both bind looser than comparison, so !a == b (C-like) and not a = b (OCL) each negate the whole comparison — !(a == b) / not (a = b), not (!a) == b. Still, pick one dialect per expression rather than mixing ! and not (or &&/and, ||/or, !=/<>) in the same guard — each front-end only recognizes its own operator tokens.
Guards are fail-closed with respect to variables that were never provided. A comparison against a variable that is missing from event’s payload or from the model instance’s variables map — `event.status <> 'rejected', vars.approved != true, or the bare-identifier equivalents — never evaluates to a trusted true just because a missing lookup resolves to null the same way a present-but-null value would. Instead the guard evaluates to GuardResult.Failed (the transition or edge does not fire), so a guard can never silently take the "allowed" branch on data the event stream never sent. This fail-closed result applies uniformly to state machine transitions and to activity/BPMN decision and gateway edges alike — but how the failure is surfaced for diagnosis differs by runtime path. On state machine transitions (StateMachineRuntime) it is recorded as a TraceEntry.GuardWarning in the trace. On activity/BPMN decision and gateway edges (ActivityRuntime, TokenFlowEngine) no trace entry is written — ActivityRuntime simply skips the edge with no diagnostic at all, and TokenFlowEngine only reports the failure through its optional guardResultListener callback, which is a no-op unless a caller wires it up; the kuml simulate CLI does, printing Warning [GUARD_EVALUATION_FAILED]: …​ to stderr. A function call anywhere in a guard expression (audit() != 1) is treated the same way, since call results are not resolved by the expression evaluator. &&/||/and/or remain short-circuiting: isVip || spendOver1000 still fires on isVip = true even when spendOver1000 was never set, because the right-hand side is never consulted.

vars.x <> null and vars.x != null are unaffected by the fail-closed rule above and remain a valid "is X set?" idiom: a missing x makes the comparison evaluate to false (not true), so it is never a candidate for the fail-closed downgrade in the first place — the transition or edge simply does not fire, exactly as when x is present and null. Because of that, <> null/!= null cannot distinguish "missing" from "present but null`" — both read as `false. oclIsUndefined() does not draw that line either: it evaluates to receiver == null, so it is true both for a genuinely missing variable and for one that is present with an explicit null value — not vars.x.oclIsUndefined() is therefore exactly equivalent to vars.x <> null, not a way around its blind spot. This OCL subset has no operator that tells "never set" apart from "set to null`"; a guard that must make that distinction needs the model to carry an explicit sentinel (e.g. a separate boolean variable such as `vars.cancelReasonProvided) rather than relying on the value of x alone.

The mirror-image idiom is not symmetric. vars.x = null, vars.x == null, and not (vars.x <> null) — the "is X not set?" spelling — are exactly the shape the fail-closed rule above targets: a missing x makes the comparison evaluate to a true that is built on data that was never provided, so it is downgraded from GuardResult.True to GuardResult.Failed (transition/edge does not fire, instead of firing on a silent true) — surfaced as a GuardWarning trace entry on the state machine path, or through the diagnostic channel described above on the activity/BPMN path. This is a behavior change from before this rule existed, where a missing x made vars.x = null evaluate to a trusted true. A guard written to mean "fire when X has never been set" must use vars.x.oclIsUndefined() instead — it evaluates to true for a genuinely missing variable without going through a null-tolerant comparison at all.

Profile-level constraints

Stereotypes can carry constraints that apply automatically to any element with that stereotype:

profile(name = "JavaEE") {
    stereotype(name = "Entity", extending = UmlMetaclass.Class) {
        constraint(name = "RequiresIdAttribute",
            body = "self.ownedAttribute->exists(a | a.appliedStereotype.name = 'Id')")
    }
}

When you applyProfile("JavaEE") and stereotype a class as Entity, the validator checks the constraint without you adding it manually.

Deliberately not supported

  • Collection-literal syntax (Set{1, 2, 3}, Bag{…​}) — collections are only produced via navigation or collect, never literal construction

  • Tuple types and tuple literals

  • oclType() reflection

  • Message expressions (^, ^^)

  • Real invocation semantics for user-defined model operations — call syntax (self.someOperation(1, 2)) parses, but there is no operation-body execution runtime behind it (no return-value computation beyond standard-library ops)

If you hit a limit, fall back to a smaller constraint or split it into multiple named constraints — the validator runs them independently anyway.

Conformance

kuml-core-ocl’s parser and evaluator are exercised against representative example expressions manually transcribed from the public OMG Object Constraint Language (OCL), Version 2.4 specification (formal/2014-02-03) — see `kuml-core/kuml-core-ocl/src/test/kotlin/dev/kuml/core/ocl/OclConformanceTest.kt for the full suite and a maintained feature-coverage matrix. There is no downloadable, machine-readable OMG OCL conformance test-case package publicly available, so this is a best-effort check against the spec’s published examples — not an official OMG certification. Evaluation-time regressions are anchored by OclBenchmarkTest.kt against small and 10,000-element collections.