Harm De Weirdt
Harm De Weirdt

Reputation: 31

Archunit package A can't access package B, except subpackage A.X can access B.Y

We are in the process of refactoring our code to a hexagonal architecture, with each domain in the code being a separate gradle module. Currently all the code is one module with a package for each domain, and modules interacting with eachother freely. We want to first get the architectural boundaries correct on the package level before extracting each domain package to a domain module. To do this we want to enforce the architecture using archunit.

Inside each domain package, I've reconstructed the rules of our new architecture using layers:

    @ParameterizedTest(name = "The hex architecture should be respected in module {0}.")
    @MethodSource("provideDomains")
    void hexArchRespectedInEveryModule(String pkg) {
        layeredArchitecture()
            .consideringOnlyDependenciesInAnyPackage(ROOT_DOTDOT)
            .withOptionalLayers(true)
            .layer("api").definedBy(ROOT_DOT + pkg + ".api..")
            .layer("usecases").definedBy(ROOT_DOT + pkg + ".usecases..")
            .layer("domain").definedBy(ROOT_DOT + pkg + ".domain..")
            .layer("incoming infra").definedBy(ROOT_DOT + pkg + ".infrastructure.incoming..")
            .layer("outgoing infra").definedBy(ROOT_DOT + pkg + ".infrastructure.outgoing..")
            .layer("vocabulary").definedBy(ROOT_DOT + pkg + ".vocabulary..")
            .whereLayer("incoming infra").mayOnlyAccessLayers("api", "vocabulary")
            .whereLayer("usecases").mayOnlyAccessLayers("api", "domain", "vocabulary")
            .whereLayer("domain").mayOnlyAccessLayers("domain", "vocabulary")
            .whereLayer("outgoing infra").mayOnlyAccessLayers("domain", "vocabulary")
            .check(new ClassFileImporter().importPackages(toPackage(pkg)));
    }

I am however failing to write the check that a domain can only access another domain by calling it from an outgoing infra adapter, using the API provided by the other domain. For that case I'd have to write something that says: "no packages in domain A may call domain B, except for the packages in outgoing infra of A that can call the API subpackage in B.

I've tried several different variations, eventually thinking that this would be correct, but violations still don't seem to get caught:

    @ParameterizedTest(name = "Module {0} should only depend on itself, except outgoing infra on other apis")
    @MethodSource("provideDomains")
    void domainModuleBoundariesAreRespected(String module) {
        noClasses()
            .that().resideInAPackage(includeSubPackages(toPackage(module)))
            .should().dependOnClassesThat().resideInAnyPackage(includeSubPackages(getPackagesOtherThan(module)))
            .allowEmptyShould(true)
            .check(allButOutgoingInfra);
        classes()
            .that().resideInAPackage(outGoingInfra(toPackage(module)))
            .should().dependOnClassesThat().resideInAnyPackage(toApi(getPackagesOtherThan(module)))
            .allowEmptyShould(true)
            .check(allClasses);
    }

There's some helper static methods I've added that add the ".." to get subpackages, or add a suffix to a package to indicate a subpackage. "getPackagesOtherThan(module)" returns a list of the other domains' packages.

I found this question but my problem seems different because I have two subpackages in relation to eachother, not just one.

Upvotes: 3

Views: 602

Answers (1)

pete83
pete83

Reputation: 989

I would actually use the "slices" API for this use case (see https://www.archunit.org/userguide/html/000_Index.html#_slices). I.e. the same thing that also allows you to ("vertically") slice your application by domain and assert that these slices are cycle-free. The easiest way would be if you have a very consistent code structure, where you can just match your domain packages with a simple package pattern, like com.myapp.(*).. where your domains are something like com.myapp.customer.., com.myapp.shop.., etc. Otherwise, you can always highly customize this by using SliceAssignment (compare the user guide link above).

In any case, once you create your slices by domain, you can define a custom ArchCondition on them:

slices()
  .matching("com.myapp.(*)..")
  .as("Domain Modules")
  .namingSlices("Module[$1]")
  .should(only_depend_on_each_other_through_allowed_adapters())

// ...

ArchCondition<Slice> only_depend_on_each_other_through_allowed_adapters() {
  return new ArchCondition<Slice>("only depend on each other through allowed adapters") {
    final PackageMatcher validOutgoingAdapter = PackageMatcher.of("..outgoing..");
    final PackageMatcher validIncomingAdapter = PackageMatcher.of("..incoming..");
    final Map<JavaClass, Slice> classesToModules = new HashMap<>();

    @Override
    public void init(Collection<Slice> allModules) {
      // create some reverse lookup so we can easily find the module of each class
      allModules.forEach(module -> module.forEach(clazz -> classesToModules.put(clazz, module)));
    }

    @Override
    public void check(Slice module, ConditionEvents events) {
      module.getDependenciesFromSelf().stream()
          // The following are dependencies to other modules
          .filter(it -> classesToModules.containsKey(it.getTargetClass()))
          // The following violate either the outgoing or incoming adapter rule
          .filter(it ->
              !validOutgoingAdapter.matches(it.getOriginClass().getPackageName()) ||
                  !validIncomingAdapter.matches(it.getTargetClass().getPackageName())
          )
          .forEach(dependency -> events.add(SimpleConditionEvent.violated(dependency,
              String.format("%s depends on %s in an illegal way: %s",
                  module, classesToModules.get(dependency.getTargetClass()), dependency.getDescription()))));
    }
  };

This might not exactly match your needs (e.g. maybe you need to do more precise checks on how the incoming/outgoing adapter should look like), but I hope it helps to get you started.

Upvotes: 2

Related Questions