rgoussu@goussu: ~/library/java/hexagonal-reference-implementation
~/library/java/hexagonal-reference-implementation cat module-composition.md

Module composition — the in-process service seam

# How two modules of the modulith meet in Java — a consumer-owned driven port implemented by an infra gateway that delegates to the peer's user-side/service adapter, and the build rules that keep it the only edge.

Conceptsaved 2026-08-14 #hexagonal-architecture#architecture#java#modulith#modules#composition#ports

Overview

Everything the modulith buys rests on one rule: modules meet at user-side/service, and nowhere else. When billing needs something from ordering, billing declares a driven port in its own vocabulary, implements that port with a small gateway in billing/infra/, and that gateway is the only class in the system that knows ordering exists. ordering offers itself through an ordinary driving adapter — one that happens to speak Java method calls instead of HTTP. This note is the Java mechanics of that seam and the build rules that hold it.

Key points

  • user-side/service is a driving adapter, not a domain artifact. It sits beside the REST resources and the message consumers because it is the same kind of thing: something outside the hexagon calling in. Its transport just happens to be the JVM call stack.
  • The consumer owns the port and names it. billing/domain/contract declares OrderLookup, in billing's language, returning billing's types. It must not be OrderingService re-exported — a port named after the provider is the coupling the layout exists to prevent.
  • The gateway lives in the consumer's infra/. billing/infra/ordering-gateway implements OrderLookup by calling ordering-user-side-service and mapping its DTOs into billing's. It is a driven adapter in every respect, including how it is tested and how it is swapped.
  • The service module publishes its own interface and DTOs. Peers program against OrderingService + its records, never against ordering/domain/contract. Gradle's implementation configuration makes that a compile-time fact rather than a rule people remember.
  • The service adapter maps, it does not decide. It translates its own DTOs into the module's commands, pushes them through the module's mediator, and maps results back — the identical job the REST adapter does, minus serialization.
  • No shared transaction across the seam. Treat a peer call as if it were already remote: no ambient @Transactional spanning two modules, no lazy entity handed across, no assumption of read-your-writes. Code that relies on co-location is the one thing that makes extraction expensive.
  • The assembly wires it. application/api constructs ordering's service adapter and injects it into billing's gateway. Choosing the in-process gateway over an HTTP one is a one-line binding, which is exactly the point.
  • A cycle between two modules is a modelling bug, not a wiring problem. If ordering also needs billing, either the boundary is wrong or the dependency should be inverted into an event the consumer subscribes to.

Details

The four artifacts, and who may see whom

Artifact Lives in Depends on Visible to
OrderLookup (driven port) billing/domain/contract platform/kernel billing's core and infra
OrderingGateway (implements it) billing/infra/ordering-gateway billing's domain/contract, ordering's user-side/service the assembly only
OrderingService (peer-facing interface + DTOs) ordering/user-side/service ordering's domain/contract anyone — it is the module's published in-process API
OrderingServiceAdapter (implements it) ordering/user-side/service ordering's domain/contract the assembly, which constructs it

Interface and implementation share one module because the consumer's gateway needs the interface at compile time and the assembly needs the implementation at wiring time — splitting them buys nothing that the dependency configuration doesn't already give.

Keeping the provider's domain out of the consumer's classpath

Depending on ordering-user-side-service must not transitively expose ordering-domain-contract. Gradle says so directly:

// modules/ordering/user-side/service/build.gradle.kts
dependencies {
    implementation(project(":modules:ordering:domain:contract"))  // NOT api(...)
}

implementation keeps ordering's commands and ports off billing's compile classpath, so an accidental import com.acme.ordering.domain... in billing does not compile. Maven's equivalent is scoping the dependency and letting the enforcer's bannedDependencies state the rule per module — less elegant, same outcome. Either way it is checked by the build, and the ArchUnit slice rule in the reference tree catches what the build graph cannot see.

The flow of one cross-module call

