Skip to main content
ElastiCORE 3.0 is a release candidate: 3.0.261022-rc1 on Maven Central. Projects on the 2.2 line: see the 2.2 documentation.
Version: 3.0

Making new files

Rules can not only change files that already exist; they can also make new kinds of files. The example: sending domain events to a message broker needs a topic name per event. Instead of scattering strings across the code, write it once in the DSL and generate a constants class.

model/entity.yml
  PurchaseOrder:
meta: entity @root @expose(20) @event(Placed(total), Shipped, Cancelled) @topic(petstore.order) @owner(customerId)

The generated file - PetstoreEventTopics.java:

package io.elasticore.sample.petstore.domain.event;

import java.util.HashMap;
import java.util.Map;

/**
* PetstoreEventTopics - where each domain event of the domain is published.
*
* Generated from @topic by the pet store rules. Change the DSL, not this file.
*/
public final class PetstoreEventTopics {

public static final String PURCHASE_ORDER_PLACED = "petstore.order.placed";
public static final String PURCHASE_ORDER_SHIPPED = "petstore.order.shipped";
public static final String PURCHASE_ORDER_CANCELLED = "petstore.order.cancelled";

private static final Map<String, String> TOPICS = new HashMap<>();

static {
TOPICS.put("io.elasticore.sample.petstore.domain.event.PurchaseOrderPlacedEvent", PURCHASE_ORDER_PLACED);
TOPICS.put("io.elasticore.sample.petstore.domain.event.PurchaseOrderShippedEvent", PURCHASE_ORDER_SHIPPED);
TOPICS.put("io.elasticore.sample.petstore.domain.event.PurchaseOrderCancelledEvent", PURCHASE_ORDER_CANCELLED);
}

private PetstoreEventTopics() {
}

/** The topic of an event, by its class name as the outbox records it; null when it has none. */
public static String topicOf(String eventType) {
return TOPICS.get(eventType);
}
}

A new file needs three things.

WhatWhere
1The file's declaration - for which domain or entity, under what name, in which packagethe ruleset implements DomainArtifactContributor/ArtifactContributor
2The file's skeleton - the fixed parts and ${slots}the skeleton elasticore-template/v3/<target>.tmpl
3Filling the slots - from what the model saysa @Rule(target = "<target>") rule

1. Declaring the file​

The generator does not know the new kind of file in advance. The ruleset tells it "this domain has one file like this".

The file isInterface to implementMethodShape of the rule that fills it
one per domainDomainArtifactContributorartifactsFor(DomainSpec d)(DomainSpec d)
one per entityArtifactContributorartifactsFor(EntitySpec e)(EntitySpec e)
public class PetstoreRules implements CodeRules, DomainArtifactContributor {

@Override
public List<ContributedArtifact> artifactsFor(DomainSpec d) {
for (EntitySpec e : d.entities()) {
if (topicOf(e) != null && !e.events().isEmpty()) {
return Collections.singletonList(
ContributedArtifact.of("eventTopics", "event", className(d)).orNamespace("entity"));
}
}
return Collections.emptyList(); // no @topic in this domain: no file
}

ContributedArtifact.of("eventTopics", "event", "PetstoreEventTopics"):

ValueMeans
"eventTopics"The new target's name. The target of the rule that fills this file, and the skeleton's file name (eventTopics.tmpl)
"event"The package key the file goes in (namespace in env.yml). With namespace: base: io.elasticore.sample.petstore it is …domain.event
"PetstoreEventTopics"The class (file) name
.orNamespace("entity")The key to use instead in a project with no event key (layered)

The keys namespace: base: … makes:

KeyPackage
entity, enumeration…domain.model
event…domain.event
domainService, domainRepository…domain.service, …domain.repository
service, dto…application, …application.dto
inboundPort / queryPort, port…application.port.in / …application.port.out
repository, persistence…infrastructure.persistence
control…adapter.in.web

If your file needs a place of its own, add one more line to namespace (messaging: io.elasticore.sample.petstore.infrastructure.messaging) and use that key. When the condition does not hold, return an empty list - and there is no file.

2. The skeleton​

Put it in the rules module under src/main/resources/elasticore-template/, in the folder for the language, named after the target.

LanguagePath
Javaelasticore-template/v3/eventTopics.tmpl
Kotlinelasticore-template/kt/eventTopics.tmpl
ps-rules/src/main/resources/elasticore-template/v3/eventTopics.tmpl
package ${packageName};

import java.util.HashMap;
import java.util.Map;

/**
* ${className} - where each domain event of the domain is published.
*
* Generated from @topic by the pet store rules. Change the DSL, not this file.
*/
public final class ${className} {

${constantList}

private static final Map<String, String> TOPICS = new HashMap<>();

static {
${entryList}
}

private ${className}() {
}

/** The topic of an event, by its class name as the outbox records it; null when it has none. */
public static String topicOf(String eventType) {
return TOPICS.get(eventType);
}
}
  • Rules do not fill ${packageName}. The generator puts in the package chosen in artifactsFor beforehand, so the file's location and its package line cannot disagree.
  • A ${slot} left unfilled is not an error; it becomes an empty line. Catch a misspelled name with unfilledSlots() in a test.
  • Put ${importList} or ${classAnnotationList} on a line of its own, and the rule's addImport(...) and ensureClassAnnotation(...) go there. The example above writes its imports directly because they are fixed.

3. Filling the slots​

