DEV Community

Charles Hornick
Charles Hornick

Posted on

Ports & Adapters: Beyond the Theory — Organizing the Application with Package-by-Component

This is the second article in the "Ports & Adapters: Beyond the Theory" series.
Article 1 demonstrated the ability to hot-swap adapters at runtime by leveraging the advantages of the P&A pattern.
This article tackles what Alistair Cockburn deliberately left open: how to organize the inside of the application.
Every line of code in the companion repository was written by hand. No AI-generated code.

GitHub Repository: GitHub — branch article/2-package-by-component


Series — Ports & Adapters: Beyond the Theory

  1. Runtime Adapter Hot-Swapping
  2. Organizing the Application with Package-by-Component ← you are here
  3. Isolating with JPMS (coming soon)
  4. Testing with Result (coming soon)
  5. Adapter Switching Strategies (coming soon)
  6. Spring Modulith + P&A (coming soon)

"I need to be assured that certain interests are package-private" — Arthur Case, talking about the application.

The application is not protected

Let's be clear, Alistair Cockburn's Ports & Adapters pattern can be summed up with the following points:

  • The system is split into two parts: the inside (the application) and the outside (the adapters);
  • The application focuses solely on business needs;
  • The application communicates with the outside world through ports;
  • Adapters translate and communicate with the application through these ports.

There you go, as simple as it sounds.

Imagine a laptop that only has USB-C ports. You want to connect your wired mouse but it's USB-A.
To allow communication, you need a USB-A to USB-C adapter.
Now your mouse click, going through the adapter, is understood by the laptop because the connection has been established.

(This analogy is much harder to make with the term "hexagonal architecture")

Unfortunately, while P&A guarantees an effective separation between the inside and the outside, it absolutely does not guarantee that the inside is properly organized.

But what do we see in tutorials and blog posts? That the organization is almost always the same:

domain/
├── model/
├── port/
│   ├── in/
│   └── out/
└── service/
Enter fullscreen mode Exit fullscreen mode

And with such an approach, two things stand out immediately:

  • domain is used even though it's a DDD (Domain-Driven Design) term, and is never used by Alistair in his writings, where he uses the term Application;

"The application is everything on the inside, everything that isn't technology-specific."

  • everything ends up public so that each element can access whatever it needs to function.

And this last point is the most problematic. The whole purpose of having an inside and an outside is that what's on the inside, and has no reason to leak out, stays there. Yet with this keyword, everything that should remain internal becomes accessible to any adapter.

Now, if article 1 showed one thing, it's that it is possible to swap an adapter on the fly with zero impact on the application, simply because it doesn't know and doesn't care.

"Remember, in Ports & Adapters you are free to organize the inside of the app in any way you like, and the things outside the app in any way you like. Just put ports in place."

Right, just put ports in place. But what are they exactly?

There are two types of ports that are important to explain in order to properly understand their role.

Primary ports

These ports represent the way the application wants to be talked to, meaning that any adapter talking to a primary port becomes by definition a primary adapter and follows a precise protocol.

As Alistair puts it:

"The protocol for a port is given by the purpose of the conversation between the two devices. The protocol takes the form of an application program interface (API)."

In the case of primary ports, this protocol represents the action that the adapter wants to see executed when it uses the application.

Except that when we look at articles, conference talks, and tutorials, this protocol often boils down to a Java interface. And worse, that interface tends to become a catch-all.

Yet in the updated edition of Hexagonal Architecture Explained (2025), Alistair explains that a port can be either an interface or a class.

"In your life, decide which way you prefer to write."

In this implementation, primary ports are concrete final classes that represent the API, and offer a protocol that makes the conversation between the adapter and the application explicit.

Secondary ports

On their side, secondary ports remain interfaces in the Java sense.

The reason is simple:

"A port identifies a purposeful conversation. There will typically be multiple adapters for any one port, for various technologies that may plug into that port."

The application sets up a conversation with an adapter it knows nothing about. If the application used anything other than an interface, it would mean that it becomes part of the implementation, breaking the boundary.

What about the rest of the application?

While ports are perfectly justified in being public, the same is not true for all the other elements that make up the application.

This brings us back to the point raised earlier: primary and secondary adapters can only use what the application allows them to use, for the sake of the conversations/protocols.

Except that this aspect is only reinforced if adapters rely solely on the defined conversation, not on the hallway rumors that an unfortunate keyword made public.