sequenceDiagram
    participant BC as billing/domain/core
    participant BP as OrderLookup (billing port)
    participant BG as billing/infra/ordering-gateway
    participant OS as ordering/user-side/service
    participant OM as ordering mediator
    participant OC as ordering/domain/core

    BC->>BP: lookup(customerId)
    BP->>BG: (bound by the assembly)
    BG->>OS: findOrdersFor(customerId)
    OS->>OM: dispatch(FindOrdersQuery)
    OM->>OC: handle
    OC-->>OS: ordering result
    OS-->>BG: OrderingService DTOs
    BG-->>BC: billing types

Two mappings happen on purpose. Ordering maps its domain results into service DTOs so its internals stay free to change; billing maps those DTOs into its own types so its domain never learns ordering's vocabulary. It is the same double mapping a REST call would do — and that is why replacing the middle with HTTP changes nothing on either end.

Where events fit

Not every cross-module need is a query. When the peer relationship is "tell me when something happened" rather than "answer me now", the seam is an event instead: ordering publishes a domain event through a driven port, and billing consumes it through a user-side/consumers adapter. The rules are unchanged — billing owns the consumer, the event payload is a published contract, and the transport (in-process dispatch today, Kafka after extraction) is an assembly choice. Prefer the event shape whenever the consumer does not need a synchronous answer: it survives extraction without acquiring new failure modes.

Testing the seam

  • Billing's core is tested against an in-memory fake of OrderLookup, like any driven port. It never sees ordering in any form.
  • The gateway is tested against ordering's real service adapter wired to ordering's own in-memory fakes — a fast, honest integration test of the mapping, and the closest thing the layout has to a contract test.
  • Ordering's service adapter gets a plain unit test proving it dispatches the right command and maps the result; it is a driving adapter, so it is tested like one.
  • The assembly carries the ArchUnit rules and one end-to-end test per typology.

Why this is the carve-out seam

When ordering is extracted, billing gains billing/infra/ordering-http — the same OrderLookup, implemented against ordering's user-side/api/contract artifact instead of its user-side/service one — and the assembly binds that instead. Billing's domain, ordering's domain, and every test above the gateway are untouched. The full procedure, including the parts that are genuinely not free (data separation, and re-reading the port's failure semantics), is in modulith & microservices.

Examples

Ordering's side — a published interface and the adapter behind it:

// modules/ordering/user-side/service — the module's in-process API
public interface OrderingService {
    List<OrderSummary> findOrdersFor(String customerId);

    record OrderSummary(String orderId, long totalCents, Instant placedAt) {}
}

public final class OrderingServiceAdapter implements OrderingService {
    private final Mediator mediator;   // ordering's own, from platform/kernel

    public OrderingServiceAdapter(Mediator mediator) {
        this.mediator = mediator;
    }

    @Override
    public List<OrderSummary> findOrdersFor(String customerId) {
        return mediator.dispatch(new FindOrders(new CustomerId(customerId)))
            .orders().stream()
            .map(o -> new OrderSummary(o.id().value(), o.total().cents(), o.placedAt()))
            .toList();
    }
}

Billing's side — a port in billing's language, and the gateway implementing it:

// modules/billing/domain/contract — billing's vocabulary, no mention of ordering
public interface OrderLookup {
    Optional<BillableActivity> activityFor(CustomerRef customer);
}

// modules/billing/infra/ordering-gateway — the only class that knows ordering exists
public final class OrderingGateway implements OrderLookup {
    private final OrderingService ordering;

    public OrderingGateway(OrderingService ordering) {
        this.ordering = ordering;
    }

    @Override
    public Optional<BillableActivity> activityFor(CustomerRef customer) {
        var orders = ordering.findOrdersFor(customer.value());
        return orders.isEmpty()
            ? Optional.empty()
            : Optional.of(BillableActivity.of(orders.size(),
                orders.stream().mapToLong(OrderingService.OrderSummary::totalCents).sum()));
    }
}

The assembly — the one place the two modules are named together:

// application/api — the composition root
@ApplicationScoped
class OrderingCompositionRoot {

    @Produces
    OrderingService orderingService(@Ordering Mediator mediator) {
        return new OrderingServiceAdapter(mediator);
    }

    @Produces
    OrderLookup orderLookup(OrderingService ordering) {
        return new OrderingGateway(ordering);   // ← swap for OrderingHttpClient on extraction
    }
}

Related