Every type is in the io.elasticore.base.model.pub.rules package. The rules module takes
elasticore-base as compileOnly (Your first ruleset). This page collects only
what team rules use. The other public methods are used by ElastiCORE's own rules and may change between
versions.
import io.elasticore.base.model.pub.rules.*;
import static io.elasticore.base.model.pub.rules.Ctx.ctx;
@Rule and CodeRules
CodeRules is a marker interface with no methods to implement. Each method with @Rule is one rule.
The method is void and needs no access modifier. The class must have a no-argument constructor.
| Attribute | Default | Means |
|---|
value | (required) | The rule id. It appears in -PlistRules and is used in suppress. Add a team prefix (ps-…, acme-…) |
target | "entity" | The kind of file it contributes to (Targets) |
order | 0 | Lowest first within the same phase; ties in registration order |
phase | HEAD | Whether a file rule runs before (HEAD) or after (TAIL) the field rules. Meaningless for field rules |
The method's shape decides when it is called
| Parameters | Called | target examples |
|---|
(EntitySpec e) | once each time a file of that entity is made | entity, dto, srchdto, repository, control … |
(EntitySpec e, FieldSpec f) | once per field of that file | entity, dto, srchdto |
(DomainSpec d) | once per domain file | mapper, apiErrors, a domain file of your own |
(EnumSpec e) | once per enum | enum |
(PortSpec p) | once per external integration port | port |
(ProtoServiceSpec p) | once per proto port | proto |
Within one file the order is HEAD file rules → field rules (per field) → TAIL file rules. Team rules
run in each phase after all of ElastiCORE's rules have finished, so they can remove or change what
a default rule added; use order only when team rules need an order among themselves.
Reading the model
EntitySpec - an entity, a DTO from dto.yml, a search DTO
| Method | In the DSL | Example (pet store) |
|---|
getName() | the entity name | "PurchaseOrder" |
domainName(), domainClassName() | the domain | "petstore", "Petstore" |
hasMeta("x"), metaValue("x") | @x in meta:, the value of @x(value) | metaValue("expose") → "20" |
metaValues("x") | all of them, when the same word appears several times | |
metaText("access") | the text as written of a paired value such as @access(read=public, update=owner) | |
aggregateRoot() | whether it is a root (false for a member or value object) | PurchaseOrder → true |
containedIn() | for a member, the name of its root | OrderLine → "PurchaseOrder" |
isSplitForBehavior(e.getName()) | whether it is a root split into two files (XxxBase + the developer class) | |
fields(), allFields(), dtoFields(), pkFields() | its own fields / including inherited / the DTO side / the identifiers | |
events(), eventClassName(ev), eventPackage() | the @event(...) names, the generated event class name and package | [Placed, Shipped, Cancelled] |
commands() → name(), params() | the business commands in commands: | |
queries() → QuerySpec | the methods in repository.yml (target repository) | |
pkg("dto") | the package of an env.yml namespace key | |
config("ps.maxPageSize") | elasticore.config.ps.maxPageSize in that domain's env.yml, null if absent | |
tablePrefix(), apiPath() | config.table.prefix, config.apiPath | "ps_", "/api" |
FieldSpec - one field line
| Method | In the DSL | Example |
|---|
name(), capitalName() | the field name, for accessors | "email", "Email" |
typeName(), coreItemType() | the declared type / the X of List<X> | |
required() | ! | |
hasDsl("x"), hasDslAny("x", "y") | whether @x is there (case-insensitive) | hasDsl("pii") |
dslValue("x"), dslValueAny(...) | the value of @x(value) | dslValue("search") → "like" |
dslProperty("onetomany", "mappedBy") | a named attribute such as @OneToMany(mappedBy="parent") | |
maxLength() | string(100) or @length (0 if absent) | |
label(), description() | the -- description, @label | |
dbColumnName() | @column or the snake_case name | |
isList(), isEntityRef(), isBaseType(), isStringType() | a collection / a reference to another entity / a base type / a string | |
isMoney(), isDate(), isDateTime() | money, date, datetime | |
DomainSpec - one domain
| Method | Means |
|---|
getName(), className() | "petstore", "Petstore" |
entities() | every entity of the domain |
namespace("event") | the package of a namespace key (keys you added included), null if absent |
config("a.b"), isOn("a.b") | an env.yml setting / whether it is "true" |
EnumSpec - enum.yml
| Method | Means |
|---|
getName() | "OrderStatus" |
constants() → name(), values() | the values and each value's columns (["DRF", "Draft"]) |
fields() | the column names in fields: |
PortSpec - port.yml
| Method | Means |
|---|
getName(), protocol(), url() | CreditCheckPort, "http"/"dbms", @url(...) |
operations() → name(), httpMethod(), url(), returnType(), params() | the calls in methods: |
QuerySpec - methods in repository.yml
| Method | Means |
|---|
name() | id: |
sql() | the query: string. null for a query derived from the method name |
isSelect(), isNative() | whether it reads / whether it is nativeQuery: true |
returnType(), params() | return:, params: |
Writing code
SourceCode - the file being made, returned by ctx(spec)
Pass ctx(...) what the rule received, as it is: ctx(e), ctx(d), ctx(enumeration), ctx(port).
| Method | Does |
|---|
ensureClassAnnotation(anno) | a class annotation. Never added twice if already there. Import automatic |
removeClassAnnotation(fqcn) | removes a class annotation (including one a default rule added) |
field(f) → FieldCode | that field's declaration |
removeField(f) | leaves that field out of this file |
method("name") → ensure(anno) | an annotation on every method with that name (repository, port …). A name that does not exist is reported in the build log |
addMethod(source) | adds a hand-written method at the end of the class (Annotations and methods) |
excludeMethod("deleteById", …) | leaves out generated endpoint methods |
addImport(fqcn) | registers an import (only those in use are rendered) |
dontMake() | does not make this file (SKIP(dontMake) in the log) |
var(k, v), var(k, fmt, args…) | fills ${k} in the skeleton. null is ignored - an empty value is "" |
varLine(k, fmt, args…), varLines(k, list) | adds lines where ${k} is |
syntax() | this file's language (Syntax.JAVA, Syntax.KOTLIN, Syntax.TYPESCRIPT) |
requireSyntaxAnyOf(ruleId, Syntax…) | used for another language's generation, fails that file instead of quietly making a wrong one |
artifactName() | which of the domain files this is (the class name of the ContributedArtifact) |
render(), unfilledSlots() | in tests: the whole file as a string / the names of unfilled ${…} |
FieldCode - one field
| Method | Does |
|---|
ensure(anno) | adds it only when the same annotation is not there (usually this one) |
add(anno) | always adds it (repeatable annotations) |
merge(anno) | adds only the attributes to the same annotation already there; adds it if absent |
replace(anno) | replaces the same annotation with this one |
removeAnnotation(fqcn) | removes it |
type("String"), rename("x"), initial("0") | the declared type / the name / the initial value |
comment(fmt, …), before(fmt, …), after(fmt, …) | the comment / a line above the declaration / a member after the declaration |
AnnoCode - annotations
Never build an annotation by concatenating strings. AnnoCode takes care of Java ({}) and Kotlin
([]) syntax, imports and quotes.
| Code | Result |
|---|
AnnoCode.of("jakarta.persistence.Column") | @Column + import |
AnnoCode.of("org.jmolecules.ddd.annotation.%s", "Identity") | the name built with a format |
.val("name", "user_id") | name = "user_id" (a string - gets quotes) |
.val("hidden", true), .val("min", 2) | hidden = true, min = 2 |
.valRaw("access", "JsonProperty.Access.WRITE_ONLY") | as written - access = JsonProperty.Access.WRITE_ONLY |
.valRaw("length", "%d", 200) | formatted |
.value("x"), .valueRaw("A.B") | the unnamed attribute - @X("x"), @X(A.B) |
.qualified() | with the full name (when the simple name clashes with jakarta.persistence.Entity) |
AnnoCode.AnnoList.of(...) | a list of annotations, such as indexes = {...} |
ModelCheck - checks
| Method | Result |
|---|
ModelCheck.warn(msg) | a warning only. The same message once |
ModelCheck.fail(msg) | runs generation to the end, shows all failures together and fails the build (elcore3 exit code 1) |
ModelCheck.failures(), ModelCheck.reset() | checking results in tests / resetting between tests |
Do not throw exceptions from a rule. Only the one file that rule was writing is left failed, and the
cause is hard to find.
New files
| Type | Does |
|---|
ArtifactContributor → artifactsFor(EntitySpec e) | declares a file per entity |
DomainArtifactContributor → artifactsFor(DomainSpec d) | declares a file per domain |
ContributedArtifact.of(target, namespaceKey, className) | the new target name (= the skeleton name), the package key, the class name |
.orNamespace("entity") | the key to use in a project that does not have that package key |
Skeletons go in the rules module's src/main/resources/elasticore-template/v3/<target>.tmpl (Java) and
elasticore-template/kt/<target>.tmpl (Kotlin). More in Making new files.
Syntax - the language seam
The same rules make both Java and Kotlin. The generator writes annotations and imports in the right
language, so team rules usually need no language branch. Only when you write code fragments yourself
(lines of a new file, addMethod) do you branch on ctx(e).syntax() or ask Syntax:
| Call | Java | Kotlin |
|---|
s.statement("return x") | return x; | return x |
s.getter("dto", "name") | dto.getName() | dto.name |
s.setter("to", "name", "v") | to.setName(v) | to.name = v |
s.localVar("T", "x", "init") | T x = init; | val x = init |
s.newInstance("X", "") | new X() | X() |
s.nullableType("String") | String | String? |
s.classLiteral("X") | X.class | X::class |
s.escapeName("in") | in | `in` |
TypeScript does not answer JVM concepts (classLiteral, sam, overriding …) and throws an exception.
Team rules are added only to server (Java, Kotlin) generation, not to the TypeScript client.