But how do we protect the inside?

This is where Simon Brown enters the scene

In "The Missing Chapter" of the book "Clean Architecture", Simon Brown offers a different approach, in opposition to the layered approach as well as, indeed, Ports & Adapters.

But what if, instead of opposing them, the application used both in a complementary way, P&A protecting from adapters, Brown protecting from itself?

Where P&A provides the boundaries protecting the application, package-by-component provides the internal organization it lacks.

The idea is simple: instead of organizing code by technical layer (model/, port/, service/), code is organized by use case and component. Java's package-private becomes the default visibility level: what has no reason to be public is not.

In the example code, the application itself is the component, turning package-by-component into package-by-use-case: the same idea applied at the right level of granularity.

Here is the application's organization, with brief explanations of each element:

be.charleshornick.supra
├── ErrorCause.java                         # cross-cutting error constants
├── ForStoringSnapshot.java                 # secondary port, shared across use cases
│
├── create/                               # use case: create a character
│   ├── CreateCharacter.java                # primary port (public final)
│   ├── ForCheckingNameUnicity.java         # secondary port (specific)
│   ├── Character.java                      # package-private
│   └── CharacterNameValidator.java         # package-private
│
├── define/                                 # use cases: modify an existing character
│   ├── ForLoadingSnapshot.java             # secondary port, shared within define/
│   ├── ToCharacter.java                    # step interface, shared within define/
│   ├── race/
│   │   ├── DefineRace.java                 # primary port (public final)
│   │   ├── ForLoadingRace.java             # secondary port (specific)
│   │   ├── DefineRaceStep.java             # package-private
│   │   └── Character.java                  # package-private
│   ├── profession/
│   │   ├── DefineProfession.java           # primary port (public final)
│   │   ├── ForLoadingProfession.java       # secondary port (specific)
│   │   ├── DefineProfessionStep.java       # package-private
│   │   └── Character.java                  # package-private
│   └── characteristic/
│       ├── DefineCharacteristic.java        # primary port (public final)
│       ├── AddOnePoint.java                 # package-private (step builder)
│       ├── RemoveOnePoint.java              # package-private (step builder)
│       └── Character.java                   # package-private
│
├── retrieve/                                # use cases: read data
│   ├── race/
│   │   ├── ForGettingRaces.java             # secondary port (specific)
│   │   ├── GetAllRaces.java                 # primary port (public final)
│   ├── profession/
│   │   ├── ForGettingProfessions.java       # secondary port (specific)
│   │   ├── GetAllProfessions.java           # primary port (public final)
│   └── snapshot/
│       ├── ForGettingSnapshot.java          # secondary port (specific)
│       ├── GetAllSnapshots.java             # primary port (public final)
│       └── GetLatestSnapshot.java           # primary port (public final)
│
├── race/                                    # vocabulary: what a race IS
│   ├── Race.java
│   └── RaceName.java
├── profession/                              # vocabulary: what a profession IS
│   ├── Profession.java
│   ├── ProfessionName.java
│   ├── ProfessionType.java
│   └── Prerequisite.java
├── characteristic/                          # vocabulary: what a characteristic IS
│   ├── PrimaryCharacteristic.java
│   └── PrimaryCharacteristicName.java
└── snapshot/                                # state management internals
  ├── Snapshot.java
  ├── SnapshotBuilder.java
  ├── Action.java
  ├── Recorder.java
  ├── CreationPoint.java
  ├── CreationPointConsumer.java
  └── InvestedPoint.java
Enter fullscreen mode Exit fullscreen mode

Two categories of packages are visible:

  • actions (create/, define/, retrieve/) describing what can be done;
  • vocabulary (race/, profession/, characteristic/, snapshot/) describing what things are.

The first contains all the business logic, hidden behind package-private, while the second contains the shared types that define the application's language.

This isolation by use case keeps the risk of side effects as low as possible, since each internal element serves only its own case.
This fits within the principle of code cohesion: what changes together lives together. Modifying a use case touches only a single package.

Let's take a concrete example of what this isolation brings: the Character class is present in all 3 sub-packages of define/ but its role is different each time.

Following the organization model mentioned earlier, a single class would have existed in models/ and would have exposed as many methods as there are use cases.

There is nothing wrong with this, but modifying that class for one use case risks breaking the others. Brown's approach makes that impossible.

Now that the structure is in place, let's look at what's inside, starting with what stands out in the tree: the port names.

Port naming

