As software projects become increasingly complex, the structured organization of code becomes a key challenge. Traditionally, layered architectures have become the standard for larger applications, designed to create a clear separation between presentation, business logic, and data access. In practice, however, this approach often leads precisely to technical dependencies permeating all layers and the actual business logic being dominated by infrastructure decisions.
In our Architecture Circle, our Xperts focus on modern software design approaches, among other things, and contribute their expertise to ensure that projects remain maintainable and flexible over the long term. One particularly proven pattern here is Alistair Cockburn’s hexagonal architecture—also known as „Ports and Adapters“—which brings about a fundamental reversal of dependency directions: The business logic takes center stage, while all technical details are abstracted to the outside via defined interfaces.
This architecture addresses a central problem in modern software development: How can we implement business requirements without being constrained by technical decisions? How do we create software that remains flexibly adaptable over the years?
Why Traditional Layered Architectures Are Reaching Their Limits
To understand the added value of hexagonal architecture, it’s worth taking a brief look at the classic layered models—and at why they often reach their limits in practice.

The widely used layered architecture, with its three-tier division into presentation, business logic, and data access, often leads to problems in larger projects:
- Technical dependencies permeate all layers: If the business logic accesses the data access layer directly, dependencies on technical details arise that affect the business logic. This often becomes apparent when database models are used directly within the business logic. These models are usually designed according to the specifications of the persistence layer—for example, due to requirements from frameworks such as JPA or Hibernate, which enforce public getters and setters. As a result, business invariants can be undermined, and the logic becomes unnecessarily tied to specific database technologies.
- Layer boundaries are becoming less distinct: When under time pressure, developers tend to blur the boundaries between the layers. As a result, business logic may end up in the presentation layer, or technical details may find their way into the business logic. As a result, the business logic code becomes increasingly difficult to understand; important aspects are harder to identify and can easily be lost during technical adjustments.
- Testability suffers: Business logic cannot be tested in isolation without technical dependencies such as databases.
The Hexagonal Principle: Business Logic at the Center
The hexagonal architecture addresses precisely these weaknesses and flips the perspective: the business logic takes center stage, not the data. All technical details are abstracted to the outside via defined interfaces—known as ports—and implemented using adapters. Ports are part of the business logic and are therefore also given business-specific names.
The core idea: The application defines what it needs (ports), but not how it is implemented (adapters). This results in clear dependency directions: All dependencies point inward toward the application core. To make this principle more tangible, a distinction is made between primary and secondary ports—depending on whether the application is being controlled or is itself controlling other systems.

Application
In practice, this means that the business logic exposes its use cases via primary ports and uses secondary ports to connect to external systems, such as databases or payment services. In doing so, it remains completely free of technical dependencies.

Example 1:
// primärer Port
interface UserRegistrationUseCase {
fun registerUser(user: User): User
}
// sekundäre Ports
interface UserRepository {
fun add(user: User): User
fun existsByEmail(email: String): Boolean
}
interface UserNotificationService {
fun sendWelcomeMessage(user: User)
}
// Geschäftslogik
data class User(
private val id: String,
private val name: String,
private val email: String
) {
companion object {
fun create(name: String, email: String): Appointment {
require(!name.isBlank(), „name must not be blank“)
require(isMailAddress(email), „email must be a valid mail adress“)
return User(UUID.randomUUID().toString(), name, email)
}
}
}
class UserRegistrationService(
private val userRepository: UserRepository,
private val userNotificationService: UserNotificationService
) : UserRegistrationUseCase {
override fun registerUser(user: User): User {
check(!userRepository.existsByEmail(user.email), "user with that email is already registered")
val newUser = userRepository.add(user)
userNotificationService.sendWelcomeMessage(newUser)
return newUser
}
}
Adapters – Interfaces to the Outside World
Adapters technically implement the previously defined ports. They translate the business logic interfaces into specific technologies—such as REST controllers, database access, or integrations with external services. This ensures that the business logic itself remains unchanged, even if the infrastructure or the frameworks used change over time.
Primary Adapters – Controlling the Application
Primary adapters, or driving adapters, provide the technical connection to the application. They control the respective use case by calling the corresponding primary ports.
A REST controller, acting as the primary adapter, translates HTTP requests into business operations:

Example 2:
@RestController
@RequestMapping("/api/users")
class UserRegistrationController(
private val userRegistrationUseCase: UserRegistrationUseCase
) {
@PostMapping("/register")
fun registerUser(@RequestBody request: UserRegistrationRequest): ResponseEntity {
try {
userRegistrationUseCase.registerUser(request.toDomain())
return ResponseEntity.ok()
} catch (Exception e) {
return ResponseEntity.badRequest().body(createErrorResponse(e))
}
}
}
Secondary Adapters – Connecting the Infrastructure
This is where the Dependency Inversion Principle comes into play: The business logic defines the interface through secondary ports, and the infrastructure implements it using a secondary adapter. As a result, the business logic remains independent of specific technologies and can continue to function even if external systems or frameworks change.

