| name | hexagonal-architecture |
| description | Use when creating features, domain models, use cases, or organizing backend code with Hexagonal Architecture (Ports and Adapters) and CQRS. |
| allowed-tools | Read, Edit, Write, Glob, Grep, Bash |
| metadata | {"author":"profiletailors","version":"1.0"} |
Hexagonal Architecture Skill
Patterns for implementing Hexagonal Architecture (Ports and Adapters) with CQRS in Kotlin/Spring
Boot.
When to Use
- Creating new features or bounded contexts
- Designing domain models and value objects
- Implementing use cases (commands/queries)
- Organizing code within a feature
- Deciding where code belongs (domain vs application vs infrastructure)
Critical Concepts
| Concept | Description |
|---|
| Domain | Pure business logic, NO framework or library dependencies |
| Application | Use cases orchestrating domain operations |
| Infrastructure | Framework integration (Spring, R2DBC, HTTP) |
| Ports | Interfaces defined by domain, implemented by infrastructure |
| Adapters | Infrastructure implementations of ports |
Feature Structure
EVERY feature follows this structure:
📁 {feature}/
├── 📁 domain/ # Pure Kotlin, NO Spring annotations
├── 📁 application/ # Use cases, CQRS handlers
└── 📁 infrastructure/ # Spring Boot, R2DBC, HTTP
Dependency Flow (CRITICAL)
domain ← application ← infrastructure
↑ ↑ ↑
NOTHING domain domain + application
| Layer | Can Depend On | NEVER Depends On |
|---|
| Domain | Nothing (pure) | Application, Infrastructure, Spring |
| Application | Domain only | Infrastructure, Spring |
| Infrastructure | Domain + Application | - |
1. Domain Layer (domain/)
Pure Kotlin. NO framework dependencies. NO Spring annotations.
What Belongs Here
| Element | Purpose | Example |
|---|
| Entities | Core business objects with identity | Workspace.kt |
| Value Objects | Immutable domain concepts | WorkspaceId.kt, Email.kt |
| Repository Interfaces | Contracts for persistence (PORTS) | WorkspaceRepository.kt |
| Domain Events | Facts that happened in the domain | WorkspaceCreatedEvent.kt |
| Domain Exceptions | Business rule violations | WorkspaceNotFoundException.kt |
Entity Example
data class Workspace(
val id: WorkspaceId,
val name: WorkspaceName,
val ownerId: UserId,
val members: List<WorkspaceMember> = emptyList(),
val createdAt: Instant = Instant.now(),
) {
fun addMember(userId: UserId, role: WorkspaceRole): Workspace {
require(!hasMember(userId)) { "User is already a member" }
return copy(
members = members + WorkspaceMember(userId, role),
)
}
fun hasMember(userId: UserId): Boolean =
members.any { it.userId == userId }
fun isOwner(userId: UserId): Boolean = ownerId == userId
}
Value Object Example
@JvmInline
value class WorkspaceId(val value: UUID) {
companion object {
fun generate(): WorkspaceId = WorkspaceId(UUID.randomUUID())
}
}
@JvmInline
value class WorkspaceName(val value: String) {
init {
require(value.isNotBlank()) { "Workspace name cannot be blank" }
require(value.length <= 100) { "Workspace name too long" }
}
}
@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "Invalid email format" }
}
fun domain(): String = value.substringAfter("@")
}
Repository Interface (PORT)
interface WorkspaceRepository {
suspend fun save(workspace: Workspace): Workspace
suspend fun findById(id: WorkspaceId): Workspace?
suspend fun delete(id: WorkspaceId)
}
interface WorkspaceFinderRepository {
suspend fun findByOwnerId(ownerId: UserId): List<Workspace>
suspend fun findByMemberId(memberId: UserId): List<Workspace>
suspend fun existsByName(name: WorkspaceName): Boolean
}
Domain Exception
sealed class WorkspaceException(message: String) : RuntimeException(message)
class WorkspaceNotFoundException(id: WorkspaceId) :
WorkspaceException("Workspace not found: ${id.value}")
class WorkspaceAlreadyExistsException(name: WorkspaceName) :
WorkspaceException("Workspace already exists: ${name.value}")
class WorkspaceMemberAlreadyExistsException(userId: UserId) :
WorkspaceException("User is already a member: ${userId.value}")
Domain Event
data class WorkspaceCreatedEvent(
val workspaceId: WorkspaceId,
val ownerId: UserId,
val name: WorkspaceName,
val occurredAt: Instant = Instant.now(),
) : DomainEvent
Domain Structure
📁 domain/
├── Workspace.kt # Entity
├── WorkspaceMember.kt # Entity
├── WorkspaceId.kt # Value Object
├── WorkspaceName.kt # Value Object
├── WorkspaceRole.kt # Value Object (enum)
├── WorkspaceRepository.kt # Repository interface (write)
├── WorkspaceFinderRepository.kt # Repository interface (read)
├── WorkspaceException.kt # Domain exceptions
└── 📁 event/
└── WorkspaceCreatedEvent.kt # Domain event
2. Application Layer (application/)
Use cases organized by CQRS. Framework-agnostic.
CQRS Organization
📁 application/
├── 📁 create/ # Command: Create workspace
│ ├── CreateWorkspaceCommand.kt
│ ├── CreateWorkspaceCommandHandler.kt
│ └── WorkspaceCreator.kt
├── 📁 find/ # Query: Find workspaces
│ ├── FindWorkspaceQuery.kt
│ ├── FindWorkspaceQueryHandler.kt
│ └── WorkspaceFinder.kt
├── 📁 update/ # Command: Update workspace
└── 📁 delete/ # Command: Delete workspace
Command Pattern (Writes)
data class CreateWorkspaceCommand(
val name: String,
val ownerId: UUID,
)
class CreateWorkspaceCommandHandler(
private val creator: WorkspaceCreator,
) {
suspend fun handle(command: CreateWorkspaceCommand): WorkspaceId {
return creator.create(
name = WorkspaceName(command.name),
ownerId = UserId(command.ownerId),
)
}
}
class WorkspaceCreator(
private val repository: WorkspaceRepository,
private val finderRepository: WorkspaceFinderRepository,
private val eventPublisher: DomainEventPublisher,
private val auditLogger: WorkspaceAuditPort,
) {
suspend fun create(name: WorkspaceName, ownerId: UserId): WorkspaceId {
auditLogger.creating(name, ownerId)
if (finderRepository.existsByName(name)) {
throw WorkspaceAlreadyExistsException(name)
}
val workspace = Workspace(
id = WorkspaceId.generate(),
name = name,
ownerId = ownerId,
)
repository.save(workspace)
eventPublisher.publish(
WorkspaceCreatedEvent(
workspaceId = workspace.id,
ownerId = ownerId,
name = name,
),
)
auditLogger.created(workspace.id)
workspace.id
}
}
Query Pattern (Reads)
data class FindWorkspaceQuery(
val workspaceId: UUID,
)
class FindWorkspaceQueryHandler(
private val finder: WorkspaceFinder,
) {
suspend fun handle(query: FindWorkspaceQuery): Workspace {
return finder.findById(WorkspaceId(query.workspaceId))
}
}
class WorkspaceFinder(
private val repository: WorkspaceFinderRepository,
) {
suspend fun findById(id: WorkspaceId): Workspace {
return repository.findById(id)
?: throw WorkspaceNotFoundException(id)
}
suspend fun findByMember(memberId: UserId): List<Workspace> {
return repository.findByMemberId(memberId)
}
}
Key Principles
- Handlers are thin: Receive command/query → delegate to service → return
- Services contain logic: Validation, orchestration, event publishing
- NO Spring annotations: Application layer is framework-agnostic
- Return domain objects: Not DTOs (mapping happens in infrastructure)
3. Infrastructure Layer (infrastructure/)
Spring Boot integration. Implements domain ports.
Structure
📁 infrastructure/
├── 📁 http/ # REST controllers
│ ├── CreateWorkspaceController.kt
│ ├── FindWorkspaceController.kt
│ └── 📁 request/
│ └── CreateWorkspaceRequest.kt
│ └── 📁 response/
│ └── WorkspaceResponse.kt
├── 📁 persistence/ # Database (R2DBC)
│ ├── WorkspaceStoreR2DbcRepository.kt # Implements WorkspaceRepository
│ ├── 📁 entity/
│ │ └── WorkspaceEntity.kt
│ ├── 📁 mapper/
│ │ └── WorkspaceMapper.kt
│ └── 📁 repository/
│ └── WorkspaceR2DbcRepository.kt # Spring Data interface
├── 📁 event/
│ └── WorkspaceEventPublisher.kt
└── 📁 configuration/
└── WorkspaceConfiguration.kt # Bean definitions
Controller (HTTP Adapter)
@RestController
@RequestMapping("/api/workspaces")
class CreateWorkspaceController(
private val commandHandler: CreateWorkspaceCommandHandler,
) {
@PostMapping
suspend fun create(
@Valid @RequestBody request: CreateWorkspaceRequest,
): ResponseEntity<WorkspaceIdResponse> {
val command = CreateWorkspaceCommand(
name = request.name,
ownerId = request.ownerId,
)
val workspaceId = commandHandler.handle(command)
return ResponseEntity
.status(HttpStatus.CREATED)
.body(WorkspaceIdResponse(workspaceId.value))
}
}
data class CreateWorkspaceRequest(
@field:NotBlank
val name: String,
@field:NotNull
val ownerId: UUID,
)
data class WorkspaceResponse(
val id: UUID,
val name: String,
val ownerId: UUID,
val memberCount: Int,
val createdAt: Instant,
)
fun Workspace.toResponse() = WorkspaceResponse(
id = id.value,
name = name.value,
ownerId = ownerId.value,
memberCount = members.size,
createdAt = createdAt,
)
Persistence (Repository Adapter)
@Repository
class WorkspaceStoreR2DbcRepository(
private val r2dbcRepository: WorkspaceR2DbcRepository,
private val mapper: WorkspaceMapper,
) : WorkspaceRepository {
override suspend fun save(workspace: Workspace): Workspace {
val entity = mapper.toEntity(workspace)
val saved = r2dbcRepository.save(entity).awaitSingle()
return mapper.toDomain(saved)
}
override suspend fun findById(id: WorkspaceId): Workspace? {
return r2dbcRepository.findById(id.value).awaitSingleOrNull()
?.let { mapper.toDomain(it) }
}
override suspend fun delete(id: WorkspaceId) {
r2dbcRepository.deleteById(id.value).awaitSingleOrNull()
}
}
interface WorkspaceR2DbcRepository : ReactiveCrudRepository<WorkspaceEntity, UUID> {
fun findByOwnerId(ownerId: UUID): Flux<WorkspaceEntity>
}
@Table("workspaces")
(
id: UUID? = ,
name: String,
ownerId: UUID,
createdAt: Instant = Instant.now(),
updatedAt: Instant = Instant.now(),
)
{
: Workspace = Workspace(
id = WorkspaceId(requireNotNull(entity.id)),
name = WorkspaceName(entity.name),
ownerId = UserId(entity.ownerId),
createdAt = entity.createdAt,
)
: WorkspaceEntity = WorkspaceEntity(
id = domain.id.value,
name = domain.name.value,
ownerId = domain.ownerId.value,
createdAt = domain.createdAt,
)
}
Configuration (Wire Everything)
@Configuration
class WorkspaceConfiguration {
@Bean
fun workspaceCreator(
repository: WorkspaceRepository,
finderRepository: WorkspaceFinderRepository,
eventPublisher: DomainEventPublisher,
) = WorkspaceCreator(repository, finderRepository, eventPublisher)
@Bean
fun createWorkspaceCommandHandler(
creator: WorkspaceCreator,
) = CreateWorkspaceCommandHandler(creator)
@Bean
fun workspaceFinder(
repository: WorkspaceFinderRepository,
) = WorkspaceFinder(repository)
@Bean
fun findWorkspaceQueryHandler(
finder: WorkspaceFinder,
) = FindWorkspaceQueryHandler(finder)
}
Decision Tree: Where Does This Code Belong?
Is it business logic with NO framework dependencies?
├── YES → DOMAIN
│ ├── Has identity? → Entity
│ ├── Immutable concept? → Value Object
│ ├── Data access contract? → Repository Interface
│ └── Business event? → Domain Event
│
└── NO → Does it orchestrate domain operations?
├── YES → APPLICATION
│ ├── Writes data? → Command + Handler + Service
│ └── Reads data? → Query + Handler + Service
│
└── NO → INFRASTRUCTURE
├── HTTP? → Controller + Request/Response DTOs
├── Database? → Repository Implementation + Entity + Mapper
└── External service? → Adapter
Anti-Patterns
❌ Spring annotations in Domain - Domain must be pure Kotlin
❌ Business logic in Controllers - Controllers only handle HTTP
❌ Business logic in Repository implementations - Logic belongs in Domain/Application
❌ Exposing database entities - Always map to domain, then to DTOs
❌ Application depending on Infrastructure - Only Infrastructure depends down
❌ Fat handlers - Handlers should be thin, delegate to services
❌ Anemic domain models - Put behavior IN the entities
Error Handling Across Layers
Error propagation follows the hexagonal boundaries:
| Layer | Error Type | Responsibility |
|---|
| Domain | Domain exceptions | Pure business errors (e.g., InsufficientFundsException) |
| Application | Domain/application errors | Orchestrate use cases without introducing transport concerns |
| Infrastructure | HTTP-friendly responses | Map exceptions to status codes and ProblemDetail |
Rules:
- Domain errors bubble as domain exceptions – Keep domain exceptions pure Kotlin, no framework
dependencies.
- Application coordinates use cases, not HTTP semantics – Do not translate business failures
into transport-specific concepts inside handlers or services.
- Infrastructure maps to HTTP – Use
@ControllerAdvice / @RestControllerAdvice to convert
domain/application exceptions into ProblemDetail or another explicit API error contract.
Result<T> is optional, not the default contract – If used, standardize it deliberately at
the application boundary. Do not mix ad-hoc Result usage with exception-based flows in the same
feature.
class UserAlreadyExistsException(val email: Email) :
DomainException("User with email ${email.value} already exists")
class CreateUserHandler(private val userRepository: UserRepository) {
suspend fun handle(command: CreateUserCommand): UserId {
userRepository.findByEmail(command.email)?.let {
throw UserAlreadyExistsException(command.email)
}
return UserId.generate()
}
}
@RestControllerAdvice
class DomainExceptionHandler {
@ExceptionHandler(UserAlreadyExistsException::class)
fun handleConflict(ex: UserAlreadyExistsException) =
ResponseEntity.status(HttpStatus.CONFLICT)
.body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message))
}
Testing Strategy by Layer
Each layer has specific testing requirements (see
also: Decision Tree
and Architecture Tests):
| Layer | Test Type | Strategy |
|---|
| Domain | Pure unit tests | Pure logic, no Spring, no mocks needed for value objects/entities |
| Application | Plain unit / component-style tests | Mock or fake ports, verify orchestration without starting Spring |
| Infrastructure | Focused integration tests | Real DB, HTTP, broker, or container only where adapter realism matters |
@UnitTest
class EmailTest {
@Test
fun `should reject invalid email format`() {
shouldThrow<IllegalArgumentException> {
Email("invalid")
}
}
}
@UnitTest
class CreateUserHandlerTest {
private val userRepository = mockk<UserRepository>()
private val handler = CreateUserHandler(userRepository)
@Test
fun `should create user when email is unique`() = runTest {
coEvery { userRepository.findByEmail(any()) } returns null
coEvery { userRepository.save(any()) } returns testUser
val result = handler.handle(CreateUserCommand(email, name))
result shouldBe expectedUserId
}
}
@IntegrationTest
class UserControllerIntegrationTest {
@Test
fun `POST users should create user and return 201`() {
webTestClient.post().uri("/api/users")
.bodyValue(CreateUserRequest(email, name))
.exchange()
.expectStatus().isCreated
.expectBody<UserIdResponse>()
}
}
Commands
FEATURE="new-feature"; mkdir -p "server/smp/src/main/kotlin/com/profiletailors/smp/${FEATURE}/{domain,application,infrastructure}"
rg "import org.springframework" server/smp/src/main/kotlin/com/profiletailors/smp/*/domain/
Architecture Tests (ArchUnit)
Layer boundaries are enforced by ArchUnit in the smp module. Every new bounded context must be
added to the boundedContexts list so rules run for it.
- File:
server/smp/src/test/kotlin/com/profiletailors/smp/HexagonalArchTest.kt
- Component-scan rules:
server/smp/src/test/kotlin/com/profiletailors/smp/ComponentScanArchTest.kt
- Update the
boundedContexts list to include the new context name (matches
com.profiletailors.smp.{context}):
private val boundedContexts = listOf(
"authorization",
"credentials",
"governance",
"identity",
"platform",
"publishing",
"tenancy",
"audit",
"observability",
)
Run tests to verify rules apply to your feature:
./gradlew :server:smp:test --tests com.profiletailors.smp.HexagonalArchTest
Resources
- Hexagonal Architecture
- spring-boot skill - Framework patterns for Infrastructure
layer
- kotlin skill - Kotlin conventions for all layers
- ddd-architecture skill - DDD conformance inside the domain:
aggregate boundaries, identity-only inter-aggregate references, value-object immutability,
bounded-context isolation, ADR-backed decision enforcement. Complements this skill — that one
validates intra-domain references, this one validates inter-layer imports.
References
- references/kotlin-clean-architecture.md - Kotlin-specific patterns (records, sealed classes,
strongly-typed IDs)