As mentioned earlier, Alistair explains that ports have precise conversations with adapters.

A secondary port represents the request that the use case makes to the outside world. To reflect this, I followed the naming convention promoted by Cockburn: ForDoingSomething.

Each secondary port starts a conversation with a specific need, an action it wants to see carried out. These ports are however not placed in a fixed package but as close as possible to where they are used.

For instance, ForCheckingNameUnicity is in the character creation package because that's the only place where it's used. Conversely, ForLoadingSnapshot is at the root of the define/ action because it's used by all of its sub-packages.

Secondary ports are functional interfaces to allow finer granularity in conversations. These secondary ports do not, and cannot, have default methods for the reasons discussed earlier.

On their side, primary ports don't request, they are the actions themselves, and expose them through protocols. Their naming reflects this: each step in the protocol is explicit in order to form a clear protocol.

But naming is not the only reason for setting up protocols this way, this writing also guarantees at compile time that the port is correctly called.

The Step Builder reinforces the protocol

Let's take the following example:

defineCharacteristic
    .byAddingOnePoint()
    .toCharacteristicNamed(CharacteristicName.COURAGE)
    .toCharacterNamed("Borgrim");
Enter fullscreen mode Exit fullscreen mode

Each step refines the protocol by making it more precise about the action it will perform. The action reads naturally: define a characteristic by adding one point to the one named courage, on the character named Borgrim.

But most importantly, it is impossible to call it in a different order, the compiler will automatically block anything that is not explicitly allowed by the protocol.

To guarantee this, the Step Builder pattern is used. Looking at DefineCharacteristic, we can see that two methods are exposed: byAddingOnePoint and byRemovingOnePoint, each returning a new instance of a package-private class implementing DefineCharacteristic.ToCharacteristic.

Since the return type of these methods is DefineCharacteristic.ToCharacteristic, the next available method is necessarily toCharacteristicNamed(String).

Furthermore, that same method returns the ToCharacter interface, which the same package-private classes implement, completing the protocol without ever allowing to bypass the protocol's defined behavior.

public final class DefineCharacteristic {

    public interface ToCharacteristic {
        ToCharacter toCharacteristicNamed(PrimaryCharacteristicName characterName);
    }

    private final ForLoadingSnapshot forLoadingSnapshot;
    private final ForStoringSnapshot forStoringSnapshot;

    public DefineCharacteristic(final ForLoadingSnapshot forLoadingSnapshot, final ForStoringSnapshot forStoringSnapshot) {
        this.forLoadingSnapshot = forLoadingSnapshot;
        this.forStoringSnapshot = forStoringSnapshot;
    }

    public ToCharacteristic byAddingOnePoint() {
        return new AddOnePoint(this.forLoadingSnapshot, this.forStoringSnapshot);
    }