Example 3:
class JpaUserRepository(
private val springDataUserRepository: SpringDataUserRepository
) : UserRepository {
override fun add(user: User): User {
val entity = mapToEntity(user)
val savedEntity = springDataUserRepository.save(entity)
return savedEntity.toDomain()
}
override fun existsByEmail(email: String): Boolean {
return springDataUserRepository.existsByEmail(email)
}
}
Mapping Between Layers – A Conscious Decision
A key aspect of hexagonal architecture is the mapping between domain objects and infrastructure objects.
- Domain Objects represent the business logic—that is, concepts and rules from the application domain, such as "Order" or "Invoice." They do not include technical details.
- Infrastructure Assets (or adapter models), on the other hand, are tailored to frameworks or external systems, such as database entities or DTOs for a REST API.
Even though this mapping may seem like extra work at first, it offers significant advantages:
- Technical details are left out: Annotations for, e.g., the Validation API, JSON serialization, JPA, and other framework-specific details are present only in the adapter models and do not affect the business logic models.
- Flexibility in Technology Transitions: Switching from, for example, REST to a message bus requires changes only in the adapter.
- Clear Data Contracts: Each layer has its own optimized data structures.
However, this approach also comes at a cost: Mapping logic and data conversions must be maintained for each interface. In smaller projects, the overhead can quickly seem disproportionate—but in more complex applications, the benefits usually outweigh the costs.
Example 4:
data class UserRegistrationRequest(
@Size(min = 3) val name: String,
@Email val email: String
) {
fun toDomain() = User.create(name = name, email = email)
}
@Entity
@Table(name = "users")
data class UserEntity(
@Id val id: String,
@Column(unique = true) val email: String,
val name: String,
@Column(name = "created_at") val createdAt: LocalDateTime
) {
fun toDomain() = User(id = id, name = name, email = email)
}
Testing
Another advantage becomes apparent during testing: Thanks to this clear separation, technical tests can be easily conducted independently of the infrastructure.
The hexagonal architecture elegantly enables isolated testing. The business logic can be tested entirely without external dependencies, since only the port interfaces need to be replaced with mocks.
Example 5:
class UserRegistrationServiceTest {
private val mockUserRepository = mockk()
private val mockUserNotificationService = mockk()
private val userRegistrationService = UserRegistrationService(
mockUserRepository,
mockUserNotificationService
)
@Test
fun `should successfully register a user`() {
// Arrange
val user = User (email = "test@example.com", name = "Test User")
every { mockUserRepository.existsByEmail(user.email) } returns false
every { mockUserRepository.save(any()) } returnsArgument 0
every { mockUserNotificationService.sendWelcomeMessage(any()) } just Runs
// Act
userRegistrationService.registerUser(user)
// Assert
verify { mockUserRepository.save(any()) }
verify { mockEmailService.sendWelcomeMessage(user) }
}
}
Benefits for Sustainable Software Development
The hexagonal architecture offers concrete advantages for the long-term maintainability of software:
- Flexibility in Technology Decisions: Frameworks or external APIs can be replaced without altering the business logic.
- Improved testability: Business logic can be tested in a fully isolated environment.
- Clearer structures: Business and technical aspects are clearly separated; in particular, the business logic is easier to understand and therefore easier to maintain.
- Parallel development: Teams can work on adapters and business logic in parallel once the interfaces have been defined by ports.
- Reduced technical debt: Upgrades and refactorings become less risky.
When is it worth using?
Despite these advantages, the hexagonal architecture is not the right choice for every project. It is particularly well-suited for:
- Complex business applications with an expected lifespan of several years.
- Projects with evolving technology requirements.
- Applications with complex business logic that must be testable in isolation.
The additional effort involved in mapping between domain and infrastructure objects is the biggest drawback here. For simple applications or prototypes, this overhead can quickly become excessive.
Conclusion
The hexagonal architecture brings order to complex systems: business logic at the core, with technology neatly decoupled. This makes applications durable, testable, and flexible—exactly the qualities that are crucial in future-proof software projects.
Of course, this approach comes at a cost. The additional mapping involves extra effort, which can quickly seem excessive in small or short-term projects. However, in complex business applications, this investment pays off: It prevents technical dependencies, reduces risks associated with technology changes, and lays the foundation for sustainable further development.
This means that the hexagonal architecture is not a panacea, but rather a strategic tool. Anyone seeking to develop stable systems over the long term will benefit from a clear separation between business logic and technology. It becomes particularly interesting when this approach is combined with other concepts such as Domain-Driven Design (DDD)—since both aim to consistently place business logic at the heart of the software. You can find out more about DDD itself in one of our earlier Blog Posts.