diff --git a/ragconnect.tests/build.gradle b/ragconnect.tests/build.gradle
index 468c22acdfb055d1820c30158049248e4e0b98be..e0250d81e554a31b5aa5abe2f7036200f921d59b 100644
--- a/ragconnect.tests/build.gradle
+++ b/ragconnect.tests/build.gradle
@@ -657,7 +657,29 @@ task compileAttributeIncremental(type: RagConnectTest) {
         extraOptions = JASTADD_INCREMENTAL_OPTIONS_TRACING_FULL
     }
 }
-compileAttributeIncremental.outputs.upToDateWhen { false }
+
+// --- Test: relation-incremental ---
+task compileRelationIncremental(type: RagConnectTest) {
+    ragconnect {
+        outputDir = file('src/test/02-after-ragconnect/relationInc')
+        inputFiles = [file('src/test/01-input/relation/Test.relast'),
+                      file('src/test/01-input/relation/Test.connect')]
+        rootNode = 'Root'
+        extraOptions = defaultRagConnectOptionsAnd(['--experimental-jastadd-329'])
+    }
+    relast {
+        useJastAddNames = true
+        grammarName = 'src/test/03-after-relast/relationInc/relationInc'
+        serializer = 'jackson'
+    }
+    jastadd {
+        jastAddList = 'JastAddList'
+        packageName = 'relationInc.ast'
+        inputFiles = [file('src/test/01-input/relation/Test.jadd')]
+        extraOptions = JASTADD_INCREMENTAL_OPTIONS_TRACING_FULL
+    }
+}
+compileRelationIncremental.outputs.upToDateWhen { false }
 
 static ArrayList<String> defaultRagConnectOptionsAnd(ArrayList<String> options = []) {
     if (!options.contains('--logTarget=slf4j')) {
diff --git a/ragconnect.tests/src/test/01-input/relation/README.md b/ragconnect.tests/src/test/01-input/relation/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..12556e1ad76171b40a2e4a8e58717dfa41d4c52b
--- /dev/null
+++ b/ragconnect.tests/src/test/01-input/relation/README.md
@@ -0,0 +1,3 @@
+# Relation
+
+Idea: Use send definitions for relations.
diff --git a/ragconnect.tests/src/test/01-input/relation/Test.connect b/ragconnect.tests/src/test/01-input/relation/Test.connect
new file mode 100644
index 0000000000000000000000000000000000000000..f63801b9fb48d7daae3ea7415c2f89449b7d6625
--- /dev/null
+++ b/ragconnect.tests/src/test/01-input/relation/Test.connect
@@ -0,0 +1,43 @@
+//send SenderRoot.MyA;
+//send SenderRoot.OptionalA;
+//send SenderRoot.ListA;
+//
+//send SenderRoot.BiMyA;
+//send SenderRoot.BiOptionalA;
+//send SenderRoot.BiListA;
+//
+//send SenderRoot.MyB using ConcatValues;
+//send SenderRoot.OptionalB using ConcatValues;
+//send SenderRoot.ListB using ConcatValueList;
+//
+//send SenderRoot.BiMyB using ConcatValues;
+//send SenderRoot.BiOptionalB using ConcatValues;
+//send SenderRoot.BiListB using ConcatValueList;
+
+ConcatValues maps B b to String {:
+  return b,getValue() + b.getInner().getInnerValue();
+:}
+
+ConcatValueList maps JastAddList<B> list to String {:
+  StringBuilder sb = new StringBuilder();
+  for (B b : list) {
+    sb.append(b,getValue() + b.getInner().getInnerValue()).append(";");
+  }
+  return sb.toString();
+:}
+
+receive ReceiverRoot.FromMyA;
+receive ReceiverRoot.FromOptionalA;
+receive ReceiverRoot.FromListA;
+
+receive ReceiverRoot.FromBiMyA;
+receive ReceiverRoot.FromBiOptionalA;
+receive ReceiverRoot.FromBiListA;
+
+receive ReceiverRoot.FromMyB;
+receive ReceiverRoot.FromOptionalB;
+receive ReceiverRoot.FromListB;
+
+receive ReceiverRoot.FromBiMyB;
+receive ReceiverRoot.FromBiOptionalB;
+receive ReceiverRoot.FromBiListB;
diff --git a/ragconnect.tests/src/test/01-input/relation/Test.jadd b/ragconnect.tests/src/test/01-input/relation/Test.jadd
new file mode 100644
index 0000000000000000000000000000000000000000..2f54e7f851452f24d623b8d424744ecc8544af79
--- /dev/null
+++ b/ragconnect.tests/src/test/01-input/relation/Test.jadd
@@ -0,0 +1,38 @@
+aspect Computation {
+}
+aspect MakeCodeCompile {
+
+}
+aspect MakeCodeWork {
+  public boolean SenderRoot.connectMyA(String uriString, boolean sendCurrentValue) { return true; }
+  public boolean SenderRoot.connectOptionalA(String uriString, boolean sendCurrentValue) { return true; }
+  public boolean SenderRoot.connectListA(String uriString, boolean sendCurrentValue) { return true; }
+  //
+  public boolean SenderRoot.connectBiMyA(String uriString, boolean sendCurrentValue) { return true; }
+  public boolean SenderRoot.connectBiOptionalA(String uriString, boolean sendCurrentValue) { return true; }
+  public boolean SenderRoot.connectBiListA(String uriString, boolean sendCurrentValue) { return true; }
+  //
+  public boolean SenderRoot.connectMyB(String uriString, boolean sendCurrentValue) { return true; }
+  public boolean SenderRoot.connectOptionalB(String uriString, boolean sendCurrentValue) { return true; }
+  public boolean SenderRoot.connectListB(String uriString, boolean sendCurrentValue) { return true; }
+  //
+  public boolean SenderRoot.connectBiMyB(String uriString, boolean sendCurrentValue) { return true; }
+  public boolean SenderRoot.connectBiOptionalB(String uriString, boolean sendCurrentValue) { return true; }
+  public boolean SenderRoot.connectBiListB(String uriString, boolean sendCurrentValue) { return true; }
+}
+aspect NameResolution {
+  // overriding customID guarantees to produce the same JSON representation for equal lists
+  // otherwise, the value for id is different each time
+  @Override
+  protected String A.customID() {
+    return getClass().getSimpleName() + getValue();
+  }
+  @Override
+  protected String B.customID() {
+    return getClass().getSimpleName() + getValue();
+  }
+  @Override
+  protected String Inner.customID() {
+    return getClass().getSimpleName() + getInnerValue();
+  }
+}
diff --git a/ragconnect.tests/src/test/01-input/relation/Test.relast b/ragconnect.tests/src/test/01-input/relation/Test.relast
new file mode 100644
index 0000000000000000000000000000000000000000..5c56aefbedb08269b0a57cfd5e3d5fe259fd7760
--- /dev/null
+++ b/ragconnect.tests/src/test/01-input/relation/Test.relast
@@ -0,0 +1,28 @@
+Root ::= SenderRoot* ReceiverRoot;
+SenderRoot ::= A* B* ;
+rel SenderRoot.MyA -> A;
+rel SenderRoot.OptionalA? -> A;
+rel SenderRoot.ListA* -> A;
+
+rel SenderRoot.BiMyA <-> A.ToMyA;
+rel SenderRoot.BiOptionalA? <-> A.ToOptionalA;
+rel SenderRoot.BiListA* <-> A.ToListA;
+
+rel SenderRoot.MyB -> B;
+rel SenderRoot.OptionalB? -> B;
+rel SenderRoot.ListB* -> B;
+
+rel SenderRoot.BiMyB <-> B.ToMyB;
+rel SenderRoot.BiOptionalB? <-> B.ToOptionalB;
+rel SenderRoot.BiListB* <-> B.ToListB;
+
+ReceiverRoot ::=
+FromMyA:A   FromOptionalA:A   FromListA:A
+FromBiMyA:A FromBiOptionalA:A FromBiListA:A
+<FromMyB:String>   <FromOptionalB:String>   <FromListB:String>
+<FromBiMyB:String> <FromBiOptionalB:String> <FromBiListB:String>
+;
+
+A ::= <Value> Inner ;
+B ::= <Value> Inner ;
+Inner ::= <InnerValue> ;
diff --git a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/RelationTest.java b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/RelationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..1c857f6d8aafabd3304dc40fd7bab6c76878b0cd
--- /dev/null
+++ b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/RelationTest.java
@@ -0,0 +1,219 @@
+package org.jastadd.ragconnect.tests;
+
+import org.assertj.core.groups.Tuple;
+import org.hamcrest.Matchers;
+import relationInc.ast.*;
+import org.junit.jupiter.api.Tag;
+
+import java.io.IOException;
+import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import static java.util.function.Predicate.isEqual;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasProperty;
+import static org.jastadd.ragconnect.tests.TestUtils.*;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Test case "relation".
+ *
+ * @author rschoene - Initial contribution
+ */
+@Tag("Incremental")
+public class RelationTest extends AbstractMqttTest {
+
+  private static final String TOPIC_WILDCARD = "rel/#";
+  private static final String TOPIC_MY_A = "rel/my_a";
+  private static final String TOPIC_OPTIONAL_A = "rel/optional_a";
+  private static final String TOPIC_LIST_A = "rel/list_a";
+  private static final String TOPIC_BI_MY_A = "rel/bi_my_a";
+  private static final String TOPIC_BI_OPTIONAL_A = "rel/bi_optional_a";
+  private static final String TOPIC_BI_LIST_A = "rel/bi_list_a";
+  private static final String TOPIC_MY_B = "rel/my_b";
+  private static final String TOPIC_OPTIONAL_B = "rel/optional_b";
+  private static final String TOPIC_LIST_B = "rel/list_b";
+  private static final String TOPIC_BI_MY_B = "rel/bi_my_b";
+  private static final String TOPIC_BI_OPTIONAL_B = "rel/bi_optional_b";
+  private static final String TOPIC_BI_LIST_B = "rel/bi_list_b";
+
+  private MqttHandler handler;
+  private ReceiverData data;
+
+  private Root model;
+  private SenderRoot senderUni;
+  private SenderRoot senderBi;
+  private ReceiverRoot receiverRoot;
+
+  @Override
+  protected void createModel() {
+    model = new Root();
+//    model.trace().setReceiver(TestUtils::logEvent);
+    senderUni = new SenderRoot();
+    A a1 = createA("a1");
+    senderUni.addA(a1);
+    senderUni.setMyA(a1);
+    senderUni.setBiMyA(a1);
+    senderUni.addA(createA("a2"));
+    senderUni.addA(createA("a3"));
+
+    senderBi = new SenderRoot();
+    B b1 = createB("b1");
+    senderUni.addB(b1);
+    senderUni.setMyB(b1);
+    senderUni.setBiMyB(b1);
+    senderUni.addB(createB("b2"));
+    senderUni.addB(createB("b3"));
+
+    receiverRoot = new ReceiverRoot();
+    model.addSenderRoot(senderUni);
+    model.addSenderRoot(senderBi);
+    model.setReceiverRoot(receiverRoot);
+  }
+
+  private A createA(String value) {
+    return new A().setValue(value)
+            .setInner(new Inner().setInnerValue("inner" + value));
+  }
+
+  private B createB(String value) {
+    return new B().setValue(value)
+            .setInner(new Inner().setInnerValue("inner" + value));
+  }
+
+  @Override
+  protected void setupReceiverAndConnect() throws IOException, InterruptedException {
+    model.ragconnectSetupMqttWaitUntilReady(2, TimeUnit.SECONDS);
+    handler = new MqttHandler().setHost(TestUtils.getMqttHost()).dontSendWelcomeMessage();
+    assertTrue(handler.waitUntilReady(2, TimeUnit.SECONDS));
+
+    data = new ReceiverData();
+    assertTrue(handler.newConnection(TOPIC_WILDCARD, bytes -> data.numberOfValues += 1));
+
+    // connect receive
+    assertTrue(receiverRoot.connectFromMyA(mqttUri(TOPIC_MY_A)));
+    assertTrue(receiverRoot.connectFromOptionalA(mqttUri(TOPIC_OPTIONAL_A)));
+    assertTrue(receiverRoot.connectFromListA(mqttUri(TOPIC_LIST_A)));
+    assertTrue(receiverRoot.connectFromBiMyA(mqttUri(TOPIC_BI_MY_A)));
+    assertTrue(receiverRoot.connectFromBiOptionalA(mqttUri(TOPIC_BI_OPTIONAL_A)));
+    assertTrue(receiverRoot.connectFromBiListA(mqttUri(TOPIC_BI_LIST_A)));
+    assertTrue(receiverRoot.connectFromMyB(mqttUri(TOPIC_MY_B)));
+    assertTrue(receiverRoot.connectFromOptionalB(mqttUri(TOPIC_OPTIONAL_B)));
+    assertTrue(receiverRoot.connectFromListB(mqttUri(TOPIC_LIST_B)));
+    assertTrue(receiverRoot.connectFromBiMyB(mqttUri(TOPIC_BI_MY_B)));
+    assertTrue(receiverRoot.connectFromBiOptionalB(mqttUri(TOPIC_BI_OPTIONAL_B)));
+    assertTrue(receiverRoot.connectFromBiListB(mqttUri(TOPIC_BI_LIST_B)));
+
+    // connect send, and wait to receive (if writeCurrentValue is set)
+    assertTrue(senderUni.connectMyA(mqttUri(TOPIC_MY_A), isWriteCurrentValue()));
+    assertTrue(senderUni.connectOptionalA(mqttUri(TOPIC_OPTIONAL_A), isWriteCurrentValue()));
+    assertTrue(senderUni.connectListA(mqttUri(TOPIC_LIST_A), isWriteCurrentValue()));
+
+    assertTrue(senderBi.connectBiMyA(mqttUri(TOPIC_BI_MY_A), isWriteCurrentValue()));
+    assertTrue(senderBi.connectBiOptionalA(mqttUri(TOPIC_BI_OPTIONAL_A), isWriteCurrentValue()));
+    assertTrue(senderBi.connectBiListA(mqttUri(TOPIC_BI_LIST_A), isWriteCurrentValue()));
+
+    assertTrue(senderUni.connectMyB(mqttUri(TOPIC_MY_B), isWriteCurrentValue()));
+    assertTrue(senderUni.connectOptionalB(mqttUri(TOPIC_OPTIONAL_B), isWriteCurrentValue()));
+    assertTrue(senderUni.connectListB(mqttUri(TOPIC_LIST_B), isWriteCurrentValue()));
+
+    assertTrue(senderBi.connectBiMyB(mqttUri(TOPIC_BI_MY_B), isWriteCurrentValue()));
+    assertTrue(senderBi.connectBiOptionalB(mqttUri(TOPIC_BI_OPTIONAL_B), isWriteCurrentValue()));
+    assertTrue(senderBi.connectBiListB(mqttUri(TOPIC_BI_LIST_B), isWriteCurrentValue()));
+
+    waitForNonNull(receiverRoot::getFromMyA);
+    waitForNonNull(receiverRoot::getFromBiMyA);
+    waitForNonNull(receiverRoot::getFromMyB);
+    waitForNonNull(receiverRoot::getFromBiMyB);
+  }
+
+  private <T> void waitForValue(T expectedValue, Callable<T> callable) {
+    if (isWriteCurrentValue()) {
+      awaitMqtt().until(callable, isEqual(expectedValue));
+    }
+  }
+
+  private <T> void waitForNonNull(Callable<T> callable) {
+    if (isWriteCurrentValue()) {
+      awaitMqtt().until(callable, Predicate.not(isEqual(null)));
+    }
+  }
+
+  @Override
+  protected void communicateSendInitialValue() throws IOException, InterruptedException {
+    // TODO implement test
+    // TODO also check disconnect
+  }
+
+  @Override
+  protected void communicateOnlyUpdatedValue() throws IOException, InterruptedException {
+    waitForMqtt();
+    // TODO implement test
+    // TODO also check disconnect
+  }
+
+  private void checkAs(int numberOfValues, String myA, String optionalA, Tuple listA,
+                       String biMyA, String biOptionalA, Tuple biListA) {
+    awaitEquals(numberOfValues, () -> data.numberOfValues, "numberOfValues");
+
+//    awaitEquals(Objects.requireNonNullElse(basic, ""),
+//            receiverRoot::getFromBasic, "basic");
+
+    awaitAorNull(myA, receiverRoot::getFromMyA, "myA");
+    awaitAorNull(optionalA, receiverRoot::getFromOptionalA, "myA");
+    // TODO compare list
+
+    awaitAorNull(biMyA, receiverRoot::getFromBiMyA, "biMyA");
+    awaitAorNull(biOptionalA, receiverRoot::getFromBiOptionalA, "biMyA");
+    // TODO compare bi-list
+  }
+
+  // TODO checkBs
+
+  private void awaitNull(Supplier<A> actual, String alias) {
+    awaitMqtt().alias(alias).until(() -> actual.get() == null);
+  }
+
+  private <T> void awaitEquals(T expected, Callable<T> actual, String alias) {
+    awaitMqtt().alias(alias).until(actual, isEqual(expected));
+  }
+
+  private void awaitAorNull(String expectedValue, Callable<A> actual, String alias) {
+    if (expectedValue == null) {
+      awaitNull(convertToSupplier(actual), alias + " null");
+      return;
+    }
+    String expectedInner = "inner" + expectedValue;
+    awaitMqtt().alias(alias).until(actual, Matchers.allOf(
+            hasProperty("Value", equalTo(expectedValue)),
+            hasProperty("Inner", hasProperty("InnerValue", equalTo(expectedInner)))));
+  }
+
+  private Supplier<A> convertToSupplier(Callable<A> actual) {
+    return () -> {
+      try {
+        return actual.call();
+      } catch (Exception e) {
+        fail(e);
+        return null;
+      }
+    };
+  }
+
+  @Override
+  protected void closeConnections() {
+    if (handler != null) {
+      handler.close();
+    }
+    if (model != null) {
+      model.ragconnectCloseConnections();
+    }
+  }
+
+  private static class ReceiverData {
+    int numberOfValues = 0;
+  }
+}