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.
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.
| What | Where | |
|---|---|---|
| 1 | The file's declaration - for which domain or entity, under what name, in which package | the ruleset implements DomainArtifactContributor/ArtifactContributor |
| 2 | The file's skeleton - the fixed parts and ${slots} | the skeleton elasticore-template/v3/<target>.tmpl |
| 3 | Filling the slots - from what the model says | a @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 is | Interface to implement | Method | Shape of the rule that fills it |
|---|---|---|---|
| one per domain | DomainArtifactContributor | artifactsFor(DomainSpec d) | (DomainSpec d) |
| one per entity | ArtifactContributor | artifactsFor(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"):
| Value | Means |
|---|---|
"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:
| Key | Package |
|---|---|
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.
| Language | Path |
|---|---|
| Java | elasticore-template/v3/eventTopics.tmpl |
| Kotlin | elasticore-template/kt/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 inartifactsForbeforehand, so the file's location and itspackageline cannot disagree. - A
${slot}left unfilled is not an error; it becomes an empty line. Catch a misspelled name withunfilledSlots()in a test. - Put
${importList}or${classAnnotationList}on a line of its own, and the rule'saddImport(...)andensureClassAnnotation(...)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();
}
| Code | Means |
|---|---|
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 line | The generated file is |
|---|---|
| (no marker) - the example above | The 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).
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.