In hexagonal architecture, ports are meant to isolate your business logic from how it's delivered. In practice, most of them are still named after HTTP operations like createUser or updateUser, a habit inherited from years of REST first development. This mismatch turns your tests into simple database checks instead of real business specifications. It also happens to be exactly the convention that AI coding agents will pick up from your existing code and repeat at scale. This piece looks at why your ports keep lying about your business, and what it takes to name them so a domain expert would recognize what they actually do.
You open a hexagonal architecture codebase. The ports look like this: getUser, createOrder. The controllers look identical. Something feels off, but you have a ticket to close. The Jira ticket you're assigned to describes how, as a user, you want to be able to register in your online shop. You tell the AI agent to implement it. It reads the prompt, reads the codebase, and writes the logical addition: createUser. The controller and port method read more or less the same. You commit and move on.
What just happened? The code works; the tests are green; the feature ships. But somewhere between the OpenAPI spec and the port interface, a quiet mistake was made. One that will cost you the next time you extend the codebase, the next time you try to link features to your use cases, or the next time you try to write a test that reflects what the software is supposed to do.
Here's the quiet mistake: the ports are speaking technology when they should be speaking business.
The controller is not the contract
REST-first development trained us well. You design the endpoint, write the contract, generate the client, ship. Tools like Spring REST Docs or OpenAPI-based contract testing cemented this pattern into muscle memory. The controller is the contract. Everything downstream serves it.
Hexagonal architecture changed the rules. The concepts shifted. The habits didn't.
In a REST-first world, it makes perfect sense to name things after HTTP operations. createUser is clear, consistent, and maps neatly onto a POST /users endpoint. But the entire premise of ports-and-adapters is that the business core should be independent of how it's delivered. If the port is just the controller in a trench coat, you haven't built an architecture - you've built indirection.
Alistair Cockburn's original insight was that the application should be equally drivable by a user interface, an automated test, or another system. The port is the mechanism that makes that possible, but it only works if the port speaks business, not HTTP.
Here is what controller-first looks like in practice:
// The REST adapter - where it all starts
@RestController
@RequestMapping("/users")
public class UserController {
private final UserPort userPort;
public UserController(UserPort userPort) {
this.userPort = userPort;
}
@PostMapping
public UserResponse create(@RequestBody UserRequest request) {
return userPort.createUser(request);
}
@PutMapping
public UserResponse update(@RequestBody UserRequest request) {
return userPort.updateUser(request);
}
}
// The port - named after HTTP operations, not business intent
public interface UserPort {
UserResponse createUser(UserRequest request);
UserResponse updateUser(UserRequest request);
}
// The test adapter - mirrors HTTP, ignores intent
class UserPortTest {
@Test
void test_createUser() {
UserResponse response = userPort.createUser(new UserRequest("john@example.com"));
assertNotNull(response.getId());
assertEquals("john@example.com", response.getEmail());
}
@Test
void test_updateUser() {
userPort.createUser(new UserRequest("john@example.com"));
UserResponse response = userPort.updateUser(new UserRequest("john@example.com"));
assertEquals("john@example.com", response.getEmail());
}
}
Read that second test again. It writes a value, reads the same value back, and asserts they match. It is a test that a field assignment assigns a field. The port speaks HTTP, so the test can only validate a CRUD service behaving like a CRUD service. Nobody asked what the business actually needs.
Now invert it. Start with defining the intent and translate that to the port:
// The port - business intent only
public interface CustomerRegistration {
RegisteredCustomer register(RegistrationRequest request);
void confirmEmail(EmailConfirmationToken token);
}
Suddenly the customer is registered instead of an ambiguous user being created. After all, no business has ever "created" a customer. Now, defining the adapters is a pure technical translation for a consumer. In this case, the REST endpoint and test class look like this:
// The REST adapter - one of many possible adapters
@RestController
@RequestMapping("/registrations")
public class CustomerRegistrationController {
private final CustomerRegistration customerRegistration;
public CustomerRegistrationController(CustomerRegistration customerRegistration) {
this.customerRegistration = customerRegistration;
}
@PostMapping
public RegisteredCustomer register(@RequestBody RegistrationRequest request) {
return customerRegistration.register(request);
}
}
// The test adapter - drives the same port, no HTTP needed
class CustomerRegistrationTest {
@Test
void test_newly_registered_customer_cannot_checkout() {
RegisteredCustomer customer = customerRegistration.register(
new RegistrationRequest("john@example.com")
);
assertFalse(customer.isEligibleForCheckout());
}
@Test
void test_customer_can_checkout_after_confirmation() {
RegisteredCustomer customer = customerRegistration.register(
new RegistrationRequest("john@example.com")
);
customerRegistration.confirmEmail(customer.getConfirmationToken());
assertTrue(customer.isEligibleForCheckout());
}
}
The port now reads like a conversation with your product owner. The test adapter drives the application at the business level: no HTTP, no framework noise. And notice what the assertions are made of. One of these test suites would survive a rewrite of the delivery mechanism and the persistence layer. The other one wouldn't.
Cockburn puts the ultimate benefit of ports and adapters in "the ability to run the application in a fully isolated mode". The test adapter is what cashes that in - but only if the port speaks business.
It's there for the meaning
So you've spotted the mistake, yet you still find CRUD-style methods all over your ports and adapters.
The problem remains that the vocabulary is still coming from the database, not the domain. createUser doesn't tell you anything about its context or what kind of user you're dealing with. It could be an onboarding session at a store, a customer clicking through an order process, or an automated employee registration. The port doesn't say. Now look at what changes when you rename it to onboardStoreCustomer.
One rename. User becomes customer. Create becomes onboard. The intent is now part of the code. Notice how much of that work the verb is doing. But this shift is harder than it sounds, because it requires stepping outside the codebase entirely and into the domain, often mid-coding. It forces the whole team to think about what customer onboarding actually implies rather than what the database needs to insert a row.
To many developers this feels like pure boilerplate. A three-layered architecture is simpler and puts the same rows in the same tables at the end. So why bother making it read like a business process?
Because of the test adapter. That is the concrete return on the naming work, and it is the reason the interface earns its keep. The moment the port speaks business, your test suite stops describing the shape of your database and starts describing the promises your software makes. Those tests survive a migration from REST to gRPC, from Postgres to an event store, from a monolith to three services. CRUD-named tests survive none of that, because they were never testing the business in the first place.
To be fair: sometimes the vocabulary really is CRUD. An internal admin tool for reference data, a back-office editor whose users genuinely say "create a row" and mean it - there createUser is the honest name, and dressing it up as provisionIdentity is worse than leaving it alone. The test isn't whether the name sounds technical. The test is whether a domain expert would recognize it.
"But what if I only have one adapter? Then I can skip the port interface altogether."
Fair point, and I won't argue that you should over-engineer for adapters you'll never write. But the interface isn't there for the adapters. It's there for the meaning. An interface in any language needs almost no keywords, carries no dependencies, and forces you to commit to intent before implementation. If you drop it, something else becomes the contract by default. Usually the controller. And you're right back where you started.
Two people name the port, and neither is the database
Getting the vocabulary right doesn't happen on the keyboard. It happens in a conversation you need to have before you open your IDE. Two people need to be in that conversation, and neither of them is the database.
Domain experts define the intent.
A store manager knows what onboarding a customer means. A compliance officer knows what constraints apply. They are the ones who can name the port correctly. This comes with an uncomfortable truth - more often than not, this conversation gets skipped while defining business cases and user stories. Users themselves have often absorbed technical jargon like "active", "delete", or "load", which makes it even harder to trace back the original business process.
Consumers define the shape.
A frontend team knows they need a flat object with five fields. Another service knows it only needs an ID and a status. They can't tell you what the business intent is, but they can tell you exactly what form the response needs to take to be useful. What is often overlooked is that adapters aren't just the same implementation of one another with different dependencies to fit a technological constraint like REST. Adapters form what the ports expose into a consumable shape. Here lies another gotcha of port design: its response.
The port should respond with domain objects: rich, business-meaningful types that reflect what actually happened, e.g. a RegisteredCustomer, not a UserResponse. The adapter's job is to take that and map it into whatever the consumer needs: a flat DTO, a trimmed JSON object, a specific response contract. This keeps the consumer's technical requirements out of the port without leaving them unsatisfied. The port stays clean. The adapter carries the mismatch. That's not extra work; that's exactly what the adapter is for.
"But I only have one adapter. Won't the port just overfetch for it?"
Yes, and that's fine. The port returns a complete domain object because it has no business knowing what any specific consumer wants to display. A RegisteredCustomer carries everything that's true about a registered customer from the domain's perspective. The REST adapter maps that into a five-field JSON response. The internal service adapter retrieves just the ID and status. In fact, you likely already have an adapter that fetches more than it needs: your test adapter. It only tests behavior, which doesn't mean it needs to check every single object property. Each adapter takes what it needs and discards the rest. If that feels wasteful, the alternative is worse. A port that returns a different shape for every consumer is just a controller with extra steps.
Skip the domain expert and the port ends up named after a database operation. Let the consumer dictate the response and the port ends up shaped around a delivery format. Either way, the port stops being a business contract and becomes something else: indirection with good intentions.
Before you write your next port, consider this question:
Will a domain expert know exactly what process this port refers to, simply by its name?
If the answer is no, that's your cue. Not what the database needs, not what the controller expects. What is the business trying to accomplish at this moment, for this actor, in this context.
AI is not designing the solution, you are
Go back to the opening. The agent didn't invent createUser. It read a codebase full of getUser and createOrder, inferred the house style, and produced the name that fit. It did its job well. That's the part worth sitting with: coding assistants don't introduce naming drift, they propagate whatever convention is already there, faithfully and at speed. Your first three port names used to set the tone for the codebase. Now they set the tone for everything an agent writes in it, for as long as the repo lives.
Which makes the design conversation more valuable, not less. AI writes implementation competently, quickly, without complaint. Given a well-scoped port, it produces a working implementation in seconds. What it cannot do is sit with your product owner, ask the right questions, and translate messy business reality into a clean interface. That conversation - the one that ends with method names reading like a business process - is where the actual design work happens.
Many people believe this can be short-cut by adding MCPs, plugins, or AI chats to the picture, and I don't blame them; there's a lot of advertising budget behind that idea. Integration solves the problem of available context. It does nothing for right context. Your wiki holds inconsistent, half-migrated, and outdated wording, and far more of it than anyone needs. Point an agent at that and it will pattern-match its way to something that sounds coherent, then hand you newly invented vocabulary for a process that already had a name. Adapting to that afterwards is slow and error-prone - the exact opposite of what you set out to design.
For what it's worth, I think AI is pushing software engineering back towards its roots: away from framework wars, back to arguing about what the software is actually for. Trivialising the implementation cycle doesn't remove the work, it relocates it. And it lands squarely on the part of the job that was always the hard part. Knowing how to translate business into software means knowing how to use ports properly, and good judgement calls became more valuable the moment implementation got cheap.