diff --git a/README.md b/README.md
index 0976d69..fc91408 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ The repo contains the following modules:
* **[lifecycle](lifecycle)** [[JavaDocs](https://javadoc.io/doc/org.creekservice/creek-observability-lifecycle)]: a common model of lifecycle events.
* **[logging](logging)** [[JavaDocs](https://javadoc.io/doc/org.creekservice/creek-observability-logging)]: handles the logging of _structured_ events via [Slf4J][slf4j].
* **[logging fixtures](logging-fixtures)** [[JavaDocs](https://javadoc.io/doc/org.creekservice/creek-observability-logging-fixtures)]: test fixtures for testing logging output.
+* **[patterns](patterns)** [[JavaDocs](https://javadoc.io/doc/org.creekservice/creek-observability-patterns)]: util code and patterns to help with observability
[slf4j]: https://www.slf4j.org
[splunk]: https://www.splunk.com
diff --git a/patterns/README.md b/patterns/README.md
new file mode 100644
index 0000000..65f9c63
--- /dev/null
+++ b/patterns/README.md
@@ -0,0 +1,8 @@
+[![javadoc](https://javadoc.io/badge2/org.creekservice/creek-observability-patterns/javadoc.svg)](https://javadoc.io/doc/org.creekservice/creek-observability-patterns)
+
+# Creek Observability Patterns
+
+Contains helpers and utility code to help implement good observability in your code.
+
+
+
diff --git a/patterns/build.gradle.kts b/patterns/build.gradle.kts
new file mode 100644
index 0000000..4f3cfeb
--- /dev/null
+++ b/patterns/build.gradle.kts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022-2023 Creek Contributors (https://github.com/creek-service)
+ *
+ * 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.
+ */
+
+plugins {
+ `java-library`
+}
+
+dependencies {
+}
diff --git a/patterns/src/main/java/org/creekservice/api/observability/patterns/CompositeObserverBuilder.java b/patterns/src/main/java/org/creekservice/api/observability/patterns/CompositeObserverBuilder.java
new file mode 100644
index 0000000..89f5270
--- /dev/null
+++ b/patterns/src/main/java/org/creekservice/api/observability/patterns/CompositeObserverBuilder.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2023 Creek Contributors (https://github.com/creek-service)
+ *
+ * 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.creekservice.api.observability.patterns;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * Easily build a chain of multiple Observer / Listener implementations.
+ *
+ *
This encourages decomposition and decoupling of functionality.
+ *
+ *
Given an interface {@code MyListener}, this class can be used to chain multiple listeners
+ * together, without the need to write any custom code.
+ *
+ *
{@code
+ * MyListener composite = CompositeObserverBuilder.builder(MyListener.class, listener1)
+ * .observer(listener2)
+ * ...
+ * .observer(listenerN)
+ * .build()
+ * }
+ *
+ * @param the observer interface type
+ */
+public final class CompositeObserverBuilder {
+
+ private final Class observerClass;
+ private final List observers = new ArrayList<>();
+
+ /**
+ * Create a builder
+ *
+ * @param observerClass the observer interface type.
+ * @param observer the first observer to call.
+ * @return the composite builder
+ * @param the type of the observer interface.
+ */
+ public static CompositeObserverBuilder builder(
+ final Class observerClass, final Observer observer) {
+ if (!observerClass.isInterface()) {
+ throw new IllegalArgumentException("observerClass must be an interface");
+ }
+ validateMethodsAreVoidReturnType(observerClass);
+ return new CompositeObserverBuilder<>(observerClass, observer);
+ }
+
+ private CompositeObserverBuilder(final Class observerClass, final Observer observer) {
+ this.observerClass = observerClass;
+ add(observer);
+ }
+
+ /**
+ * Add another observer to the chain
+ *
+ * @param observer the observer to add.
+ * @return self, to allow chaining.
+ */
+ public CompositeObserverBuilder add(final Observer observer) {
+ this.observers.add(Objects.requireNonNull(observer, "observer"));
+ return this;
+ }
+
+ /**
+ * @return the composite observer implementation.
+ */
+ @SuppressWarnings("unchecked")
+ public Observer build() {
+ try {
+ final Map handles = methodHandles(observerClass);
+ return (Observer)
+ Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[] {observerClass},
+ (proxy, method, args) -> {
+ if (method.getName().equals("toString")) {
+ return "Proxy composite for " + observerClass.getName();
+ }
+ if (method.getName().equals("equals")) {
+ return this == proxy;
+ }
+ if (method.getName().equals("hashCode")) {
+ return observerClass.getName().hashCode();
+ }
+
+ for (Observer delegate : observers) {
+ final MethodHandle methodHandle = handles.get(method);
+ methodHandle.bindTo(delegate).invokeWithArguments(args);
+ }
+ return null;
+ });
+ } catch (Exception e) {
+ throw new CompositeCreationFailedException(
+ "failed to generate composite observer for interface: " + observerClass, e);
+ }
+ }
+
+ private Map methodHandles(final Class> observerClass) {
+ final Map handles =
+ Arrays.stream(observerClass.getDeclaredMethods())
+ .collect(
+ Collectors.toMap(
+ Function.identity(),
+ m -> {
+ try {
+ return MethodHandles.publicLookup().unreflect(m);
+ } catch (IllegalAccessException e) {
+ throw new CompositeCreationFailedException(
+ "Unable to unreflect method", e);
+ }
+ }));
+
+ for (Class> extending : observerClass.getInterfaces()) {
+ handles.putAll(methodHandles(extending));
+ }
+ return handles;
+ }
+
+ private static void validateMethodsAreVoidReturnType(final Class> observerClass) {
+ final List invalidMethods =
+ Arrays.stream(observerClass.getDeclaredMethods())
+ .filter(method -> !method.getReturnType().equals(void.class))
+ .collect(Collectors.toList());
+
+ if (!invalidMethods.isEmpty()) {
+ throw new UnsupportedOperationException(
+ "Only observer interfaces, where all methods have a void return type, are"
+ + " supported. Interface: "
+ + observerClass
+ + " invalidMethods: "
+ + invalidMethods);
+ }
+
+ for (Class> extending : observerClass.getInterfaces()) {
+ validateMethodsAreVoidReturnType(extending);
+ }
+ }
+
+ private static class CompositeCreationFailedException extends RuntimeException {
+ CompositeCreationFailedException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/patterns/src/test/java/org/creekservice/api/observability/patterns/CompositeObserverBuilderTest.java b/patterns/src/test/java/org/creekservice/api/observability/patterns/CompositeObserverBuilderTest.java
new file mode 100644
index 0000000..349a370
--- /dev/null
+++ b/patterns/src/test/java/org/creekservice/api/observability/patterns/CompositeObserverBuilderTest.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2023 Creek Contributors (https://github.com/creek-service)
+ *
+ * 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.creekservice.api.observability.patterns;
+
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class CompositeObserverBuilderTest {
+
+ @Mock Observer observer1;
+ @Mock Observer observer2;
+
+ @Test
+ public void shouldDelegateToSingle() {
+ // Given:
+ final Observer result = CompositeObserverBuilder.builder(Observer.class, observer1).build();
+
+ // When:
+ result.foo("text");
+
+ // Then:
+ verify(observer1).foo("text");
+ }
+
+ @Test
+ public void shouldDelegateToMultipleInOrder() {
+ // Given:
+ final Observer result =
+ CompositeObserverBuilder.builder(Observer.class, observer1).add(observer2).build();
+
+ // When:
+ result.foo("text");
+
+ // Then:
+ final InOrder inOrder = inOrder(observer1, observer2);
+ inOrder.verify(observer1).foo("text");
+ inOrder.verify(observer2).foo("text");
+ }
+
+ @Test
+ public void shouldBlowUpIfObserverTypeNotInterface() {
+ // When:
+ final Exception e =
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ CompositeObserverBuilder.builder(
+ CompositeObserverBuilderTest.class, this));
+
+ // Then:
+ assertThat(e.getMessage(), is("observerClass must be an interface"));
+ }
+
+ @Test
+ public void shouldBlowUpIfYouTryDelegateToNull() {
+ // When:
+ final Exception e =
+ assertThrows(
+ NullPointerException.class,
+ () -> CompositeObserverBuilder.builder(Observer.class, null));
+
+ // Then:
+ assertThat(e.getMessage(), is("observer"));
+ }
+
+ @Test
+ public void shouldBlowUpIfYouTryToAddNullObserver() {
+ // Given:
+ final CompositeObserverBuilder builder =
+ CompositeObserverBuilder.builder(Observer.class, observer1);
+
+ // When:
+ final Exception e = assertThrows(NullPointerException.class, () -> builder.add(null));
+
+ // Then:
+ assertThat(e.getMessage(), is("observer"));
+ }
+
+ @Test
+ public void shouldBlowUpIfInterfaceHasNonVoidReturnMethods() {
+ // When:
+ final Exception e =
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> CompositeObserverBuilder.builder(NotAnObserver.class, mock()));
+
+ // Then:
+ assertThat(
+ e.getMessage(),
+ is(
+ "Only observer interfaces, where all methods have a void return type, are"
+ + " supported. Interface: interface"
+ + " org.creekservice.api.observability.patterns.CompositeObserverBuilderTest$NotAnObserver"
+ + " invalidMethods: [public abstract int"
+ + " org.creekservice.api.observability.patterns.CompositeObserverBuilderTest$NotAnObserver.foo(java.lang.String)]"));
+ }
+
+ @Test
+ public void shouldHandleInterfaceExtensionWithoutBlowingUp() {
+ // Given:
+ final ExtendingExtendingObserver observer1 = mock();
+
+ final ExtendingExtendingObserver observer =
+ CompositeObserverBuilder.builder(ExtendingExtendingObserver.class, observer1)
+ .build();
+
+ // When:
+ observer.foo("blah");
+ observer.bar();
+ observer.lar();
+
+ // Then:
+ verify(observer1).foo("blah");
+ verify(observer1).bar();
+ verify(observer1).lar();
+ }
+
+ @SuppressWarnings("ResultOfMethodCallIgnored")
+ @Test
+ public void shouldNotBlowUpOnEquals() {
+ CompositeObserverBuilder.builder(ExtendingExtendingObserver.class, mock())
+ .build()
+ .equals(new Object());
+ }
+
+ @SuppressWarnings("ResultOfMethodCallIgnored")
+ @Test
+ public void shouldNotBlowUpOnHashCode() {
+ CompositeObserverBuilder.builder(ExtendingExtendingObserver.class, mock())
+ .build()
+ .hashCode();
+ }
+
+ @Test
+ public void shouldNotBlowUpOnToString() {
+ assertThat(
+ CompositeObserverBuilder.builder(ExtendingExtendingObserver.class, mock())
+ .build()
+ .toString(),
+ notNullValue());
+ }
+
+ public interface Observer {
+
+ void foo(String message);
+ }
+
+ public interface ExtendingObserver extends Observer {
+ void bar();
+ }
+
+ public interface ExtendingExtendingObserver extends ExtendingObserver {
+ void lar();
+ }
+
+ @SuppressWarnings("unused")
+ public interface NotAnObserver {
+ int foo(String message);
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 33bac40..f4ff808 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,6 +19,7 @@ rootProject.name = "creek-observability"
include(
"lifecycle",
"logging",
- "logging-fixtures"
+ "logging-fixtures",
+ "patterns"
)