How 5 Gradle modules, Arrow-kt Either, and explicit DI keep your Spring Boot codebase actually maintainable.
Your Spring Boot app is more coupled than you think
Six months into most Spring Boot projects, something quietly breaks down. Your @Service starts importing JPA annotations. Your domain objects grow HTTP-specific fields. Writing a unit test requires spinning up an entire Spring context.
Nothing crashed. No one made a bad decision. The coupling crept in annotation by annotation.
Clean Architecture solves this with one rule: inner layers know nothing about outer layers. Your business logic never imports Spring. Your domain never touches PostgreSQL. These aren't conventions — in this implementation, they're enforced at compile time.
The patterns below — Arrow-kt Either, @JvmInline value classes, R2DBC with coroutines, and explicit dependency injection — are what made my production codebase actually maintainable.
The project: 5 Gradle modules, not 5 packages
Most Clean Architecture examples use packages. This implementation uses Gradle modules — and the difference is everything.
With packages, the compiler won't stop you from writing import com.example.infrastructure.JpaRepository inside your domain. With Gradle modules, that import is a build error.
clean-architecture-kotlin/
├── domain/ # Pure Kotlin. Zero framework dependencies.
├── application/ # Use cases. Still zero Spring.
├── infrastructure/ # Spring Data R2DBC.
├── presentation/ # Spring MVC controllers and DTOs.
└── framework/ # Spring Boot entry point. Wires everything.
Dependencies flow in one direction only:
framework
├── infrastructure ──► application ──► domain
└── presentation ──► application ──► domain
Want to swap PostgreSQL for MongoDB? Change infrastructure only. Moving from Spring MVC to Ktor? Change presentation only. The core doesn't know the difference — it literally can't, because the module boundaries prevent it.
The domain layer: making illegal states unrepresentable
The domain has no Spring, no JPA, no HTTP. It's pure Kotlin with Arrow-kt and coroutines.
Value objects with zero runtime cost
Using raw primitives is a trap:
// Which Long is the owner? Which is the repo?
fun save(ownerId: Long, repoId: Long): GitHubRepo
Value objects solve this — but wrapping primitives traditionally adds heap allocations. Kotlin's @JvmInline value class gives you the type safety at zero runtime overhead:
@JvmInline
value class GitHubRepoId private constructor(val value: Long) {
companion object {
operator fun invoke(value: Long) = GitHubRepoId(value)
fun of(value: Long) =
either {
ensure(value > 0L) { GitHubError.InvalidId(value) }
GitHubRepoId(value)
}
}
}
Three design choices worth noting:
-
private constructor— you can't create an invalidGitHubRepoIdby accident -
operator fun invoke— trusted internal construction withGitHubRepoId(42L) -
fun of(Long)— untrusted external input validates the domain invariant (must be positive), returningEitherand forcing the caller to handle the failure
The same pattern enforces business constraints. PageSize can only exist between 1 and 100:
@JvmInline
value class PageSize private constructor(val value: Int) {
companion object {
const val MIN_VALUE = 1
const val MAX_VALUE = 100
operator fun invoke(value: Int) = PageSize(value)
fun of(value: Int) = either {
ensure(value >= MIN_VALUE) { PageSizeError.BelowMinimum(value) }
ensure(value <= MAX_VALUE) { PageSizeError.AboveMaximum(value) }
PageSize(value)
}
}
}
If a PageSize exists, it's valid. The type system guarantees it — no runtime checks needed downstream.
Errors as sealed interfaces
Errors are values, not exceptions:
sealed interface GitHubError : DomainError {
data class InvalidId(val value: Long) : GitHubError {
override val message = "Invalid GitHub repo ID: $value (must be positive)"
}
data object InvalidName : GitHubError {
override val message = "GitHub repo name must not be blank"
}
data object InvalidOwner : GitHubError {
override val message = "GitHub owner must not be blank"
}
data class NotFound(val id: GitHubRepoId) : GitHubError {
override val message = "GitHub repo not found: ${id.value}"
}
data class RepositoryError(
override val message: String,
val cause: Throwable? = null,
) : GitHubError
}
when on a sealed interface is exhaustive. Add a new error variant and every when without an else clause becomes a compile error. You literally cannot silently ignore a new failure mode.
The repository interface
The domain defines what persistence looks like. Not how.
interface GitHubRepoRepository {
suspend fun findById(id: GitHubRepoId): Either<GitHubError, GitHubRepo>
suspend fun list(pageNumber: PageNumber, pageSize: PageSize): Either<GitHubError, Page<GitHubRepo>>
suspend fun save(repo: GitHubRepo): Either<GitHubError, GitHubRepo>
}
Every method is suspend. Every method returns Either. The infrastructure implements this contract — and the domain never cares whether it's PostgreSQL, an in-memory map, or something else entirely.
The application layer: use cases as living specifications
Here's something that rarely gets mentioned in Clean Architecture tutorials: transaction management belongs in the use case layer.
In a typical Spring Boot app, @Transactional ends up scattered across services and repositories — wherever someone remembers to add it. But a transaction boundary isn't a technical detail. It's a business decision: "these operations must succeed or fail together."
That decision belongs with the business logic. Which means it belongs in the use case.
This has a profound consequence. Because the application layer controls:
- What operations happen in a single transaction
- What the success and failure conditions are
- What domain rules are enforced
...the application module becomes a direct representation of your specification. Read the use cases, and you're reading the business requirements. Every execute() method maps to a user-facing operation in your spec document.
This unlocks specification-driven development: write the use case interface from the spec before any infrastructure exists, validate the business logic with tests, and wire the database last. Part 2 of this series covers exactly how that workflow plays out in practice.
The domain and application modules have no knowledge of Spring, PostgreSQL, or HTTP. They can be developed, tested, and validated in complete isolation. When a business rule changes, you change the application layer — and the compiler tells you exactly what breaks downstream.
Infrastructure is a detail you fill in last. The specification is what you build first.
Error handling: Either over exceptions
Exceptions are invisible in function signatures. A method that throws is indistinguishable from one that doesn't — until it blows up in production.
Either<E, A> makes failure explicit and typed. Right<A> holds the success value; Left<E> holds the error. The compiler forces callers to handle both.
Chaining with the Raise DSL
Arrow's either { } block gives you railway-oriented programming with readable, sequential code:
override suspend fun execute(
pageNumber: Int,
pageSize: Int,
): Either<GitHubRepoListError, Page<GitHubRepo>> =
either {
val validPageNumber =
PageNumber.of(pageNumber)
.mapLeft(GitHubRepoListError::InvalidPageNumber)
.bind()
val validPageSize =
PageSize.of(pageSize)
.mapLeft(GitHubRepoListError::InvalidPageSize)
.bind()
gitHubRepoRepository
.list(validPageNumber, validPageSize)
.mapLeft(GitHubRepoListError::FetchFailed)
.bind()
}
Each .bind() either unwraps the Right value or short-circuits the block with the Left. The code reads like the happy path. Errors are handled, not hidden.
Error transformation at layer boundaries
Each layer has its own error type. mapLeft translates between them:
override suspend fun execute(id: Long): Either<GitHubRepoFindByIdError, GitHubRepo> =
either {
val repoId = GitHubRepoId.of(id).mapLeft(GitHubRepoFindByIdError::InvalidId).bind()
gitHubRepoRepository
.findById(repoId)
.mapLeft { error ->
when (error) {
is GitHubError.NotFound -> GitHubRepoFindByIdError.NotFound(error)
is GitHubError.InvalidId,
is GitHubError.InvalidName,
is GitHubError.InvalidOwner,
is GitHubError.RepositoryError,
-> GitHubRepoFindByIdError.FetchFailed(error)
}
}.bind()
}
First, the raw Long is validated through GitHubRepoId.of(id). An invalid ID short-circuits immediately with InvalidId — before touching the repository. For a valid ID, the repository call's GitHubError is mapped: GitHubError.NotFound → GitHubRepoFindByIdError.NotFound; everything else → GitHubRepoFindByIdError.FetchFailed. The when is exhaustive — add a new GitHubError variant and this won't compile until you decide how to surface it at the application layer.
Infrastructure: coroutines over reactive streams
The infrastructure layer implements GitHubRepoRepository with Spring Data R2DBC, wrapping the reactive API in coroutines immediately:
@Repository
class GitHubRepoRepositoryImpl(
private val r2dbcRepository: GitHubRepoR2dbcRepository,
) : GitHubRepoRepository {
override suspend fun findById(id: GitHubRepoId): Either<GitHubError, GitHubRepo> =
either {
val entity =
Either
.catch { r2dbcRepository.findById(id.value).awaitSingleOrNull() }
.mapLeft { GitHubError.RepositoryError("Failed to find repo: ${it.message}", it) }
.bind()
if (entity == null) raise(GitHubError.NotFound(id))
entity.toDomain()
}
override suspend fun list(
pageNumber: PageNumber,
pageSize: PageSize,
): Either<GitHubError, Page<GitHubRepo>> =
Either.catch {
val offset = (pageNumber.value - 1) * pageSize.value
val items = r2dbcRepository
.list(pageSize.value, offset)
.asFlow()
.map { it.toDomain() }
.toList()
val total = r2dbcRepository.count().awaitSingle()
Page(totalCount = total.toInt(), items = items)
}.mapLeft { GitHubError.RepositoryError("Failed to list repos: ${it.message}", it) }
}
.awaitSingleOrNull() and .asFlow() bridge Reactor's Mono/Flux to Kotlin coroutines. Reactive plumbing stays entirely inside infrastructure. The domain sees only suspend functions with Either returns.
Presentation and DI: no magic allowed
Either.fold as an HTTP adapter
The controller's job is to translate domain results into HTTP responses. Either.fold does exactly that:
@GetMapping("/{id}")
suspend fun findById(
@PathVariable id: Long,
): ResponseEntity<*> =
gitHubRepoFindByIdUseCase.execute(id).fold(
ifLeft = { error ->
when (error) {
is GitHubRepoFindByIdError.InvalidId ->
ResponseEntity.badRequest().body(ErrorResponse(error.message))
is GitHubRepoFindByIdError.NotFound ->
ResponseEntity.notFound().build<Nothing>()
is GitHubRepoFindByIdError.FetchFailed -> {
logger.error("Failed to fetch GitHub repo (id=$id): ${error.message}")
ResponseEntity.internalServerError().body(ErrorResponse("Internal server error"))
}
}
},
ifRight = { repo ->
ResponseEntity.ok(GitHubRepoResponse.fromDomain(repo))
},
)
A 400 (invalid ID), a 404 (not found), and a 500 (internal failure) are three different responses. The when on GitHubRepoFindByIdError ensures you handle all three explicitly. There's no fallthrough, no forgotten case.
Explicit DI: what you see is what you get
The use case classes have no Spring annotations — no @Service, no @Component. They're plain Kotlin classes.
@Configuration
class UseCaseConfig(
private val gitHubRepoRepository: GitHubRepoRepository,
) {
@Bean fun gitHubRepoListUseCase() = GitHubRepoListUseCaseImpl(gitHubRepoRepository)
@Bean fun gitHubRepoFindByIdUseCase() = GitHubRepoFindByIdUseCaseImpl(gitHubRepoRepository)
@Bean fun gitHubRepoSaveUseCase() = GitHubRepoSaveUseCaseImpl(gitHubRepoRepository)
}
Every dependency is explicit. Read UseCaseConfig and you know exactly what each use case receives. And because use cases are plain classes, you instantiate them in unit tests with a constructor call — no Spring context needed.
The honest tradeoff
This architecture has real costs:
| Cost | Benefit |
|---|---|
| More files, more boilerplate | Swap databases by changing one module |
| Steeper onboarding | Unit tests with no Spring context |
| Verbose layer boundaries | New engineers can't accidentally break the dependency rule |
| Explicit DI wiring | Every dependency is visible and traceable |
One tradeoff deserves more than a table row. Keeping Spring out of the application module means you can't use @Transactional directly on use cases — it's a Spring annotation, and importing it would break the module boundary. Instead, you define a TransactionRunner port in application, implement it with Spring's TransactionalOperator in framework, and wire it there via DI. That's extra ceremony.
The payoff: domain and application have zero Spring imports. They're specification-driven — testable, readable, and independent of any framework. Which side of that tradeoff you land on depends on how seriously you take the boundary.
For a weekend project or prototype, this is overkill. Don't use it there.
For a team building a system that will grow — new engineers, evolving requirements, possible infrastructure changes — the investment pays back quickly. The boundaries that feel like friction in week one are the same boundaries that let you change the database without touching business logic in year two.
The full source is on GitHub: https://github.com/wakita181009/clean-architecture-kotlin/tree/v1
Top comments (0)