Skip to main content

Command Palette

Search for a command to run...

Moving Ports to Core: Enforcing Hexagonal Architecture Boundaries in Java

Updated
13 min readView as Markdown
Moving Ports to Core: Enforcing Hexagonal Architecture Boundaries in Java

TL;DR

  • Ports (facades and repositories) were scattered across api/, core/domain/, and core/domain/ports/ — this PR consolidated them into core/ports/inbound and core/ports/outbound across all four bounded contexts

  • Eliminated 11 of 12 "core importing from api" violations in a single commit

  • Touched 46 files across 2 bounded contexts with zero behavioral changes and zero test failures

  • DTOs relocated from api/ to api/dtos/; domain models moved into core/domain/model/

  • One known violation remains (AuthorAccessPort returns AuthorDTO from api layer) — deferred due to contract refactor cost

  • ArchUnit tests recommended to prevent this class of drift from recurring


The Problem

After three previous refactoring PRs (#265, #266, #267), the codebase had a structural inconsistency that quietly accumulated into real cognitive overhead: port interfaces weren't where hexagonal architecture says they should be.

Specifically:

  • Inbound ports (facades) lived in api/ports/inbound — the API layer — instead of core/ports/inbound

  • Outbound ports (repositories) lived in core/domain or core/domain/ports/outbound instead of core/ports/outbound

  • DTOs were loose in api/ instead of api/dtos/

  • Domain models were flat in core/domain instead of core/domain/model

This created several compounding problems. The package structure was telling developers a lie — that the API layer owned the contracts. Core services were importing from the API layer, creating an inverted dependency that's exactly what hexagonal architecture is designed to prevent. And a simple question like "where's BookFacade?" had no reliable answer.


The Target Architecture

Hexagonal architecture (ports-and-adapters) has one fundamental rule about dependency direction: the core domain defines contracts; everything else implements them.

         ┌─────────────────────────────────────────┐
         │               core/                      │
         │   ┌─────────────────────────────────┐   │
         │   │         domain/model/            │   │
         │   │   entities, value objects,       │   │
         │   │   aggregates                     │   │
         │   └─────────────────────────────────┘   │
         │   ┌─────────────────────────────────┐   │
         │   │         ports/                   │   │
         │   │  inbound/  ←  facades            │   │
         │   │  outbound/ →  repositories,      │   │
         │   │               access ports       │   │
         │   └─────────────────────────────────┘   │
         └─────────────────────────────────────────┘
                  ↑                       ↑
         api/ (inbound adapters)   infrastructure/ (outbound adapters)

Rule: api → core ← infrastructure. Arrows point inward. The core layer never imports from api/ or infrastructure/.

When facades live in api/ports/, this rule breaks down. Every caller of that facade — including core services — ends up importing from the API layer. The dependency arrow flips.


Before vs After

Before (relevant paths only):

library/
├── cataloging/
│   ├── author/
│   │   ├── api/
│   │   │   ├── AuthorDTO.java                    ❌ DTO not in dtos/
│   │   │   └── ports/inbound/AuthorFacade.java   ❌ Inbound port in api
│   │   └── core/
│   │       └── domain/AuthorRepository.java      ❌ Repository in domain
│   └── book/
│       ├── api/ports/
│       │   ├── inbound/BookFacade.java            ❌ Inbound port in api
│       │   └── outbound/AuthorAccessPort.java     ❌ Outbound port in api
│       └── core/domain/Booklist.java             ❌ Model not in model/
└── stacks/
    ├── bookcase/
    │   ├── api/ports/inbound/BookcaseFacade.java  ❌ Inbound port in api
    │   └── core/domain/ports/outbound/            ❌ Inconsistent nesting
    └── shelf/
        └── api/ports/inbound/ShelfFacade.java     ❌ Inbound port in api

After (same paths):

library/
├── cataloging/
│   ├── author/
│   │   ├── api/dtos/AuthorDTO.java                ✅ DTO in dtos/
│   │   └── core/
│   │       ├── ports/inbound/AuthorFacade.java     ✅ Inbound port in core
│   │       ├── ports/outbound/AuthorRepository.java ✅ Repository in ports
│   │       └── domain/model/                       ✅ Models isolated
│   └── book/
│       └── core/port/
│           ├── inbound/BookFacade.java              ✅ Inbound port in core
│           └── outbound/AuthorAccessPort.java       ✅ Outbound port in core
└── stacks/
    ├── bookcase/
    │   └── core/ports/
    │       ├── inbound/BookcaseFacade.java          ✅ Inbound port in core
    │       └── outbound/BookcaseRepository.java     ✅ Flattened nesting
    └── shelf/
        └── core/ports/inbound/ShelfFacade.java      ✅ Inbound port in core

Key rules that changed:

  • Ports live in core/, not api/ — inbound and outbound, always

  • DTOs live in api/dtos/ — not loose in api/

  • Domain models live in core/domain/model/ — distinct from port definitions

  • Outbound ports don't nest inside domain/core/ports/outbound/ is a sibling of core/domain/, not a child


What Changed (Commit Walkthrough)

Commit 63ae13d: refactor: relocate ports and DTOs to enforce hexagonal architecture boundaries

This was a structural-only commit: 10 file renames, 36 import-only modifications, zero logic changes.

Here's the narrative of each move:

I moved AuthorFacade from author.api.ports.inbound to author.core.ports.inbound because an inbound port is a contract the core layer defines — it describes what the outside world can ask of the domain. The API layer implements that contract, not the other way around.

I moved AuthorRepository from author.core.domain to author.core.ports.outbound because a repository interface is an outbound port — a dependency the core layer needs satisfied by infrastructure. Mixing it into the domain package blurs the distinction between what the domain is and what it needs.

I moved BookcaseRepository from stacks.bookcase.core.domain.ports.outbound (three levels deep into domain) to stacks.bookcase.core.ports.outbound to flatten an extra domain/ nesting that added path length without adding semantic value.

I moved AuthorAccessPort and ShelfAccessPort from book.api.ports.outbound to book.core.port.outbound because cross-context access ports are still outbound ports — they're contracts the book core defines to express its dependency on other bounded contexts. Placing them in api/ again inverted the dependency direction.

I moved AuthorDTO from loose author.api to author.api.dtos as part of a broader cleanup: all data transfer objects should have a dedicated home that's separate from adapters, controllers, and port interfaces.

I moved Booklist from book.core.domain to book.core.domain.model to give domain models a dedicated sub-package, separating them from port definitions that were also previously living in core.domain.

With all 10 files relocated, IntelliJ's "Move Class" refactoring auto-updated 36 import statements across 7 CLI commands, 6 application services, 7 infrastructure adapters, 2 web controllers, and 6 test files.


A Concrete Flow Example

The clearest way to see why this matters is to trace a single request end-to-end.

Before — Book creation via CLI:

BookCreateCommands
  ↓ imports api.ports.inbound.BookFacade    ❌ CLI depends on API layer
  ↓ imports api.ports.inbound.AuthorFacade  ❌
BookFacadeAdapter (book.api.adapters)
  ↓ implements api.ports.inbound.BookFacade
  ↓ calls BookService
BookService
  ↓ uses api.ports.outbound.AuthorAccessPort ❌ Core depends on API layer
AuthorAccessPortAdapter
  ↓ calls AuthorFacade (api.ports.inbound)   ❌
AuthorFacadeImpl
  ↓ calls AuthorService
AuthorService
  ↓ uses core.domain.AuthorRepository        ❌ Port buried in domain
AuthorRepositoryImpl → JPA

The dependency violations aren't just cosmetic. BookService importing from api.ports.outbound means the core layer directly depends on the API layer — exactly what hexagonal architecture forbids.

After — the same flow:

BookCreateCommands
  ↓ imports core.port.inbound.BookFacade     ✅ CLI depends on core
  ↓ imports core.ports.inbound.AuthorFacade  ✅
BookFacadeAdapter (book.api.adapters)
  ↓ implements core.port.inbound.BookFacade  ✅ API implements core contract
  ↓ calls BookService
BookService
  ↓ uses core.port.outbound.AuthorAccessPort ✅ Core owns its outbound ports
AuthorAccessPortAdapter (book.api.adapters)
  ↓ implements core.port.outbound.AuthorAccessPort ✅
  ↓ calls AuthorFacade (core.ports.inbound)   ✅
AuthorFacadeImpl (infrastructure.adapters)
  ↓ implements core.ports.inbound.AuthorFacade ✅
  ↓ calls AuthorService
AuthorService
  ↓ uses core.ports.outbound.AuthorRepository ✅ Port in ports package
AuthorRepositoryImpl → JPA

Every in the after-flow represents a layer importing from something closer to the center of the hexagon, never outward. That's what correct dependency direction looks like at the package level.


Tradeoffs & Known Issues

Remaining Violation: AuthorAccessPort DTO Leakage

Smell: core/port/outbound/AuthorAccessPort.java still returns Set<AuthorDTO> — a type that lives in author.api.dtos. This means the Book context's core layer has a compile-time dependency on the Author context's API layer.

public interface AuthorAccessPort {
  AuthorRef findOrCreateAuthor(String namePart, String namePart1);
  Set<AuthorDTO> findByBookId(Long id); // ⚠️ Core depends on api.dtos
}

Decision: Deferred. Fixing the location of AuthorAccessPort took 2 minutes. Fixing its contract would take roughly 2–3 hours: create a Book-context-specific value object (BookAuthorDTO or AuthorMetadata), update AuthorAccessPortAdapter, BookService, and all CLI callers — about 11 files. That's a contract refactor, not a location refactor, and mixing both into one PR would inflate scope and risk.

Options for the fix (follow-up):

// Option A: Return domain objects
Set<Author> findByBookId(Long id);

// Option B: Book-context-specific DTO
Set<BookAuthorDTO> findByBookId(Long id);

// Option C: Value object
Set<AuthorMetadata> findByBookId(Long id);

Package Naming Inconsistency: port vs ports

The Book module uses core/port/ (singular) while Author, Bookcase, and Shelf all use core/ports/ (plural). This was either a typo from an earlier refactor or introduced during this one. It's low-risk but creates unnecessary inconsistency.

Follow-up:

git mv src/main/java/com/penrose/bibby/library/cataloging/book/core/port \
       src/main/java/com/penrose/bibby/library/cataloging/book/core/ports
# Then update imports in 11 files

Verification

1. Compile check:

mvn clean compile -DskipTests

Success = no compilation errors across 46 modified files.

2. Full test suite:

mvn test

Success = all existing tests pass. Since all 36 modifications were import-only, any failure would indicate tests are incorrectly coupled to package structure.

3. Module-scoped test runs (faster feedback):

mvn test -Dtest="com.penrose.bibby.library.cataloging.author.**"
mvn test -Dtest="com.penrose.bibby.library.cataloging.book.**"
mvn test -Dtest="com.penrose.bibby.library.stacks.bookcase.**"
mvn test -Dtest="com.penrose.bibby.library.stacks.shelf.**"
mvn test -Dtest="com.penrose.bibby.cli.command.**"

4. Dependency cycle check:

mvn dependency:tree | grep -i cycle

Success = no output.

5. Formatting:

mvn spotless:check

Success = consistent with the formatting changes applied to AuthorService.java.

6. Spot-check for residual api imports in core:

grep -r "import.*\.api\.ports\." src/main/java/*/core/

Success = returns only AuthorAccessPort (the known exception documented above).

The real long-term verification is to make these rules impossible to accidentally violate. Here's the ArchUnit test class worth adding:

@AnalyzeClasses(packages = "com.penrose.bibby.library")
public class PortLocationTest {

  @ArchTest
  static final ArchRule inbound_ports_must_be_in_core_ports_inbound =
    classes()
      .that().haveSimpleNameEndingWith("Facade")
      .should().resideInAPackage("..core.ports.inbound")
      .because("Inbound ports (facades) must be defined in core layer");

  @ArchTest
  static final ArchRule outbound_ports_must_be_in_core_ports_outbound =
    classes()
      .that().haveSimpleNameEndingWith("Repository")
      .or().haveSimpleNameEndingWith("AccessPort")
      .should().resideInAPackage("..core.ports.outbound")
      .because("Outbound ports must be defined in core layer");

  @ArchTest
  static final ArchRule core_should_not_depend_on_api =
    noClasses()
      .that().resideInAPackage("..core..")
      .should().dependOnClassesThat().resideInAPackage("..api..")
      .because("Core layer must not depend on API layer");
}

Without these tests, the next developer (or future you) can silently drift back to the old pattern and no CI check will catch it.


Metrics

Metric

Value

Files changed

46

Lines added / deleted

+206 / -196

Net lines

+10 (formatting)

Files renamed

10

Import-only changes

36

Bounded contexts touched

2 (Cataloging, Stacks)

Modules affected

4 (Author, Book, Bookcase, Shelf)

Behavioral changes

0

Architectural violations removed

11 of 12

New dependencies added

0

Breaking changes

None


What I Learned

1. Package structure is architectural documentation. Before this refactor, the package tree actively misled developers — ports in api/ports/ implied the API layer owned the contracts. After the move, the directory layout teaches hexagonal architecture by inspection: core/ports/inbound means "what the outside world can ask the domain to do," and core/ports/outbound means "what the domain needs from others." Treat package names as architectural assertions, not just folders.

2. Define target architecture upfront when doing multi-step refactors. This was the fourth major structural PR in two weeks (PRs #265 through #268). Each previous PR fixed one piece and left ports in inconsistent locations because there was no shared end-state definition. An Architecture Decision Record (ADR) written before PR #265 could have made each subsequent PR directional rather than reactive. Technical debt compounds across incremental refactors if there's no shared destination.

3. Location refactoring and contract refactoring are different problems — don't mix them. Moving AuthorAccessPort from api/ to core/ took minutes. Changing its findByBookId return type from Set<AuthorDTO> to a domain-safe type touches 11 callers, requires new mappers, and needs its own test coverage. Conflating the two in a single PR would have ballooned scope and risk. Keep structural moves and signature changes in separate commits.

4. IDE refactoring tools are mechanical, not architectural. IntelliJ's "Move Class" correctly updated 36 imports automatically. It also introduced a subtle port vs ports naming inconsistency (or preserved one that already existed) without any warning. It didn't flag AuthorAccessPort's DTO leakage. IDE tooling handles the mechanical work well but doesn't reason about design intent — that judgment still belongs to the developer.

5. Zero-behavior refactors validate your test suite. If a commit moves 10 classes and updates 36 import statements without changing any test assertions, and all tests still pass, you've learned something important: your tests are testing behavior, not implementation location. That's good. If import-only changes broke tests, it would mean tests are coupled to package structure — a design smell worth addressing.

6. Naming inconsistencies hide until you compare diffs side by side. The port vs ports discrepancy existed across multiple PRs without being noticed. It only became visible when scanning the diff holistically. This is the case for adding a Checkstyle or ArchUnit naming rule: "all port directories must be named ports." Manual consistency doesn't scale.

7. Documentation code snippets go stale fast. The previous devlog (devlog-2026-02-18-...) had outdated import paths in its code examples, which this commit also had to update. Inline code examples in docs drift from the source of truth quickly. A better long-term pattern is storing runnable examples in src/ and referencing them from docs, or using a doc-testing tool that fails CI when examples no longer compile.


Next Steps

Immediate (this week):

  • Push to CI and confirm GitHub Actions passes

  • Fix portports naming in the Book module (11 import updates, low risk)

  • Run grep -r "import.*\.api\.ports\." src/main/java/*/core/ to confirm only the known AuthorAccessPort exception remains

Short-term hardening:

  • Add archunit to pom.xml and implement the three PortLocationTest rules above — this prevents future drift without requiring manual audits

  • Fix the AuthorAccessPort DTO leakage: create BookAuthorDTO in book.api.dtos, update AuthorAccessPort.findByBookId return type, update the 11 callers

  • Write an ADR for the package structure convention and store it at docs/architecture/decisions/ADR-001-package-structure.md

Strategic (later):

  • Eliminate cross-context DTO usage: the Book context currently imports AuthorDTO directly from the Author context's API layer, creating a coupling between bounded contexts at the API level. The correct fix is a Book-context-specific value object (AuthorMetadata) that the AuthorAccessPortAdapter translates into at the boundary

  • Introduce anti-corruption layers in adapters: each context's outbound port adapters should translate incoming types into their own domain vocabulary, so the core layer never sees types from other contexts' API layers

  • Audit port interface naming conventions (*Facade, *Repository, *AccessPort) and consider consolidating cross-context port names to something shorter (e.g., AuthorPort instead of AuthorAccessPort)


Closing

Package structure is one of the few things in a codebase that's visible to every developer, every day — in the file tree, in import autocomplete, in "go to definition." When it misrepresents the architecture, it silently accumulates cognitive debt that compounds across every future feature and refactor.

This PR (#268) closes out four weeks of systematic architectural work across PRs #265 through #268. The dependency direction is now correct, the boundaries are explicit, and the one remaining violation is named and scoped. The next step is making the rules machine-enforceable with ArchUnit so they stay correct without relying on code review alone.


Tags: java, spring-boot, hexagonal-architecture, domain-driven-design, software-architecture, refactoring, clean-architecture, portfolio