    public ToCharacteristic byRemovingOnePoint() {
        return new RemoveOnePoint(this.forLoadingSnapshot, this.forStoringSnapshot);
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach goes further than simply enabling protocol-like writing: the port is singleton and stateless.

Indeed, each call to the exposed methods creates a new ephemeral step instance, making the pattern thread-safe. Since the instance only lives for the duration of the call, it is immediately collected by the garbage collector once the call ends.

Note that the Step Builder Pattern is not systematic.

createCharacter.named("Borgrim");
Enter fullscreen mode Exit fullscreen mode

Here, the method exposed by the port is sufficient for the protocol. The pattern serves the application, not the other way around.

A note on Result<T>

While going through the code, you will have noticed that ports systematically return Result<T>, and that tests use .onSuccess() and .onFailure().

I use the Pragmatica library (Apache 2.0, Java 25+) to never throw exceptions in the application. This choice considerably simplifies error handling and testing, but it's a rich enough topic to deserve its own article in this series.

Testability

One of the direct benefits of this organization is testability. The application is pure POJO, no framework is needed to test it.

Since secondary ports are functional interfaces, a lambda is all it takes to create a fake:

@Test
@DisplayName("Succeed when the name is available")
void succeedWhenNameIsFreeToUse() {
    new CreateCharacter(_ -> true, Result::ok)
            .named("Borgrim")
            .onSuccess(snapshot ->
                assertThat(snapshot).isEqualTo(SnapshotFixture.getDefaultOne()))
            .onFailure(cause ->
                fail("Cannot create new character: " + cause.message()));
}
Enter fullscreen mode Exit fullscreen mode

_ -> true means the name is available. Result::ok means storage always succeeds. No Mockito, no mock framework, no when().thenReturn().
The behavior is explicit, readable, and verified at compile time: if a port's signature changes, the lambda breaks, whereas a Mockito mock can keep compiling silently.

The Step Builder is tested the same way:

new DefineRace(forLoadingSnapshot, forStoringSnapshot, forLoadingRace)
        .named(RaceName.ELF)
        .toCharacterNamed("Borgrim")
        .onSuccess(snapshot -> assertEqualsAgainst(snapshot, expected))
        .onFailure(cause -> fail("Failed to define new race: " + cause.message()));
Enter fullscreen mode Exit fullscreen mode

No setup, no teardown, no Spring context to load. We instantiate the port with fakes, call the protocol, verify the result. The test reads like a specification.

The complete testing approach, including sharing scenarios between unit tests and integration tests, will be detailed in article 4.

The honest trade-off: public betrays us

Everything shown so far works:

  • package-private hides internal elements;
  • the Step Builder enforces the protocol;
  • ports are final;
  • no framework infiltrates the application.

But there is a hole because primary port constructors are public:

public final class CreateCharacter {

    public CreateCharacter(
        final ForCheckingNameUnicity forCheckingNameUnicity,
        final ForStoringSnapshot forStoringSnapshot) {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

And this is intentional, it has to be. A composition root, which will live in another package and in another module, will need to instantiate this class and inject the secondary port implementations. Without a public constructor, that's not possible.

Except that also means any adapter can bypass the intended wiring and do:

var rogue = new CreateCharacter(myOwnChecker, myOwnStorage);
Enter fullscreen mode Exit fullscreen mode

The same problem exists for SnapshotBuilder. It is public because the Character classes, which are package-private in each sub-package of define/, need to call it from their doSnapshot() methods.
Different packages, so public is mandatory. And yet, no adapter has any legitimate reason to touch SnapshotBuilder.

Java's visibility model gives us two options: public (everyone sees it) and package-private (only the same package sees it), there is nothing in between. No way to say "this constructor is visible only to that module" or "this class is only accessible from within the application."

Brown's approach with package-private covers a good portion of the isolation we need. The rest, such as public constructors and shared utility classes, remain exposed.

This is a limitation of the language, not of the pattern.

Trade-offs

No approach is perfect, and organizing by use case with Ports & Adapters comes with its own set of trade-offs to be aware of.

The number of files increases

Each use case has its own package with its own Character class, its own step builder, its own ports. Navigating the IDE requires familiarity with the structure to find things quickly.

The learning curve exists

Developers used to service/ and repository/ packages need to shift their mental model. The separation between actions and vocabulary is not intuitive for everyone at first.

Risk of application rigidity

Applied indiscriminately, the approach can become rigid. A trivial use case, like a simple read that passes straight through to the secondary port, does not necessarily justify a dedicated package with four classes.

Risk of duplication

Two use cases that would need the same internal type must either extract it into a vocabulary package, or accept code duplication.

These trade-offs are real and should be weighed carefully. But they should be balanced against the alternative: a domain/ package where everything is public, where nothing is enforced, and where the isolation promise of Ports & Adapters relies solely on the team's good will.

Conclusion

Cockburn protects the application from the outside world. Brown protects the application from itself. Together, they offer an application that is structured, isolated, and testable without any framework.

But Java leaves us a hole: the public keyword is a master key that cannot be revoked at the package level. Internal classes that should only be used within the application are accessible to everyone.

We need an enforcement mechanism beyond package-private.

That's the subject of article 3: JPMS.


The example application implements an RPG character creation system with real business rules: creation, race and profession definition with bidirectional constraints, and point investment in primary characteristics with race-dependent limits.

Tech stack: Java 25, Pragmatica (Result<T>, Option<T>), JUnit 6, AssertJ, Maven. No Spring. No framework. No annotations.

Source code: GitHub — article/2-package-by-component


This article is part of the "Ports & Adapters: Beyond the Theory" series. The series was initiated after Alistair Cockburn, creator of the Ports & Adapters pattern and co-author of the Agile Manifesto, shared the first article and described the approach as "an amazing use of Hexagonal Architecture."

The architectural decisions in this series are grounded in Cockburn's original 2005 article and in Hexagonal Architecture Explained (Cockburn & Garrido de Paz, updated 1st edition, 2025).

Top comments (0)