Debug your business behavior where you wrote it
Your breakpoint belongs in the business rule, not in a generated wrapper. Vernac’s
IntelliJ integration lets you stop inside an inline behavior, inspect its arguments
and locals, step through the operation and evaluate ordinary Java expressions while
working in the .vernac source.
Version: use matching 0.5.0 compiler and plugin artifacts, or matching 0.5.0-SNAPSHOT artifacts from a source checkout. Earlier plugin versions do not provide this debugger integration. Build the application before starting a debug session.
A small example, with a real JVM behind it
A parcel label is a value object. Its behavior trims the text, converts it to upper case and adds a suffix. Those separate steps give us a useful place to pause and inspect what happens. No Spring context, database, Docker or credentials are needed.
Use the existing standalone examples/debugger project. Its first model file is
src/main/vernac/example/debug/label.vernac:
namespace example.debug;
term Label {description "A parcel's human-readable label.";}
value Label(String) behavior {
public String decorate(String suffix) {
String normalized = self.string().strip();
String result = normalized.toUpperCase(java.util.Locale.ROOT);
return result + suffix;
}
public String withMarker() {
return self.string() + marker();
}
private String marker() {
return "!";
}
public String externallyDecorated()
implemented by example.debug.handwritten.LabelFormatting;
};
The external implementation at the end is intentional: later we will step from a Vernac operation into handwritten Java. The example already contains that class. Do not paste another copy of the model into the same namespace.
Build once, then start an ordinary Java debug session
From the Vernac repository root, using JDK 25, run:
mvn clean install
mvn -f examples/debugger/pom.xml clean test
Install the freshly built plugin ZIP from vernac-intellij/target, restart IntelliJ
and import examples/debugger/pom.xml as a Maven project. Wait for Maven import and
indexing. The application targets Java 21; the Vernac build needs JDK 25.
The example POM enables debug information for source lines and local variables. Maven generation also writes the source mappings used by the plugin. Build before placing breakpoints, and rebuild and restart debugging after source changes.
Open label.vernac and click the gutter beside:
return result + suffix;
Debug the Java main class example.debug.demo.DebugWalkthrough using IntelliJ’s
normal Debug action. No separate Vernac run configuration is required. When the
companion class loads and execution reaches the line, the editor should stop in
your Vernac file.
Read the state of the operation
At this breakpoint the two preceding assignments have executed; the return has not. Inspect the Variables panel:
| Name | Value at this stop | Meaning |
|---|---|---|
self |
A Label instance |
The value object whose behavior is running. |
suffix |
"!" |
The method argument supplied by the Java caller. |
normalized |
"parcel" |
Text after removing surrounding whitespace. |
result |
"PARCEL" |
Text after uppercase conversion. |
self is the same access mechanism you use in the behavior. For a value object it
is the value object; entity and aggregate behaviors receive their generated Read or
Access view. The debugger currently displays those real runtime objects. A polished
Vernac-specific presentation of these views is future work.
Open Evaluate Expression and evaluate:
result + suffix
The result should be "PARCEL!". Try self.string() to see the original text,
including its spaces. These are Java expressions: method calls can execute code and
have side effects, exactly as in ordinary Java debugging.
You can also try adding suffix to Watches. Watches and more complex expressions
use the same Java context; the supported range of expressions remains subject to
IntelliJ’s Java evaluator and the currently visible variables.
Follow private helpers and handwritten Java
Use the normal Step Over, Step Into and Resume actions. Shortcut bindings depend on your keymap. Source mapping covers the supported inline method bodies; generated wrappers and unmapped positions can still appear while stepping. There is no promise that every step skips all generated infrastructure.
Set a second breakpoint on return "!"; inside marker. Resume until withMarker
calls it: private helpers are part of the same debugging workflow.
Next set an ordinary Java breakpoint in
src/main/java/example/debug/handwritten/LabelFormatting.java. The main calls
externallyDecorated, whose implementation lives there. The debugger should show
Java for that implementation. Vernac does not pretend that handwritten code lives
inside the DSL.
Stop only when it matters, or log without stopping
Right-click the breakpoint on return result + suffix;. IntelliJ exposes its usual
breakpoint properties. To test a condition, use:
suffix.equals("!")
Keep suspension enabled. Execution should stop because this call supplies "!".
Change the condition to suffix.equals("?") and restart the run: that breakpoint
should no longer stop on this call. Restore or remove the condition afterwards.
A logpoint records information while the program continues. Remove the condition,
choose Evaluate and log / Expression to log, enter result + suffix, and
disable Suspend. Run again and look for PARCEL! in the debug console.
| Option | What it does |
|---|---|
| Log message | Prints IntelliJ’s standard breakpoint-hit message. |
| Expression to log | Evaluates your Java expression and prints its value. |
| Suspend | Controls whether the debugger pauses at the breakpoint. |
| Condition | Controls which hits satisfy the breakpoint condition. |
A yellow Log indicator is an IntelliJ breakpoint setting, not a Vernac compiler warning or an application logging level. Labels and layout vary across IntelliJ versions. Restore Suspend and disable logging when you want an ordinary stopping breakpoint again.
Check expression editing separately
The expression editor and the runtime evaluator are separate IntelliJ mechanisms. The 0.5.0 plugin maps the requested Vernac source line to its verified generated Java scope for both Java and source-file expression requests. This addresses the reported red variables and missing completion; installed-IDE acceptance is still required and must not be inferred from successful evaluation.
In UsecaseMappingProbe, stop at String suffixText = ... in FormatLabel:
- Open Evaluate Expression and enter
normalized. Check that it resolves without a red unresolved-variable marker, then evaluate it. - Enter
normand press Ctrl+Space. Check thatnormalizedis offered. - Try
self.to inspect the generated input/dependency accessors. - Step past the assignment and check
suffixTexttoo. Before its declaration, it must not be offered as an already available local. - Repeat in a private helper and in a condition or Expression to log editor. Each editor must use its own requested method and line, not another selected frame’s variables.
If resolution remains red, report it separately from runtime evaluation. Rebuild the example and restart the debug session after installing the updated plugin.
Read and Access views are real objects
At Parcel behavior and validation breakpoints, self is a generated Read or
Access facade, not the Parcel itself. The debugger keeps its actual Java type and
adds the contract name and role. Expand it to inspect stored properties directly,
without first following the technical enclosing-object reference. Usecase and
service validation inputs similarly expose their captured parameters under their
API names. Dependencies/configuration with simple stored accessors are included too.
This is an inspection presentation. The plugin reads fields over JDI and checks a small supported subset of the loaded getter bytecode; it does not call getters, behaviors or toString to assemble the view. Computed methods are not shown as stored properties. If it cannot recognize the facade or validate its current source/runtime mapping, normal Java presentation remains available. Empty stored-property views explain that methods can be called explicitly through Evaluate Expression.
Optional properties are labeled optional stored value: the displayed child is
the actual nullable backing value, not a fabricated Optional object. A Watch created
from that child uses the accessor and .orElse(null) so its value matches this
projection. Your ordinary self.note() expression still returns Optional as before.
These projected children offer no Set Value action, even through an Access facade.
An Access view exposes mutation through its methods, not direct field writes.
Try this at Parcel.changeUnits: expand self, inspect id/label/units, and add units
to Watches. Its expression should address the facade’s units() accessor. In a
validation, only the Read/input contract’s stored properties should be offered.
Ordinary Java local variables keep their normal debugger editing behavior.
Fewer stops in pure facade reads
Normal stepping can now step out of a verified Read/Access accessor that only forwards to a stored field. It does not skip behavior bodies, computed read methods, setters with guards, modification scopes, validation rules or callback-bearing runtime helpers. Such frames can therefore still appear when you deliberately step into an operation. The actual stack is not rewritten or shortened.
This rule participates in IntelliJ’s stepping decisions, not breakpoint lookup. It does not remove explicit breakpoints or exception requests. Use Tools → Vernac → Show Generated Java in Debugger and start a fresh session to inspect the raw facades and disable this filter alongside Vernac source presentation. The filter is deliberately narrow; it is not a package-wide skip rule.
Changing values during a debug session
At a suspended frame, IntelliJ normally offers Set Value for a writable local
variable in Threads & Variables. For example, after normalized has been
assigned in FormatLabel, try changing that local string and evaluating it again.
This changes the paused execution for your experiment; it is not a Vernac model
operation or an application feature.
Do not expect the same action on every expanded object field. Value objects store
immutable final fields, and a validation self is a read-only view. The plugin
does not add setters or override Java’s debugger write restrictions. Missing
Set Value on such a value is different from a missing writable local-variable
action. Report the latter separately, including the paused line and variable.
Inspect a changing entity
The second model file, src/main/vernac/example/debug/parcel.vernac, adds a parcel
with a mutable quantity:
namespace example.debug;
id ParcelId;
value Units(int) validates {
require(self.intValue() > 0, "Units must be positive");
};
entity Parcel[ParcelId](Label, mut Units) validates {
require(self.units().intValue() <= 10, "A parcel supports at most ten units");
} behavior {
read String display() {
String decorated = self.label().decorate("!");
return decorated;
}
modify void changeUnits(Units units) {
self.units(units);
}
};
Set a breakpoint at self.units(units);. The main creates the parcel with one unit
and calls changeUnits(Units.of(2)). At the stop, inspect the units argument and
evaluate self.units().intValue(). Before the setter the current quantity is 1;
after stepping over it the quantity is 2.
This is the actual mutation, not a simulated debugger state or a transaction
rollback preview. The debugger does not change Vernac’s mutation or validation
contracts. The Units validation block also supports a breakpoint on its require line;
the validation exercise below explores passing and failing rules.
Follow a usecase into its private helper
Inline usecase execute blocks and private helpers now have source mappings too.
Open src/main/vernac/example/debug/format-label.vernac:
namespace example.debug;
usecase FormatLabel(Label label, Label suffix) returns Label
validates {
require(
!self.label().string().isBlank(),
"A label is required"
);
} behavior {
execute {
String normalized = label.string().strip();
String suffixText = suffix.string();
String result = uppercase(normalized);
return Label.of(result + suffixText);
}
private String uppercase(String normalized) {
String result = normalized.toUpperCase(java.util.Locale.ROOT);
return result;
}
}
Set breakpoints on return Label.of(result + suffixText); and on return result;
in uppercase. Debug example.debug.demo.UsecaseMappingProbe. At the helper’s
return you should see normalized = "parcel" and result = "PARCEL". The private
helper has only its explicit arguments and locals, with no implicit self.
Resume to execute’s return. You should now see the label and suffix domain
arguments, normalized = "parcel", suffixText = "!" and result = "PARCEL".
The self object is the generated usecase Access view. Evaluate result + suffixText
to obtain "PARCEL!". In a usecase declaring uses, its declared dependencies are
available through that Access view, as in the source code.
This small mapping probe deliberately constructs the generated object directly and starts no Spring context or transaction. It tests source navigation and live variables, not REQUIRED propagation. In a real application, inject the generated usecase as a Spring bean and debug the call through that bean so its transaction interceptor remains active. The existing persistence examples demonstrate that application setup; source mapping does not change it.
A usecase using execute implemented by ... continues into its handwritten Java
implementation. Validates blocks have their own rule-entry mappings, described below. Generated
transaction and forwarding wrappers are still not copied inline source.
Step through a domain service
A domain service coordinates behavior that does not belong to one model object.
Its named inline operations and private helpers now have native source mappings.
The example’s src/main/vernac/example/debug/label-preparation.vernac contains:
namespace example.debug;
service LabelPreparation behavior {
public Label prepare(Label label, Label suffix)
validates {
require(
!self.label().string().isBlank(),
"A label is required"
);
} {
String normalized = label.string().strip();
String suffixText = suffix.string();
String result = uppercase(normalized);
return Label.of(result + suffixText);
}
private String uppercase(String normalized) {
String result = normalized.toUpperCase(java.util.Locale.ROOT);
return result;
}
}
Set a breakpoint at return result; in uppercase and at
return Label.of(result + suffixText); in prepare. Debug
example.debug.demo.ServiceMappingProbe. The helper has normalized = "parcel"
and result = "PARCEL". After resuming to prepare, suffixText is "!" and
self is the generated service Access view. Inspect the two Label arguments and
evaluate result + suffixText to obtain "PARCEL!".
The probe constructs this dependency-free, stateless service directly. Unlike a usecase, a domain service does not establish its own transaction. In an application, Spring normally injects the service and its dependencies; a surrounding usecase owns the transaction. These rules are unchanged by debugging support.
The same mapping machinery handles overloaded service operations. Service uses
dependencies remain available through self; private helpers receive only their
explicit arguments. implemented by implementations stay ordinary Java, and the
service’s validates block has its own Read/input-view context, described below.
Pause before an invariant is checked
A validation breakpoint belongs on the require line. Vernac maps that line to
the generated expression evaluation. The Read/input self is available, so you can
inspect the same values that the rule reads without gaining mutation access.
Open parcel.vernac and set a breakpoint on:
require(self.intValue() > 0, "Units must be positive");
Debug example.debug.demo.ValidationMappingProbe with program arguments
value to exercise a valid value. Evaluate self.intValue() at the stop: it is 2.
Resume and the probe reports Validation passed: value.
Run again with program arguments value invalid. The same breakpoint now exposes
self.intValue() == 0. Resume: the normal DomainValidationException is thrown and
the probe catches it to report the expected failure. It does not repair the value
or suppress an error in application code; catching the expected exception is part
of this controlled example.
Try the other kinds, setting only the breakpoint for the rule you want to inspect:
| Program arguments | Rule location | Value visible through self |
|---|---|---|
entity / entity invalid |
Parcel’s require in parcel.vernac |
self.units().intValue() is 2 / 11. |
aggregate / aggregate invalid |
Batch’s require in batch.vernac |
self.units().intValue() is 2 / 11. |
usecase / usecase invalid |
The require in format-label.vernac |
self.label().string() is "parcel" / " ". |
service / service invalid |
The require in label-preparation.vernac |
self.label().string() is "parcel" / " ". |
For a multiline require, use its opening line, not an arbitrary continuation or the message string. There is one mapped entry per rule, before its condition is evaluated. The generated exception throw is not a second breakpoint on that rule. Compiler- optimized constant conditions may have no executable location, just as in Java.
Step/exception handling may still expose generated throw or wrapper code. The mapping does not hide the actual exception machinery. Default field null checks and generated infrastructure checks are not user-authored requires and do not gain fictional source mappings. Event-payload and adapter validation delegates are outside this rule-entry mapping increment.
The usecase probe directly constructs an object to isolate validation; it has no Spring transaction interception. Production usecases still run through their Spring beans. Services still rely on their caller’s transaction. Failed domain mutations retain the ordinary Vernac state semantics; the debugger adds no rollback.
Debug an event listener on its actual stack
Synchronous and deferred listener execute bodies and private helpers have the same
source mapping as usecases. Open src/main/vernac/example/debug/label-events.vernac:
namespace example.debug;
event LabelPrepared(Label);
synchronous listener ObserveLabel on LabelPrepared behavior {
execute {
String text = event.label().string();
String upper = uppercase(text);
System.out.println(upper);
}
private String uppercase(String text) {
String upper = text.toUpperCase(java.util.Locale.ROOT);
return upper;
}
}
deferred listener DeliverLabel on LabelPrepared behavior {
execute {
String text = event.label().string();
String upper = uppercase(text);
System.out.println(upper);
}
private String uppercase(String text) {
String upper = text.toUpperCase(java.util.Locale.ROOT);
return upper;
}
}
Set a breakpoint at System.out.println(upper); in ObserveLabel or at its helper’s
return upper;. Debug example.debug.demo.ListenerMappingProbe with no arguments.
The probe directly invokes the generated synchronous handler. Inspect event, its
payload and identity, self, text = "parcel" and upper = "PARCEL". At the private
helper only its explicit text argument and upper local are present.
For DeliverLabel, set the equivalent breakpoint and run the probe with program
argument background. It directly invokes the generated deferred handler on the
thread named vernac-listener-mapping-probe. The stack belongs to that background
thread; the main thread waits for its result. There is no invented emitter frame.
Probe scope: these two runs isolate source mapping and thread/stack presentation. They do not call emit, persist an event or a delivery, run JdbcEventWorker or establish a database transaction. The example depends on the JDBC runtime and Spring JDBC so generated storage classes for the deferred declaration compile; this probe never connects to a database. Do not copy its direct handler invocation as an application event-publication pattern.
Try the complete durable delivery path
To debug actual publication and the database-backed worker, use
the persistence demo with its PostgreSQL
container and normal startup configuration. Build with matching development artifacts,
then start its org.example.delivery.demo.PersistenceDemoApplication in Debug from
the IDE, rather than launching it as an unattached Maven process.
In src/main/vernac/org/example/delivery/events.vernac, set a breakpoint on the execute
line of ObserveDemoNotice and another on DeliverDemoNotice’s execute line. Resume
between stops. The synchronous receiver runs inside publication’s transaction. The
deferred receiver later runs from JdbcEventWorker’s processing call, in its own
transaction; the original publishing call is not part of that stack. RetryDemoNotice
also demonstrates a deliberate first failure followed by another delivery attempt.
The event tutorial explains delivery semantics. Breakpoints do not create exactly-once processing or change retry/lease behavior. Long pauses can allow a persisted claim’s wall-clock lease to expire, particularly with other worker processes running; use the local demo and interpret repeated delivery accordingly. Ordinary Java breakpoints remain available in EventDemoProbe and JdbcEventWorker.
Follow the adapter and its ACL
Business behavior does not end at the domain boundary. The same debugger mapping now covers inline outbound adapter methods and helpers, Java mapping bodies and explicit declarative assignments in inbound and outbound adapters.
The example adds src/main/vernac/example/debug/label-adapters.vernac:
namespace example.debug;
port LabelArchive {
Label load(Label label);
}
adapter LocalLabelArchive implements LabelArchive {
behavior {
java imports {
example.debug.adapter.outbound.dto.LocalLabelArchiveDtos.Payload;
example.debug.adapter.outbound.mapping.LocalLabelArchiveMappings;
}
public Label load(Label label) {
String text = normalize(label.string());
var payload = Payload.of(text);
return LocalLabelArchiveMappings.toLabel(payload);
}
private String normalize(String text) {
String normalized = text.strip();
return normalized;
}
}
schema Payload(String text);
mapping toLabel: Label <- Payload {
java {
String text = source.text();
return Label.of(text);
}
}
mapping toPayload: Payload <- Label {
text <- source.string;
}
}
event LabelChecked(Label) validates {
require(!self.label().string().isBlank(), "A checked label is required");
};
Rebuild the example and debug example.debug.demo.AdapterMappingProbe. It directly
invokes the adapter, mappings and an inbound controller. It needs no HTTP server,
Spring application context, database, credentials or remote system. Like the earlier
usecase probe, these direct calls do not establish Spring transaction interception.
Try these breakpoints, resuming between stops:
| Source line | What to inspect |
|---|---|
return normalized; in the adapter helper |
text and normalized; the private helper has no self. |
return LocalLabelArchiveMappings.toLabel(payload); |
Adapter self, text and payload. |
return Label.of(text); in toLabel |
Mapping parameter source and local text; a mapping has no self. |
text <- source.string; in toPayload |
source.string() evaluates to "parcel". |
Event’s require(...) |
Read-only event self, including payload and metadata. |
A declarative assignment maps to its generated conversion statement, before the
target local is initialized. Inspect source and already initialized locals; the
converted target value may only be available after stepping. Implicit defaults for
omitted optional fields do not receive invented Vernac breakpoints. Mapping helpers
may appear if you explicitly step into runtime conversion code. The current mapping
follows target-field order, so declarations written in a different order can be
visited in a different source-line order. No extra ordering guarantee is introduced.
To exercise a failing event validation, debug the same main class with argument
event-invalid. It catches the expected validation exception after your breakpoint.
The probe creates an event; it does not publish it or create an outbox delivery.
Follow an inbound argument and response
The second file, src/main/vernac/example/debug/label-api.vernac, uses the existing
FormatLabel usecase:
namespace example.debug;
adapter LabelApi inbound http uses FormatLabel formatLabel {
base "/debug/labels";
format {
POST "/";
body Request;
execute formatLabel {
label <- body.text;
suffix <- body.suffix;
}
respond 200 Reply using output;
}
schema Request(String text, String suffix);
schema Reply(String text);
mapping output: Reply <- Label {
text <- source.string;
}
mapping manual: Reply <- Label {
java imports { example.debug.adapter.inbound.http.dto.LabelApiDtos.Reply; }
java {
String text = source.string();
return Reply.of(text);
}
}
}
In the same probe, set a breakpoint on label <- body.text;. This stops at the
conversion to the usecase’s Label argument. The actual controller frame contains
this and __body; evaluate __body.text() to inspect the request. Generated wire
and argument locals keep their actual Java names. The debugger does not manufacture
a domain self for a controller or rename its variables.
The 0.5.0 MVC adapter uses the same argument mapping mechanism. A breakpoint on
noteText <- form.text; in an execute block stops in its generated conversion
helper with the actual wire locals. A real-JVM regression test covers this path;
HTML/template expressions remain the template engine’s tooling. See the
MVC walkthrough.
Set another breakpoint on text <- source.string; in output. The response mapping
sees the complete usecase result as source; its string is "PARCEL!". The probe
also directly calls manual, so you can stop at return Reply.of(text); and compare
the Java mapping experience with the declarative version. Argument assignments are
executed in usecase-parameter order.
These are breakpoints on executable conversions. HTTP verbs, routes, status codes,
error-policy declarations and schemas are configuration, not artificial execution
steps. implemented by implementations continue to use their real Java files.
Adapter-method validates blocks are currently rejected by the language contract;
this debugger change does not introduce them. Existing VO and usecase validation
still runs normally when an adapter or mapping calls their factories or methods.
If a breakpoint does not bind
First check that you built the example with matching compiler/runtime/plugin artifacts and installed the new plugin. Choose an executable statement inside a supported inline behavior; comments, empty lines and declarations alone do not necessarily have a bytecode location.
If you edited the source, run the example’s Maven build again and restart the debug session. The plugin checks the source, generated Java and loaded companion fingerprints. An old loaded class must not silently point to a new source line. Unsaved source changes also affect this check. Do not edit generated files or the source-map JSON to force a match.
The current discovery uses the standard Maven src/main/vernac and
target/generated-sources/vernac paths. Custom output directories, dependency-module
source attachment, remote debugging and hot swap are not established by this tutorial.
When you want to inspect the generated implementation
Contributors sometimes need the opposite view. Enable Tools → Vernac → Show Generated Java in Debugger before starting a new debug run. Uncheck it and start another session to prefer Vernac source positions again. The switch changes source presentation, not application behavior; it does not remove existing Vernac breakpoints. You can always set normal Java breakpoints in generated or handwritten code.
Coverage and what comes next
| Area | Current state |
|---|---|
| Inline VO, entity, aggregate and collection behaviors; private helpers | Source mapping is implemented. The walkthrough exercises VO and entity behavior. |
| Breakpoints, stepping, visible variables, Evaluate Expression, expression completion and conditions | Confirmed in installed IntelliJ sessions for the exercised core paths. |
| Logpoints and Watches | Java context is connected; use the exercises above to check your IDE configuration. |
Handwritten implemented by implementations |
Normal Java debugging. |
| Inline usecase execute and private helpers | Mapping and real-JVM checks implemented; initial installed-IDE walkthrough confirmed. |
| Inline domain service operations and private helpers | Mapping and real-JVM checks implemented; initial installed-IDE walkthrough confirmed. |
| Require rules for VOs, entities, aggregates, usecases and domain services | One rule-entry mapping per require; passing/failing real-JVM checks implemented and the initial require walkthrough confirmed in the IDE. |
| Synchronous/deferred listener execute and helpers | Source mapping and real-JVM thread/stack tests implemented; the probe isolates mapping and the persistence demo exercises real delivery. |
| Outbound adapter Java methods and private helpers | Source maps and real-JVM breakpoint/local-variable tests implemented. |
| Java mapping bodies, explicit declarative mappings and inbound execute argument assignments | Both directions mapped; real-JVM entry-point/local-variable tests implemented. |
| Event-payload require rules | Rule-entry mapping, including passing/failing real-JVM checks. |
| Adapter/mapping installed-IDE acceptance | Confirmed in the user walkthrough; JVM tests do not establish every IntelliJ UI interaction. |
| Read/Access/input view presentation and pure-facade-read stepping | Implemented with field-only JDI inspection and conservative bytecode checks; native tree/Watch/stepping acceptance is separate. |
| Broader wrapper/runtime stepping filters and cross-language refactorings | Future work; not implied by the narrow facade filter. |
Deferred listeners remain separate executions with their own transactions. Source mapping preserves the actual call stack; it does not join the emitting usecase to a later worker execution.
Continue with domain behavior, usecases or the IDE setup guide. Contributors can inspect the source-mapping design and acceptance plan.