aggregates
+) {
+ public Slice {
+ if (commands == null) {
+ commands = List.of();
+ }
+ if (events == null) {
+ events = List.of();
+ }
+ if (externalEvents == null) {
+ externalEvents = List.of();
+ }
+ if (aggregates == null) {
+ aggregates = List.of();
+ }
+ }
+}
diff --git a/main/codegen/src/main/resources/akces-event-model.schema.json b/main/codegen/src/main/resources/akces-event-model.schema.json
new file mode 100644
index 00000000..4d7de2c0
--- /dev/null
+++ b/main/codegen/src/main/resources/akces-event-model.schema.json
@@ -0,0 +1,330 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Akces Event Model Definition",
+ "description": "Extends the event-modeling specification (https://github.com/dilgerma/event-modeling-spec) with Akces Framework-specific aggregate configuration for code generation.",
+ "type": "object",
+ "properties": {
+ "packageName": {
+ "type": "string",
+ "description": "The base Java package for generated code"
+ },
+ "aggregateConfig": {
+ "type": "object",
+ "description": "Per-aggregate configuration keyed by aggregate name",
+ "additionalProperties": { "$ref": "#/$defs/AggregateConfig" }
+ },
+ "slices": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Slice" }
+ }
+ },
+ "required": ["packageName", "aggregateConfig", "slices"],
+ "additionalProperties": false,
+
+ "$defs": {
+ "AggregateConfig": {
+ "type": "object",
+ "description": "Akces-specific configuration for an aggregate",
+ "properties": {
+ "indexed": {
+ "type": "boolean",
+ "description": "Whether the aggregate is indexed"
+ },
+ "indexName": {
+ "type": "string",
+ "description": "The index name for the aggregate"
+ },
+ "generateGDPRKeyOnCreate": {
+ "type": "boolean",
+ "description": "Whether to generate a GDPR key on create"
+ },
+ "stateVersion": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "The version of the aggregate state"
+ },
+ "stateFields": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Field" },
+ "description": "The fields of the aggregate state"
+ }
+ },
+ "required": ["stateFields"],
+ "additionalProperties": false
+ },
+
+ "Slice": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "status": {
+ "type": "string",
+ "enum": ["Created", "Done", "InProgress"]
+ },
+ "index": { "type": "integer" },
+ "title": { "type": "string" },
+ "context": { "type": "string" },
+ "sliceType": {
+ "type": "string",
+ "enum": ["STATE_CHANGE", "STATE_VIEW", "AUTOMATION"]
+ },
+ "commands": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Element" }
+ },
+ "events": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Element" }
+ },
+ "external_events": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Element" }
+ },
+ "readmodels": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Element" }
+ },
+ "screens": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Element" }
+ },
+ "screenImages": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/ScreenImage" }
+ },
+ "processors": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Element" }
+ },
+ "tables": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Table" }
+ },
+ "specifications": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Specification" }
+ },
+ "actors": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Actor" }
+ },
+ "aggregates": {
+ "type": "array",
+ "items": { "type": "string" }
+ }
+ },
+ "required": [
+ "id",
+ "title",
+ "sliceType",
+ "commands",
+ "events",
+ "readmodels",
+ "screens",
+ "processors",
+ "tables",
+ "specifications"
+ ],
+ "additionalProperties": false
+ },
+
+ "Element": {
+ "type": "object",
+ "properties": {
+ "groupId": { "type": "string" },
+ "id": { "type": "string" },
+ "tags": { "type": "array", "items": { "type": "string" } },
+ "domain": { "type": "string" },
+ "modelContext": { "type": "string" },
+ "context": { "type": "string", "enum": ["INTERNAL", "EXTERNAL"] },
+ "slice": { "type": "string" },
+ "title": { "type": "string" },
+ "fields": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Field" }
+ },
+ "type": {
+ "type": "string",
+ "enum": ["COMMAND", "EVENT", "READMODEL", "SCREEN", "AUTOMATION"]
+ },
+ "description": { "type": "string" },
+ "aggregate": { "type": "string" },
+ "aggregateDependencies": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "dependencies": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Dependency" }
+ },
+ "apiEndpoint": { "type": "string" },
+ "service": { "type": ["string", "null"] },
+ "createsAggregate": { "type": "boolean" },
+ "triggers": { "type": "array", "items": { "type": "string" } },
+ "sketched": { "type": "boolean" },
+ "prototype": { "type": "object" },
+ "listElement": { "type": "boolean" }
+ },
+ "required": ["id", "title", "fields", "type", "dependencies"],
+ "additionalProperties": false
+ },
+
+ "ScreenImage": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "title": { "type": "string" },
+ "url": { "type": "string" }
+ },
+ "required": ["id", "title"],
+ "additionalProperties": false
+ },
+
+ "Table": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "title": { "type": "string" },
+ "fields": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Field" }
+ }
+ },
+ "required": ["id", "title", "fields"],
+ "additionalProperties": false
+ },
+
+ "Specification": {
+ "type": "object",
+ "properties": {
+ "vertical": { "type": "boolean" },
+ "id": { "type": "string" },
+ "sliceName": { "type": "string" },
+ "title": { "type": "string" },
+ "given": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/SpecificationStep" }
+ },
+ "when": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/SpecificationStep" }
+ },
+ "then": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/SpecificationStep" }
+ },
+ "comments": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Comment" }
+ },
+ "linkedId": { "type": "string" }
+ },
+ "required": ["id", "title", "given", "when", "then", "linkedId"],
+ "additionalProperties": false
+ },
+
+ "SpecificationStep": {
+ "type": "object",
+ "properties": {
+ "title": { "type": "string" },
+ "tags": { "type": "array", "items": { "type": "string" } },
+ "examples": { "type": "array", "items": { "type": "object" } },
+ "id": { "type": "string" },
+ "index": { "type": "integer" },
+ "specRow": { "type": "integer" },
+ "type": {
+ "type": "string",
+ "enum": ["SPEC_EVENT", "SPEC_COMMAND", "SPEC_READMODEL", "SPEC_ERROR"]
+ },
+ "fields": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Field" }
+ },
+ "linkedId": { "type": "string" },
+ "expectEmptyList": { "type": "boolean" }
+ },
+ "required": ["title", "id", "type"],
+ "additionalProperties": false
+ },
+
+ "Comment": {
+ "type": "object",
+ "properties": { "description": { "type": "string" } },
+ "required": ["description"],
+ "additionalProperties": false
+ },
+
+ "Actor": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "authRequired": { "type": "boolean" }
+ },
+ "required": ["name", "authRequired"],
+ "additionalProperties": false
+ },
+
+ "Dependency": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "type": { "type": "string", "enum": ["INBOUND", "OUTBOUND"] },
+ "title": { "type": "string" },
+ "elementType": {
+ "type": "string",
+ "enum": ["EVENT", "COMMAND", "READMODEL", "SCREEN", "AUTOMATION"]
+ }
+ },
+ "required": ["id", "type", "title", "elementType"],
+ "additionalProperties": false
+ },
+
+ "Field": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "type": {
+ "type": "string",
+ "enum": [
+ "String",
+ "Boolean",
+ "Double",
+ "Decimal",
+ "Long",
+ "Custom",
+ "Date",
+ "DateTime",
+ "UUID",
+ "Int"
+ ]
+ },
+ "example": {
+ "oneOf": [
+ { "type": "string" },
+ { "type": "object" }
+ ]
+ },
+ "subfields": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Field" }
+ },
+ "mapping": { "type": "string" },
+ "optional": { "type": "boolean" },
+ "piiData": {
+ "type": "boolean",
+ "description": "Whether this field contains PII data requiring GDPR protection"
+ },
+ "technicalAttribute": { "type": "boolean" },
+ "generated": { "type": "boolean" },
+ "idAttribute": { "type": "boolean" },
+ "schema": { "type": "string" },
+ "cardinality": {
+ "type": "string",
+ "enum": ["List", "Single"]
+ }
+ },
+ "required": ["name", "type"],
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
new file mode 100644
index 00000000..30fd89cf
--- /dev/null
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2022 - 2026 The Original Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.elasticsoftware.akces.codegen;
+
+import org.elasticsoftware.akces.codegen.model.EventModelDefinition;
+import org.testng.annotations.Test;
+
+import javax.tools.*;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.testng.Assert.*;
+
+/**
+ * Tests that verify the generated Java source code actually compiles.
+ * Uses the Java Compiler API ({@link javax.tools.JavaCompiler}) to compile
+ * the generated files with the Akces API on the classpath.
+ *
+ * Generated sources are written to {@code target/generated-test-sources}
+ * which is managed by Maven (cleaned by {@code mvn clean}).
+ */
+public class CompilationTest {
+
+ private static final Path GENERATED_SOURCES_DIR = Path.of("target", "generated-test-sources");
+ private static final Path TEST_CLASSES_DIR = Path.of("target", "test-classes");
+
+ private final AkcesCodeGenerator generator = new AkcesCodeGenerator("com.example.aggregates");
+
+ @Test
+ public void testAccountGeneratedCodeCompiles() throws Exception {
+ EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
+ assertCompiles(definition, "Account");
+ }
+
+ @Test
+ public void testWalletGeneratedCodeCompiles() throws Exception {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ assertCompiles(definition, "Wallet");
+ }
+
+ @Test
+ public void testBothAggregatesCompileTogether() throws Exception {
+ // Generate and compile both definitions into the same source tree
+ // to verify there are no naming collisions
+ EventModelDefinition accountDef = loadDefinition("crypto-trading-account.json");
+ EventModelDefinition walletDef = loadDefinition("crypto-trading-wallet.json");
+
+ Path outputDir = GENERATED_SOURCES_DIR;
+ Files.createDirectories(outputDir);
+
+ generator.generateToDirectory(accountDef, outputDir);
+ generator.generateToDirectory(walletDef, outputDir);
+
+ List sourceFiles = collectJavaFiles(outputDir);
+ assertFalse(sourceFiles.isEmpty(), "Should have generated Java source files");
+
+ compileAndAssert(sourceFiles, outputDir);
+ }
+
+ /**
+ * Generates code from the definition, writes it to
+ * {@code target/generated-test-sources},
+ * and compiles it using the Java Compiler API.
+ */
+ private void assertCompiles(EventModelDefinition definition, String aggregateName) throws Exception {
+ Path outputDir = GENERATED_SOURCES_DIR;
+ Files.createDirectories(outputDir);
+
+ List generated = generator.generateToDirectory(definition, outputDir);
+ assertFalse(generated.isEmpty(),
+ "Should have generated files for " + aggregateName);
+
+ // Verify all generated files exist on disk
+ for (GeneratedFile gf : generated) {
+ Path filePath = outputDir.resolve(gf.relativePath());
+ assertTrue(Files.exists(filePath),
+ "Generated file should exist: " + gf.relativePath());
+ }
+
+ List sourceFiles = collectJavaFiles(outputDir);
+ assertFalse(sourceFiles.isEmpty(), "Should have .java files on disk");
+
+ compileAndAssert(sourceFiles, outputDir);
+ }
+
+ /**
+ * Compiles the given Java source files and asserts that compilation succeeds.
+ * Compiled classes are written to {@code target/test-classes}.
+ */
+ private void compileAndAssert(List sourceFiles, Path outputDir) throws Exception {
+ JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+ assertNotNull(compiler, "Java compiler not available (requires JDK, not JRE)");
+
+ DiagnosticCollector diagnostics = new DiagnosticCollector<>();
+ try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null)) {
+ // Set the classpath to include the test classpath (which has akces-api and its transitive deps)
+ String classpath = System.getProperty("java.class.path");
+
+ // Compile into the Maven test-classes directory
+ Files.createDirectories(TEST_CLASSES_DIR);
+
+ List options = List.of(
+ "-classpath", classpath,
+ "-d", TEST_CLASSES_DIR.toString()
+ );
+
+ Iterable extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromPaths(sourceFiles);
+ JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, diagnostics, options, null, compilationUnits);
+
+ Boolean success = task.call();
+
+ // Collect error diagnostics for the failure message
+ if (!success) {
+ StringBuilder errorMsg = new StringBuilder("Compilation failed:\n");
+ for (Diagnostic extends JavaFileObject> diagnostic : diagnostics.getDiagnostics()) {
+ if (diagnostic.getKind() == Diagnostic.Kind.ERROR) {
+ errorMsg.append(" ").append(diagnostic.getSource() != null ? diagnostic.getSource().getName() : "unknown")
+ .append(":").append(diagnostic.getLineNumber())
+ .append(": ").append(diagnostic.getMessage(null))
+ .append("\n");
+ }
+ }
+ // Also print the generated source files for debugging
+ errorMsg.append("\nGenerated source files:\n");
+ for (Path sourceFile : sourceFiles) {
+ errorMsg.append(" ").append(sourceFile.getFileName()).append(":\n");
+ errorMsg.append(Files.readString(sourceFile)).append("\n");
+ }
+ fail(errorMsg.toString());
+ }
+ }
+ }
+
+ /**
+ * Recursively collects all .java files under the given directory.
+ */
+ private List collectJavaFiles(Path dir) throws Exception {
+ List javaFiles = new ArrayList<>();
+ try (var stream = Files.walk(dir)) {
+ stream.filter(p -> p.getFileName().toString().endsWith(".java"))
+ .forEach(javaFiles::add);
+ }
+ return javaFiles;
+ }
+
+ private EventModelDefinition loadDefinition(String resourceName) {
+ InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName);
+ assertNotNull(is, "Test resource not found: " + resourceName);
+ return generator.parse(is);
+ }
+}
diff --git a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java
new file mode 100644
index 00000000..28041baa
--- /dev/null
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java
@@ -0,0 +1,458 @@
+/*
+ * Copyright 2022 - 2026 The Original Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.elasticsoftware.akces.codegen;
+
+import org.elasticsoftware.akces.codegen.model.EventModelDefinition;
+import org.testng.annotations.Test;
+
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.testng.Assert.*;
+
+/**
+ * Tests the Akces code generator against the crypto-trading test app definitions.
+ * Verifies that the generated code structurally matches the actual crypto-trading
+ * aggregate implementations.
+ */
+public class CryptoTradingCodeGenTest {
+
+ private final AkcesCodeGenerator generator = new AkcesCodeGenerator("com.example.aggregates");
+
+ // --- Account Aggregate Tests ---
+
+ @Test
+ public void testParseAccountDefinition() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
+
+ assertNotNull(definition);
+ assertEquals(definition.packageName(), "org.elasticsoftware.cryptotrading.aggregates");
+ assertEquals(definition.slices().size(), 1);
+ assertNotNull(definition.aggregateConfig());
+ assertTrue(definition.aggregateConfig().containsKey("Account"));
+ }
+
+ @Test
+ public void testGenerateAccountFiles() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
+ List files = generator.generate(definition);
+
+ assertNotNull(files);
+ assertFalse(files.isEmpty());
+
+ Map fileMap = files.stream()
+ .collect(Collectors.toMap(GeneratedFile::relativePath, f -> f));
+
+ // Verify expected files are generated
+ assertTrue(fileMap.containsKey("com/example/aggregates/account/commands/CreateAccountCommand.java"),
+ "Should generate CreateAccountCommand.java");
+ assertTrue(fileMap.containsKey("com/example/aggregates/account/events/AccountCreatedEvent.java"),
+ "Should generate AccountCreatedEvent.java");
+ assertTrue(fileMap.containsKey("com/example/aggregates/account/AccountState.java"),
+ "Should generate AccountState.java");
+ assertTrue(fileMap.containsKey("com/example/aggregates/account/Account.java"),
+ "Should generate Account.java");
+
+ assertEquals(files.size(), 4, "Should generate exactly 4 files for Account aggregate");
+ }
+
+ @Test
+ public void testGeneratedAccountCommand() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile commandFile = findFile(files, "com/example/aggregates/account/commands/CreateAccountCommand.java");
+ assertNotNull(commandFile);
+
+ String content = commandFile.content();
+
+ // Verify structure matches actual CreateAccountCommand
+ assertTrue(content.contains("@CommandInfo(type = \"CreateAccount\""),
+ "Should have correct CommandInfo annotation");
+ assertTrue(content.contains("public record CreateAccountCommand("),
+ "Should be a record");
+ assertTrue(content.contains("implements Command"),
+ "Should implement Command");
+ assertTrue(content.contains("@AggregateIdentifier"),
+ "Should have AggregateIdentifier on id field");
+ assertTrue(content.contains("@NotNull String userId"),
+ "Should have userId field");
+ assertTrue(content.contains("@NotNull String country"),
+ "Should have country field");
+ assertTrue(content.contains("@PIIData @NotNull String firstName"),
+ "Should have PIIData annotation on firstName field");
+ assertTrue(content.contains("@PIIData @NotNull String lastName"),
+ "Should have PIIData annotation on lastName field");
+ assertTrue(content.contains("@PIIData @NotNull String email"),
+ "Should have PIIData annotation on email field");
+ assertTrue(content.contains("return userId()"),
+ "Should return userId as aggregate ID");
+ }
+
+ @Test
+ public void testGeneratedAccountEvent() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile eventFile = findFile(files, "com/example/aggregates/account/events/AccountCreatedEvent.java");
+ assertNotNull(eventFile);
+
+ String content = eventFile.content();
+
+ // Verify structure matches actual AccountCreatedEvent
+ assertTrue(content.contains("@DomainEventInfo(type = \"AccountCreated\""),
+ "Should have correct DomainEventInfo annotation");
+ assertTrue(content.contains("public record AccountCreatedEvent("),
+ "Should be a record");
+ assertTrue(content.contains("implements DomainEvent"),
+ "Should implement DomainEvent");
+ assertTrue(content.contains("@AggregateIdentifier"),
+ "Should have AggregateIdentifier on id field");
+ assertTrue(content.contains("@NotNull String userId"),
+ "Should have userId field");
+ assertTrue(content.contains("@PIIData @NotNull String firstName"),
+ "Should have PIIData annotation on firstName field");
+ assertTrue(content.contains("@PIIData @NotNull String lastName"),
+ "Should have PIIData annotation on lastName field");
+ assertTrue(content.contains("@PIIData @NotNull String email"),
+ "Should have PIIData annotation on email field");
+ assertTrue(content.contains("return userId()"),
+ "Should return userId as aggregate ID");
+ }
+
+ @Test
+ public void testGeneratedAccountState() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile stateFile = findFile(files, "com/example/aggregates/account/AccountState.java");
+ assertNotNull(stateFile);
+
+ String content = stateFile.content();
+
+ // Verify structure matches actual AccountState
+ assertTrue(content.contains("@AggregateStateInfo(type = \"Account\", version = 1)"),
+ "Should have correct AggregateStateInfo annotation");
+ assertTrue(content.contains("public record AccountState("),
+ "Should be a record");
+ assertTrue(content.contains("implements AggregateState"),
+ "Should implement AggregateState");
+ assertTrue(content.contains("@PIIData"),
+ "Should have PIIData annotation on PII fields");
+ assertTrue(content.contains("@NotNull String userId"),
+ "Should have userId field");
+ assertTrue(content.contains("@NotNull String country"),
+ "Should have country field");
+ assertTrue(content.contains("return userId()"),
+ "Should return userId as aggregate ID");
+
+ // Count PIIData annotations - should be 3 (firstName, lastName, email)
+ int piiCount = content.split("@PIIData", -1).length - 1;
+ assertEquals(piiCount, 3, "Should have 3 PIIData annotations");
+ }
+
+ @Test
+ public void testGeneratedAccountAggregate() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile aggregateFile = findFile(files, "com/example/aggregates/account/Account.java");
+ assertNotNull(aggregateFile);
+
+ String content = aggregateFile.content();
+
+ // Verify structure matches actual Account aggregate
+ assertTrue(content.contains("@AggregateInfo("),
+ "Should have AggregateInfo annotation");
+ assertTrue(content.contains("value = \"Account\""),
+ "Should have correct aggregate name");
+ assertTrue(content.contains("stateClass = AccountState.class"),
+ "Should reference state class");
+ assertTrue(content.contains("generateGDPRKeyOnCreate = true"),
+ "Should have GDPR key generation enabled");
+ assertTrue(content.contains("indexed = true"),
+ "Should be indexed");
+ assertTrue(content.contains("indexName = \"Users\""),
+ "Should have correct index name");
+ assertTrue(content.contains("public final class Account implements Aggregate"),
+ "Should implement Aggregate with correct state type");
+ assertTrue(content.contains("@CommandHandler(create = true"),
+ "Should have create command handler");
+ assertTrue(content.contains("produces = AccountCreatedEvent.class"),
+ "Should produce AccountCreatedEvent");
+ assertTrue(content.contains("@EventSourcingHandler(create = true)"),
+ "Should have create event sourcing handler");
+ assertTrue(content.contains("return \"Account\""),
+ "getName() should return aggregate name");
+ assertTrue(content.contains("return AccountState.class"),
+ "getStateClass() should return state class");
+ }
+
+ // --- Wallet Aggregate Tests ---
+
+ @Test
+ public void testParseWalletDefinition() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+
+ assertNotNull(definition);
+ assertEquals(definition.slices().size(), 6);
+ assertNotNull(definition.aggregateConfig());
+ assertTrue(definition.aggregateConfig().containsKey("Wallet"));
+ }
+
+ @Test
+ public void testGenerateWalletFiles() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ assertNotNull(files);
+ assertFalse(files.isEmpty());
+
+ Map fileMap = files.stream()
+ .collect(Collectors.toMap(GeneratedFile::relativePath, f -> f));
+
+ // Commands (6)
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/CreateWalletCommand.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/CreateBalanceCommand.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/CreditWalletCommand.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/DebitWalletCommand.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/ReserveAmountCommand.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/commands/CancelReservationCommand.java"));
+
+ // Success Events (6)
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/WalletCreatedEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/BalanceCreatedEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/WalletCreditedEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/WalletDebitedEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/AmountReservedEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/ReservationCancelledEvent.java"));
+
+ // Error Events (5)
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/BalanceAlreadyExistsErrorEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/InvalidCryptoCurrencyErrorEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/InvalidAmountErrorEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/InsufficientFundsErrorEvent.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/events/ReservationNotFoundErrorEvent.java"));
+
+ // State + Aggregate class
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/WalletState.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/Wallet.java"));
+ }
+
+ @Test
+ public void testGeneratedWalletCreateCommand() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/CreateWalletCommand.java");
+ assertNotNull(file);
+
+ String content = file.content();
+ assertTrue(content.contains("@CommandInfo(type = \"CreateWallet\""));
+ assertTrue(content.contains("public record CreateWalletCommand("));
+ assertTrue(content.contains("implements Command"));
+ assertTrue(content.contains("@AggregateIdentifier"));
+ assertTrue(content.contains("@NotNull String id"));
+ assertTrue(content.contains("@NotNull String currency"));
+ }
+
+ @Test
+ public void testGeneratedWalletCreditCommand() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/CreditWalletCommand.java");
+ assertNotNull(file);
+
+ String content = file.content();
+ assertTrue(content.contains("@CommandInfo(type = \"CreditWallet\""));
+ assertTrue(content.contains("BigDecimal amount"));
+ assertTrue(content.contains("import java.math.BigDecimal;"));
+ }
+
+ @Test
+ public void testGeneratedWalletCreditedEvent() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/WalletCreditedEvent.java");
+ assertNotNull(file);
+
+ String content = file.content();
+ assertTrue(content.contains("@DomainEventInfo(type = \"WalletCredited\""));
+ assertTrue(content.contains("public record WalletCreditedEvent("));
+ assertTrue(content.contains("implements DomainEvent"));
+ assertTrue(content.contains("BigDecimal amount"));
+ assertTrue(content.contains("BigDecimal balance"));
+ }
+
+ @Test
+ public void testGeneratedInsufficientFundsErrorEvent() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/InsufficientFundsErrorEvent.java");
+ assertNotNull(file);
+
+ String content = file.content();
+ assertTrue(content.contains("@DomainEventInfo(type = \"InsufficientFundsError\""));
+ assertTrue(content.contains("public record InsufficientFundsErrorEvent("));
+ assertTrue(content.contains("implements ErrorEvent"),
+ "Error events should implement ErrorEvent");
+ assertTrue(content.contains("@Nullable String referenceId"),
+ "Optional fields should use @Nullable");
+ assertTrue(content.contains("BigDecimal availableAmount"));
+ assertTrue(content.contains("BigDecimal requestedAmount"));
+ }
+
+ @Test
+ public void testGeneratedWalletAggregate() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/Wallet.java");
+ assertNotNull(file);
+
+ String content = file.content();
+
+ // Verify aggregate structure
+ assertTrue(content.contains("@AggregateInfo("));
+ assertTrue(content.contains("value = \"Wallet\""));
+ assertTrue(content.contains("stateClass = WalletState.class"));
+ assertTrue(content.contains("indexed = true"));
+ assertTrue(content.contains("indexName = \"Users\""));
+ assertTrue(content.contains("public final class Wallet implements Aggregate"));
+
+ // Verify command handlers exist
+ assertTrue(content.contains("@CommandHandler(create = true"));
+ assertTrue(content.contains("CreateWalletCommand cmd"));
+ assertTrue(content.contains("CreateBalanceCommand cmd"));
+ assertTrue(content.contains("CreditWalletCommand cmd"));
+ assertTrue(content.contains("DebitWalletCommand cmd"));
+ assertTrue(content.contains("ReserveAmountCommand cmd"));
+ assertTrue(content.contains("CancelReservationCommand cmd"));
+
+ // Verify event sourcing handlers exist
+ assertTrue(content.contains("@EventSourcingHandler(create = true)"));
+ assertTrue(content.contains("WalletCreatedEvent event"));
+
+ // Verify error events are referenced
+ assertTrue(content.contains("BalanceAlreadyExistsErrorEvent.class"));
+ assertTrue(content.contains("InsufficientFundsErrorEvent.class"));
+ assertTrue(content.contains("InvalidCryptoCurrencyErrorEvent.class"));
+ assertTrue(content.contains("InvalidAmountErrorEvent.class"));
+ }
+
+ @Test
+ public void testGeneratedWalletState() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/WalletState.java");
+ assertNotNull(file);
+
+ String content = file.content();
+ assertTrue(content.contains("@AggregateStateInfo(type = \"Wallet\", version = 1)"));
+ assertTrue(content.contains("public record WalletState("));
+ assertTrue(content.contains("implements AggregateState"));
+ assertTrue(content.contains("@AggregateIdentifier"));
+ assertTrue(content.contains("return id()"));
+ }
+
+ @Test
+ public void testGeneratedReserveAmountCommand() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/ReserveAmountCommand.java");
+ assertNotNull(file);
+
+ String content = file.content();
+ assertTrue(content.contains("@CommandInfo(type = \"ReserveAmount\""));
+ assertTrue(content.contains("@AggregateIdentifier"));
+ assertTrue(content.contains("@NotNull String userId"));
+ assertTrue(content.contains("@NotNull String currency"));
+ assertTrue(content.contains("@NotNull BigDecimal amount"));
+ assertTrue(content.contains("@NotNull String referenceId"));
+ assertTrue(content.contains("return userId()"),
+ "Should use userId as aggregate ID");
+ }
+
+ @Test
+ public void testGeneratedAmountReservedEvent() {
+ EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
+ List files = generator.generate(definition);
+
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/AmountReservedEvent.java");
+ assertNotNull(file);
+
+ String content = file.content();
+ assertTrue(content.contains("@DomainEventInfo(type = \"AmountReserved\""));
+ assertTrue(content.contains("public record AmountReservedEvent("));
+ assertTrue(content.contains("@AggregateIdentifier"));
+ assertTrue(content.contains("@NotNull String userId"));
+ assertTrue(content.contains("@NotNull BigDecimal amount"));
+ assertTrue(content.contains("@NotNull String referenceId"));
+ }
+
+ // --- File Output Test ---
+
+ @Test
+ public void testGenerateToDirectory() throws Exception {
+ EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
+ java.nio.file.Path outputDir = java.nio.file.Files.createTempDirectory("akces-codegen-test");
+ try {
+ List files = generator.generateToDirectory(definition, outputDir);
+ assertEquals(files.size(), 4);
+
+ // Verify files exist on disk
+ for (GeneratedFile file : files) {
+ java.nio.file.Path filePath = outputDir.resolve(file.relativePath());
+ assertTrue(java.nio.file.Files.exists(filePath),
+ "File should exist: " + file.relativePath());
+ String diskContent = java.nio.file.Files.readString(filePath);
+ assertEquals(diskContent, file.content(),
+ "Disk content should match generated content");
+ }
+ } finally {
+ // Clean up
+ try (var walk = java.nio.file.Files.walk(outputDir)) {
+ walk.sorted(java.util.Comparator.reverseOrder())
+ .map(java.nio.file.Path::toFile)
+ .forEach(java.io.File::delete);
+ }
+ }
+ }
+
+ // --- Helper Methods ---
+
+ private EventModelDefinition loadDefinition(String resourceName) {
+ InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName);
+ assertNotNull(is, "Test resource not found: " + resourceName);
+ return generator.parse(is);
+ }
+
+ private GeneratedFile findFile(List files, String relativePath) {
+ return files.stream()
+ .filter(f -> f.relativePath().equals(relativePath))
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java
new file mode 100644
index 00000000..f0851c4f
--- /dev/null
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java
@@ -0,0 +1,376 @@
+/*
+ * Copyright 2022 - 2026 The Original Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.elasticsoftware.akces.codegen;
+
+import org.everit.json.schema.Schema;
+import org.everit.json.schema.ValidationException;
+import org.everit.json.schema.loader.SchemaLoader;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import java.io.InputStream;
+
+import static org.testng.Assert.*;
+
+/**
+ * Validates that the event-modeling JSON definition files conform to the
+ * augmented Akces event-model JSON schema.
+ */
+public class SchemaValidationTest {
+
+ private Schema schema;
+
+ @BeforeClass
+ public void loadSchema() {
+ InputStream schemaStream = getClass().getClassLoader()
+ .getResourceAsStream("akces-event-model.schema.json");
+ assertNotNull(schemaStream, "Schema resource not found: akces-event-model.schema.json");
+ JSONObject rawSchema = new JSONObject(new JSONTokener(schemaStream));
+ schema = SchemaLoader.load(rawSchema);
+ }
+
+ @Test
+ public void testAccountDefinitionConformsToSchema() {
+ JSONObject json = loadJson("crypto-trading-account.json");
+ schema.validate(json);
+ }
+
+ @Test
+ public void testWalletDefinitionConformsToSchema() {
+ JSONObject json = loadJson("crypto-trading-wallet.json");
+ schema.validate(json);
+ }
+
+ @Test
+ public void testMissingRequiredPackageName() {
+ JSONObject json = new JSONObject()
+ .put("aggregateConfig", new JSONObject())
+ .put("slices", new org.json.JSONArray());
+
+ ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
+ assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("packageName")),
+ "Should report missing packageName: " + ex.getAllMessages());
+ }
+
+ @Test
+ public void testMissingRequiredSlices() {
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject());
+
+ ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
+ assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("slices")),
+ "Should report missing slices: " + ex.getAllMessages());
+ }
+
+ @Test
+ public void testMissingRequiredAggregateConfig() {
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("slices", new org.json.JSONArray());
+
+ ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
+ assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("aggregateConfig")),
+ "Should report missing aggregateConfig: " + ex.getAllMessages());
+ }
+
+ @Test
+ public void testAdditionalPropertiesRejected() {
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject())
+ .put("slices", new org.json.JSONArray())
+ .put("unknownProperty", "value");
+
+ assertThrows(ValidationException.class, () -> schema.validate(json));
+ }
+
+ @Test
+ public void testInvalidSliceTypeRejected() {
+ JSONObject slice = new JSONObject()
+ .put("id", "test")
+ .put("title", "Test")
+ .put("sliceType", "INVALID_TYPE")
+ .put("commands", new org.json.JSONArray())
+ .put("events", new org.json.JSONArray())
+ .put("readmodels", new org.json.JSONArray())
+ .put("screens", new org.json.JSONArray())
+ .put("processors", new org.json.JSONArray())
+ .put("tables", new org.json.JSONArray())
+ .put("specifications", new org.json.JSONArray());
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject())
+ .put("slices", new org.json.JSONArray().put(slice));
+
+ assertThrows(ValidationException.class, () -> schema.validate(json));
+ }
+
+ @Test
+ public void testInvalidFieldTypeRejected() {
+ JSONObject field = new JSONObject()
+ .put("name", "test")
+ .put("type", "InvalidType");
+
+ JSONObject element = new JSONObject()
+ .put("id", "test-cmd")
+ .put("title", "Test")
+ .put("type", "COMMAND")
+ .put("fields", new org.json.JSONArray().put(field))
+ .put("dependencies", new org.json.JSONArray());
+
+ JSONObject slice = new JSONObject()
+ .put("id", "test")
+ .put("title", "Test")
+ .put("sliceType", "STATE_CHANGE")
+ .put("commands", new org.json.JSONArray().put(element))
+ .put("events", new org.json.JSONArray())
+ .put("readmodels", new org.json.JSONArray())
+ .put("screens", new org.json.JSONArray())
+ .put("processors", new org.json.JSONArray())
+ .put("tables", new org.json.JSONArray())
+ .put("specifications", new org.json.JSONArray());
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject())
+ .put("slices", new org.json.JSONArray().put(slice));
+
+ assertThrows(ValidationException.class, () -> schema.validate(json));
+ }
+
+ @Test
+ public void testInvalidStateFieldTypeRejected() {
+ JSONObject stateField = new JSONObject()
+ .put("name", "id")
+ .put("type", "NotAType");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ assertThrows(ValidationException.class, () -> schema.validate(json));
+ }
+
+ @Test
+ public void testValidMinimalDefinition() {
+ JSONObject stateField = new JSONObject()
+ .put("name", "id")
+ .put("type", "String");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ schema.validate(json);
+ }
+
+ @Test
+ public void testStateFieldPiiDataAccepted() {
+ JSONObject stateField = new JSONObject()
+ .put("name", "email")
+ .put("type", "String")
+ .put("piiData", true)
+ .put("idAttribute", false)
+ .put("optional", false);
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("User", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ schema.validate(json);
+ }
+
+ @Test
+ public void testAggregateConfigWithAllProperties() {
+ JSONObject stateField = new JSONObject()
+ .put("name", "id")
+ .put("type", "String")
+ .put("idAttribute", true);
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("indexed", true)
+ .put("indexName", "TestIndex")
+ .put("generateGDPRKeyOnCreate", true)
+ .put("stateVersion", 1)
+ .put("stateFields", new org.json.JSONArray().put(stateField));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ schema.validate(json);
+ }
+
+ @Test
+ public void testExternalEventsWithValidElementAccepted() {
+ JSONObject externalEvent = new JSONObject()
+ .put("id", "account-created-ext")
+ .put("title", "AccountCreated")
+ .put("type", "EVENT")
+ .put("aggregate", "Account")
+ .put("fields", new org.json.JSONArray().put(
+ new JSONObject().put("name", "userId").put("type", "String").put("idAttribute", true)))
+ .put("dependencies", new org.json.JSONArray());
+
+ JSONObject stateField = new JSONObject()
+ .put("name", "userId")
+ .put("type", "String")
+ .put("idAttribute", true);
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField));
+
+ JSONObject slice = new JSONObject()
+ .put("id", "create-wallet")
+ .put("title", "Create Wallet")
+ .put("sliceType", "STATE_CHANGE")
+ .put("commands", new org.json.JSONArray())
+ .put("events", new org.json.JSONArray())
+ .put("external_events", new org.json.JSONArray().put(externalEvent))
+ .put("readmodels", new org.json.JSONArray())
+ .put("screens", new org.json.JSONArray())
+ .put("processors", new org.json.JSONArray())
+ .put("tables", new org.json.JSONArray())
+ .put("specifications", new org.json.JSONArray());
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Wallet", aggregateConfig))
+ .put("slices", new org.json.JSONArray().put(slice));
+
+ schema.validate(json);
+ }
+
+ @Test
+ public void testExternalEventsFieldOptionalInSlice() {
+ JSONObject stateField = new JSONObject()
+ .put("name", "userId")
+ .put("type", "String");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField));
+
+ JSONObject slice = new JSONObject()
+ .put("id", "test")
+ .put("title", "Test")
+ .put("sliceType", "STATE_CHANGE")
+ .put("commands", new org.json.JSONArray())
+ .put("events", new org.json.JSONArray())
+ .put("readmodels", new org.json.JSONArray())
+ .put("screens", new org.json.JSONArray())
+ .put("processors", new org.json.JSONArray())
+ .put("tables", new org.json.JSONArray())
+ .put("specifications", new org.json.JSONArray());
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
+ .put("slices", new org.json.JSONArray().put(slice));
+
+ schema.validate(json);
+ }
+
+ @Test
+ public void testExternalEventsInvalidElementRejected() {
+ JSONObject invalidElement = new JSONObject()
+ .put("id", "test-ext")
+ .put("title", "Test")
+ .put("type", "INVALID")
+ .put("fields", new org.json.JSONArray())
+ .put("dependencies", new org.json.JSONArray());
+
+ JSONObject stateField = new JSONObject()
+ .put("name", "id")
+ .put("type", "String");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField));
+
+ JSONObject slice = new JSONObject()
+ .put("id", "test")
+ .put("title", "Test")
+ .put("sliceType", "STATE_CHANGE")
+ .put("commands", new org.json.JSONArray())
+ .put("events", new org.json.JSONArray())
+ .put("external_events", new org.json.JSONArray().put(invalidElement))
+ .put("readmodels", new org.json.JSONArray())
+ .put("screens", new org.json.JSONArray())
+ .put("processors", new org.json.JSONArray())
+ .put("tables", new org.json.JSONArray())
+ .put("specifications", new org.json.JSONArray());
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
+ .put("slices", new org.json.JSONArray().put(slice));
+
+ assertThrows(ValidationException.class, () -> schema.validate(json));
+ }
+
+ @Test
+ public void testInvalidElementTypeRejected() {
+ JSONObject element = new JSONObject()
+ .put("id", "test-cmd")
+ .put("title", "Test")
+ .put("type", "INVALID")
+ .put("fields", new org.json.JSONArray())
+ .put("dependencies", new org.json.JSONArray());
+
+ JSONObject slice = new JSONObject()
+ .put("id", "test")
+ .put("title", "Test")
+ .put("sliceType", "STATE_CHANGE")
+ .put("commands", new org.json.JSONArray().put(element))
+ .put("events", new org.json.JSONArray())
+ .put("readmodels", new org.json.JSONArray())
+ .put("screens", new org.json.JSONArray())
+ .put("processors", new org.json.JSONArray())
+ .put("tables", new org.json.JSONArray())
+ .put("specifications", new org.json.JSONArray());
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject())
+ .put("slices", new org.json.JSONArray().put(slice));
+
+ assertThrows(ValidationException.class, () -> schema.validate(json));
+ }
+
+ private JSONObject loadJson(String resourceName) {
+ InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName);
+ assertNotNull(is, "Test resource not found: " + resourceName);
+ return new JSONObject(new JSONTokener(is));
+ }
+}
diff --git a/main/codegen/src/test/resources/crypto-trading-account.json b/main/codegen/src/test/resources/crypto-trading-account.json
new file mode 100644
index 00000000..9366c765
--- /dev/null
+++ b/main/codegen/src/test/resources/crypto-trading-account.json
@@ -0,0 +1,67 @@
+{
+ "packageName": "org.elasticsoftware.cryptotrading.aggregates",
+ "aggregateConfig": {
+ "Account": {
+ "indexed": true,
+ "indexName": "Users",
+ "generateGDPRKeyOnCreate": true,
+ "stateVersion": 1,
+ "stateFields": [
+ { "name": "userId", "type": "String", "idAttribute": true },
+ { "name": "country", "type": "String" },
+ { "name": "firstName", "type": "String", "piiData": true },
+ { "name": "lastName", "type": "String", "piiData": true },
+ { "name": "email", "type": "String", "piiData": true }
+ ]
+ }
+ },
+ "slices": [
+ {
+ "id": "create-account",
+ "title": "Create Account",
+ "sliceType": "STATE_CHANGE",
+ "aggregates": ["Account"],
+ "commands": [
+ {
+ "id": "create-account-cmd",
+ "title": "CreateAccount",
+ "type": "COMMAND",
+ "aggregate": "Account",
+ "createsAggregate": true,
+ "description": "Create a new account",
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true },
+ { "name": "country", "type": "String" },
+ { "name": "firstName", "type": "String", "piiData": true },
+ { "name": "lastName", "type": "String", "piiData": true },
+ { "name": "email", "type": "String", "piiData": true }
+ ],
+ "dependencies": []
+ }
+ ],
+ "events": [
+ {
+ "id": "account-created-evt",
+ "title": "AccountCreated",
+ "type": "EVENT",
+ "aggregate": "Account",
+ "createsAggregate": true,
+ "description": "Account created",
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true },
+ { "name": "country", "type": "String" },
+ { "name": "firstName", "type": "String", "piiData": true },
+ { "name": "lastName", "type": "String", "piiData": true },
+ { "name": "email", "type": "String", "piiData": true }
+ ],
+ "dependencies": []
+ }
+ ],
+ "readmodels": [],
+ "screens": [],
+ "processors": [],
+ "tables": [],
+ "specifications": []
+ }
+ ]
+}
diff --git a/main/codegen/src/test/resources/crypto-trading-wallet.json b/main/codegen/src/test/resources/crypto-trading-wallet.json
new file mode 100644
index 00000000..1daca2e1
--- /dev/null
+++ b/main/codegen/src/test/resources/crypto-trading-wallet.json
@@ -0,0 +1,317 @@
+{
+ "packageName": "org.elasticsoftware.cryptotrading.aggregates",
+ "aggregateConfig": {
+ "Wallet": {
+ "indexed": true,
+ "indexName": "Users",
+ "generateGDPRKeyOnCreate": false,
+ "stateVersion": 1,
+ "stateFields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "balances", "type": "Custom" }
+ ]
+ }
+ },
+ "slices": [
+ {
+ "id": "create-wallet",
+ "title": "Create Wallet",
+ "sliceType": "STATE_CHANGE",
+ "aggregates": ["Wallet"],
+ "commands": [
+ {
+ "id": "create-wallet-cmd",
+ "title": "CreateWallet",
+ "type": "COMMAND",
+ "aggregate": "Wallet",
+ "createsAggregate": true,
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "events": [
+ {
+ "id": "wallet-created-evt",
+ "title": "WalletCreated",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "createsAggregate": true,
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true }
+ ],
+ "dependencies": []
+ }
+ ],
+ "readmodels": [],
+ "screens": [],
+ "processors": [],
+ "tables": [],
+ "specifications": []
+ },
+ {
+ "id": "create-balance",
+ "title": "Create Balance",
+ "sliceType": "STATE_CHANGE",
+ "aggregates": ["Wallet"],
+ "commands": [
+ {
+ "id": "create-balance-cmd",
+ "title": "CreateBalance",
+ "type": "COMMAND",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "events": [
+ {
+ "id": "balance-created-evt",
+ "title": "BalanceCreated",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" }
+ ],
+ "dependencies": []
+ },
+ {
+ "id": "balance-already-exists-err",
+ "title": "BalanceAlreadyExistsError",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "tags": ["error"],
+ "fields": [
+ { "name": "walletId", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "readmodels": [],
+ "screens": [],
+ "processors": [],
+ "tables": [],
+ "specifications": []
+ },
+ {
+ "id": "credit-wallet",
+ "title": "Credit Wallet",
+ "sliceType": "STATE_CHANGE",
+ "aggregates": ["Wallet"],
+ "commands": [
+ {
+ "id": "credit-wallet-cmd",
+ "title": "CreditWallet",
+ "type": "COMMAND",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "amount", "type": "Decimal" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "events": [
+ {
+ "id": "wallet-credited-evt",
+ "title": "WalletCredited",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "amount", "type": "Decimal" },
+ { "name": "balance", "type": "Decimal" }
+ ],
+ "dependencies": []
+ },
+ {
+ "id": "invalid-crypto-currency-err",
+ "title": "InvalidCryptoCurrencyError",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "tags": ["error"],
+ "fields": [
+ { "name": "walletId", "type": "String", "idAttribute": true },
+ { "name": "cryptoCurrency", "type": "String" },
+ { "name": "referenceId", "type": "String", "optional": true }
+ ],
+ "dependencies": []
+ },
+ {
+ "id": "invalid-amount-err",
+ "title": "InvalidAmountError",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "tags": ["error"],
+ "fields": [
+ { "name": "walletId", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "readmodels": [],
+ "screens": [],
+ "processors": [],
+ "tables": [],
+ "specifications": []
+ },
+ {
+ "id": "debit-wallet",
+ "title": "Debit Wallet",
+ "sliceType": "STATE_CHANGE",
+ "aggregates": ["Wallet"],
+ "commands": [
+ {
+ "id": "debit-wallet-cmd",
+ "title": "DebitWallet",
+ "type": "COMMAND",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "amount", "type": "Decimal" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "events": [
+ {
+ "id": "wallet-debited-evt",
+ "title": "WalletDebited",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "amount", "type": "Decimal" },
+ { "name": "newBalance", "type": "Decimal" }
+ ],
+ "dependencies": []
+ },
+ {
+ "id": "insufficient-funds-err",
+ "title": "InsufficientFundsError",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "tags": ["error"],
+ "fields": [
+ { "name": "walletId", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "availableAmount", "type": "Decimal" },
+ { "name": "requestedAmount", "type": "Decimal" },
+ { "name": "referenceId", "type": "String", "optional": true }
+ ],
+ "dependencies": []
+ }
+ ],
+ "readmodels": [],
+ "screens": [],
+ "processors": [],
+ "tables": [],
+ "specifications": []
+ },
+ {
+ "id": "reserve-amount",
+ "title": "Reserve Amount",
+ "sliceType": "STATE_CHANGE",
+ "aggregates": ["Wallet"],
+ "commands": [
+ {
+ "id": "reserve-amount-cmd",
+ "title": "ReserveAmount",
+ "type": "COMMAND",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "amount", "type": "Decimal" },
+ { "name": "referenceId", "type": "String" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "events": [
+ {
+ "id": "amount-reserved-evt",
+ "title": "AmountReserved",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "amount", "type": "Decimal" },
+ { "name": "referenceId", "type": "String" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "readmodels": [],
+ "screens": [],
+ "processors": [],
+ "tables": [],
+ "specifications": []
+ },
+ {
+ "id": "cancel-reservation",
+ "title": "Cancel Reservation",
+ "sliceType": "STATE_CHANGE",
+ "aggregates": ["Wallet"],
+ "commands": [
+ {
+ "id": "cancel-reservation-cmd",
+ "title": "CancelReservation",
+ "type": "COMMAND",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "referenceId", "type": "String" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "events": [
+ {
+ "id": "reservation-cancelled-evt",
+ "title": "ReservationCancelled",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "referenceId", "type": "String" }
+ ],
+ "dependencies": []
+ },
+ {
+ "id": "reservation-not-found-err",
+ "title": "ReservationNotFoundError",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "tags": ["error"],
+ "fields": [
+ { "name": "id", "type": "String", "idAttribute": true },
+ { "name": "currency", "type": "String" },
+ { "name": "referenceId", "type": "String" }
+ ],
+ "dependencies": []
+ }
+ ],
+ "readmodels": [],
+ "screens": [],
+ "processors": [],
+ "tables": [],
+ "specifications": []
+ }
+ ]
+}
diff --git a/main/pom.xml b/main/pom.xml
index 26ec5e6a..b2a5f714 100644
--- a/main/pom.xml
+++ b/main/pom.xml
@@ -398,6 +398,7 @@
shared
query-support
eventcatalog
+ codegen