    @Rule(value = "ps-event-topics", target = "eventTopics")
void eventTopics(DomainSpec d) {
SourceCode c = ctx(d);
c.requireSyntaxAnyOf("ps-event-topics", Syntax.JAVA, Syntax.KOTLIN);
boolean kotlin = c.syntax() == Syntax.KOTLIN;

for (EntitySpec e : d.entities()) {
String topic = topicOf(e);
if (topic == null) continue;
for (String event : e.events()) {
String constant = upperSnake(e.getName() + event);
String eventClass = e.eventPackage() + "." + e.eventClassName(event);
if (kotlin) {
c.varLine("constantList", " const val %s = \"%s.%s\"", constant, topic, kebab(event));
c.varLine("entryList", " \"%s\" to %s,", eventClass, constant);
} else {
c.varLine("constantList", " public static final String %s = \"%s.%s\";", constant, topic, kebab(event));
c.varLine("entryList", " TOPICS.put(\"%s\", %s);", eventClass, constant);
}
}
}
// ${packageName} is already the package artifactsFor chose.
c.var("className", className(d));
}

/** @topic(petstore.order), with or without quotes. */
private static String topicOf(EntitySpec e) {
String v = e.metaValue("topic");
if (v == null) return null;
v = v.replace("\"", "").replace("'", "").trim();
return v.isEmpty() ? null : v;
}

private static String className(DomainSpec d) {
return d.className() + "EventTopics";
}

/** PurchaseOrderShipped -> PURCHASE_ORDER_SHIPPED */
private static String upperSnake(String name) {
return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase();
}

/** PartiallyShipped -> partially-shipped */
private static String kebab(String name) {
return name.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase();
}
CodeMeans
c.var("className", v)fills ${className}
c.varLine("constantList", fmt, args…)adds one line at a time where ${constantList} is
e.events()the @event names: [Placed, Shipped, Cancelled]
e.eventClassName(ev), e.eventPackage()the generated event class: PurchaseOrderShippedEvent, …domain.event
requireSyntaxAnyOf(...)the languages this rule can write. Used for another language's generation, such as TypeScript, it fails that file instead of quietly making a strange one

The rule writes the new file's lines itself, so here it branches on the language. What the generator writes - annotations, imports - needs no such branch.

The generation log:

    eventTopics  src/main/java/io/elasticore/sample/petstore/domain/event/PetstoreEventTopics.java  WROTE

-PlistRules shows the new target with its rule:

  eventTopics
0 once ps-event-topics PetstoreRules

Who manages this file​

The skeleton's first line decides.

The skeleton's first lineThe generated file is
(no marker) - the example aboveThe generator's. Rewritten when the DSL changes. Edited by hand, it is skipped with a notice
// Initially generated by ElastiCORE; no longer managed - do not remove this line.The developer's. Once made and then edited, it is left alone (for a stub to fill in)

Kotlin​

The same ruleset also runs in a Kotlin project (language = 'kotlin'). Only new files need a separate Kotlin skeleton - without one, generation does not fall back to the Java skeleton; that file is left failed (ERROR).

ps-rules/src/main/resources/elasticore-template/kt/eventTopics.tmpl
package ${packageName}

/**
* ${className} - where each domain event of the domain is published.
*
* Generated from @topic by the pet store rules. Change the DSL, not this file.
*/
object ${className} {

${constantList}

private val TOPICS: Map<String, String> = mapOf(
${entryList}
)

/** The topic of an event, by its class name as the outbox records it; null when it has none. */
@JvmStatic
fun topicOf(eventType: String): String? = TOPICS[eventType]
}

One file per entity​

To make one file per root (PurchaseOrderEvents) rather than one per domain, implement ArtifactContributor:

public class PerRootRules implements CodeRules, ArtifactContributor {

@Override
public List<ContributedArtifact> artifactsFor(EntitySpec e) {
if (!e.aggregateRoot() || e.events().isEmpty()) return Collections.emptyList();
return Collections.singletonList(
ContributedArtifact.of("rootEvents", "event", e.getName() + "Events").orNamespace("entity"));
}

@Rule(value = "ps-root-events", target = "rootEvents")
void rootEvents(EntitySpec e) {
SourceCode c = ctx(e);
for (String event : e.events()) {
c.varLine("eventList", " %s.class,", e.eventClassName(event));
}
c.var("className", e.getName() + "Events");
}
}

The skeleton goes the same way, in elasticore-template/v3/rootEvents.tmpl. There can be several rulesets - add them to rules = [...] in the build.

If you want to change a default skeleton​

Do not replace an ElastiCORE .tmpl wholesale; you would have to follow it on every version upgrade. Instead, turn that file off (Turning off) and make it as a new file.

Using the generated class​

PetstoreEventTopics is used together with the outbox (outbox: true in env.yml, @EnableScheduling on the main class). Implement OutboxPublisher to choose the topic:

@Component
public class TopicOutboxPublisher implements OutboxPublisher {

@Override
public void publish(OutboxEnvelope message) {
String topic = PetstoreEventTopics.topicOf(message.getEventType());
if (topic == null) throw new IllegalStateException("No topic for " + message.getEventType());
kafkaTemplate.send(topic, message.getAggregateId(), message.getPayload());
}
}

How the outbox behaves (retries, ordering) is in Derived values and events.