Repository: avaje/avaje-inject
Pull request #174 (2021-12-21 09:26:34)

Add support for AOP Aspects


Patch:
diff --git a/inject-generator/pom.xml b/inject-generator/pom.xml
index 1d57d314..1d9521d0 100644
--- a/inject-generator/pom.xml
+++ b/inject-generator/pom.xml
@@ -4,7 +4,7 @@
   <parent>
     <groupId>io.avaje</groupId>
     <artifactId>avaje-inject-parent</artifactId>
-    <version>6.17</version>
+    <version>6.18-SNAPSHOT</version>
   </parent>
 
   <artifactId>avaje-inject-generator</artifactId>
@@ -16,7 +16,7 @@
     <dependency>
       <groupId>io.avaje</groupId>
       <artifactId>avaje-inject</artifactId>
-      <version>6.17</version>
+      <version>6.18-SNAPSHOT</version>
     </dependency>
 
     <!-- test dependencies -->
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/AspectAnnotationReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/AspectAnnotationReader.java
new file mode 100644
index 00000000..90da30d3
--- /dev/null
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/AspectAnnotationReader.java
@@ -0,0 +1,79 @@
+package io.avaje.inject.generator;
+
+import io.avaje.inject.aop.Aspect;
+
+import javax.lang.model.element.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Read the annotations on the type.
+ */
+class AspectAnnotationReader {
+
+  private static final String ASPECT = Constants.ASPECT;
+  private final TypeElement baseType;
+  private final Element element;
+  private final ProcessingContext context;
+
+  AspectAnnotationReader(ProcessingContext context, TypeElement baseType, Element element) {
+    this.context = context;
+    this.baseType = baseType;
+    this.element = element;
+  }
+
+  List<AspectPair> read() {
+    List<AspectPair> aspects = new ArrayList<>();
+    for (AnnotationMirror annotationMirror : element.getAnnotationMirrors()) {
+      Element anElement = annotationMirror.getAnnotationType().asElement();
+      Aspect aspect = anElement.getAnnotation(Aspect.class);
+      if (aspect != null) {
+        Meta meta = readTarget(anElement);
+        if (meta != null) {
+          aspects.add(new AspectPair(anElement, meta.target, meta.ordering));
+        }
+      }
+    }
+    return aspects;
+  }
+
+  private Meta readTarget(Element anElement) {
+    for (AnnotationMirror annotationMirror : anElement.getAnnotationMirrors()) {
+      if (ASPECT.equals(annotationMirror.getAnnotationType().toString())) {
+        Meta meta = new Meta();
+        for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> entry : annotationMirror.getElementValues().entrySet()) {
+          String key = entry.getKey().toString();
+          if (key.equals("target()")) {
+            meta.target(entry.getValue().getValue().toString());
+          } else if (key.equals("ordering()")) {
+            meta.ordering(Integer.parseInt(entry.getValue().getValue().toString()));
+          }
+        }
+        return meta;
+      }
+    }
+    context.logError(baseType + " aspect target() not found on " + element);
+    return null;
+  }
+
+  private static class Meta {
+
+    String target;
+
+    int ordering = Constants.ORDERING_DEFAULT;
+
+    void target(String target) {
+      this.target = target;
+    }
+
+    void ordering(int ordering) {
+      this.ordering = ordering;
+    }
+
+    @Override
+    public String toString() {
+      return "Meta(target=" + target + ", ordering=" + ordering + ')';
+    }
+  }
+}
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/AspectMethod.java b/inject-generator/src/main/java/io/avaje/inject/generator/AspectMethod.java
new file mode 100644
index 00000000..4e936a13
--- /dev/null
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/AspectMethod.java
@@ -0,0 +1,197 @@
+package io.avaje.inject.generator;
+
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.type.TypeMirror;
+import java.util.*;
+
+class AspectMethod {
+
+  private final List<AspectPair> aspectPairs;
+  private final ExecutableElement method;
+  private final List<MethodReader.MethodParam> params;
+  private final String rawReturn;
+  private final String simpleName;
+  private final List<? extends TypeMirror> thrownTypes;
+  private final String localName;
+
+  AspectMethod(int nameIndex, List<AspectPair> aspectPairs, ExecutableElement method) {
+    this.aspectPairs = sort(aspectPairs);
+    this.method = method;
+    this.simpleName = method.getSimpleName().toString();
+    this.params = initParams(method.getParameters());
+    this.rawReturn = method.getReturnType().toString();
+    this.thrownTypes = method.getThrownTypes();
+    this.localName = simpleName + nameIndex;
+  }
+
+  private List<AspectPair> sort(List<AspectPair> aspectPairs) {
+    Collections.sort(aspectPairs);
+    return aspectPairs;
+  }
+
+  List<MethodReader.MethodParam> initParams(List<? extends VariableElement> parameters) {
+    List<MethodReader.MethodParam> mps = new ArrayList<>(parameters.size());
+    for (VariableElement var : parameters) {
+      mps.add(new MethodReader.MethodParam(var));
+    }
+    return mps;
+  }
+
+  void addTargets(Set<String> targets) {
+    for (AspectPair aspectPair : aspectPairs) {
+      targets.add(aspectPair.target());
+    }
+  }
+
+  boolean isVoid() {
+    return rawReturn.equals("void");
+  }
+
+  void addImports(Set<String> importTypes) {
+    for (AspectPair aspect : aspectPairs) {
+      aspect.addImports(importTypes);
+    }
+    for (TypeMirror thrownType : method.getThrownTypes()) {
+      importTypes.add(thrownType.toString());
+    }
+  }
+
+  void writeMethod(Append writer) {
+    writer.eol().append("  @Override").eol();
+    writer.append("  public %s %s(", rawReturn, simpleName);
+    for (int i = 0, size = params.size(); i < size; i++) {
+      if (i > 0) {
+        writer.append(", ");
+      }
+      params.get(i).writeMethodParam(writer);
+    }
+    writer.append(")");
+    writeThrowsClause(writer);
+
+    writer.append(" {").eol();
+
+    String type = isVoid() ? "Run" : "Call<>";
+    writer.append("    var call = new Invocation.%s(() ->", type);
+    invokeSuper(writer, simpleName);
+    writer.append(")").eol();
+    writeArgs(writer);
+    writer.append("  }").eol();
+  }
+
+  private void writeThrowsClause(Append writer) {
+    if (!thrownTypes.isEmpty()) {
+      writer.append(" throws ");
+      for (int i = 0; i < thrownTypes.size(); i++) {
+        if (i > 0) {
+          writer.append(", ");
+        }
+        writer.append(Util.shortName(thrownTypes.get(i).toString()));
+      }
+    }
+  }
+
+  private void invokeSuper(Append writer, String simpleName) {
+    writer.append(" super.%s(", simpleName);
+    for (int i = 0, size = params.size(); i < size; i++) {
+      if (i > 0) {
+        writer.append(", ");
+      }
+      writer.append(params.get(i).simpleName());
+    }
+    writer.append(")");
+  }
+
+  void writeSetupFields(Append writer) {
+    writer.append("  private Method %s;", localName).eol();
+    for (AspectPair aspectPair : aspectPairs) {
+      String sn = aspectPair.annotationShortName();
+      writer.append("  private MethodInterceptor %s%s;", localName, sn).eol();
+    }
+  }
+
+  void writeSetupForMethods(Append writer, String shortName) {
+    writer.append("      %s = %s.class.getDeclaredMethod(\"%s\"", localName, shortName, simpleName);
+    for (MethodReader.MethodParam param : params) {
+      writer.append(", ");
+      param.writeMethodParamType(writer);
+      writer.append(".class");
+    }
+    writer.append(");").eol();
+    for (AspectPair aspect : aspectPairs) {
+      String target = aspect.target();
+      String name = aspectTargetShortName(target);
+      String sn = aspect.annotationShortName();
+      writer.append("      %s%s = %s.interceptor(%s, %s.getAnnotation(%s.class));", localName, sn, name, localName, localName, sn).eol();
+    }
+    writer.eol();
+  }
+
+  static String aspectTargetShortName(String target) {
+    String type = Util.shortName(target);
+    return Util.initLower(type);
+  }
+
+  void writeArgs(Append writer) {
+    writer.append("      .with(this, %s", localName);
+    if (!params.isEmpty()) {
+      writer.append(", ");
+      for (int i = 0, size = params.size(); i < size; i++) {
+        if (i > 0) {
+          writer.append(", ");
+        }
+        writer.append(params.get(i).simpleName());
+      }
+    }
+    writer.append(")");
+    int aspectCount = aspectPairs.size();
+    if (aspectCount < 2) {
+      writer.append(";").eol();
+    } else {
+      // nesting all but last aspect
+      writer.eol();
+      writer.append("      // wrapping inner nested aspects based on ordering attribute").eol();
+      int nesting = aspectCount - 1;
+      for (int i = 0; i < nesting; i++) {
+        AspectPair aspect = aspectPairs.get(i);
+        String sn = aspect.annotationShortName();
+        writer.append("      .wrap(%s%s)", localName, sn);
+        if (i < nesting -1) {
+          writer.eol();
+        } else {
+          writer.append(";").eol();
+        }
+      }
+    }
+    writer.append("    try {").eol();
+    if (aspectCount > 1) {
+      writer.append("      // outer-most aspect").eol();
+    }
+    AspectPair outerAspect = aspectPairs.get(aspectCount - 1);
+    String sn = outerAspect.annotationShortName();
+    writer.append("      %s%s.invoke(call);", localName, sn).eol();
+
+    if (!isVoid()) {
+      writer.append("      return call.finalResult();").eol();
+    }
+
+    writeThrowsCatch(writer);
+    writer.append("    } catch (Throwable e) {").eol();
+    writer.append("      throw new InvocationException(e);").eol();
+    writer.append("    }").eol();
+  }
+
+  private void writeThrowsCatch(Append writer) {
+    writer.append("    } catch (");
+    if (thrownTypes.isEmpty()) {
+      writer.append("InvocationException");
+    } else {
+      writer.append("InvocationException");
+      for (TypeMirror thrownType : thrownTypes) {
+        writer.append(" | ").append(Util.shortName(thrownType.toString()));
+      }
+    }
+    writer.append(" e) {").eol();
+    writer.append("      throw e;").eol();
+  }
+}
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/AspectPair.java b/inject-generator/src/main/java/io/avaje/inject/generator/AspectPair.java
new file mode 100644
index 00000000..2a79b4f4
--- /dev/null
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/AspectPair.java
@@ -0,0 +1,37 @@
+package io.avaje.inject.generator;
+
+import javax.lang.model.element.Element;
+import java.util.Set;
+
+class AspectPair implements Comparable<AspectPair> {
+
+  private final String target;
+  private final int ordering;
+  private final String annotationFullName;
+  private final String annotationShortName;
+
+  AspectPair(Element anElement, String target, int ordering) {
+    this.target = target;
+    this.ordering = ordering;
+    this.annotationFullName = anElement.asType().toString();
+    this.annotationShortName = Util.shortName(annotationFullName);
+  }
+
+  String target() {
+    return target;
+  }
+
+  void addImports(Set<String> importTypes) {
+    importTypes.add(target);
+    importTypes.add(annotationFullName);
+  }
+
+  String annotationShortName() {
+    return annotationShortName;
+  }
+
+  @Override
+  public int compareTo(AspectPair o) {
+    return Integer.compare(ordering, o.ordering);
+  }
+}
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/BeanAspects.java b/inject-generator/src/main/java/io/avaje/inject/generator/BeanAspects.java
new file mode 100644
index 00000000..4fde670c
--- /dev/null
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/BeanAspects.java
@@ -0,0 +1,60 @@
+package io.avaje.inject.generator;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+class BeanAspects {
+
+  static final BeanAspects EMPTY = new BeanAspects();
+
+  private final List<AspectMethod> aspectMethods;
+  private final Set<String> targets;
+
+  private BeanAspects() {
+    this.aspectMethods = Collections.emptyList();
+    this.targets = Collections.emptySet();
+  }
+
+  BeanAspects(List<AspectMethod> aspectMethods) {
+    this.aspectMethods = aspectMethods;
+    this.targets = initTargets();
+  }
+
+  boolean hasAspects() {
+    return !aspectMethods.isEmpty();
+  }
+
+  Set<String> targets() {
+    return targets;
+  }
+
+  void extraImports(Set<String> importTypes) {
+    for (AspectMethod aspectMethod : aspectMethods) {
+      aspectMethod.addImports(importTypes);
+    }
+  }
+
+  Set<String> initTargets() {
+    Set<String> targets = new LinkedHashSet<>();
+    for (AspectMethod aspectMethod : aspectMethods) {
+      aspectMethod.addTargets(targets);
+    }
+    return targets;
+  }
+
+  void writeFields(Append writer) {
+    for (String target : targets) {
+      String type = Util.shortName(target);
+      String name = Util.initLower(type);
+      writer.append("  private final %s %s;", type, name).eol();
+    }
+    writer.eol();
+  }
+
+  List<AspectMethod> methods() {
+    return aspectMethods;
+  }
+
+}
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
index 862e83f8..1efa4c62 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
@@ -2,6 +2,7 @@ package io.avaje.inject.generator;
 
 import io.avaje.inject.Primary;
 import io.avaje.inject.Secondary;
