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/serviceis 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/contractdeclaresOrderLookup, in billing's language, returning billing's types. It must not beOrderingServicere-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-gatewayimplementsOrderLookupby callingordering-user-side-serviceand 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 againstordering/domain/contract. Gradle'simplementationconfiguration 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
@Transactionalspanning 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/apiconstructs 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
- Hexagonal architecture reference implementation — Java — the tree and dependency rules this seam sits inside.
- Domain layer — where the consumer's driven port is declared and why it must be in the consumer's vocabulary.
- Infrastructure layer — the gateway is a driven adapter and obeys that layer's granularity rules.
- User-side adapters & assemblies — the service adapter's siblings, and the assembly that wires this together.
- Modulith & microservices — what this seam is for.
- DDD — context mapping; the consumer-owned port is an anticorruption layer by another name.
- Coupling & cohesion — why the port is named after the need, not the provider.