Date: Thu, 26 Mar 2026 14:26:11 +0100
Subject: [PATCH 06/16] Potential fix for pull request finding 'Useless
parameter'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
---
.../org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java | 1 +
1 file changed, 1 insertion(+)
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
index 23834c8e..3b78179e 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
@@ -615,6 +615,7 @@ private String generateCommandHandler(
sb.append("@NotNull ").append(stateClassName).append(" currentState");
}
sb.append(") {\n");
+ sb.append(" // Command handler for aggregate: ").append(aggregateName).append("\n");
// Method body - generate event construction
if (producesEvents.size() == 1 && successEventElements.size() == 1) {
From 262e897d9b355c6a2e9ca4f35f13e7a03a00ee73 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Mar 2026 13:30:41 +0000
Subject: [PATCH 07/16] Write generated sources to
target/generated-test-sources instead of temp dir
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/97be6153-b154-4b46-9232-b8ed79d27306
---
.../akces/codegen/CompilationTest.java | 72 ++++++++-----------
1 file changed, 31 insertions(+), 41 deletions(-)
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
index bc34dcf9..660de875 100644
--- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
@@ -21,12 +21,10 @@
import org.testng.annotations.Test;
import javax.tools.*;
-import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
-import java.util.Comparator;
import java.util.List;
import static org.testng.Assert.*;
@@ -35,9 +33,14 @@
* 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/akces-codegen}
+ * 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", "akces-codegen");
+
private final AkcesCodeGenerator generator = new AkcesCodeGenerator();
@Test
@@ -59,45 +62,42 @@ public void testBothAggregatesCompileTogether() throws Exception {
EventModelDefinition accountDef = loadDefinition("crypto-trading-account.json");
EventModelDefinition walletDef = loadDefinition("crypto-trading-wallet.json");
- Path outputDir = Files.createTempDirectory("akces-codegen-compile-test");
- try {
- generator.generateToDirectory(accountDef, outputDir);
- generator.generateToDirectory(walletDef, outputDir);
+ Path outputDir = GENERATED_SOURCES_DIR;
+ Files.createDirectories(outputDir);
- List sourceFiles = collectJavaFiles(outputDir);
- assertFalse(sourceFiles.isEmpty(), "Should have generated Java source files");
+ generator.generateToDirectory(accountDef, outputDir);
+ generator.generateToDirectory(walletDef, outputDir);
- compileAndAssert(sourceFiles, outputDir);
- } finally {
- deleteRecursively(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 a temp directory,
+ * Generates code from the definition, writes it to
+ * {@code target/generated-test-sources/akces-codegen},
* and compiles it using the Java Compiler API.
*/
private void assertCompiles(EventModelDefinition definition, String aggregateName) throws Exception {
- Path outputDir = Files.createTempDirectory("akces-codegen-compile-test");
- try {
- 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());
- }
+ 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");
+ List sourceFiles = collectJavaFiles(outputDir);
+ assertFalse(sourceFiles.isEmpty(), "Should have .java files on disk");
- compileAndAssert(sourceFiles, outputDir);
- } finally {
- deleteRecursively(outputDir);
- }
+ compileAndAssert(sourceFiles, outputDir);
}
/**
@@ -165,14 +165,4 @@ private EventModelDefinition loadDefinition(String resourceName) {
assertNotNull(is, "Test resource not found: " + resourceName);
return generator.parse(is);
}
-
- private void deleteRecursively(Path dir) throws Exception {
- if (Files.exists(dir)) {
- try (var walk = Files.walk(dir)) {
- walk.sorted(Comparator.reverseOrder())
- .map(Path::toFile)
- .forEach(File::delete);
- }
- }
- }
}
From a7ef2aa5ca55850994b45ed7d9cf5279e6dee70d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Mar 2026 13:43:31 +0000
Subject: [PATCH 08/16] Compile generated sources into target/test-classes
directory
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/b30dff13-d92e-460f-9b47-4ae9135227ab
---
.../elasticsoftware/akces/codegen/CompilationTest.java | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
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
index 660de875..8ce92717 100644
--- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
@@ -40,6 +40,7 @@
public class CompilationTest {
private static final Path GENERATED_SOURCES_DIR = Path.of("target", "generated-test-sources", "akces-codegen");
+ private static final Path TEST_CLASSES_DIR = Path.of("target", "test-classes");
private final AkcesCodeGenerator generator = new AkcesCodeGenerator();
@@ -102,6 +103,7 @@ private void assertCompiles(EventModelDefinition definition, String aggregateNam
/**
* 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();
@@ -112,13 +114,12 @@ private void compileAndAssert(List sourceFiles, Path outputDir) throws Exc
// Set the classpath to include the test classpath (which has akces-api and its transitive deps)
String classpath = System.getProperty("java.class.path");
- // Create output directory for compiled classes
- Path classOutputDir = outputDir.resolve("classes");
- Files.createDirectories(classOutputDir);
+ // Compile into the Maven test-classes directory
+ Files.createDirectories(TEST_CLASSES_DIR);
List options = List.of(
"-classpath", classpath,
- "-d", classOutputDir.toString()
+ "-d", TEST_CLASSES_DIR.toString()
);
Iterable extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromPaths(sourceFiles);
From 099aa09793d5b9c839b393701787cc6dfddf100e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Mar 2026 13:53:57 +0000
Subject: [PATCH 09/16] Add basePackage constructor parameter to
AkcesCodeGenerator
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/77cf2f48-5f1a-4852-b219-7249a9025ad3
---
.../akces/codegen/AkcesCodeGenerator.java | 19 +++--
.../akces/codegen/CompilationTest.java | 2 +-
.../codegen/CryptoTradingCodeGenTest.java | 72 +++++++++----------
3 files changed, 50 insertions(+), 43 deletions(-)
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
index 3b78179e..a44adf4d 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
@@ -67,8 +67,15 @@ public final class AkcesCodeGenerator {
""";
private final JsonMapper jsonMapper;
+ private final String basePackage;
- public AkcesCodeGenerator() {
+ /**
+ * Creates a new code generator with the given base package.
+ *
+ * @param basePackage the base Java package for generated code (e.g., "com.example.aggregates")
+ */
+ public AkcesCodeGenerator(String basePackage) {
+ this.basePackage = Objects.requireNonNull(basePackage, "basePackage must not be null");
this.jsonMapper = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.build();
@@ -110,7 +117,7 @@ public List generate(EventModelDefinition definition) {
: null;
files.addAll(generateAggregate(
- definition.packageName(),
+ basePackage,
aggregateName,
config,
slices));
@@ -303,7 +310,7 @@ private GeneratedFile generateCommandRecord(String aggregatePackage, Element com
sb.append(" }\n");
sb.append("}\n");
- String relativePath = aggregateLower + "/commands/" + className + ".java";
+ String relativePath = commandsPackage.replace('.', '/') + "/" + className + ".java";
return new GeneratedFile(relativePath, sb.toString());
}
@@ -378,7 +385,7 @@ private GeneratedFile generateEventRecord(String aggregatePackage, Element event
sb.append(" }\n");
sb.append("}\n");
- String relativePath = aggregateLower + "/events/" + className + ".java";
+ String relativePath = eventsPackage.replace('.', '/') + "/" + className + ".java";
return new GeneratedFile(relativePath, sb.toString());
}
@@ -453,7 +460,7 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg
sb.append(" }\n");
sb.append("}\n");
- String relativePath = aggregateLower + "/" + className + ".java";
+ String relativePath = aggregatePackage.replace('.', '/') + "/" + className + ".java";
return new GeneratedFile(relativePath, sb.toString());
}
@@ -574,7 +581,7 @@ private GeneratedFile generateAggregateClass(
sb.append("}\n");
- String relativePath = aggregateLower + "/" + aggregateName + ".java";
+ String relativePath = aggregatePackage.replace('.', '/') + "/" + aggregateName + ".java";
return new GeneratedFile(relativePath, sb.toString());
}
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
index 8ce92717..9840bc66 100644
--- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
@@ -42,7 +42,7 @@ public class CompilationTest {
private static final Path GENERATED_SOURCES_DIR = Path.of("target", "generated-test-sources", "akces-codegen");
private static final Path TEST_CLASSES_DIR = Path.of("target", "test-classes");
- private final AkcesCodeGenerator generator = new AkcesCodeGenerator();
+ private final AkcesCodeGenerator generator = new AkcesCodeGenerator("com.example.aggregates");
@Test
public void testAccountGeneratedCodeCompiles() throws Exception {
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
index 8b9088e2..6a3b42d2 100644
--- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java
@@ -34,7 +34,7 @@
*/
public class CryptoTradingCodeGenTest {
- private final AkcesCodeGenerator generator = new AkcesCodeGenerator();
+ private final AkcesCodeGenerator generator = new AkcesCodeGenerator("com.example.aggregates");
// --- Account Aggregate Tests ---
@@ -61,13 +61,13 @@ public void testGenerateAccountFiles() {
.collect(Collectors.toMap(GeneratedFile::relativePath, f -> f));
// Verify expected files are generated
- assertTrue(fileMap.containsKey("account/commands/CreateAccountCommand.java"),
+ assertTrue(fileMap.containsKey("com/example/aggregates/account/commands/CreateAccountCommand.java"),
"Should generate CreateAccountCommand.java");
- assertTrue(fileMap.containsKey("account/events/AccountCreatedEvent.java"),
+ assertTrue(fileMap.containsKey("com/example/aggregates/account/events/AccountCreatedEvent.java"),
"Should generate AccountCreatedEvent.java");
- assertTrue(fileMap.containsKey("account/AccountState.java"),
+ assertTrue(fileMap.containsKey("com/example/aggregates/account/AccountState.java"),
"Should generate AccountState.java");
- assertTrue(fileMap.containsKey("account/Account.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");
@@ -78,7 +78,7 @@ public void testGeneratedAccountCommand() {
EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
List files = generator.generate(definition);
- GeneratedFile commandFile = findFile(files, "account/commands/CreateAccountCommand.java");
+ GeneratedFile commandFile = findFile(files, "com/example/aggregates/account/commands/CreateAccountCommand.java");
assertNotNull(commandFile);
String content = commandFile.content();
@@ -111,7 +111,7 @@ public void testGeneratedAccountEvent() {
EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
List files = generator.generate(definition);
- GeneratedFile eventFile = findFile(files, "account/events/AccountCreatedEvent.java");
+ GeneratedFile eventFile = findFile(files, "com/example/aggregates/account/events/AccountCreatedEvent.java");
assertNotNull(eventFile);
String content = eventFile.content();
@@ -136,7 +136,7 @@ public void testGeneratedAccountState() {
EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
List files = generator.generate(definition);
- GeneratedFile stateFile = findFile(files, "account/AccountState.java");
+ GeneratedFile stateFile = findFile(files, "com/example/aggregates/account/AccountState.java");
assertNotNull(stateFile);
String content = stateFile.content();
@@ -167,7 +167,7 @@ public void testGeneratedAccountAggregate() {
EventModelDefinition definition = loadDefinition("crypto-trading-account.json");
List files = generator.generate(definition);
- GeneratedFile aggregateFile = findFile(files, "account/Account.java");
+ GeneratedFile aggregateFile = findFile(files, "com/example/aggregates/account/Account.java");
assertNotNull(aggregateFile);
String content = aggregateFile.content();
@@ -223,31 +223,31 @@ public void testGenerateWalletFiles() {
.collect(Collectors.toMap(GeneratedFile::relativePath, f -> f));
// Commands (6)
- assertTrue(fileMap.containsKey("wallet/commands/CreateWalletCommand.java"));
- assertTrue(fileMap.containsKey("wallet/commands/CreateBalanceCommand.java"));
- assertTrue(fileMap.containsKey("wallet/commands/CreditWalletCommand.java"));
- assertTrue(fileMap.containsKey("wallet/commands/DebitWalletCommand.java"));
- assertTrue(fileMap.containsKey("wallet/commands/ReserveAmountCommand.java"));
- assertTrue(fileMap.containsKey("wallet/commands/CancelReservationCommand.java"));
+ 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("wallet/events/WalletCreatedEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/BalanceCreatedEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/WalletCreditedEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/WalletDebitedEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/AmountReservedEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/ReservationCancelledEvent.java"));
+ 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("wallet/events/BalanceAlreadyExistsErrorEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/InvalidCryptoCurrencyErrorEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/InvalidAmountErrorEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/InsufficientFundsErrorEvent.java"));
- assertTrue(fileMap.containsKey("wallet/events/ReservationNotFoundErrorEvent.java"));
+ 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("wallet/WalletState.java"));
- assertTrue(fileMap.containsKey("wallet/Wallet.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/WalletState.java"));
+ assertTrue(fileMap.containsKey("com/example/aggregates/wallet/Wallet.java"));
}
@Test
@@ -255,7 +255,7 @@ public void testGeneratedWalletCreateCommand() {
EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
List files = generator.generate(definition);
- GeneratedFile file = findFile(files, "wallet/commands/CreateWalletCommand.java");
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/CreateWalletCommand.java");
assertNotNull(file);
String content = file.content();
@@ -272,7 +272,7 @@ public void testGeneratedWalletCreditCommand() {
EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
List files = generator.generate(definition);
- GeneratedFile file = findFile(files, "wallet/commands/CreditWalletCommand.java");
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/CreditWalletCommand.java");
assertNotNull(file);
String content = file.content();
@@ -286,7 +286,7 @@ public void testGeneratedWalletCreditedEvent() {
EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
List files = generator.generate(definition);
- GeneratedFile file = findFile(files, "wallet/events/WalletCreditedEvent.java");
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/WalletCreditedEvent.java");
assertNotNull(file);
String content = file.content();
@@ -302,7 +302,7 @@ public void testGeneratedInsufficientFundsErrorEvent() {
EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
List files = generator.generate(definition);
- GeneratedFile file = findFile(files, "wallet/events/InsufficientFundsErrorEvent.java");
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/InsufficientFundsErrorEvent.java");
assertNotNull(file);
String content = file.content();
@@ -321,7 +321,7 @@ public void testGeneratedWalletAggregate() {
EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
List files = generator.generate(definition);
- GeneratedFile file = findFile(files, "wallet/Wallet.java");
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/Wallet.java");
assertNotNull(file);
String content = file.content();
@@ -359,7 +359,7 @@ public void testGeneratedWalletState() {
EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
List files = generator.generate(definition);
- GeneratedFile file = findFile(files, "wallet/WalletState.java");
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/WalletState.java");
assertNotNull(file);
String content = file.content();
@@ -375,7 +375,7 @@ public void testGeneratedReserveAmountCommand() {
EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
List files = generator.generate(definition);
- GeneratedFile file = findFile(files, "wallet/commands/ReserveAmountCommand.java");
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/commands/ReserveAmountCommand.java");
assertNotNull(file);
String content = file.content();
@@ -394,7 +394,7 @@ public void testGeneratedAmountReservedEvent() {
EventModelDefinition definition = loadDefinition("crypto-trading-wallet.json");
List files = generator.generate(definition);
- GeneratedFile file = findFile(files, "wallet/events/AmountReservedEvent.java");
+ GeneratedFile file = findFile(files, "com/example/aggregates/wallet/events/AmountReservedEvent.java");
assertNotNull(file);
String content = file.content();
From b3cfe209beb930e3c5911581eda6d5fcf5c8d8ca Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Mar 2026 14:04:09 +0000
Subject: [PATCH 10/16] Write generated sources to root of
generated-test-sources directory
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/a17a7419-3fd2-4bc7-b872-46b0d2da5fa1
---
.../org/elasticsoftware/akces/codegen/CompilationTest.java | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
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
index 9840bc66..30fd89cf 100644
--- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CompilationTest.java
@@ -34,12 +34,12 @@
* 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/akces-codegen}
+ * 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", "akces-codegen");
+ 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");
@@ -77,7 +77,7 @@ public void testBothAggregatesCompileTogether() throws Exception {
/**
* Generates code from the definition, writes it to
- * {@code target/generated-test-sources/akces-codegen},
+ * {@code target/generated-test-sources},
* and compiles it using the Java Compiler API.
*/
private void assertCompiles(EventModelDefinition definition, String aggregateName) throws Exception {
From 75a7756853fa5b6748da14f7cbb794026e121189 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Mar 2026 20:38:36 +0000
Subject: [PATCH 11/16] Add @PIIData annotation support on generated Command
and Event field definitions
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/4721cb7c-f433-4df8-9027-40e317c87a39
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
---
.../akces/codegen/AkcesCodeGenerator.java | 11 +++++++++++
.../akces/codegen/model/Field.java | 9 +++++++++
.../resources/akces-event-model.schema.json | 4 ++++
.../codegen/CryptoTradingCodeGenTest.java | 18 ++++++++++++------
.../test/resources/crypto-trading-account.json | 12 ++++++------
5 files changed, 42 insertions(+), 12 deletions(-)
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
index a44adf4d..553e182a 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
@@ -266,12 +266,16 @@ private GeneratedFile generateCommandRecord(String aggregatePackage, Element com
boolean hasRequired = command.fields().stream().anyMatch(f -> !f.isOptional());
boolean hasOptional = command.fields().stream().anyMatch(Field::isOptional);
+ boolean hasPii = command.fields().stream().anyMatch(Field::isPiiData);
if (hasRequired) {
imports.add("jakarta.validation.constraints.NotNull");
}
if (hasOptional) {
imports.add("jakarta.annotation.Nullable");
}
+ if (hasPii) {
+ imports.add("org.elasticsoftware.akces.annotations.PIIData");
+ }
collectFieldTypeImports(command.fields(), imports);
@@ -341,12 +345,16 @@ private GeneratedFile generateEventRecord(String aggregatePackage, Element event
boolean hasRequired = event.fields().stream().anyMatch(f -> !f.isOptional());
boolean hasOptional = event.fields().stream().anyMatch(Field::isOptional);
+ boolean hasPii = event.fields().stream().anyMatch(Field::isPiiData);
if (hasRequired) {
imports.add("jakarta.validation.constraints.NotNull");
}
if (hasOptional) {
imports.add("jakarta.annotation.Nullable");
}
+ if (hasPii) {
+ imports.add("org.elasticsoftware.akces.annotations.PIIData");
+ }
collectFieldTypeImports(event.fields(), imports);
@@ -756,6 +764,9 @@ private void appendFieldAnnotations(StringBuilder sb, Field field) {
if (field.isIdAttribute()) {
sb.append("@AggregateIdentifier ");
}
+ if (field.isPiiData()) {
+ sb.append("@PIIData ");
+ }
if (field.isOptional()) {
sb.append("@Nullable ");
} else {
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java
index 4b6dbb14..978388bc 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Field.java
@@ -28,6 +28,7 @@
* @param type the field type (String, Boolean, Decimal, Long, Int, etc.)
* @param optional whether the field is optional (nullable)
* @param idAttribute whether this field is the aggregate identifier
+ * @param piiData whether this field contains PII data requiring GDPR protection
* @param cardinality Single or List
* @param subfields nested fields for Custom types
* @param technicalAttribute whether this is a technical attribute (not business relevant)
@@ -39,6 +40,7 @@ public record Field(
String type,
Boolean optional,
Boolean idAttribute,
+ Boolean piiData,
String cardinality,
List subfields,
Boolean technicalAttribute,
@@ -57,6 +59,13 @@ public boolean isIdAttribute() {
return idAttribute != null && idAttribute;
}
+ /**
+ * Returns true if this field contains PII data requiring GDPR protection.
+ */
+ public boolean isPiiData() {
+ return piiData != null && piiData;
+ }
+
/**
* Returns true if this field is optional (nullable).
*/
diff --git a/main/codegen/src/main/resources/akces-event-model.schema.json b/main/codegen/src/main/resources/akces-event-model.schema.json
index a28f69cf..e3065523 100644
--- a/main/codegen/src/main/resources/akces-event-model.schema.json
+++ b/main/codegen/src/main/resources/akces-event-model.schema.json
@@ -347,6 +347,10 @@
},
"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" },
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
index 6a3b42d2..28041baa 100644
--- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/CryptoTradingCodeGenTest.java
@@ -96,12 +96,12 @@ public void testGeneratedAccountCommand() {
"Should have userId field");
assertTrue(content.contains("@NotNull String country"),
"Should have country field");
- assertTrue(content.contains("@NotNull String firstName"),
- "Should have firstName field");
- assertTrue(content.contains("@NotNull String lastName"),
- "Should have lastName field");
- assertTrue(content.contains("@NotNull String email"),
- "Should have email 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");
}
@@ -127,6 +127,12 @@ public void testGeneratedAccountEvent() {
"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");
}
diff --git a/main/codegen/src/test/resources/crypto-trading-account.json b/main/codegen/src/test/resources/crypto-trading-account.json
index d9d495b7..9366c765 100644
--- a/main/codegen/src/test/resources/crypto-trading-account.json
+++ b/main/codegen/src/test/resources/crypto-trading-account.json
@@ -32,9 +32,9 @@
"fields": [
{ "name": "userId", "type": "String", "idAttribute": true },
{ "name": "country", "type": "String" },
- { "name": "firstName", "type": "String" },
- { "name": "lastName", "type": "String" },
- { "name": "email", "type": "String" }
+ { "name": "firstName", "type": "String", "piiData": true },
+ { "name": "lastName", "type": "String", "piiData": true },
+ { "name": "email", "type": "String", "piiData": true }
],
"dependencies": []
}
@@ -50,9 +50,9 @@
"fields": [
{ "name": "userId", "type": "String", "idAttribute": true },
{ "name": "country", "type": "String" },
- { "name": "firstName", "type": "String" },
- { "name": "lastName", "type": "String" },
- { "name": "email", "type": "String" }
+ { "name": "firstName", "type": "String", "piiData": true },
+ { "name": "lastName", "type": "String", "piiData": true },
+ { "name": "email", "type": "String", "piiData": true }
],
"dependencies": []
}
From c040b01265816742e24ce5d0300dab19a6843718 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 10:05:14 +0000
Subject: [PATCH 12/16] Add SCHEMA_DIFFERENCES.md documenting Akces extensions
to the event-modeling spec schema
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/7e9ace42-a4f7-4a15-b965-222abbf6d814
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
---
main/codegen/SCHEMA_DIFFERENCES.md | 142 +++++++++++++++++++++++++++++
1 file changed, 142 insertions(+)
create mode 100644 main/codegen/SCHEMA_DIFFERENCES.md
diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md
new file mode 100644
index 00000000..2c2434b8
--- /dev/null
+++ b/main/codegen/SCHEMA_DIFFERENCES.md
@@ -0,0 +1,142 @@
+# Akces Event Model Schema vs Original Event-Modeling Spec
+
+This document describes the differences between the Akces augmented event-model schema
+([`akces-event-model.schema.json`](src/main/resources/akces-event-model.schema.json)) and the
+original [event-modeling specification schema](https://github.com/dilgerma/event-modeling-spec/blob/main/eventmodeling.schema.json)
+by Martin Dilger.
+
+The Akces schema is a **strict superset** of the original — it preserves every definition from the
+original schema unchanged (Slice, Element, ScreenImage, Table, Specification, SpecificationStep,
+Comment, Actor, Dependency) and extends it with Akces Framework-specific properties for aggregate
+code generation.
+
+---
+
+## Root-Level Changes
+
+| Aspect | Original Schema | Akces Schema |
+|---|---|---|
+| **title** | *(none)* | `"Akces Event Model Definition"` |
+| **description** | *(none)* | Describes the Akces extension of the event-modeling spec |
+| **properties** | `slices` | `packageName`, `aggregateConfig`, `slices` |
+| **required** | `["slices"]` | `["packageName", "aggregateConfig", "slices"]` |
+
+### New root-level property: `packageName`
+
+```json
+"packageName": {
+ "type": "string",
+ "description": "The base Java package for generated code"
+}
+```
+
+Specifies the base Java package name used in the JSON definition. The code generator uses its own
+`basePackage` constructor parameter for the generated `package` declarations and directory structure.
+
+### New root-level property: `aggregateConfig`
+
+```json
+"aggregateConfig": {
+ "type": "object",
+ "description": "Per-aggregate configuration keyed by aggregate name",
+ "additionalProperties": { "$ref": "#/$defs/AggregateConfig" }
+}
+```
+
+A map keyed by aggregate name (e.g., `"Account"`, `"Wallet"`) where each value is an
+`AggregateConfig` object containing Akces-specific metadata for that aggregate.
+
+---
+
+## New Definition: `AggregateConfig`
+
+Akces-specific configuration for an aggregate, used to generate the state record and aggregate class
+annotations.
+
+| Property | Type | Required | Description |
+|---|---|---|---|
+| `indexed` | `boolean` | No | Whether the aggregate is indexed (maps to `@AggregateInfo(indexed = true)`) |
+| `indexName` | `string` | No | The index name (maps to `@AggregateInfo(indexName = "...")`) |
+| `generateGDPRKeyOnCreate` | `boolean` | No | Whether to generate a GDPR encryption key on aggregate creation (maps to `@AggregateInfo(generateGDPRKeyOnCreate = true)`) |
+| `stateVersion` | `integer` (≥ 1) | No | The version of the aggregate state (maps to `@AggregateStateInfo(version = ...)`) |
+| `stateFields` | `StateField[]` | **Yes** | The fields of the aggregate state record |
+
+**Example:**
+
+```json
+"aggregateConfig": {
+ "Account": {
+ "indexed": true,
+ "indexName": "Users",
+ "generateGDPRKeyOnCreate": true,
+ "stateVersion": 1,
+ "stateFields": [
+ { "name": "userId", "type": "String", "idAttribute": true },
+ { "name": "firstName", "type": "String", "piiData": true },
+ { "name": "email", "type": "String", "piiData": true }
+ ]
+ }
+}
+```
+
+---
+
+## New Definition: `StateField`
+
+Defines a field in the aggregate state record with Akces-specific metadata for PII marking and
+identifier designation.
+
+| Property | Type | Required | Description |
+|---|---|---|---|
+| `name` | `string` | **Yes** | The field name |
+| `type` | `string` (enum) | **Yes** | The field type — same enum as `Field.type`: `String`, `Boolean`, `Double`, `Decimal`, `Long`, `Custom`, `Date`, `DateTime`, `UUID`, `Int` |
+| `idAttribute` | `boolean` | No | Whether this field is the aggregate identifier (maps to `@AggregateIdentifier`) |
+| `piiData` | `boolean` | No | Whether this field contains PII data (maps to `@PIIData` annotation) |
+| `optional` | `boolean` | No | Whether the field is optional/nullable (maps to `@Nullable` instead of `@NotNull`) |
+
+---
+
+## Modified Definition: `Field`
+
+The `Field` definition (used in Element fields for commands, events, read models, etc.) has one
+addition compared to the original schema:
+
+| Property | Type | Description |
+|---|---|---|
+| **`piiData`** *(new)* | `boolean` | Whether this field contains PII data requiring GDPR protection |
+
+All other `Field` properties are unchanged from the original schema: `name`, `type`, `example`,
+`subfields`, `mapping`, `optional`, `technicalAttribute`, `generated`, `idAttribute`, `schema`,
+`cardinality`.
+
+When `piiData: true` is set on a command or event field, the code generator emits the `@PIIData`
+annotation on the corresponding record parameter, ensuring GDPR-sensitive fields are consistently
+marked across commands, events, and state records.
+
+---
+
+## Unchanged Definitions
+
+The following definitions are **identical** to the original event-modeling spec schema:
+
+- **`Slice`** — Represents a vertical slice with commands, events, read models, screens, processors, tables, specifications, and actors
+- **`Element`** — Represents a command, event, read model, screen, or automation element with fields and dependencies
+- **`ScreenImage`** — A screen mockup image reference
+- **`Table`** — A data table definition with fields
+- **`Specification`** — A BDD-style specification with given/when/then steps
+- **`SpecificationStep`** — A step in a specification
+- **`Comment`** — A comment with a description
+- **`Actor`** — An actor with authentication requirements
+- **`Dependency`** — An inbound or outbound dependency between elements
+
+---
+
+## Summary of Additions
+
+| Addition | Location | Purpose |
+|---|---|---|
+| `packageName` | Root property | Java package name declared in the definition |
+| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields) |
+| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition |
+| `StateField` | New `$defs` entry | State record field with PII and identifier markers |
+| `piiData` | Added to `Field` | Marks command/event fields as containing PII data |
From 13b6c3918c92f044f3967b5aebd3ae4dddf35d69 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 10:25:38 +0000
Subject: [PATCH 13/16] Add ExternalEventHandler to schema for cross-aggregate
@EventHandler support
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/e48b9560-faa9-428c-ba26-c745c77a9035
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
---
main/codegen/SCHEMA_DIFFERENCES.md | 65 +++++++-
.../resources/akces-event-model.schema.json | 41 +++++
.../akces/codegen/SchemaValidationTest.java | 147 ++++++++++++++++++
3 files changed, 251 insertions(+), 2 deletions(-)
diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md
index 2c2434b8..57e6ca46 100644
--- a/main/codegen/SCHEMA_DIFFERENCES.md
+++ b/main/codegen/SCHEMA_DIFFERENCES.md
@@ -60,6 +60,7 @@ annotations.
| `generateGDPRKeyOnCreate` | `boolean` | No | Whether to generate a GDPR encryption key on aggregate creation (maps to `@AggregateInfo(generateGDPRKeyOnCreate = true)`) |
| `stateVersion` | `integer` (≥ 1) | No | The version of the aggregate state (maps to `@AggregateStateInfo(version = ...)`) |
| `stateFields` | `StateField[]` | **Yes** | The fields of the aggregate state record |
+| `externalEventHandlers` | `ExternalEventHandler[]` | No | Handlers for domain events from other aggregates (maps to `@EventHandler` annotation) |
**Example:**
@@ -75,6 +76,23 @@ annotations.
{ "name": "firstName", "type": "String", "piiData": true },
{ "name": "email", "type": "String", "piiData": true }
]
+ },
+ "Wallet": {
+ "stateFields": [
+ { "name": "userId", "type": "String", "idAttribute": true }
+ ],
+ "externalEventHandlers": [
+ {
+ "eventName": "AccountCreated",
+ "sourceAggregate": "Account",
+ "create": true,
+ "produces": ["WalletCreated"],
+ "errors": [],
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true }
+ ]
+ }
+ ]
}
}
```
@@ -96,6 +114,48 @@ identifier designation.
---
+## New Definition: `ExternalEventHandler`
+
+Defines a handler for an external domain event from another aggregate. In the Akces Framework,
+aggregates (especially Process Managers) can react to events produced by other aggregates using the
+`@EventHandler` annotation. This definition captures that cross-aggregate event handling pattern.
+
+| Property | Type | Required | Description |
+|---|---|---|---|
+| `eventName` | `string` | **Yes** | Name of the external domain event (e.g., `"AccountCreated"`) |
+| `sourceAggregate` | `string` | **Yes** | Name of the aggregate that produces this event (e.g., `"Account"`) |
+| `create` | `boolean` | No | Whether handling this event creates a new aggregate instance (maps to `@EventHandler(create = true)`) |
+| `produces` | `string[]` | **Yes** | Names of domain events produced by this handler (maps to `@EventHandler(produces = {...})`) |
+| `errors` | `string[]` | No | Names of error events produced by this handler (maps to `@EventHandler(errors = {...})`) |
+| `fields` | `Field[]` | No | Fields of the external event |
+
+**Example** — Wallet aggregate reacting to Account's `AccountCreatedEvent`:
+
+```json
+{
+ "eventName": "AccountCreated",
+ "sourceAggregate": "Account",
+ "create": true,
+ "produces": ["WalletCreated"],
+ "errors": [],
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true }
+ ]
+}
+```
+
+This maps to the following generated code:
+
+```java
+@EventHandler(create = true, produces = WalletCreatedEvent.class, errors = {})
+public Stream create(AccountCreatedEvent event, WalletState isNull) {
+ // TODO: implement business logic
+ return Stream.of(new WalletCreatedEvent(event.userId()));
+}
+```
+
+---
+
## Modified Definition: `Field`
The `Field` definition (used in Element fields for commands, events, read models, etc.) has one
@@ -136,7 +196,8 @@ The following definitions are **identical** to the original event-modeling spec
| Addition | Location | Purpose |
|---|---|---|
| `packageName` | Root property | Java package name declared in the definition |
-| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields) |
-| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition |
+| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields, external event handlers) |
+| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition, external event handlers |
+| `ExternalEventHandler` | New `$defs` entry | Handler for external domain events from other aggregates (`@EventHandler`) |
| `StateField` | New `$defs` entry | State record field with PII and identifier markers |
| `piiData` | Added to `Field` | Marks command/event fields as containing PII data |
diff --git a/main/codegen/src/main/resources/akces-event-model.schema.json b/main/codegen/src/main/resources/akces-event-model.schema.json
index e3065523..5c8512bc 100644
--- a/main/codegen/src/main/resources/akces-event-model.schema.json
+++ b/main/codegen/src/main/resources/akces-event-model.schema.json
@@ -47,12 +47,53 @@
"type": "array",
"items": { "$ref": "#/$defs/StateField" },
"description": "The fields of the aggregate state"
+ },
+ "externalEventHandlers": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/ExternalEventHandler" },
+ "description": "Handlers for domain events from other aggregates (maps to @EventHandler annotation)"
}
},
"required": ["stateFields"],
"additionalProperties": false
},
+ "ExternalEventHandler": {
+ "type": "object",
+ "description": "Defines a handler for an external domain event from another aggregate, mapping to the @EventHandler annotation",
+ "properties": {
+ "eventName": {
+ "type": "string",
+ "description": "Name of the external domain event (e.g., 'AccountCreated')"
+ },
+ "sourceAggregate": {
+ "type": "string",
+ "description": "Name of the aggregate that produces this event"
+ },
+ "create": {
+ "type": "boolean",
+ "description": "Whether handling this event creates a new aggregate instance"
+ },
+ "produces": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Names of domain events produced by this handler"
+ },
+ "errors": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Names of error events produced by this handler"
+ },
+ "fields": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Field" },
+ "description": "Fields of the external event"
+ }
+ },
+ "required": ["eventName", "sourceAggregate", "produces"],
+ "additionalProperties": false
+ },
+
"StateField": {
"type": "object",
"description": "A field in the aggregate state definition with Akces-specific metadata",
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
index c5fdd8b0..b380be3a 100644
--- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java
@@ -233,6 +233,153 @@ public void testAggregateConfigWithAllProperties() {
schema.validate(json);
}
+ @Test
+ public void testExternalEventHandlerAccepted() {
+ JSONObject handler = new JSONObject()
+ .put("eventName", "AccountCreated")
+ .put("sourceAggregate", "Account")
+ .put("create", true)
+ .put("produces", new org.json.JSONArray().put("WalletCreated"))
+ .put("errors", new org.json.JSONArray())
+ .put("fields", new org.json.JSONArray().put(
+ new JSONObject().put("name", "userId").put("type", "String").put("idAttribute", true)));
+
+ 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))
+ .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Wallet", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ schema.validate(json);
+ }
+
+ @Test
+ public void testExternalEventHandlerMinimalAccepted() {
+ JSONObject handler = new JSONObject()
+ .put("eventName", "AmountReserved")
+ .put("sourceAggregate", "Wallet")
+ .put("produces", new org.json.JSONArray().put("BuyOrderPlaced").put("SellOrderPlaced"));
+
+ JSONObject stateField = new JSONObject()
+ .put("name", "userId")
+ .put("type", "String");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField))
+ .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("OrderProcessManager", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ schema.validate(json);
+ }
+
+ @Test
+ public void testExternalEventHandlerMissingRequiredEventName() {
+ JSONObject handler = new JSONObject()
+ .put("sourceAggregate", "Account")
+ .put("produces", new org.json.JSONArray().put("WalletCreated"));
+
+ JSONObject stateField = new JSONObject()
+ .put("name", "id")
+ .put("type", "String");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField))
+ .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
+ assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("eventName")),
+ "Should report missing eventName: " + ex.getAllMessages());
+ }
+
+ @Test
+ public void testExternalEventHandlerMissingRequiredSourceAggregate() {
+ JSONObject handler = new JSONObject()
+ .put("eventName", "AccountCreated")
+ .put("produces", new org.json.JSONArray().put("WalletCreated"));
+
+ JSONObject stateField = new JSONObject()
+ .put("name", "id")
+ .put("type", "String");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField))
+ .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
+ assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("sourceAggregate")),
+ "Should report missing sourceAggregate: " + ex.getAllMessages());
+ }
+
+ @Test
+ public void testExternalEventHandlerMissingRequiredProduces() {
+ JSONObject handler = new JSONObject()
+ .put("eventName", "AccountCreated")
+ .put("sourceAggregate", "Account");
+
+ JSONObject stateField = new JSONObject()
+ .put("name", "id")
+ .put("type", "String");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField))
+ .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+
+ JSONObject json = new JSONObject()
+ .put("packageName", "com.example")
+ .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
+ .put("slices", new org.json.JSONArray());
+
+ ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
+ assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("produces")),
+ "Should report missing produces: " + ex.getAllMessages());
+ }
+
+ @Test
+ public void testExternalEventHandlerAdditionalPropertiesRejected() {
+ JSONObject handler = new JSONObject()
+ .put("eventName", "AccountCreated")
+ .put("sourceAggregate", "Account")
+ .put("produces", new org.json.JSONArray().put("WalletCreated"))
+ .put("unknownProp", "value");
+
+ JSONObject stateField = new JSONObject()
+ .put("name", "id")
+ .put("type", "String");
+
+ JSONObject aggregateConfig = new JSONObject()
+ .put("stateFields", new org.json.JSONArray().put(stateField))
+ .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+
+ 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 testInvalidElementTypeRejected() {
JSONObject element = new JSONObject()
From 5cfacd9de63b89fd5b8fcc615a5ffddfd3ba885e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 11:42:45 +0000
Subject: [PATCH 14/16] Remove StateField, reuse Field for aggregate state in
codegen schema
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/4dacb82e-8135-4bb7-a9b9-79a4f73e59c0
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
---
main/codegen/SCHEMA_DIFFERENCES.md | 18 +-----
.../akces/codegen/AkcesCodeGenerator.java | 55 ++++---------------
.../akces/codegen/model/AggregateConfig.java | 2 +-
.../akces/codegen/model/StateField.java | 39 -------------
.../resources/akces-event-model.schema.json | 43 +--------------
5 files changed, 13 insertions(+), 144 deletions(-)
delete mode 100644 main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java
diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md
index 57e6ca46..31a7fdf2 100644
--- a/main/codegen/SCHEMA_DIFFERENCES.md
+++ b/main/codegen/SCHEMA_DIFFERENCES.md
@@ -59,7 +59,7 @@ annotations.
| `indexName` | `string` | No | The index name (maps to `@AggregateInfo(indexName = "...")`) |
| `generateGDPRKeyOnCreate` | `boolean` | No | Whether to generate a GDPR encryption key on aggregate creation (maps to `@AggregateInfo(generateGDPRKeyOnCreate = true)`) |
| `stateVersion` | `integer` (≥ 1) | No | The version of the aggregate state (maps to `@AggregateStateInfo(version = ...)`) |
-| `stateFields` | `StateField[]` | **Yes** | The fields of the aggregate state record |
+| `stateFields` | `Field[]` | **Yes** | The fields of the aggregate state record (reuses the standard `Field` definition) |
| `externalEventHandlers` | `ExternalEventHandler[]` | No | Handlers for domain events from other aggregates (maps to `@EventHandler` annotation) |
**Example:**
@@ -99,21 +99,6 @@ annotations.
---
-## New Definition: `StateField`
-
-Defines a field in the aggregate state record with Akces-specific metadata for PII marking and
-identifier designation.
-
-| Property | Type | Required | Description |
-|---|---|---|---|
-| `name` | `string` | **Yes** | The field name |
-| `type` | `string` (enum) | **Yes** | The field type — same enum as `Field.type`: `String`, `Boolean`, `Double`, `Decimal`, `Long`, `Custom`, `Date`, `DateTime`, `UUID`, `Int` |
-| `idAttribute` | `boolean` | No | Whether this field is the aggregate identifier (maps to `@AggregateIdentifier`) |
-| `piiData` | `boolean` | No | Whether this field contains PII data (maps to `@PIIData` annotation) |
-| `optional` | `boolean` | No | Whether the field is optional/nullable (maps to `@Nullable` instead of `@NotNull`) |
-
----
-
## New Definition: `ExternalEventHandler`
Defines a handler for an external domain event from another aggregate. In the Akces Framework,
@@ -199,5 +184,4 @@ The following definitions are **identical** to the original event-modeling spec
| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields, external event handlers) |
| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition, external event handlers |
| `ExternalEventHandler` | New `$defs` entry | Handler for external domain events from other aggregates (`@EventHandler`) |
-| `StateField` | New `$defs` entry | State record field with PII and identifier markers |
| `piiData` | Added to `Field` | Marks command/event fields as containing PII data |
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
index 553e182a..bea7bd3e 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
@@ -412,14 +412,14 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg
imports.add("org.elasticsoftware.akces.aggregate.AggregateState");
imports.add("org.elasticsoftware.akces.annotations.AggregateStateInfo");
- boolean hasIdAttribute = config.stateFields().stream().anyMatch(StateField::idAttribute);
+ boolean hasIdAttribute = config.stateFields().stream().anyMatch(Field::isIdAttribute);
if (hasIdAttribute) {
imports.add("org.elasticsoftware.akces.annotations.AggregateIdentifier");
}
- boolean hasRequired = config.stateFields().stream().anyMatch(f -> !f.optional());
- boolean hasOptional = config.stateFields().stream().anyMatch(StateField::optional);
- boolean hasPii = config.stateFields().stream().anyMatch(StateField::piiData);
+ boolean hasRequired = config.stateFields().stream().anyMatch(f -> !f.isOptional());
+ boolean hasOptional = config.stateFields().stream().anyMatch(Field::isOptional);
+ boolean hasPii = config.stateFields().stream().anyMatch(Field::isPiiData);
if (hasRequired) {
imports.add("jakarta.validation.constraints.NotNull");
@@ -431,7 +431,7 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg
imports.add("org.elasticsoftware.akces.annotations.PIIData");
}
- collectStateFieldTypeImports(config.stateFields(), imports);
+ collectFieldTypeImports(config.stateFields(), imports);
for (String imp : imports) {
sb.append("import ").append(imp).append(";\n");
@@ -445,11 +445,11 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg
// Record declaration
sb.append("public record ").append(className).append("(\n");
- List fields = config.stateFields();
+ List fields = config.stateFields();
for (int i = 0; i < fields.size(); i++) {
- StateField field = fields.get(i);
+ Field field = fields.get(i);
sb.append(" ");
- appendStateFieldAnnotations(sb, field);
+ appendFieldAnnotations(sb, field);
sb.append(mapType(field.type())).append(" ").append(field.name());
if (i < fields.size() - 1) {
sb.append(",\n");
@@ -461,7 +461,7 @@ private GeneratedFile generateStateRecord(String aggregatePackage, String aggreg
sb.append(") implements AggregateState {\n");
// getAggregateId method
- String idFieldName = findStateIdFieldName(fields);
+ String idFieldName = findIdFieldName(fields);
sb.append(" @Override\n");
sb.append(" public String getAggregateId() {\n");
sb.append(" return ").append(idFieldName).append("();\n");
@@ -744,7 +744,7 @@ private String getDefaultValue(Field field) {
}
private String generateStateConstructorArgsFromEvent(AggregateConfig config, Element event) {
- List stateFields = config.stateFields();
+ List stateFields = config.stateFields();
Set eventFieldNames = event.fields().stream()
.map(Field::name)
.collect(Collectors.toSet());
@@ -774,20 +774,6 @@ private void appendFieldAnnotations(StringBuilder sb, Field field) {
}
}
- private void appendStateFieldAnnotations(StringBuilder sb, StateField field) {
- if (field.idAttribute()) {
- sb.append("@AggregateIdentifier ");
- }
- if (field.piiData()) {
- sb.append("@PIIData ");
- }
- if (field.optional()) {
- sb.append("@Nullable ");
- } else {
- sb.append("@NotNull ");
- }
- }
-
private void collectFieldTypeImports(List fields, Set imports) {
for (Field field : fields) {
String javaType = mapFieldType(field);
@@ -807,19 +793,6 @@ private void collectFieldTypeImports(List fields, Set imports) {
}
}
- private void collectStateFieldTypeImports(List fields, Set imports) {
- for (StateField field : fields) {
- String javaType = mapType(field.type());
- if (javaType.equals("BigDecimal")) {
- imports.add("java.math.BigDecimal");
- } else if (javaType.equals("LocalDate")) {
- imports.add("java.time.LocalDate");
- } else if (javaType.equals("LocalDateTime")) {
- imports.add("java.time.LocalDateTime");
- }
- }
- }
-
/**
* Maps an event-modeling field type to a Java type, handling list cardinality.
*/
@@ -860,14 +833,6 @@ private String findIdFieldName(List fields) {
.orElse(fields.isEmpty() ? "id" : fields.getFirst().name());
}
- private String findStateIdFieldName(List fields) {
- return fields.stream()
- .filter(StateField::idAttribute)
- .map(StateField::name)
- .findFirst()
- .orElse(fields.isEmpty() ? "id" : fields.getFirst().name());
- }
-
private String toCamelCase(String title) {
if (title == null || title.isEmpty()) return title;
// Convert PascalCase title like "CreditWallet" to camelCase "creditWallet"
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/AggregateConfig.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/AggregateConfig.java
index 3590d001..2e7e3971 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/AggregateConfig.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/AggregateConfig.java
@@ -36,7 +36,7 @@ public record AggregateConfig(
String indexName,
boolean generateGDPRKeyOnCreate,
int stateVersion,
- List stateFields
+ List stateFields
) {
public AggregateConfig {
if (stateVersion <= 0) {
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java
deleted file mode 100644
index e09056ae..00000000
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/StateField.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * 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.model;
-
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-
-/**
- * Represents a field in the aggregate state definition with Akces-specific metadata.
- *
- * @param name the field name
- * @param type the field type (using event-modeling type names)
- * @param idAttribute whether this field is the aggregate identifier
- * @param piiData whether this field contains PII data requiring GDPR protection
- * @param optional whether the field is optional (nullable)
- */
-@JsonIgnoreProperties(ignoreUnknown = true)
-public record StateField(
- String name,
- String type,
- boolean idAttribute,
- boolean piiData,
- boolean optional
-) {
-}
diff --git a/main/codegen/src/main/resources/akces-event-model.schema.json b/main/codegen/src/main/resources/akces-event-model.schema.json
index 5c8512bc..a1256442 100644
--- a/main/codegen/src/main/resources/akces-event-model.schema.json
+++ b/main/codegen/src/main/resources/akces-event-model.schema.json
@@ -45,7 +45,7 @@
},
"stateFields": {
"type": "array",
- "items": { "$ref": "#/$defs/StateField" },
+ "items": { "$ref": "#/$defs/Field" },
"description": "The fields of the aggregate state"
},
"externalEventHandlers": {
@@ -94,47 +94,6 @@
"additionalProperties": false
},
- "StateField": {
- "type": "object",
- "description": "A field in the aggregate state definition with Akces-specific metadata",
- "properties": {
- "name": {
- "type": "string",
- "description": "The field name"
- },
- "type": {
- "type": "string",
- "enum": [
- "String",
- "Boolean",
- "Double",
- "Decimal",
- "Long",
- "Custom",
- "Date",
- "DateTime",
- "UUID",
- "Int"
- ],
- "description": "The field type (using event-modeling type names)"
- },
- "idAttribute": {
- "type": "boolean",
- "description": "Whether this field is the aggregate identifier"
- },
- "piiData": {
- "type": "boolean",
- "description": "Whether this field contains PII data requiring GDPR protection"
- },
- "optional": {
- "type": "boolean",
- "description": "Whether the field is optional (nullable)"
- }
- },
- "required": ["name", "type"],
- "additionalProperties": false
- },
-
"Slice": {
"type": "object",
"properties": {
From ebb5a92ad0daf71a5ab8d4dedf1c155ed6a691a7 Mon Sep 17 00:00:00 2001
From: Joost van de Wijgerd
Date: Mon, 30 Mar 2026 15:11:50 +0200
Subject: [PATCH 15/16] Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
---
.../org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java | 3 ---
1 file changed, 3 deletions(-)
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
index bea7bd3e..c019d287 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
@@ -248,7 +248,6 @@ private List generateAggregate(
private GeneratedFile generateCommandRecord(String aggregatePackage, Element command) {
String className = command.title() + "Command";
String commandsPackage = aggregatePackage + ".commands";
- String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1);
StringBuilder sb = new StringBuilder();
sb.append(LICENSE_HEADER).append("\n");
@@ -323,7 +322,6 @@ private GeneratedFile generateCommandRecord(String aggregatePackage, Element com
private GeneratedFile generateEventRecord(String aggregatePackage, Element event, boolean isError) {
String className = event.title() + "Event";
String eventsPackage = aggregatePackage + ".events";
- String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1);
StringBuilder sb = new StringBuilder();
sb.append(LICENSE_HEADER).append("\n");
@@ -401,7 +399,6 @@ private GeneratedFile generateEventRecord(String aggregatePackage, Element event
private GeneratedFile generateStateRecord(String aggregatePackage, String aggregateName, AggregateConfig config) {
String className = aggregateName + "State";
- String aggregateLower = aggregatePackage.substring(aggregatePackage.lastIndexOf('.') + 1);
StringBuilder sb = new StringBuilder();
sb.append(LICENSE_HEADER).append("\n");
From 32325e1682808cdead9aa9b8b3b22bea98558cb8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:43:05 +0000
Subject: [PATCH 16/16] Move external events from AggregateConfig to Slice as
external_events Element array
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/cfed3c79-d934-4609-9b5d-5e8a48c5bdc1
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
---
main/codegen/SCHEMA_DIFFERENCES.md | 99 ++++++-----
.../akces/codegen/AkcesCodeGenerator.java | 8 +-
.../akces/codegen/model/Slice.java | 18 +-
.../resources/akces-event-model.schema.json | 45 +----
.../akces/codegen/SchemaValidationTest.java | 159 +++++++-----------
5 files changed, 129 insertions(+), 200 deletions(-)
diff --git a/main/codegen/SCHEMA_DIFFERENCES.md b/main/codegen/SCHEMA_DIFFERENCES.md
index 31a7fdf2..49cb847c 100644
--- a/main/codegen/SCHEMA_DIFFERENCES.md
+++ b/main/codegen/SCHEMA_DIFFERENCES.md
@@ -60,7 +60,6 @@ annotations.
| `generateGDPRKeyOnCreate` | `boolean` | No | Whether to generate a GDPR encryption key on aggregate creation (maps to `@AggregateInfo(generateGDPRKeyOnCreate = true)`) |
| `stateVersion` | `integer` (≥ 1) | No | The version of the aggregate state (maps to `@AggregateStateInfo(version = ...)`) |
| `stateFields` | `Field[]` | **Yes** | The fields of the aggregate state record (reuses the standard `Field` definition) |
-| `externalEventHandlers` | `ExternalEventHandler[]` | No | Handlers for domain events from other aggregates (maps to `@EventHandler` annotation) |
**Example:**
@@ -76,66 +75,65 @@ annotations.
{ "name": "firstName", "type": "String", "piiData": true },
{ "name": "email", "type": "String", "piiData": true }
]
- },
- "Wallet": {
- "stateFields": [
- { "name": "userId", "type": "String", "idAttribute": true }
- ],
- "externalEventHandlers": [
- {
- "eventName": "AccountCreated",
- "sourceAggregate": "Account",
- "create": true,
- "produces": ["WalletCreated"],
- "errors": [],
- "fields": [
- { "name": "userId", "type": "String", "idAttribute": true }
- ]
- }
- ]
}
}
```
---
-## New Definition: `ExternalEventHandler`
+## Modified Definition: `Slice`
-Defines a handler for an external domain event from another aggregate. In the Akces Framework,
-aggregates (especially Process Managers) can react to events produced by other aggregates using the
-`@EventHandler` annotation. This definition captures that cross-aggregate event handling pattern.
+The `Slice` definition has one addition compared to the original event-modeling spec schema:
| Property | Type | Required | Description |
|---|---|---|---|
-| `eventName` | `string` | **Yes** | Name of the external domain event (e.g., `"AccountCreated"`) |
-| `sourceAggregate` | `string` | **Yes** | Name of the aggregate that produces this event (e.g., `"Account"`) |
-| `create` | `boolean` | No | Whether handling this event creates a new aggregate instance (maps to `@EventHandler(create = true)`) |
-| `produces` | `string[]` | **Yes** | Names of domain events produced by this handler (maps to `@EventHandler(produces = {...})`) |
-| `errors` | `string[]` | No | Names of error events produced by this handler (maps to `@EventHandler(errors = {...})`) |
-| `fields` | `Field[]` | No | Fields of the external event |
+| **`external_events`** *(new)* | `Element[]` | No | External events from other aggregates consumed in this slice |
-**Example** — Wallet aggregate reacting to Account's `AccountCreatedEvent`:
+All other `Slice` properties are unchanged from the original schema.
-```json
-{
- "eventName": "AccountCreated",
- "sourceAggregate": "Account",
- "create": true,
- "produces": ["WalletCreated"],
- "errors": [],
- "fields": [
- { "name": "userId", "type": "String", "idAttribute": true }
- ]
-}
-```
+External events use the same `Element` definition as regular events and commands. They represent
+events produced by other aggregates that are consumed by the aggregate in this slice. This is used
+for cross-aggregate event handling, particularly for process managers.
-This maps to the following generated code:
+**Example** — Wallet aggregate consuming Account's `AccountCreatedEvent`:
-```java
-@EventHandler(create = true, produces = WalletCreatedEvent.class, errors = {})
-public Stream create(AccountCreatedEvent event, WalletState isNull) {
- // TODO: implement business logic
- return Stream.of(new WalletCreatedEvent(event.userId()));
+```json
+{
+ "id": "create-wallet",
+ "title": "Create Wallet",
+ "sliceType": "AUTOMATION",
+ "aggregates": ["Wallet"],
+ "commands": [],
+ "events": [
+ {
+ "id": "wallet-created-evt",
+ "title": "WalletCreated",
+ "type": "EVENT",
+ "aggregate": "Wallet",
+ "createsAggregate": true,
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true }
+ ],
+ "dependencies": []
+ }
+ ],
+ "external_events": [
+ {
+ "id": "account-created-ext",
+ "title": "AccountCreated",
+ "type": "EVENT",
+ "aggregate": "Account",
+ "fields": [
+ { "name": "userId", "type": "String", "idAttribute": true }
+ ],
+ "dependencies": []
+ }
+ ],
+ "readmodels": [],
+ "screens": [],
+ "processors": [],
+ "tables": [],
+ "specifications": []
}
```
@@ -164,7 +162,6 @@ marked across commands, events, and state records.
The following definitions are **identical** to the original event-modeling spec schema:
-- **`Slice`** — Represents a vertical slice with commands, events, read models, screens, processors, tables, specifications, and actors
- **`Element`** — Represents a command, event, read model, screen, or automation element with fields and dependencies
- **`ScreenImage`** — A screen mockup image reference
- **`Table`** — A data table definition with fields
@@ -181,7 +178,7 @@ The following definitions are **identical** to the original event-modeling spec
| Addition | Location | Purpose |
|---|---|---|
| `packageName` | Root property | Java package name declared in the definition |
-| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields, external event handlers) |
-| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition, external event handlers |
-| `ExternalEventHandler` | New `$defs` entry | Handler for external domain events from other aggregates (`@EventHandler`) |
+| `aggregateConfig` | Root property | Per-aggregate Akces configuration (indexing, GDPR, state fields) |
+| `AggregateConfig` | New `$defs` entry | Aggregate-level metadata: indexing, GDPR key generation, state definition |
+| `external_events` | Added to `Slice` | External events from other aggregates consumed in a slice |
| `piiData` | Added to `Field` | Marks command/event fields as containing PII data |
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
index c019d287..bda2de1b 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/AkcesCodeGenerator.java
@@ -150,7 +150,8 @@ private record SliceData(
Slice slice,
List commands,
List successEvents,
- List errorEvents
+ List errorEvents,
+ List externalEvents
) {
}
@@ -180,8 +181,11 @@ private Map> groupByAggregate(EventModelDefinition defin
List successEvents = allEvents.stream().filter(e -> !e.isError()).toList();
List errorEvents = allEvents.stream().filter(Element::isError).toList();
+ // External events are associated with the consuming aggregate (via the slice's aggregate context)
+ List externalEvents = slice.externalEvents();
+
result.computeIfAbsent(aggregateName, k -> new ArrayList<>())
- .add(new SliceData(slice, commands, successEvents, errorEvents));
+ .add(new SliceData(slice, commands, successEvents, errorEvents, externalEvents));
}
}
diff --git a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java
index cbfd5a13..2a735771 100644
--- a/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java
+++ b/main/codegen/src/main/java/org/elasticsoftware/akces/codegen/model/Slice.java
@@ -18,6 +18,7 @@
package org.elasticsoftware.akces.codegen.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@@ -25,12 +26,13 @@
* Represents an event-modeling slice, which groups related commands and events
* that form a coherent use case or business operation.
*
- * @param id unique identifier for the slice
- * @param title human-readable title
- * @param sliceType the type of slice (STATE_CHANGE, STATE_VIEW, AUTOMATION)
- * @param commands commands in this slice
- * @param events events in this slice
- * @param aggregates aggregate names referenced by this slice
+ * @param id unique identifier for the slice
+ * @param title human-readable title
+ * @param sliceType the type of slice (STATE_CHANGE, STATE_VIEW, AUTOMATION)
+ * @param commands commands in this slice
+ * @param events events in this slice
+ * @param externalEvents external events from other aggregates consumed in this slice
+ * @param aggregates aggregate names referenced by this slice
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Slice(
@@ -39,6 +41,7 @@ public record Slice(
String sliceType,
List commands,
List events,
+ @JsonProperty("external_events") List externalEvents,
List aggregates
) {
public Slice {
@@ -48,6 +51,9 @@ public record Slice(
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
index a1256442..4d7de2c0 100644
--- a/main/codegen/src/main/resources/akces-event-model.schema.json
+++ b/main/codegen/src/main/resources/akces-event-model.schema.json
@@ -47,53 +47,12 @@
"type": "array",
"items": { "$ref": "#/$defs/Field" },
"description": "The fields of the aggregate state"
- },
- "externalEventHandlers": {
- "type": "array",
- "items": { "$ref": "#/$defs/ExternalEventHandler" },
- "description": "Handlers for domain events from other aggregates (maps to @EventHandler annotation)"
}
},
"required": ["stateFields"],
"additionalProperties": false
},
- "ExternalEventHandler": {
- "type": "object",
- "description": "Defines a handler for an external domain event from another aggregate, mapping to the @EventHandler annotation",
- "properties": {
- "eventName": {
- "type": "string",
- "description": "Name of the external domain event (e.g., 'AccountCreated')"
- },
- "sourceAggregate": {
- "type": "string",
- "description": "Name of the aggregate that produces this event"
- },
- "create": {
- "type": "boolean",
- "description": "Whether handling this event creates a new aggregate instance"
- },
- "produces": {
- "type": "array",
- "items": { "type": "string" },
- "description": "Names of domain events produced by this handler"
- },
- "errors": {
- "type": "array",
- "items": { "type": "string" },
- "description": "Names of error events produced by this handler"
- },
- "fields": {
- "type": "array",
- "items": { "$ref": "#/$defs/Field" },
- "description": "Fields of the external event"
- }
- },
- "required": ["eventName", "sourceAggregate", "produces"],
- "additionalProperties": false
- },
-
"Slice": {
"type": "object",
"properties": {
@@ -117,6 +76,10 @@
"type": "array",
"items": { "$ref": "#/$defs/Element" }
},
+ "external_events": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Element" }
+ },
"readmodels": {
"type": "array",
"items": { "$ref": "#/$defs/Element" }
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
index b380be3a..f0851c4f 100644
--- a/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java
+++ b/main/codegen/src/test/java/org/elasticsoftware/akces/codegen/SchemaValidationTest.java
@@ -234,15 +234,15 @@ public void testAggregateConfigWithAllProperties() {
}
@Test
- public void testExternalEventHandlerAccepted() {
- JSONObject handler = new JSONObject()
- .put("eventName", "AccountCreated")
- .put("sourceAggregate", "Account")
- .put("create", true)
- .put("produces", new org.json.JSONArray().put("WalletCreated"))
- .put("errors", new org.json.JSONArray())
+ 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)));
+ new JSONObject().put("name", "userId").put("type", "String").put("idAttribute", true)))
+ .put("dependencies", new org.json.JSONArray());
JSONObject stateField = new JSONObject()
.put("name", "userId")
@@ -250,132 +250,91 @@ public void testExternalEventHandlerAccepted() {
.put("idAttribute", true);
JSONObject aggregateConfig = new JSONObject()
- .put("stateFields", new org.json.JSONArray().put(stateField))
- .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+ .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("slices", new org.json.JSONArray().put(slice));
schema.validate(json);
}
@Test
- public void testExternalEventHandlerMinimalAccepted() {
- JSONObject handler = new JSONObject()
- .put("eventName", "AmountReserved")
- .put("sourceAggregate", "Wallet")
- .put("produces", new org.json.JSONArray().put("BuyOrderPlaced").put("SellOrderPlaced"));
-
+ 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))
- .put("externalEventHandlers", new org.json.JSONArray().put(handler));
-
- JSONObject json = new JSONObject()
- .put("packageName", "com.example")
- .put("aggregateConfig", new JSONObject().put("OrderProcessManager", aggregateConfig))
- .put("slices", new org.json.JSONArray());
-
- schema.validate(json);
- }
-
- @Test
- public void testExternalEventHandlerMissingRequiredEventName() {
- JSONObject handler = new JSONObject()
- .put("sourceAggregate", "Account")
- .put("produces", new org.json.JSONArray().put("WalletCreated"));
-
- JSONObject stateField = new JSONObject()
- .put("name", "id")
- .put("type", "String");
-
- JSONObject aggregateConfig = new JSONObject()
- .put("stateFields", new org.json.JSONArray().put(stateField))
- .put("externalEventHandlers", new org.json.JSONArray().put(handler));
-
- JSONObject json = new JSONObject()
- .put("packageName", "com.example")
- .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
- .put("slices", new org.json.JSONArray());
-
- ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
- assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("eventName")),
- "Should report missing eventName: " + ex.getAllMessages());
- }
-
- @Test
- public void testExternalEventHandlerMissingRequiredSourceAggregate() {
- JSONObject handler = new JSONObject()
- .put("eventName", "AccountCreated")
- .put("produces", new org.json.JSONArray().put("WalletCreated"));
-
- JSONObject stateField = new JSONObject()
- .put("name", "id")
- .put("type", "String");
+ .put("stateFields", new org.json.JSONArray().put(stateField));
- JSONObject aggregateConfig = new JSONObject()
- .put("stateFields", new org.json.JSONArray().put(stateField))
- .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+ 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("slices", new org.json.JSONArray().put(slice));
- ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
- assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("sourceAggregate")),
- "Should report missing sourceAggregate: " + ex.getAllMessages());
+ schema.validate(json);
}
@Test
- public void testExternalEventHandlerMissingRequiredProduces() {
- JSONObject handler = new JSONObject()
- .put("eventName", "AccountCreated")
- .put("sourceAggregate", "Account");
+ 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))
- .put("externalEventHandlers", new org.json.JSONArray().put(handler));
-
- JSONObject json = new JSONObject()
- .put("packageName", "com.example")
- .put("aggregateConfig", new JSONObject().put("Test", aggregateConfig))
- .put("slices", new org.json.JSONArray());
-
- ValidationException ex = expectThrows(ValidationException.class, () -> schema.validate(json));
- assertTrue(ex.getAllMessages().stream().anyMatch(m -> m.contains("produces")),
- "Should report missing produces: " + ex.getAllMessages());
- }
-
- @Test
- public void testExternalEventHandlerAdditionalPropertiesRejected() {
- JSONObject handler = new JSONObject()
- .put("eventName", "AccountCreated")
- .put("sourceAggregate", "Account")
- .put("produces", new org.json.JSONArray().put("WalletCreated"))
- .put("unknownProp", "value");
-
- JSONObject stateField = new JSONObject()
- .put("name", "id")
- .put("type", "String");
+ .put("stateFields", new org.json.JSONArray().put(stateField));
- JSONObject aggregateConfig = new JSONObject()
- .put("stateFields", new org.json.JSONArray().put(stateField))
- .put("externalEventHandlers", new org.json.JSONArray().put(handler));
+ 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("slices", new org.json.JSONArray().put(slice));
assertThrows(ValidationException.class, () -> schema.validate(json));
}