+import io.avaje.inject.spi.Proxy;
 
 import javax.lang.model.element.Element;
 import javax.lang.model.element.TypeElement;
@@ -9,7 +10,6 @@ import java.util.*;
 
 class BeanReader {
 
-  private final ProcessingContext context;
   private final TypeElement beanType;
   private final String shortName;
   private final String type;
@@ -27,21 +27,23 @@ class BeanReader {
   private final TypeReader typeReader;
   private final boolean primary;
   private final boolean secondary;
-
+  private final boolean proxy;
+  private final BeanAspects aspects;
   private boolean writtenToFile;
 
   BeanReader(TypeElement beanType, ProcessingContext context, boolean factory) {
     this.beanType = beanType;
-    this.context = context;
     this.type = beanType.getQualifiedName().toString();
     this.shortName = shortName(beanType);
     this.primary = (beanType.getAnnotation(Primary.class) != null);
     this.secondary = !primary && (beanType.getAnnotation(Secondary.class) != null);
+    this.proxy = (beanType.getAnnotation(Proxy.class) != null);
     this.typeReader = new TypeReader(beanType, context, importTypes, factory);
 
     typeReader.process();
     this.requestParams = new BeanRequestParams(type);
     this.name = typeReader.getName();
+    this.aspects = typeReader.hasAspects();
     this.injectMethods = typeReader.getInjectMethods();
     this.injectFields = typeReader.getInjectFields();
     this.factoryMethods = typeReader.getFactoryMethods();
@@ -59,6 +61,10 @@ class BeanReader {
     return beanType;
   }
 
+  BeanAspects aspects() {
+    return aspects;
+  }
+
   BeanReader read() {
     if (constructor != null) {
       constructor.addImports(importTypes);
@@ -182,6 +188,7 @@ class BeanReader {
     }
     typeReader.extraImports(importTypes);
     requestParams.addImports(importTypes);
+    aspects.extraImports(importTypes);
     return importTypes;
   }
 
@@ -266,4 +273,22 @@ class BeanReader {
   List<MethodReader> getInjectMethods() {
     return typeReader.getInjectMethods();
   }
+
+  boolean isGenerateProxy() {
+    return aspects.hasAspects() && !proxy;
+  }
+
+  void writeConstructorParams(Append writer) {
+    if (constructor != null) {
+      constructor.writeConstructorParams(writer);
+    }
+  }
+
+  void writeConstructorInit(Append writer) {
+    if (constructor != null) {
+      writer.append("    super(");
+      constructor.writeConstructorInit(writer);
+      writer.append(");").eol();
+    }
+  }
 }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/Constants.java b/inject-generator/src/main/java/io/avaje/inject/generator/Constants.java
index 3b4e8c95..b29002f3 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/Constants.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/Constants.java
@@ -2,6 +2,8 @@ package io.avaje.inject.generator;
 
 class Constants {
 
+  static final int ORDERING_DEFAULT = 1000;
+
   static final String FACTORY = "$Factory";
   static final String DI = "$DI";
   static final String IO_CLOSEABLE = "java.io.Closeable";
@@ -19,6 +21,7 @@ class Constants {
   static final String CONTROLLER = "io.avaje.http.api.Controller";
 
   static final String AT_SINGLETON = "@Singleton";
+  static final String AT_PROXY = "@Proxy";
   static final String AT_GENERATED = "@Generated(\"io.avaje.inject.generator\")";
   static final String META_INF_MODULE = "META-INF/services/io.avaje.inject.spi.Module";
   static final String META_INF_TESTMODULE = "META-INF/services/io.avaje.inject.test.TestModule";
@@ -27,6 +30,13 @@ class Constants {
   static final String BEANSCOPE = "io.avaje.inject.BeanScope";
   static final String INJECTMODULE = "io.avaje.inject.InjectModule";
 
+  static final String REFLECT_METHOD = "java.lang.reflect.Method";
+  static final String ASPECT = "io.avaje.inject.aop.Aspect";
+  static final String INVOCATION = "io.avaje.inject.aop.Invocation";
+  static final String INVOCATION_EXCEPTION = "io.avaje.inject.aop.InvocationException";
+  static final String METHOD_INTERCEPTOR = "io.avaje.inject.aop.MethodInterceptor";
+  static final String PROXY = "io.avaje.inject.spi.Proxy";
+
   static final String GENERATED = "io.avaje.inject.spi.Generated";
   static final String BEAN_FACTORY = "io.avaje.inject.spi.BeanFactory";
   static final String BEAN_FACTORY2 = "io.avaje.inject.spi.BeanFactory2";
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
index 03f21e86..698dbf01 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
@@ -231,6 +231,39 @@ class MethodReader {
     return !element.getModifiers().contains(Modifier.PRIVATE);
   }
 
+  void writeConstructorParams(Append writer) {
+    for (MethodParam param : params) {
+      writer.append(", ");
+      param.writeMethodParam(writer);
+    }
+  }
+
+  void writeConstructorInit(Append writer) {
+    for (int i = 0, size = params.size(); i < size; i++) {
+      if (i > 0) {
+        writer.append(", ");
+      }
+      params.get(i).writeConstructorInit(writer);
+    }
+  }
+
+  void writeAspectBefore(Append writer, String targetName) {
+    writer.append("    // %s.%s( ... ", targetName, methodName);
+    for (int i = 0, size = params.size(); i < size; i++) {
+      if (i > 0) {
+        writer.append(", ");
+      }
+      params.get(i).writeConstructorInit(writer);
+    }
+    writer.append(");", targetName, methodName);
+    writer.eol();
+  }
+
+  void writeAspectAround(Append writer, String targetName) {
+    writer.append("    // %s %s aroundAspect", targetName, methodName);
+    writer.eol();
+  }
+
   static class MethodParam {
 
     private final String named;
@@ -288,6 +321,10 @@ class MethodReader {
       return Util.shortName(Util.unwrapProvider(paramType));
     }
 
+    String simpleName() {
+      return simpleName;
+    }
+
     boolean isProvider() {
       return Util.isProvider(paramType);
     }
@@ -345,5 +382,25 @@ class MethodReader {
       return Util.shortName(raw);
     }
 
+    void writeMethodParam(Append writer) {
+      if (genericType != null) {
+        genericType.writeShort(writer);
+      } else {
+        writer.append(Util.shortName(paramType));
+      }
+      writer.append(" ").append(simpleName);
+    }
+
+    void writeMethodParamType(Append writer) {
+      if (genericType != null) {
+        genericType.writeShort(writer);
+      } else {
+        writer.append(Util.shortName(paramType));
+      }
+    }
+
+    void writeConstructorInit(Append writer) {
+      writer.append(simpleName);
+    }
   }
 }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/ProcessingContext.java b/inject-generator/src/main/java/io/avaje/inject/generator/ProcessingContext.java
index 1b3ba847..57a743ad 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/ProcessingContext.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/ProcessingContext.java
@@ -5,7 +5,6 @@ import javax.annotation.processing.FilerException;
 import javax.annotation.processing.Messager;
 import javax.annotation.processing.ProcessingEnvironment;
 import javax.lang.model.element.Element;
-import javax.lang.model.element.PackageElement;
 import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.TypeMirror;
 import javax.lang.model.util.Elements;
@@ -108,10 +107,6 @@ class ProcessingContext {
     return createMetaInfWriterFor(serviceName);
   }
 
-  FileObject createMetaInfModuleCustom() throws IOException {
-    return createMetaInfWriterFor(Constants.META_INF_CUSTOM);
-  }
-
   private FileObject createMetaInfWriterFor(String interfaceType) throws IOException {
     return filer.createResource(StandardLocation.CLASS_OUTPUT, "", interfaceType);
   }
@@ -132,10 +127,6 @@ class ProcessingContext {
     return typeUtils.asElement(returnType);
   }
 
-  PackageElement getPackageOf(Element element) {
-    return elementUtils.getPackageOf(element);
-  }
-
   void addModule(String moduleFullName) {
     if (moduleFullName != null) {
       uniqueModuleNames.add(moduleFullName);
@@ -145,4 +136,5 @@ class ProcessingContext {
   boolean isDuplicateModule(String moduleFullName) {
     return uniqueModuleNames.contains(moduleFullName);
   }
+
 }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/Processor.java b/inject-generator/src/main/java/io/avaje/inject/generator/Processor.java
index 7ad6cb9b..39485820 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/Processor.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/Processor.java
@@ -2,6 +2,7 @@ package io.avaje.inject.generator;
 
 import io.avaje.inject.Factory;
 import io.avaje.inject.InjectModule;
+import io.avaje.inject.spi.Proxy;
 import jakarta.inject.Scope;
 import jakarta.inject.Singleton;
 
@@ -61,11 +62,13 @@ public class Processor extends AbstractProcessor {
     Set<? extends Element> factoryBeans = roundEnv.getElementsAnnotatedWith(Factory.class);
     Set<? extends Element> beans = roundEnv.getElementsAnnotatedWith(Singleton.class);
     Set<? extends Element> scopes = roundEnv.getElementsAnnotatedWith(Scope.class);
+    Set<? extends Element> proxies = roundEnv.getElementsAnnotatedWith(Proxy.class);
     readScopes(scopes);
     readModule(roundEnv);
     readChangedBeans(factoryBeans, true);
     readChangedBeans(beans, false);
     readChangedBeans(controllers, false);
+    readChangedBeans(proxies, false);
     allScopes.readBeans(roundEnv);
     defaultScope.write(roundEnv.processingOver());
     allScopes.write(roundEnv.processingOver());
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/ScopeInfo.java b/inject-generator/src/main/java/io/avaje/inject/generator/ScopeInfo.java
index fc4b8084..03c4e8a0 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/ScopeInfo.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/ScopeInfo.java
@@ -170,6 +170,10 @@ class ScopeInfo {
         if (!beanReader.isWrittenToFile()) {
           SimpleBeanWriter writer = new SimpleBeanWriter(beanReader, context);
           writer.write();
+          if (beanReader.isGenerateProxy()) {
+            SimpleBeanProxyWriter proxyWriter = new SimpleBeanProxyWriter(beanReader, context);
+            proxyWriter.write();
+          }
           beanReader.setWrittenToFile();
         }
       } catch (FilerException e) {
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/SimpleBeanProxyWriter.java b/inject-generator/src/main/java/io/avaje/inject/generator/SimpleBeanProxyWriter.java
new file mode 100644
index 00000000..5ed7c73b
--- /dev/null
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/SimpleBeanProxyWriter.java
@@ -0,0 +1,119 @@
+package io.avaje.inject.generator;
+
+import javax.lang.model.element.TypeElement;
+import javax.tools.JavaFileObject;
+import java.io.IOException;
+import java.io.Writer;
+
+class SimpleBeanProxyWriter {
+
+  private final BeanReader beanReader;
+  private final ProcessingContext context;
+  private final String originName;
+  private final String suffix;
+  private final String shortName;
+  private final String packageName;
+  private final BeanAspects aspects;
+  private Append writer;
+
+  SimpleBeanProxyWriter(BeanReader beanReader, ProcessingContext context) {
+    this.beanReader = beanReader;
+    this.context = context;
+
+    TypeElement origin = beanReader.getBeanType();
+    this.originName = origin.getQualifiedName().toString();
+    this.shortName = origin.getSimpleName().toString();
+    this.packageName = Util.packageOf(originName);
+    this.suffix = "$Proxy";
+    this.aspects = beanReader.aspects();
+  }
+
+  void write() throws IOException {
+    writer = new Append(createFileWriter());
+    writePackage();
+    writeImports();
+    writeClassStart();
+    writeFields();
+    writeConstructor();
+    writeMethods();
+    writeClassEnd();
+    writer.close();
+  }
+
+  private void writeMethods() {
+    for (AspectMethod method : aspects.methods()) {
+      method.writeMethod(writer);
+    }
+  }
+
+  private void writeFields() {
+    aspects.writeFields(writer);
+    for (AspectMethod method : aspects.methods()) {
+      method.writeSetupFields(writer);
+    }
+    writer.eol();
+  }
+
+  private void writeConstructor() {
+    writer.append("  public %s%s(", shortName, suffix);
+    int count = 0;
+    for (String target : aspects.targets()) {
+      if (count++ > 0) {
+        writer.append(", ");
+      }
+      final String type = Util.shortName(target);
+      String name = Util.initLower(type);
+      writer.append(type).append(" ").append(name);
+    }
+    beanReader.writeConstructorParams(writer);
+    writer.append(") {").eol();
+    beanReader.writeConstructorInit(writer);
+    for (String target : aspects.targets()) {
+      String name = AspectMethod.aspectTargetShortName(target);
+      writer.append("    this.%s = %s;", name, name).eol();
+    }
+    writeSetupForMethods();
+    writer.append("  }").eol();
+  }
+
+  private void writeSetupForMethods() {
+    writer.append("    try {").eol();
+    for (AspectMethod method : aspects.methods()) {
+      method.writeSetupForMethods(writer, shortName);
+    }
+    writer.append("    } catch (Exception e) {").eol();
+    writer.append("      throw new IllegalStateException(e);").eol();
+    writer.append("    }").eol();
+  }
+
+  private void writePackage() {
+    if (packageName != null) {
+      writer.append("package %s;", packageName).eol().eol();
+    }
+  }
+
+  private void writeImports() {
+    writer.append("import %s;", Constants.REFLECT_METHOD).eol();
+    writer.append("import %s;", Constants.INVOCATION).eol();
+    writer.append("import %s;", Constants.INVOCATION_EXCEPTION).eol();
+    writer.append("import %s;", Constants.METHOD_INTERCEPTOR).eol();
+    writer.append("import %s;", Constants.PROXY).eol();
+    beanReader.writeImports(writer);
+  }
+
+  private void writeClassEnd() {
+    writer.append("}").eol();
+  }
+
+  private void writeClassStart() {
+    writer.append(Constants.AT_PROXY).eol();
+    writer.append(Constants.AT_GENERATED).eol();
+    writer.append("public class %s%s extends %s {", shortName, suffix, shortName).eol().eol();
+  }
+
+  private Writer createFileWriter() throws IOException {
+    JavaFileObject jfo = context.createWriter(originName + suffix);
+    return jfo.openWriter();
+  }
+
+}
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/SimpleBeanWriter.java b/inject-generator/src/main/java/io/avaje/inject/generator/SimpleBeanWriter.java
index 7cd15474..eca33d68 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/SimpleBeanWriter.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/SimpleBeanWriter.java
@@ -22,6 +22,7 @@ class SimpleBeanWriter {
   private final String shortName;
   private final String packageName;
   private final String suffix;
+  private final boolean proxied;
   private Append writer;
 
   SimpleBeanWriter(BeanReader beanReader, ProcessingContext context) {
@@ -32,6 +33,7 @@ class SimpleBeanWriter {
     this.shortName = origin.getSimpleName().toString();
     this.packageName = Util.packageOf(originName);
     this.suffix = beanReader.suffix();
+    this.proxied = beanReader.isGenerateProxy();
   }
 
   private Writer createFileWriter() throws IOException {
@@ -115,7 +117,11 @@ class SimpleBeanWriter {
       return;
     }
     writeBuildMethodStart(constructor);
-    writeAddFor(constructor);
+    if (proxied) {
+      writer.append("    // this bean is proxied, see %s$Proxy$DI instead", shortName).eol();
+    } else {
+      writeAddFor(constructor);
+    }
     writer.append("  }").eol().eol();
   }
 
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsInjection.java b/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsInjection.java
index 666150e4..a78b9628 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsInjection.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsInjection.java
@@ -6,6 +6,7 @@ import jakarta.inject.Named;
 
 import javax.lang.model.element.Element;
 import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
 import javax.lang.model.element.TypeElement;
 import java.util.*;
 
@@ -21,10 +22,13 @@ class TypeExtendsInjection {
   private final List<FieldReader> injectFields = new ArrayList<>();
   private final Map<String, MethodReader> injectMethods = new LinkedHashMap<>();
   private final Set<String> notInjectMethods = new HashSet<>();
+  private final List<AspectMethod> aspectMethods = new ArrayList<>();
+  private final Map<String, Integer> nameIndex = new HashMap<>();
 
   private final TypeElement baseType;
   private final ProcessingContext context;
   private final boolean factory;
+  private final List<AspectPair> typeAspects;
   private Element postConstructMethod;
   private Element preDestroyMethod;
 
@@ -32,6 +36,9 @@ class TypeExtendsInjection {
     this.baseType = baseType;
     this.context = context;
     this.factory = factory;
+
+    AspectAnnotationReader reader = new AspectAnnotationReader(context, baseType, baseType);
+    this.typeAspects = reader.read();
   }
 
   void read(TypeElement type) {
@@ -76,11 +83,13 @@ class TypeExtendsInjection {
   }
 
   private void readMethod(Element element, TypeElement type) {
+    boolean checkAspect = true;
     ExecutableElement methodElement = (ExecutableElement) element;
     if (factory) {
       Bean bean = element.getAnnotation(Bean.class);
       if (bean != null) {
         addFactoryMethod(methodElement, bean);
+        checkAspect = false;
       }
     }
     Inject inject = element.getAnnotation(Inject.class);
@@ -90,6 +99,7 @@ class TypeExtendsInjection {
         MethodReader methodReader = new MethodReader(context, methodElement, type).read();
         if (methodReader.isNotPrivate()) {
           injectMethods.put(methodKey, methodReader);
+          checkAspect = false;
         }
       }
     } else {
@@ -97,20 +107,55 @@ class TypeExtendsInjection {
     }
     if (AnnotationUtil.hasAnnotationWithName(element, "PostConstruct")) {
       postConstructMethod = element;
+      checkAspect = false;
     }
     if (AnnotationUtil.hasAnnotationWithName(element, "PreDestroy")) {
       preDestroyMethod = element;
+      checkAspect = false;
+    }
+    if (checkAspect) {
+      checkForAspect(methodElement);
+    }
+  }
+
+  private int methodNameIndex(String name) {
+    Integer counter = nameIndex.get(name);
+    if (counter == null) {
+      nameIndex.put(name, 1);
+      return 0;
+    } else {
+      nameIndex.put(name, counter + 1);
+      return counter;
+    }
+  }
+
+  private void checkForAspect(ExecutableElement methodElement) {
+    Set<Modifier> modifiers = methodElement.getModifiers();
+    if (modifiers.contains(Modifier.PRIVATE) || modifiers.contains(Modifier.STATIC) || modifiers.contains(Modifier.ABSTRACT)) {
+      return;
+    }
+    int nameIndex = methodNameIndex(methodElement.getSimpleName().toString());
+    List<AspectPair> aspectPairs = new AspectAnnotationReader(context, baseType, methodElement).read();
+    if (!aspectPairs.isEmpty()) {
+      aspectMethods.add(new AspectMethod(nameIndex, aspectPairs, methodElement));
+    } else if (!typeAspects.isEmpty()) {
+      aspectMethods.add(new AspectMethod(nameIndex, typeAspects, methodElement));
     }
   }
 
+
   private void addFactoryMethod(ExecutableElement methodElement, Bean bean) {
     // Not yet reading Qualifier annotations, Named only at this stage
     Named named = methodElement.getAnnotation(Named.class);
     factoryMethods.add(new MethodReader(context, methodElement, baseType, bean, named).read());
   }
 
+  BeanAspects hasAspects() {
+    return aspectMethods.isEmpty() ? BeanAspects.EMPTY : new BeanAspects(aspectMethods);
+  }
+
   List<FieldReader> getInjectFields() {
-    List<FieldReader> list = new ArrayList(injectFields);
+    List<FieldReader> list = new ArrayList<>(injectFields);
     Collections.reverse(list);
     return list;
   }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsReader.java
index e22e795a..49916b6f 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsReader.java
@@ -5,6 +5,7 @@ import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.TypeMirror;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 
 /**
  * Read the inheritance types for a given bean type.
@@ -44,6 +45,10 @@ class TypeExtendsReader {
     return qualifierName;
   }
 
+  BeanAspects hasAspects() {
+    return extendsInjection.hasAspects();
+  }
+
   List<FieldReader> getInjectFields() {
     return extendsInjection.getInjectFields();
   }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/TypeReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/TypeReader.java
index fadc2db2..d6acfd5e 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/TypeReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/TypeReader.java
@@ -50,6 +50,10 @@ class TypeReader {
     importTypes.addAll(this.importTypes);
   }
 
+  BeanAspects hasAspects() {
+    return extendsReader.hasAspects();
+  }
+
   List<FieldReader> getInjectFields() {
     return extendsReader.getInjectFields();
   }
diff --git a/inject/pom.xml b/inject/pom.xml
index f032cda5..ed9f8a4b 100644
--- a/inject/pom.xml
+++ b/inject/pom.xml
@@ -4,7 +4,7 @@
   <parent>
     <groupId>io.avaje</groupId>
     <artifactId>avaje-inject-parent</artifactId>
-    <version>6.17</version>
+    <version>6.18-SNAPSHOT</version>
   </parent>
 
   <artifactId>avaje-inject</artifactId>
@@ -58,6 +58,26 @@
 
   <build>
     <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-javadoc-plugin</artifactId>
+        <version>3.2.0</version>
+        <configuration>
+<!--          <source>15</source>-->
+          <doclint>none</doclint>
+          <show>public</show>
+          <linksource>true</linksource>
+          <overview>src/main/javadoc/overview.html</overview>
+        </configuration>
+        <executions>
+          <execution>
+            <id>attach-javadocs</id>
+            <goals>
+              <goal>jar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
       <plugin>
         <groupId>org.moditect</groupId>
         <artifactId>moditect-maven-plugin</artifactId>
diff --git a/inject/src/main/java/io/avaje/inject/aop/Aspect.java b/inject/src/main/java/io/avaje/inject/aop/Aspect.java
new file mode 100644
index 00000000..e91e616c
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/aop/Aspect.java
@@ -0,0 +1,43 @@
+package io.avaje.inject.aop;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Meta annotation used to define an Aspect.
+ * <p>
+ * Create an annotation and annotate with {@code @Aspect} to define an aspect annotation. The
+ * {@link Aspect#target()} specifies the associated type that implements {@link AspectProvider}.
+ * The aspect provider should be a {@code @Singleton} such that registers with <em>avaje-inject</em>.
+ * <p>
+ */
+@Target(ElementType.ANNOTATION_TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Aspect {
+
+  /**
+   * Specify the {@link AspectProvider} for this aspect.
+   */
+  Class<?> target();
+
+  /**
+   * Specify the priority ordering when multiple aspects are on a method.
+   * <p>
+   * When multiple aspects are on a method they are nested. The highest
+   * {@link #ordering()} value will be the outer-most aspect, the lowest
+   * ordering will be the inner-most aspect.
+   * <p>
+   * The outer-most aspect will have it's <em>before</em> executed first,
+   * followed by the <em>before</em> of the inner nested aspects ultimately
+   * down the invocation of the target method.
+   * <p>
+   * The reverse ordering occurs for <em>after</em> with the outer-most aspect
+   * having it's <em>after</em> executed last.
+   *
+   * @return The ordering of this aspect. High value for outer-most aspect.
+   */
+  int ordering() default 1000;
+
+}
diff --git a/inject/src/main/java/io/avaje/inject/aop/AspectProvider.java b/inject/src/main/java/io/avaje/inject/aop/AspectProvider.java
new file mode 100644
index 00000000..5d660944
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/aop/AspectProvider.java
@@ -0,0 +1,17 @@
+package io.avaje.inject.aop;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+
+/**
+ * Provides the {@code MethodInterceptor} for the given aspect.
+ *
+ * @param <T> The aspect annotation
+ */
+public interface AspectProvider<T extends Annotation> {
+
+  /**
+   * Return the method interceptor to use for the given method and aspect annotation.
+   */
+  MethodInterceptor interceptor(Method method, T aspectAnnotation);
+}
diff --git a/inject/src/main/java/io/avaje/inject/aop/Fallback.java b/inject/src/main/java/io/avaje/inject/aop/Fallback.java
new file mode 100644
index 00000000..826adb01
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/aop/Fallback.java
@@ -0,0 +1,37 @@
+package io.avaje.inject.aop;
+
+import java.lang.reflect.Method;
+
+/**
+ * A fallback or recovery method used with Aspects.
+ * <p>
+ * This isn't strictly required but more a helper to make it easier for aspects
+ * that want to use a fallback or recovery method.
+ */
+public interface Fallback {
+
+  /**
+   * Find and return the fallback given the name and original method.
+   * <p>
+   * This will first try and find the method for the name that has the same parameters as
+   * the method but additionally takes a {@code Throwable} as the last parameter. If no
+   * matching method is found it then looks for the method with the name that just has the
+   * matching parameters (and not the additional throwable).
+   *
+   * @param name   The name of the fallback method
+   * @param method The original method which we match to using argument types.
+   * @return The fallback
+   * @throws NoSuchMethodException When no matching fallback method is found
+   */
+  static Fallback find(String name, Method method) throws NoSuchMethodException {
+    return FallbackFinder.find(name, method);
+  }
+
+  /**
+   * Invoke the fallback method given the invocation and exception.
+   *
+   * @param call            The invocation being executed
+   * @param sourceException The exception thrown that triggers the use of the fallback method
+   */
+  Object invoke(Invocation call, Throwable sourceException);
+}
diff --git a/inject/src/main/java/io/avaje/inject/aop/FallbackFinder.java b/inject/src/main/java/io/avaje/inject/aop/FallbackFinder.java
new file mode 100644
index 00000000..cdaf257f
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/aop/FallbackFinder.java
@@ -0,0 +1,75 @@
+package io.avaje.inject.aop;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+
+final class FallbackFinder {
+
+  static Fallback find(String name, Method method) throws NoSuchMethodException {
+    Class<?> type = method.getDeclaringClass();
+    Parameter[] parameters = method.getParameters();
+    try {
+      return new WithThrowable(type.getDeclaredMethod(name, paramTypesWithThrowable(parameters)));
+    } catch (NoSuchMethodException e) {
+      return new WithoutThrowable(type.getDeclaredMethod(name, paramTypes(parameters)));
+    }
+  }
+
+  private static Class<?>[] paramTypes(Parameter[] parameters) {
+    Class<?>[] paramTypes = new Class[parameters.length];
+    for (int i = 0; i < parameters.length; i++) {
+      paramTypes[i] = parameters[i].getType();
+    }
+    return paramTypes;
+  }
+
+  private static Class<?>[] paramTypesWithThrowable(Parameter[] parameters) {
+    Class<?>[] paramTypes = new Class[parameters.length + 1];
+    for (int i = 0; i < parameters.length; i++) {
+      paramTypes[i] = parameters[i].getType();
+    }
+    paramTypes[parameters.length] = Throwable.class;
+    return paramTypes;
+  }
+
+  static class WithThrowable implements Fallback {
+
+    private final Method fallbackMethod;
+
+    WithThrowable(Method fallbackMethod) {
+      this.fallbackMethod = fallbackMethod;
+    }
+
+    @Override
+    public Object invoke(Invocation call, Throwable e) {
+      try {
+        final Object result = fallbackMethod.invoke(call.instance(), call.arguments(e));
+        call.result(result);
+        return result;
+      } catch (IllegalAccessException | InvocationTargetException ex) {
+        throw new InvocationException("Error invoking fallback method", ex);
+      }
+    }
+  }
+
+  static class WithoutThrowable implements Fallback {
+
+    private final Method fallbackMethod;
+
+    WithoutThrowable(Method fallbackMethod) {
+      this.fallbackMethod = fallbackMethod;
+    }
+
+    @Override
+    public Object invoke(Invocation call, Throwable e) {
+      try {
+        final Object result = fallbackMethod.invoke(call.instance(), call.arguments());
+        call.result(result);
+        return result;
+      } catch (IllegalAccessException | InvocationTargetException ex) {
+        throw new InvocationException("Error invoking fallback method", ex);
+      }
+    }
+  }
+}
diff --git a/inject/src/main/java/io/avaje/inject/aop/Invocation.java b/inject/src/main/java/io/avaje/inject/aop/Invocation.java
new file mode 100644
index 00000000..8b528cb9
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/aop/Invocation.java
@@ -0,0 +1,213 @@
+package io.avaje.inject.aop;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+/**
+ * Method invocation using in {@link MethodInterceptor#invoke(Invocation)} for Aspects.
+ * <p>
+ * Represents a method invocation that can be intercepted with additional before and after
+ * invocation logic.
+ */
+public interface Invocation {
+
+  /**
+   * Invoke the underlying method returning the result.
+   * <p>
+   * This will return null for void methods.
+   */
+  Object invoke() throws Throwable;
+
+  /**
+   * Set the result that will be returned to the caller.
+   * <p>
+   * This will replace a prior result set by calling {@code #invoke} or can be used
+   * to provide a result allowing to skip calling {@code #invoke} altogether.
+   *
+   * @param result The result that will be returned to the caller.
+   */
+  void result(Object result);
+
+  /**
+   * Return the arguments used for this invocation.
+   */
+  Object[] arguments();
+
+  /**
+   * Return the arguments additionally appending the throwable.
+   */
+  Object[] arguments(Throwable e);
+
+  /**
+   * Return the method being called for this invocation.
+   */
+  Method method();
+
+  /**
+   * Return the 'this' instance of the invocation.
+   * <p>
+   * This is typically used when invoking fallback/recovery methods.
+   */
+  Object instance();
+
+  /**
+   * Builds Invocation for both callable and runnable methods.
+   *
+   * @param <T> The result type
+   */
+  abstract class Build<T> implements Invocation {
+
+    protected Method method;
+    protected Object[] args;
+    protected Object instance;
+    protected T result;
+
+    /**
+     * Set the instance, method and arguments for the invocation.
+     */
+    public Build<T> with(Object instance, Method method, Object... args) {
+      this.instance = instance;
+      this.method = method;
+      this.args = args;
+      return this;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public void result(Object result) {
+      this.result = (T) result;
+    }
+
+    /**
+     * Return the final invocation result.
+     */
+    public T finalResult() {
+      return result;
+    }
+
+    @Override
+    public Object[] arguments() {
+      return args;
+    }
+
+    @Override
+    public Object[] arguments(Throwable e) {
+      if (args == null || args.length == 0) {
+        return new Object[]{e};
+      } else {
+        Object[] newArgs = Arrays.copyOf(args, args.length + 1);
+        newArgs[args.length] = e;
+        return newArgs;
+      }
+    }
+
+    @Override
+    public Method method() {
+      return method;
+    }
+
+    @Override
+    public Object instance() {
+      return instance;
+    }
+
+    /**
+     * Wrap this invocation using a methodInterceptor returning the wrapped call.
+     * <p>
+     * This invocation is effectively nested inside the returned invocation.
+     *
+     * @param methodInterceptor The method interceptor to use to wrap this call with
+     * @return The wrapped call
+     */
+    public abstract Build<T> wrap(MethodInterceptor methodInterceptor);
+  }
+
+  /**
+   * Runnable based Invocation.
+   */
+  final class Run extends Build<Void> {
+
+    private final CheckedRunnable delegate;
+
+    /**
+     * Create with a given closure to run.
+     */
+    public Run(CheckedRunnable delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public Object invoke() throws Throwable {
+      delegate.invoke();
+      return null;
+    }
+
+    @Override
+    public Build<Void> wrap(MethodInterceptor methodInterceptor) {
+      return new Invocation.Run(() -> methodInterceptor.invoke(this))
+        .with(instance, method, args);
+    }
+
+  }
+
+  /**
+   * Callable based Invocation with checked exceptions.
+   */
+  final class Call<T> extends Build<T> {
+
+    private final CheckedSupplier<T> delegate;
+
+    /**
+     * Create with a given supplier.
+     */
+    public Call(CheckedSupplier<T> delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public Object invoke() throws Throwable {
+      result = delegate.invoke();
+      return result;
+    }
+
+    @Override
+    public T finalResult() {
+      return result;
+    }
+
+    @Override
+    public Build<T> wrap(MethodInterceptor methodInterceptor) {
+      return new Invocation.Call<T>(() -> {
+        final Call<T> delegate = this;
+        methodInterceptor.invoke(delegate);
+        return delegate.finalResult();
+      }).with(instance, method, args);
+    }
+  }
+
+  /**
+   * Runnable with checked exceptions.
+   */
+  @FunctionalInterface
+  interface CheckedRunnable {
+
+    /**
+     * Invoke the method.
+     */
+    void invoke() throws Throwable;
+  }
+
+  /**
+   * Callable with checked exceptions.
+   *
+   * @param <T> The result type
+   */
+  @FunctionalInterface
+  interface CheckedSupplier<T> {
+
+    /**
+     * Invoke the method returning the result.
+     */
+    T invoke() throws Throwable;
+  }
+}
diff --git a/inject/src/main/java/io/avaje/inject/aop/InvocationException.java b/inject/src/main/java/io/avaje/inject/aop/InvocationException.java
new file mode 100644
index 00000000..a41bd0bf
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/aop/InvocationException.java
@@ -0,0 +1,25 @@
+package io.avaje.inject.aop;
+
+/**
+ * Exception occurring during method interception.
+ * <p>
+ * When using aspects and {@link MethodInterceptor} any throwable that is undeclared on the
+ * method is caught and re-throw as an InvocationException.
+ */
+public class InvocationException extends RuntimeException {
+
+  /**
+   * Create with a given cause.
+   */
+  public InvocationException(Throwable cause) {
+    super(cause);
+  }
+
+  /**
+   * Create with a given message and cause.
+   */
+  public InvocationException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+}
diff --git a/inject/src/main/java/io/avaje/inject/aop/MethodInterceptor.java b/inject/src/main/java/io/avaje/inject/aop/MethodInterceptor.java
new file mode 100644
index 00000000..2187cdf5
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/aop/MethodInterceptor.java
@@ -0,0 +1,24 @@
+package io.avaje.inject.aop;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+
+/**
+ * Provides method interception such that logic can be provided both before and after method invocation.
+ * <p>
+ * MethodInterceptor instances are provided by {@link AspectProvider#interceptor(Method, Annotation)}.
+ */
+public interface MethodInterceptor {
+
+  /**
+   * Implementation can perform before and after invocation logic.
+   * <p>
+   * If a method interceptor wants to replace the result it does this via {@link Invocation#result(Object)}.
+   * This is a little different to traditional method interceptors (i.e. <code>org.org.aopalliance.intercept.MethodInterceptor</code>).
+   * It is done this way to handle implied generic return types in the generated source code.
+   *
+   * @param invocation The invocation being intercepted
+   * @throws Throwable If the interception or underlying invocation throws an exception
+   */
+  void invoke(Invocation invocation) throws Throwable;
+}
diff --git a/inject/src/main/java/io/avaje/inject/aop/package-info.java b/inject/src/main/java/io/avaje/inject/aop/package-info.java
new file mode 100644
index 00000000..5115fe67
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/aop/package-info.java
@@ -0,0 +1,10 @@
+/**
+ * Support for AOP Aspects.
+ * <p>
+ * Aspects allow us to define method interceptors and then have this method interception
+ * apply to specific methods or all public methods of a class.
+ * <p>
+ * An example of an Aspect might be <em>Retry</em> which will invoke the underlying and
+ * then retry potentially multiple times if that invocation fails.
+ */
+package io.avaje.inject.aop;
diff --git a/inject/src/main/java/io/avaje/inject/package-info.java b/inject/src/main/java/io/avaje/inject/package-info.java
index 9a4c405c..78cb3d21 100644
--- a/inject/src/main/java/io/avaje/inject/package-info.java
+++ b/inject/src/main/java/io/avaje/inject/package-info.java
@@ -1,17 +1,8 @@
 /**
  * Avaje Inject API - see {@link io.avaje.inject.BeanScope}.
  *
- * <h2>Overview</h2>
- *
- * <h4>1. @Singleton / @Factory</h4>
- * <p>
- * Put <code>@Singleton</code> on beans that we want avaje-inject to wire.
- * <p>
- * We can use <code>@Factory/@Bean</code> to programmatically create dependencies
- * when they have interesting construction logic (e.g. construction depends on
- * system/environment properties etc - like Spring @Configuration).
- *
  * <pre>{@code
+ *   // Annotate classes with @Singleton, @Factory etc
  *
  *   @Singleton
  *   public class CoffeeMaker { ... }
@@ -19,30 +10,10 @@
  *   @Singleton
  *   public class Pump { ... }
  *
- *   // Use @Factory to programmatically build dependencies
- *
- *   @Factory
- *   public class MyFactory {
+ *  --------------------------------------------------
  *
- *     @Bean
- *     Grinder buildGrinder(Pump pump) { // interesting construction logic ... }
- *
- *   }
- *
- * }</pre>
+ *   // Create BeanScope to wire and access beans
  *
- * <h4>2. Create and use BeanScope</h4>
- * <p>
- * Create {@link io.avaje.inject.BeanScope} using a builder.
- * Obtain beans from the scope and use them.
- * <p>
- * We should ensure the BeanScope is closed in order to fire
- * preDestroy lifecycle methods. We can do this via a shutdown
- * hook, or try with resource block or explicitly via application code.
- *
- * <pre>{@code
- *
- *   // create BeanScope
  *   BeanScope scope = BeanScope.newBuilder()
  *     .build();
  *
@@ -54,106 +25,5 @@
  *   scope.close();
  *
  * }</pre>
- *
- * <h2>Default scope</h3>
- * <p>
- * All beans annotated with {@code @Singleton} and {@code @Factory} are by default included in
- * the "default scope".
- * <p>
- * When we create a BeanScope and do not specify any modules then all the beans in the default
- * scope are included. This is done via ServiceLoader and includes all 'default scope' modules
- * that are in the classpath (other jars can have default scope modules and these are all included).
- *
- * <h4>Generated code</h4>
- * <p>
- * <em>avaje-inject</em> will generate a <code>$DI</code> class for each bean that it is going to
- * wire - this has the code that instantiates the bean, performs field and method injection and
- * lifecycle support (PostConstruct and PreDestroy).
- * <p>
- * <em>avaje-inject</em> will generate a Module class for the default scope. The main job of
- * the module code is to specify the ordering of how all the beans are instantiated.
- * <p>
- * We typically find the generated source code in  <em>target/generate-sources/annotations</em>.
- *
- *
- * <h2>Custom scopes</h3>
- * <p>
- * We can create custom scopes that only contain the beans/components that we want to include in
- * that scope. These beans/components in the custom scope are not included in the default scope.
- * <p>
- * To do this we:
- *
- * <h4>1. Create the scope annotation</h4>
- * <p>
- * Create an annotation that has the {@code Scope} annotation. In the example below we create the
- * <code>@StoreComponent</code> annotation. We will put this annotation on beans that we want
- * included in the scope.
- *
- * <pre>{@code
- *
- *    @Scope
- *    public @interface StoreComponent {
- *    }
- *
- * }</pre>
- *
- * <p>
- * Note that if this scope depends on external dependencies or another scope we specify that via
- * {@code @InjectModule requires}. In the example below our StoreComponent depends on another
- * scope called QueueComponent and an external dependency - SomeExternalDependency.
- *
- * <pre>{@code
- *
- *    @Scope
- *    @InjectModule(requires = {QueueComponent.class, SomeExternalDependency.class})
- *    public @interface StoreComponent {
- *    }
- *
- * }</pre>
- *
- * <h4>2. Use the annotation</h4>
- * <p>
- * Put the <code>@StoreComponent</code> annotation on the beans that we want included in the
- * custom scope.
- *
- * <pre>{@code
- *
- *   @StoreComponent
- *   public class StoreLoader {
- *     ...
- *   }
- *
- * }</pre>
- *
- *
- * <h4>3. Generated Module</h4>
- * <p>
- * <em>avaje-inject</em> will generate a <code>StoreComponentModule</code> with the appropriate
- * code to create/wire all the beans in the custom scope.
- * <p>
- * StoreComponentModule is typically found in <em>target/generate-sources/annotations</em>.
- * For each component we will also see a <code>$DI</code> class which is the generated code
- * that creates the component.
- *
- * <h4>4. Use the custom scope</h4>
- * <p>
- * To use the custom scope we specify the <code>StoreComponentModule</code> when creating the
- * BeanScope. Only the components in our module will be create/wired into the BeanScope.
- * <p>
- * If the scope depends on another scope then we specify that using {@code withParent()}.
- *
- * <pre>{@code
- *
- *   BeanScope parentScope = ...
- *
- *   BeanScope scope = BeanScope.newBuilder()
- *     .withModules(new StoreComponentModule())
- *     .withParent(parentScope)
- *     .build());
- *
- *   StoreLoader storeLoader = scope.get(StoreLoader.class);
- *   storeLoader.load();
- *
- * }</pre>
  */
 package io.avaje.inject;
diff --git a/inject/src/main/java/io/avaje/inject/spi/DContextEntryBean.java b/inject/src/main/java/io/avaje/inject/spi/DContextEntryBean.java
index 3bb5c428..d816c09a 100644
--- a/inject/src/main/java/io/avaje/inject/spi/DContextEntryBean.java
+++ b/inject/src/main/java/io/avaje/inject/spi/DContextEntryBean.java
@@ -95,7 +95,7 @@ class DContextEntryBean {
 
     @Override
     Object getBean() {
-      // its a provider, get it once
+      // it's a provider, get it once
       if (actualBean == null) {
         actualBean = ((Provider<?>) source).get();
       }
diff --git a/inject/src/main/java/io/avaje/inject/spi/Proxy.java b/inject/src/main/java/io/avaje/inject/spi/Proxy.java
new file mode 100644
index 00000000..3f271b2a
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/spi/Proxy.java
@@ -0,0 +1,14 @@
+package io.avaje.inject.spi;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks the type as being a Proxy.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Proxy {
+}
diff --git a/inject/src/main/javadoc/overview.html b/inject/src/main/javadoc/overview.html
new file mode 100644
index 00000000..a234bca0
--- /dev/null
+++ b/inject/src/main/javadoc/overview.html
@@ -0,0 +1,162 @@
+<html>
+<body>
+<h2>Overview</h2>
+<p>
+  Annotate classes with {@code @Singleton} / {@code @Factory} and <em>avaje-inject</em>
+  will generate source code to "wire them".
+</p>
+
+<h4>1. @Singleton / @Factory</h4>
+<p>
+  Put <code>@Singleton</code> on beans that we want avaje-inject to wire.
+<p>
+  We can use <code>@Factory/@Bean</code> to programmatically create dependencies
+  when they have interesting construction logic (e.g. construction depends on
+  system/environment properties etc - like Spring @Configuration).
+
+<pre>{@code
+
+    @Singleton
+    public class CoffeeMaker { ... }
+
+    @Singleton
+    public class Pump { ... }
+
+    // Use @Factory to programmatically build dependencies
+
+    @Factory
+    public class MyFactory {
+
+      @Bean
+      Grinder buildGrinder(Pump pump) { // interesting construction logic ... }
+
+    }
+
+  }</pre>
+
+<h4>2. Create and use BeanScope</h4>
+<p>
+  Create {@link io.avaje.inject.BeanScope} using a builder.
+  Obtain beans from the scope and use them.
+<p>
+  We should ensure the BeanScope is closed in order to fire
+  preDestroy lifecycle methods. We can do this via a shutdown
+  hook, or try with resource block or explicitly via application code.
+
+<pre>{@code
+
+    // create BeanScope
+    BeanScope scope = BeanScope.newBuilder()
+      .build();
+
+    // use it
+    CoffeeMaker coffeeMaker = scope.get(CoffeeMaker.class);
+    coffeeMaker.makeIt();
+
+    // close it to fire preDestroy lifecycle methods
+    scope.close();
+
+  }</pre>
+
+<h2>Default scope</h2>
+<p>
+  All beans annotated with {@code @Singleton} and {@code @Factory} are by default included in
+  the "default scope".
+<p>
+  When we create a BeanScope and do not specify any modules then all the beans in the default
+  scope are included. This is done via ServiceLoader and includes all 'default scope' modules
+  that are in the classpath (other jars can have default scope modules and these are all included).
+
+<h4>Generated code</h4>
+<p>
+  <em>avaje-inject</em> will generate a <code>$DI</code> class for each bean that it is going to
+  wire - this has the code that instantiates the bean, performs field and method injection and
+  lifecycle support (PostConstruct and PreDestroy).
+<p>
+  <em>avaje-inject</em> will generate a Module class for the default scope. The main job of
+  the module code is to specify the ordering of how all the beans are instantiated.
+<p>
+  We typically find the generated source code in <em>target/generate-sources/annotations</em>.
+
+
+<h2>Custom scopes</h2>
+<p>
+  We can create custom scopes that only contain the beans/components that we want to include in
+  that scope. These beans/components in the custom scope are not included in the default scope.
+<p>
+  To do this we:
+
+<h4>1. Create the scope annotation</h4>
+<p>
+  Create an annotation that has the {@code Scope} annotation. In the example below we create the
+  <code>@StoreComponent</code> annotation. We will put this annotation on beans that we want
+  included in the scope.
+
+<pre>{@code
+
+     @Scope
+     public @interface StoreComponent {
+     }
+
+  }</pre>
+
+<p>
+  Note that if this scope depends on external dependencies or another scope we specify that via
+  {@code @InjectModule requires}. In the example below our StoreComponent depends on another
+  scope called QueueComponent and an external dependency - SomeExternalDependency.
+
+<pre>{@code
+
+     @Scope
+     @InjectModule(requires = {QueueComponent.class, SomeExternalDependency.class})
+     public @interface StoreComponent {
+     }
+
+  }</pre>
+
+<h4>2. Use the annotation</h4>
+<p>
+  Put the <code>@StoreComponent</code> annotation on the beans that we want included in the
+  custom scope.
+
+<pre>{@code
+
+    @StoreComponent
+    public class StoreLoader {
+      ...
+    }
+
+  }</pre>
+
+
+<h4>3. Generated Module</h4>
+<p>
+  <em>avaje-inject</em> will generate a <code>StoreComponentModule</code> with the appropriate
+  code to create/wire all the beans in the custom scope.
+<p>
+  StoreComponentModule is typically found in <em>target/generate-sources/annotations</em>.
+  For each component we will also see a <code>$DI</code> class which is the generated code
+  that creates the component.
+
+<h4>4. Use the custom scope</h4>
+<p>
+  To use the custom scope we specify the <code>StoreComponentModule</code> when creating the
+  BeanScope. Only the components in our module will be create/wired into the BeanScope.
+<p>
+  If the scope depends on another scope then we specify that using {@code withParent()}.
+
+<pre>{@code
+
+    BeanScope parentScope = ...
+
+    BeanScope scope = BeanScope.newBuilder()
+      .withModules(new StoreComponentModule())
+      .withParent(parentScope)
+      .build());
+
+    StoreLoader storeLoader = scope.get(StoreLoader.class);
+    storeLoader.load();
+
+  }</pre>
+</body>
+</html>
diff --git a/pom.xml b/pom.xml
index d6361533..cf7522f5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,12 +4,12 @@
   <parent>
     <groupId>org.avaje</groupId>
     <artifactId>java8-oss</artifactId>
-    <version>3.2</version>
+    <version>3.3</version>
   </parent>
 
   <groupId>io.avaje</groupId>
   <artifactId>avaje-inject-parent</artifactId>
-  <version>6.17</version>
+  <version>6.18-SNAPSHOT</version>
   <packaging>pom</packaging>
   <name>avaje inject parent</name>
   <description>parent pom for avaje inject library</description>


Test patch:
diff --git a/blackbox-test-inject/pom.xml b/blackbox-test-inject/pom.xml
index d484931d..069235d4 100644
--- a/blackbox-test-inject/pom.xml
+++ b/blackbox-test-inject/pom.xml
@@ -5,7 +5,7 @@
   <parent>
     <artifactId>avaje-inject-parent</artifactId>
     <groupId>io.avaje</groupId>
-    <version>6.17</version>
+    <version>6.18-SNAPSHOT</version>
   </parent>
   <modelVersion>4.0.0</modelVersion>
 
@@ -24,6 +24,18 @@
       <version>${project.version}</version>
     </dependency>
 
+    <dependency>
+      <groupId>io.github.resilience4j</groupId>
+      <artifactId>resilience4j-retry</artifactId>
+      <version>1.7.1</version>
+    </dependency>
+
+    <dependency>
+      <groupId>io.github.resilience4j</groupId>
+      <artifactId>resilience4j-annotations</artifactId>
+      <version>1.7.1</version>
+    </dependency>
+
     <!-- annotation processor -->
     <dependency>
       <groupId>io.avaje</groupId>
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/ExampleService.java b/blackbox-test-inject/src/main/java/org/example/myapp/ExampleService.java
new file mode 100644
index 00000000..022a9f86
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/ExampleService.java
@@ -0,0 +1,25 @@
+package org.example.myapp;
+
+import jakarta.inject.Singleton;
+import org.example.myapp.aspect.MyTimed;
+
+import java.io.IOException;
+
+@MyTimed
+@Singleton
+public class ExampleService {
+
+  final HelloService helloService;
+
+  public ExampleService(HelloService helloService) {
+    this.helloService = helloService;
+  }
+
+  public String other(String param0, int param1) {//} throws IOException, IllegalStateException {
+    return "other " + param0 + " " + param1;
+  }
+
+  public void runOnly(String param) {
+    System.out.println("runOnly "+param);
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/HelloService.java b/blackbox-test-inject/src/main/java/org/example/myapp/HelloService.java
index aa9cb601..26aeaef3 100644
--- a/blackbox-test-inject/src/main/java/org/example/myapp/HelloService.java
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/HelloService.java
@@ -1,12 +1,21 @@
 package org.example.myapp;
 
+import io.avaje.inject.PostConstruct;
+import io.avaje.inject.PreDestroy;
 import jakarta.inject.Singleton;
+import org.example.myapp.aspect.*;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
 
 @Singleton
 public class HelloService {
 
   private final HelloData data;
 
+  private int counter;
+  private String justRunResult;
+
   HelloService(HelloData data) {
     this.data = data;
   }
@@ -14,4 +23,75 @@ public class HelloService {
   public String hello() {
     return "hello+" + data.helloData();
   }
+
+  @MySkip
+  public String skipExample(String p0) {
+    // this is never called due to MySkip aspect
+    return p0;
+  }
+
+  @MyMultiInvoke
+  int counter() {
+    return counter++;
+  }
+
+  @MyBefore
+  public String foo(String param, int overloaded) {
+    return "foo+" + param;
+  }
+
+  @MyBefore
+  public String foo(String param) {
+    return "foo+" + param;
+  }
+
+  @MyAround(name="what")
+  public String bazz(String param0, int param1) throws IOException {
+    System.out.println("execute bazz ...");
+    return "bazz " + param0 + " " + param1;
+  }
+
+  @MyAround
+  public void justRun(String param0, int param1, int param2) throws IOException, ClassNotFoundException, UncheckedIOException {
+    System.out.println("justRun ...");
+    justRunResult = param0+" "+param1+" "+param2;
+  }
+
+  @MyThrowing
+  public void thisWillThrow() {
+    System.out.println("this just never gets called");
+    throw new IllegalCallerException("never happens");
+  }
+
+  @MyAround
+  public void appCodeThrowsUnchecked() {
+    throw new IllegalArgumentException("appCodeUnchecked");
+  }
+
+  /**
+   * Declared exception is NOT wrapped in InvocationException
+   */
+  @MyAround
+  public void appCodeThrowsDeclared() throws IllegalArgumentException {
+    throw new IllegalArgumentException("appCodeDeclared");
+  }
+
+  public void multiAspectsHere() {
+
+  }
+
+
+  public String justRunResult() {
+    return justRunResult;
+  }
+
+  @PostConstruct
+  void postCon() {
+
+  }
+
+  @PreDestroy
+  void preDest() {
+
+  }
 }
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/HelloServiceProxy.java b/blackbox-test-inject/src/main/java/org/example/myapp/HelloServiceProxy.java
new file mode 100644
index 00000000..a81cdd20
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/HelloServiceProxy.java
@@ -0,0 +1,58 @@
+package org.example.myapp;
+
+import io.avaje.inject.aop.MethodInterceptor;
+import io.avaje.inject.aop.Invocation;
+import org.example.myapp.aspect.MyAround;
+import org.example.myapp.aspect.MyAroundAspect;
+import org.example.myapp.aspect.MyBeforeAspect;
+
+import java.lang.reflect.Method;
+
+//@Proxy
+//@Singleton
+public class HelloServiceProxy extends HelloService {
+
+  final MyBeforeAspect aspect;
+  final MyAroundAspect aroundAspect;
+  private final Method fooMethod;
+  private final Method bazzMethod;
+  private final MyAround myArround;
+  private final MethodInterceptor bazzInterceptor;
+
+  public HelloServiceProxy(HelloData data, MyBeforeAspect aspect, MyAroundAspect aroundAspect) {
+    super(data);
+    this.aspect = aspect;
+    this.aroundAspect = aroundAspect;
+    try {
+      fooMethod = HelloService.class.getDeclaredMethod("foo", String.class);
+      bazzMethod = HelloService.class.getDeclaredMethod("bazz", String.class, int.class);
+      myArround = bazzMethod.getAnnotation(MyAround.class);
+
+
+      bazzInterceptor = aroundAspect.interceptor(bazzMethod, myArround);
+
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  @Override
+  public String foo(String param) {
+    //aspect.beforeInvoke(fooMethod);
+    return super.foo(param);
+  }
+
+  @Override
+  public String bazz(String param0, int param1) {
+    var call = new Invocation.Call<>(() -> super.bazz(param0, param1))
+      .with(this, bazzMethod, param0, param1);
+    try {
+      bazzInterceptor.invoke(call);
+      //aroundAspect.around3(call);
+      return call.finalResult();
+    } catch (Throwable e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/OtherService.java b/blackbox-test-inject/src/main/java/org/example/myapp/OtherService.java
new file mode 100644
index 00000000..bca4681f
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/OtherService.java
@@ -0,0 +1,24 @@
+package org.example.myapp;
+
+import jakarta.inject.Singleton;
+import org.example.myapp.aspect.MyAround;
+import org.example.myapp.aspect.MyTimed;
+
+@Singleton
+public class OtherService {
+
+  @MyAround
+  public String other(String param0, int param1) {
+    return "other " + param0 + " " + param1;
+  }
+
+  @MyAround
+  @MyTimed
+  public void multi() {
+    System.out.println("sdsd");
+  }
+
+  public void notAopWrapped() {
+    System.out.println("just a normal method");
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyAround.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyAround.java
new file mode 100644
index 00000000..600477b7
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyAround.java
@@ -0,0 +1,16 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.Aspect;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Aspect(target = MyAroundAspect.class)
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface MyAround {
+
+  String name() default "";
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyAroundAspect.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyAroundAspect.java
new file mode 100644
index 00000000..53a87187
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyAroundAspect.java
@@ -0,0 +1,44 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.AspectProvider;
+import io.avaje.inject.aop.Invocation;
+import io.avaje.inject.aop.MethodInterceptor;
+import jakarta.inject.Singleton;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+@Singleton
+//@Aspect(target = MyAround.class)
+public class MyAroundAspect implements AspectProvider<MyAround>, MethodInterceptor {
+
+  private final List<String> trace = new ArrayList<>();
+
+  @Override
+  public MethodInterceptor interceptor(Method method, MyAround around) {
+    return this;
+  }
+
+  @Override
+  public void invoke(Invocation invoke) throws Throwable {
+    TraceAspect.add("MyAroundAspect-begin");
+    trace.add(invoke.method().getName() + " args:" + Arrays.toString(invoke.arguments()));
+    System.out.println("before args: " + Arrays.toString(invoke.arguments()) + " method: " + invoke.method());
+    try {
+      invoke.invoke();
+      invoke.invoke();
+      invoke.invoke();
+    } finally {
+      System.out.println("after");
+      TraceAspect.add("MyAroundAspect-end");
+    }
+  }
+
+  public List<String> trace() {
+    ArrayList<String> copy = new ArrayList<>(trace);
+    trace.clear();
+    return copy;
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyBefore.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyBefore.java
new file mode 100644
index 00000000..de9b7c2a
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyBefore.java
@@ -0,0 +1,16 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.Aspect;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Aspect(target = MyBeforeAspect.class)
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface MyBefore {
+
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyBeforeAspect.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyBeforeAspect.java
new file mode 100644
index 00000000..9a704feb
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyBeforeAspect.java
@@ -0,0 +1,33 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.AspectProvider;
+import io.avaje.inject.aop.Invocation;
+import io.avaje.inject.aop.MethodInterceptor;
+import jakarta.inject.Singleton;
+
+import java.lang.reflect.Method;
+
+@Singleton
+public class MyBeforeAspect implements AspectProvider<MyBefore> {
+
+  @Override
+  public MethodInterceptor interceptor(Method method, MyBefore aspectAnnotation) {
+    return new Intercept(method);
+  }
+
+  static final class Intercept implements MethodInterceptor {
+    final Method method;
+
+    Intercept(Method method) {
+      this.method = method;
+    }
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      if (method.getName().equals("foo")) {
+        throw new IllegalStateException("Can't call this method");
+      }
+    }
+  }
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyMultiInvoke.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyMultiInvoke.java
new file mode 100644
index 00000000..0cbc192d
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyMultiInvoke.java
@@ -0,0 +1,14 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.Aspect;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Aspect(target = MyMultiInvokeAspect.class)
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface MyMultiInvoke {
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyMultiInvokeAspect.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyMultiInvokeAspect.java
new file mode 100644
index 00000000..3a5c5354
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyMultiInvokeAspect.java
@@ -0,0 +1,29 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.AspectProvider;
+import io.avaje.inject.aop.Invocation;
+import io.avaje.inject.aop.MethodInterceptor;
+import jakarta.inject.Singleton;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class MyMultiInvokeAspect implements AspectProvider<MyMultiInvoke>, MethodInterceptor {
+
+  public List<Object> results = new ArrayList<>();
+
+  @Override
+  public MethodInterceptor interceptor(Method method, MyMultiInvoke aspectAnnotation) {
+    return this;
+  }
+
+  @Override
+  public void invoke(Invocation invocation) throws Throwable {
+    for (int i = 0; i < 5; i++) {
+      results.add(invocation.invoke());
+    }
+  }
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MySkip.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MySkip.java
new file mode 100644
index 00000000..b8861bfb
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MySkip.java
@@ -0,0 +1,18 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.Aspect;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Test for Aspect that sets result and never invokes underlying method.
+ */
+@Aspect(target = MySkipAspect.class)
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface MySkip {
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MySkipAspect.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MySkipAspect.java
new file mode 100644
index 00000000..810a503b
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MySkipAspect.java
@@ -0,0 +1,23 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.AspectProvider;
+import io.avaje.inject.aop.Invocation;
+import io.avaje.inject.aop.MethodInterceptor;
+import jakarta.inject.Singleton;
+
+import java.lang.reflect.Method;
+
+@Singleton
+public class MySkipAspect implements AspectProvider<MySkip>, MethodInterceptor {
+
+  @Override
+  public MethodInterceptor interceptor(Method method, MySkip aspectAnnotation) {
+    return this;
+  }
+  @Override
+  public void invoke(Invocation invocation) throws Throwable {
+    // just over-write the result, never invoke underlying method
+    invocation.result("my-skip-result");
+  }
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyThrowing.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyThrowing.java
new file mode 100644
index 00000000..3a3f6e46
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyThrowing.java
@@ -0,0 +1,15 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.Aspect;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Aspect(target = MyThrowingAspect.class)
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface MyThrowing {
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyThrowingAspect.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyThrowingAspect.java
new file mode 100644
index 00000000..871058ad
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyThrowingAspect.java
@@ -0,0 +1,24 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.AspectProvider;
+import io.avaje.inject.aop.Invocation;
+import io.avaje.inject.aop.MethodInterceptor;
+import jakarta.inject.Singleton;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class MyThrowingAspect implements AspectProvider<MyThrowing>, MethodInterceptor {
+
+  @Override
+  public MethodInterceptor interceptor(Method method, MyThrowing around) {
+    return this;
+  }
+
+  @Override
+  public void invoke(Invocation invoke) throws Throwable {
+    throw new ArithmeticException("my interceptor throws this");
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyTimed.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyTimed.java
new file mode 100644
index 00000000..f2395b2c
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyTimed.java
@@ -0,0 +1,16 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.Aspect;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Aspect(target = MyTimedAspect.class, ordering = 2000)
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface MyTimed {
+
+  String name() default "";
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyTimedAspect.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyTimedAspect.java
new file mode 100644
index 00000000..39d5bb7b
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/MyTimedAspect.java
@@ -0,0 +1,31 @@
+package org.example.myapp.aspect;
+
+import io.avaje.inject.aop.AspectProvider;
+import io.avaje.inject.aop.Invocation;
+import io.avaje.inject.aop.MethodInterceptor;
+import jakarta.inject.Singleton;
+
+import java.lang.reflect.Method;
+
+@Singleton
+public class MyTimedAspect implements AspectProvider<MyTimed>, MethodInterceptor {
+
+  @Override
+  public MethodInterceptor interceptor(Method method, MyTimed aspectAnnotation) {
+    return this;
+  }
+
+  @Override
+  public void invoke(Invocation invocation) throws Throwable {
+    TraceAspect.add("MyTimedAspect-begin");
+    long start = System.nanoTime();
+    try {
+      invocation.invoke();
+    } finally {
+      long exeNanos = System.nanoTime() - start;
+      String fullName = invocation.method().getName();
+      System.out.println("executed " + fullName + " in " + exeNanos);
+      TraceAspect.add("MyTimedAspect-end");
+    }
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/aspect/TraceAspect.java b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/TraceAspect.java
new file mode 100644
index 00000000..e43763ab
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/aspect/TraceAspect.java
@@ -0,0 +1,24 @@
+package org.example.myapp.aspect;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TraceAspect {
+
+  static List<String> trace = new ArrayList<>();
+
+  static void add(String value) {
+    trace.add(value);
+  }
+
+  public static void clear() {
+    trace.clear();
+  }
+
+  public static List<String> obtain() {
+    List<String> copy = new ArrayList<>(trace);
+    trace.clear();
+    return copy;
+  }
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/r4j/MyExample.java b/blackbox-test-inject/src/main/java/org/example/myapp/r4j/MyExample.java
new file mode 100644
index 00000000..79ed0ec2
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/r4j/MyExample.java
@@ -0,0 +1,37 @@
+package org.example.myapp.r4j;
+
+import jakarta.inject.Singleton;
+import org.example.myapp.resilience4j.MyRetry;
+
+@Singleton
+public class MyExample {
+
+  public int barfCounter;
+  public int retryWithFallbackCounter;
+
+  @MyRetry
+  public void doingItWithRetry() {
+    barfCounter++;
+    throw new IllegalArgumentException("no");
+  }
+
+  @MyRetry(fallbackMethod = "myFallback", maxAttempts = 5)
+  public String retryWithFallback() {
+    retryWithFallbackCounter++;
+    throw new IllegalArgumentException("no");
+  }
+
+  public String myFallback() {
+    return "fallback-response";
+  }
+
+  @MyRetry(fallbackMethod = "fallbackRetry2")
+  public String retry2(String param0, int param1) {
+    retryWithFallbackCounter++;
+    throw new IllegalArgumentException("Retry2Fail[" + param0 + "," + param1 + "]");
+  }
+
+  public String fallbackRetry2(String param0, int param1, Throwable e) {
+    return "fallbackRetry2-" + param0 + ":" + param1 + ":" + e.getMessage();
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/resilience4j/MyRetry.java b/blackbox-test-inject/src/main/java/org/example/myapp/resilience4j/MyRetry.java
new file mode 100644
index 00000000..5fa0ea3d
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/resilience4j/MyRetry.java
@@ -0,0 +1,18 @@
+package org.example.myapp.resilience4j;
+
+import io.avaje.inject.aop.Aspect;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Aspect(target = RetryProvider.class)
+public @interface MyRetry {
+
+  String fallbackMethod() default "";
+
+  int maxAttempts() default -1;
+
+  String waitDuration() default "";
+
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/resilience4j/RetryProvider.java b/blackbox-test-inject/src/main/java/org/example/myapp/resilience4j/RetryProvider.java
new file mode 100644
index 00000000..f7e6eab4
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/resilience4j/RetryProvider.java
@@ -0,0 +1,95 @@
+package org.example.myapp.resilience4j;
+
+import io.avaje.inject.aop.AspectProvider;
+import io.avaje.inject.aop.Fallback;
+import io.avaje.inject.aop.Invocation;
+import io.avaje.inject.aop.MethodInterceptor;
+import io.github.resilience4j.retry.Retry;
+import io.github.resilience4j.retry.RetryConfig;
+import jakarta.inject.Singleton;
+
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.time.format.DateTimeParseException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+@Singleton
+public class RetryProvider implements AspectProvider<MyRetry> {
+
+  @Override
+  public MethodInterceptor interceptor(Method method, MyRetry retryAnnotation) {
+    // build and return the Retry MethodInterceptor
+    RetryConfig.Builder<Object> builder = RetryConfig.custom();
+
+    if (retryAnnotation.maxAttempts() > 0) {
+      builder.maxAttempts(retryAnnotation.maxAttempts());
+    }
+    if (!retryAnnotation.waitDuration().isEmpty()) {
+      try {
+        Duration duration = Duration.parse(retryAnnotation.waitDuration());
+        builder.waitDuration(duration);
+      } catch (DateTimeParseException e) {
+        e.printStackTrace();
+      }
+
+    }
+    RetryConfig retryConfig = RetryConfig.ofDefaults();
+    RetryConfig config = builder.build();
+    io.github.resilience4j.retry.Retry retry = io.github.resilience4j.retry.Retry.of("id", config);
+
+    Fallback fallback = fallback(method, retryAnnotation);
+
+    return fallback == null ?  new RetryInterceptor(retry) : new RetryFallbackInterceptor(retry, fallback);
+  }
+
+  private Fallback fallback(Method method, MyRetry retryAnnotation) {
+    if (!retryAnnotation.fallbackMethod().isEmpty()) {
+      try {
+        return Fallback.find(retryAnnotation.fallbackMethod(), method);
+      } catch (NoSuchMethodException e) {
+        throw new IllegalStateException(e);
+      }
+    }
+    return null;
+  }
+
+
+  public static AtomicInteger testCounter = new AtomicInteger();
+
+  static class RetryInterceptor implements MethodInterceptor {
+
+    private final io.github.resilience4j.retry.Retry retry;
+
+    public RetryInterceptor(Retry retry) {
+      this.retry = retry;
+    }
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      testCounter.incrementAndGet();
+      Retry.decorateCheckedSupplier(retry, invocation::invoke)
+        .apply();
+    }
+  }
+
+  static class RetryFallbackInterceptor implements MethodInterceptor {
+
+    private final io.github.resilience4j.retry.Retry retry;
+    private final Fallback fallback;
+
+    public RetryFallbackInterceptor(Retry retry, Fallback fallback) {
+      this.retry = retry;
+      this.fallback = fallback;
+    }
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      testCounter.incrementAndGet();
+      Retry.decorateCheckedSupplier(retry, invocation::invoke)
+        .recover((throwable) -> (Supplier<Object>) () -> fallback.invoke(invocation, throwable))
+        .apply();
+    }
+  }
+
+}
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/HelloServiceTest.java b/blackbox-test-inject/src/test/java/org/example/myapp/HelloServiceTest.java
index 03f9bfce..be94c6da 100644
--- a/blackbox-test-inject/src/test/java/org/example/myapp/HelloServiceTest.java
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/HelloServiceTest.java
@@ -1,9 +1,16 @@
 package org.example.myapp;
 
 import io.avaje.inject.BeanScope;
+import io.avaje.inject.aop.InvocationException;
+import org.example.myapp.aspect.MyAroundAspect;
+import org.example.myapp.aspect.MyMultiInvokeAspect;
 import org.junit.jupiter.api.Test;
 
-import static org.junit.jupiter.api.Assertions.*;
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
 class HelloServiceTest {
 
@@ -11,11 +18,87 @@ class HelloServiceTest {
    * No mocking, no use of <code>@TestScope</code> so just like main.
    */
   @Test
-  void basic() {
+  void basic() throws IOException {
     // just wire everything with no test scope, mocks etc
     BeanScope beanScope = BeanScope.newBuilder().build();
 
     HelloService helloService = beanScope.get(HelloService.class);
+    assertEquals("bazz foo 42", helloService.bazz("foo", 42));
     assertEquals("hello+AppHelloData", helloService.hello());
   }
+
+  @Test
+  void skip() {
+    // just wire everything with no test scope, mocks etc
+    BeanScope beanScope = BeanScope.newBuilder().build();
+
+    HelloService helloService = beanScope.get(HelloService.class);
+    String result = helloService.skipExample("echo me back");
+    assertEquals("my-skip-result", result);
+  }
+
+  @Test
+  void aspect_multiInvoke() {
+    BeanScope beanScope = BeanScope.newBuilder().build();
+
+    MyMultiInvokeAspect aspect = beanScope.get(MyMultiInvokeAspect.class);
+    HelloService helloService = beanScope.get(HelloService.class);
+
+    int result = helloService.counter();
+    assertThat(result).isEqualTo(4);
+    assertThat(aspect.results).containsExactly(0, 1, 2, 3, 4);
+  }
+
+  @Test
+  void aspect_checkedRunnable() throws IOException, ClassNotFoundException {
+    BeanScope beanScope = BeanScope.newBuilder().build();
+
+    MyAroundAspect aspect = beanScope.get(MyAroundAspect.class);
+    HelloService helloService = beanScope.get(HelloService.class);
+
+    helloService.justRun("foo", 23, 45);
+    assertThat(helloService.justRunResult()).isEqualTo("foo 23 45");
+    assertThat(aspect.trace()).containsExactly("justRun args:[foo, 23, 45]");
+
+    helloService.justRun("bar", 98, 99);
+    assertThat(helloService.justRunResult()).isEqualTo("bar 98 99");
+    assertThat(aspect.trace()).containsExactly("justRun args:[bar, 98, 99]");
+  }
+
+  @Test
+  void aspect_throwingUndeclaredException_expect_InvocationException() {
+    BeanScope beanScope = BeanScope.newBuilder().build();
+
+    HelloService helloService = beanScope.get(HelloService.class);
+
+    assertThatThrownBy(helloService::thisWillThrow)
+      .isInstanceOf(InvocationException.class)
+      .hasCauseInstanceOf(ArithmeticException.class)
+      .hasMessage("java.lang.ArithmeticException: my interceptor throws this")
+      .hasRootCauseMessage("my interceptor throws this");
+  }
+
+  @Test
+  void aspect_appCodeThrowingUnchecked_expect_InvocationException() {
+    BeanScope beanScope = BeanScope.newBuilder().build();
+
+    HelloService helloService = beanScope.get(HelloService.class);
+
+    assertThatThrownBy(helloService::appCodeThrowsUnchecked)
+      .isInstanceOf(InvocationException.class)
+      .hasCauseInstanceOf(IllegalArgumentException.class)
+      .hasRootCauseMessage("appCodeUnchecked");
+  }
+
+  @Test
+  void aspect_appCodeThrowingDeclared_expect_declaredException() {
+    BeanScope beanScope = BeanScope.newBuilder().build();
+
+    HelloService helloService = beanScope.get(HelloService.class);
+
+    assertThatThrownBy(helloService::appCodeThrowsDeclared)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("appCodeDeclared");
+  }
+
 }
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/OtherServiceProxyTest.java b/blackbox-test-inject/src/test/java/org/example/myapp/OtherServiceProxyTest.java
new file mode 100644
index 00000000..4ea81c48
--- /dev/null
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/OtherServiceProxyTest.java
@@ -0,0 +1,28 @@
+package org.example.myapp;
+
+import io.avaje.inject.BeanScope;
+import org.example.myapp.aspect.TraceAspect;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OtherServiceProxyTest {
+
+  @Test
+  void proxyMethodInvocation() {
+
+    BeanScope beanScope = BeanScope.newBuilder().build();
+    OtherService otherService = beanScope.get(OtherService.class);
+
+    String result = otherService.other("foo", 42);
+    assertThat(result).isEqualTo("other foo 42");
+
+    TraceAspect.clear();
+    otherService.multi();
+
+    List<String> trace = TraceAspect.obtain();
+    assertThat(trace).containsExactly("MyTimedAspect-begin", "MyAroundAspect-begin", "MyAroundAspect-end", "MyTimedAspect-end");
+  }
+}
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/r4j/Resilience4J_retry_Test.java b/blackbox-test-inject/src/test/java/org/example/myapp/r4j/Resilience4J_retry_Test.java
new file mode 100644
index 00000000..0a37ac76
--- /dev/null
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/r4j/Resilience4J_retry_Test.java
@@ -0,0 +1,59 @@
+package org.example.myapp.r4j;
+
+import io.avaje.inject.BeanScope;
+import io.avaje.inject.aop.InvocationException;
+import org.example.myapp.resilience4j.RetryProvider;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class Resilience4J_retry_Test {
+
+  @Test
+  void retry_test() {
+
+    MyExample myExample;
+    try (BeanScope beanScope = BeanScope.newBuilder().build()) {
+      myExample = beanScope.get(MyExample.class);
+
+      assertThatThrownBy(myExample::doingItWithRetry)
+        .isInstanceOf(InvocationException.class)
+        .hasCauseInstanceOf(IllegalArgumentException.class)
+        .hasMessage("java.lang.IllegalArgumentException: no");
+    }
+
+    assertThat(myExample.barfCounter).isEqualTo(3);
+    assertThat(RetryProvider.testCounter.getAndSet(0)).isEqualTo(1);
+  }
+
+  @Test
+  void retry_fallback_test() {
+
+    MyExample myExample;
+    try (BeanScope beanScope = BeanScope.newBuilder().build()) {
+      myExample = beanScope.get(MyExample.class);
+
+      String result = myExample.retryWithFallback();
+      assertThat(result).isEqualTo("fallback-response");
+    }
+
+    assertThat(myExample.retryWithFallbackCounter).isEqualTo(5);
+    assertThat(RetryProvider.testCounter.getAndSet(0)).isEqualTo(1);
+  }
+
+  @Test
+  void retry_fallback_throwable_test() {
+
+    MyExample myExample;
+    try (BeanScope beanScope = BeanScope.newBuilder().build()) {
+      myExample = beanScope.get(MyExample.class);
+
+      String result = myExample.retry2("foo", 45);
+      assertThat(result).isEqualTo("fallbackRetry2-foo:45:Retry2Fail[foo,45]");
+    }
+
+    assertThat(myExample.retryWithFallbackCounter).isEqualTo(3);
+    assertThat(RetryProvider.testCounter.getAndSet(0)).isEqualTo(1);
+  }
+}
diff --git a/inject-test/pom.xml b/inject-test/pom.xml
index e1ceea07..1189c962 100644
--- a/inject-test/pom.xml
+++ b/inject-test/pom.xml
@@ -4,7 +4,7 @@
   <parent>
     <groupId>io.avaje</groupId>
     <artifactId>avaje-inject-parent</artifactId>
-    <version>6.17</version>
+    <version>6.18-SNAPSHOT</version>
   </parent>
 
   <artifactId>avaje-inject-test</artifactId>
diff --git a/inject/src/test/java/io/avaje/inject/aop/InvocationCallTest.java b/inject/src/test/java/io/avaje/inject/aop/InvocationCallTest.java
new file mode 100644
index 00000000..f8122519
--- /dev/null
+++ b/inject/src/test/java/io/avaje/inject/aop/InvocationCallTest.java
@@ -0,0 +1,117 @@
+package io.avaje.inject.aop;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InvocationCallTest {
+
+  private final List<String> trace = new ArrayList<>();
+  private final List<Method> methods = new ArrayList<>();
+  private final List<Object> results = new ArrayList<>();
+  private final List<Object> args = new ArrayList<>();
+
+  private final Method doStuffMethod;
+  private final Object myArg = new Object();
+  private Object usedArg;
+
+  InvocationCallTest() {
+    try {
+      doStuffMethod = InvocationCallTest.class.getDeclaredMethod("doStuff", Object.class);
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  String doStuff(Object arg) {
+    usedArg = arg;
+    trace.add("doStuff");
+    return "hello";
+  }
+
+  @Test
+  void single() throws Throwable {
+    Invocation.Build<String> call = new Invocation.Call<>(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg);
+
+    new Inter0().invoke(call);
+
+    assertThat(trace).containsExactly("b-Inter0", "doStuff", "a-Inter0");
+    assertThat(usedArg).isSameAs(myArg);
+    assertThat(methods).containsExactly(doStuffMethod);
+    assertThat(results).containsExactly("hello");
+    assertThat(args).containsExactly(myArg);
+  }
+
+  @Test
+  void wrapped() throws Throwable {
+    Invocation.Build<String> call = new Invocation.Call<>(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg)
+      .wrap(new Inter0());
+
+    new Inter1().invoke(call);
+
+    assertThat(trace).containsExactly("b-Inter1", "b-Inter0", "doStuff", "a-Inter0", "a-Inter1");
+    assertThat(usedArg).isSameAs(myArg);
+    assertThat(methods).containsExactly(doStuffMethod, doStuffMethod);
+    assertThat(results).containsExactly("hello", "hello");
+    assertThat(args).containsExactly(myArg, myArg);
+  }
+
+  @Test
+  void wrapped_wrapped() throws Throwable {
+    Invocation.Build<String> call = new Invocation.Call<>(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg)
+      .wrap(new Inter0())
+      .wrap(new Inter1());
+
+    new Inter2().invoke(call);
+
+    assertThat(trace).containsExactly("b-Inter2", "b-Inter1", "b-Inter0", "doStuff", "a-Inter0", "a-Inter1", "a-Inter2");
+    assertThat(usedArg).isSameAs(myArg);
+    assertThat(methods).containsExactly(doStuffMethod, doStuffMethod, doStuffMethod);
+    assertThat(results).containsExactly("hello", "hello", "hello");
+    assertThat(args).containsExactly(myArg, myArg, myArg);
+  }
+
+  class Inter0 implements MethodInterceptor {
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      args.addAll(Arrays.asList(invocation.arguments()));
+      methods.add(invocation.method());
+      trace.add("b-Inter0");
+      results.add(invocation.invoke());
+      trace.add("a-Inter0");
+    }
+  }
+
+  class Inter1 implements MethodInterceptor {
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      args.addAll(Arrays.asList(invocation.arguments()));
+      methods.add(invocation.method());
+      trace.add("b-Inter1");
+      results.add(invocation.invoke());
+      trace.add("a-Inter1");
+    }
+  }
+
+  class Inter2 implements MethodInterceptor {
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      args.addAll(Arrays.asList(invocation.arguments()));
+      methods.add(invocation.method());
+      trace.add("b-Inter2");
+      results.add(invocation.invoke());
+      trace.add("a-Inter2");
+    }
+  }
+}
diff --git a/inject/src/test/java/io/avaje/inject/aop/InvocationFallbackRawTest.java b/inject/src/test/java/io/avaje/inject/aop/InvocationFallbackRawTest.java
new file mode 100644
index 00000000..9b3bf2c4
--- /dev/null
+++ b/inject/src/test/java/io/avaje/inject/aop/InvocationFallbackRawTest.java
@@ -0,0 +1,93 @@
+package io.avaje.inject.aop;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InvocationFallbackRawTest {
+
+  private final MethodHandles.Lookup lookup = MethodHandles.lookup();
+
+  private final List<String> trace = new ArrayList<>();
+  private final List<Method> methods = new ArrayList<>();
+  private final List<Object> results = new ArrayList<>();
+  private final List<Object> args = new ArrayList<>();
+
+  private final Method doStuffMethod;
+  private final Object myArg = new Object();
+  private Object usedArg;
+  private Object fallbackArg;
+
+  InvocationFallbackRawTest() {
+    try {
+      doStuffMethod = InvocationFallbackRawTest.class.getDeclaredMethod("doStuff", Object.class);
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  String doStuff(Object arg) {
+    usedArg = arg;
+    trace.add("doStuff");
+    return "hello";
+  }
+
+  String fallbackDoStuff(Object arg) {
+    fallbackArg = arg;
+    trace.add("fallbackDoStuff");
+    return "fallback-hello";
+  }
+
+  @Test
+  void invokeFallback() throws Throwable {
+    Invocation.Build<String> call = new Invocation.Call<>(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg);
+
+    MyInterceptor myInterceptor = new MyInterceptor();
+
+    // fallback via reflection Method
+    Method fallbackMethod = InvocationFallbackRawTest.class.getDeclaredMethod("fallbackDoStuff", Object.class);
+    myInterceptor.invokeFallback(call, fallbackMethod);
+
+    assertThat(fallbackArg).isSameAs(myArg);
+    assertThat(trace).containsExactly("fallbackDoStuff");
+
+    fallbackArg = null;
+    // fallback via MethodHandle
+    MethodHandle handle = lookup.findVirtual(InvocationFallbackRawTest.class, "fallbackDoStuff", MethodType.methodType(String.class, Object.class));
+    myInterceptor.invokeFallbackViaHandle(call, handle);
+
+    assertThat(fallbackArg).isSameAs(myArg);
+    assertThat(trace).containsExactly("fallbackDoStuff", "fallbackDoStuff");
+  }
+
+  static class MyInterceptor {
+
+    void invokeFallback(Invocation call, Method fallbackMethod) throws InvocationTargetException, IllegalAccessException {
+      fallbackMethod.invoke(call.instance(), call.arguments());
+    }
+
+    void invokeFallbackViaHandle(Invocation call, MethodHandle handle) throws Throwable {
+      Object[] args = call.arguments();
+      switch (args.length) {
+        case 1:
+          call.result(handle.invoke(call.instance(), args[0]));
+          break;
+        case 2:
+          call.result(handle.invoke(call.instance(), args[0], args[1]));
+          break;
+
+      }
+
+    }
+  }
+
+}
diff --git a/inject/src/test/java/io/avaje/inject/aop/InvocationFallbackTest.java b/inject/src/test/java/io/avaje/inject/aop/InvocationFallbackTest.java
new file mode 100644
index 00000000..0e1654bb
--- /dev/null
+++ b/inject/src/test/java/io/avaje/inject/aop/InvocationFallbackTest.java
@@ -0,0 +1,106 @@
+package io.avaje.inject.aop;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InvocationFallbackTest {
+
+  private final MethodHandles.Lookup lookup = MethodHandles.lookup();
+
+  private final List<String> trace = new ArrayList<>();
+  private final List<Method> methods = new ArrayList<>();
+  private final List<Object> results = new ArrayList<>();
+  private final List<Object> args = new ArrayList<>();
+
+  private final Method doStuffMethod;
+  private final Object myArg = new Object();
+  private boolean throwOnDoStuff;
+  private Object usedArg;
+  private Object fallbackArg;
+
+  InvocationFallbackTest() {
+    try {
+      doStuffMethod = InvocationFallbackTest.class.getDeclaredMethod("doStuff", Object.class);
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  String doStuff(Object arg) {
+    usedArg = arg;
+    trace.add("doStuff");
+    if (throwOnDoStuff) {
+      throw new RuntimeException("nope");
+    }
+    return "hello";
+  }
+
+  public String fallbackDoStuff(Object arg) {
+    fallbackArg = arg;
+    trace.add("fallbackDoStuff");
+    return "fallback-hello";
+  }
+
+  @Test
+  void invokeWithFallback() throws Throwable {
+    throwOnDoStuff = true;
+
+    Invocation.Build<String> call = new Invocation.Call<>(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg);
+
+    Fallback fallback = Fallback.find("fallbackDoStuff", doStuffMethod);
+    MyInterceptor myInterceptor = new MyInterceptor(fallback);
+
+    // invoke the original method
+    myInterceptor.invoke(call);
+
+    assertThat(trace).containsExactly("doStuff", "fallbackDoStuff");
+    assertThat(usedArg).isSameAs(myArg);
+    assertThat(fallbackArg).isSameAs(myArg);
+    assertThat(call.finalResult()).isEqualTo("fallback-hello");
+  }
+
+  @Test
+  void invokeWithNoFallback() throws Throwable {
+    throwOnDoStuff = false;
+
+    Invocation.Build<String> call = new Invocation.Call<>(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg);
+
+    Fallback fallback = Fallback.find("fallbackDoStuff", doStuffMethod);
+    MyInterceptor myInterceptor = new MyInterceptor(fallback);
+
+    // invoke the original method
+    myInterceptor.invoke(call);
+
+    assertThat(trace).containsExactly("doStuff");
+    assertThat(usedArg).isSameAs(myArg);
+    assertThat(fallbackArg).isNull();
+    assertThat(call.finalResult()).isEqualTo("hello");
+  }
+
+  static class MyInterceptor implements MethodInterceptor {
+
+    final Fallback fallback;
+
+    MyInterceptor(Fallback fallback) {
+      this.fallback = fallback;
+    }
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      try {
+        invocation.invoke();
+      } catch (Throwable e) {
+        fallback.invoke(invocation, e);
+      }
+    }
+  }
+
+}
diff --git a/inject/src/test/java/io/avaje/inject/aop/InvocationRunTest.java b/inject/src/test/java/io/avaje/inject/aop/InvocationRunTest.java
new file mode 100644
index 00000000..a5efed2d
--- /dev/null
+++ b/inject/src/test/java/io/avaje/inject/aop/InvocationRunTest.java
@@ -0,0 +1,112 @@
+package io.avaje.inject.aop;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InvocationRunTest {
+
+  private final List<String> trace = new ArrayList<>();
+  private final List<Method> methods = new ArrayList<>();
+  private final List<Object> args = new ArrayList<>();
+
+  private final Method doStuffMethod;
+  private final Object myArg = new Object();
+  private Object usedArg;
+
+  InvocationRunTest() {
+    try {
+      doStuffMethod = InvocationRunTest.class.getDeclaredMethod("doStuff", Object.class);
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  void doStuff(Object arg) {
+    usedArg = arg;
+    trace.add("doStuff");
+  }
+
+  @Test
+  void single() throws Throwable {
+    Invocation.Build<Void> call = new Invocation.Run(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg);
+
+    new Inter0().invoke(call);
+
+    assertThat(trace).containsExactly("b-Inter0", "doStuff", "a-Inter0");
+    assertThat(usedArg).isSameAs(myArg);
+    assertThat(methods).containsExactly(doStuffMethod);
+    assertThat(args).containsExactly(myArg);
+  }
+
+  @Test
+  void wrapped() throws Throwable {
+    Invocation.Build<Void> call = new Invocation.Run(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg)
+      .wrap(new Inter0());
+
+    new Inter1().invoke(call);
+
+    assertThat(trace).containsExactly("b-Inter1", "b-Inter0", "doStuff", "a-Inter0", "a-Inter1");
+    assertThat(usedArg).isSameAs(myArg);
+    assertThat(methods).containsExactly(doStuffMethod, doStuffMethod);
+    assertThat(args).containsExactly(myArg, myArg);
+  }
+
+  @Test
+  void wrapped_wrapped() throws Throwable {
+    Invocation.Build<Void> call = new Invocation.Run(() -> this.doStuff(myArg))
+      .with(this, doStuffMethod, myArg)
+      .wrap(new Inter0())
+      .wrap(new Inter1());
+
+    new Inter2().invoke(call);
+
+    assertThat(trace).containsExactly("b-Inter2", "b-Inter1", "b-Inter0", "doStuff", "a-Inter0", "a-Inter1", "a-Inter2");
+    assertThat(usedArg).isSameAs(myArg);
+    assertThat(methods).containsExactly(doStuffMethod, doStuffMethod, doStuffMethod);
+    assertThat(args).containsExactly(myArg, myArg, myArg);
+  }
+
+  class Inter0 implements MethodInterceptor {
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      args.addAll(Arrays.asList(invocation.arguments()));
+      methods.add(invocation.method());
+      trace.add("b-Inter0");
+      invocation.invoke();
+      trace.add("a-Inter0");
+    }
+  }
+
+  class Inter1 implements MethodInterceptor {
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      args.addAll(Arrays.asList(invocation.arguments()));
+      methods.add(invocation.method());
+      trace.add("b-Inter1");
+      invocation.invoke();
+      trace.add("a-Inter1");
+    }
+  }
+
+  class Inter2 implements MethodInterceptor {
+
+    @Override
+    public void invoke(Invocation invocation) throws Throwable {
+      args.addAll(Arrays.asList(invocation.arguments()));
+      methods.add(invocation.method());
+      trace.add("b-Inter2");
+      invocation.invoke();
+      trace.add("a-Inter2");
+    }
+  }
+}