DEV Community

Cover image for Clean Architecture in Practice: Swapping Database Clients and Adding GraphQL in Kotlin
Tetsuya Wakita
Tetsuya Wakita

Posted on • Edited on

Clean Architecture in Practice: Swapping Database Clients and Adding GraphQL in Kotlin

How Clean Architecture delivered on its promise — infrastructure and presentation changed completely, domain and application stayed identical.


The claim every architecture post makes

Every Clean Architecture tutorial promises the same thing: swap your database, change your API protocol, and the business logic stays untouched.

Most tutorials stop there. They show the diagram, explain the dependency rule, and leave you to wonder whether it actually holds when you try it.

This article is the proof of concept.

Starting from the project in Part 1 — a Spring Boot API backed by Spring Data R2DBC — I made two changes:

  1. Infrastructure: replaced Spring Data R2DBC with jOOQ for type-safe SQL
  2. Presentation: added a GraphQL API using Netflix DGS alongside the existing REST endpoints

Here's the git diff for the domain module: nothing. For the application module: nothing. Every use case, every domain entity, every value object, every error type — unchanged.

Let's walk through what actually changed, and why everything else didn't need to.


What we're changing and why

The original infrastructure layer used Spring Data R2DBC with a generated entity class and a Spring repository interface. It worked — but the mapping was manual and the queries were limited to what Spring Data's derived query methods could express.

jOOQ generates a type-safe DSL from the database schema. Instead of r2dbcRepository.findById(id), you write dsl.selectFrom(GITHUB_REPO).where(GITHUB_REPO.ID.eq(id)). The table name and column names are compile-time constants. A typo in a column name is a build error.

For the API layer, GraphQL offers something REST doesn't: clients specify exactly the fields they need. Adding it shouldn't change anything about how the business logic works — it's just a new way to receive requests and send responses.


Infrastructure: replacing Spring Data with jOOQ

Configuration

The jOOQ setup lives in infrastructure — the only module that knows about databases:

@Configuration
@EnableTransactionManagement
class JooqConfig(
    private val cfi: ConnectionFactory,
) {
    @Bean
    fun dsl(): DSLContext =
        DSL.using(
            DSL
                .using(cfi)
                .configuration()
                .derive(
                    Settings()
                        .withRenderQuotedNames(RenderQuotedNames.NEVER)
                        .withRenderNameCase(RenderNameCase.LOWER),
                ),
        )

    @Bean
    fun transactionalOperator(): TransactionalOperator =
        TransactionalOperator.create(R2dbcTransactionManager(cfi))
}
Enter fullscreen mode Exit fullscreen mode

DSLContext is initialized with the R2DBC ConnectionFactory — the same connection pool as before. jOOQ handles reactive execution through Reactor's Publisher API. The Settings configure lowercase, unquoted identifiers to match the PostgreSQL schema.

Code generation from the schema

jOOQ generates Kotlin classes directly from the Flyway migration SQL — the same SQL that was already in the project:

jooq {
    configuration {
        generator {
            name = "org.jooq.codegen.KotlinGenerator"
            database {
                name = "org.jooq.meta.extensions.ddl.DDLDatabase"
                properties {
                    property {
                        key = "scripts"
                        value = "src/main/resources/db/migration/*.sql"
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This generates GITHUB_REPO as a typed table reference and GithubRepoRecord as a typed row class. No new schema files. No separate ORM mapping configuration. The SQL migration is the single source of truth.

Type-safe queries with the same Either wrapping

The repository implementation looks different from before, but the interface it implements — GitHubRepoRepository — is identical:

@Repository
class GitHubRepoRepositoryImpl(
    private val dsl: DSLContext,
) : GitHubRepoRepository {

    override suspend fun findById(id: GitHubRepoId): Either<GitHubError, GitHubRepo> =
        either {
            val record =
                Either
                    .catch {
                        Mono
                            .from(
                                dsl
                                    .selectFrom(GITHUB_REPO)
                                    .where(GITHUB_REPO.ID.eq(id.value)),
                            ).map { it.toDomain() }
                            .awaitSingleOrNull()
                    }.mapLeft { GitHubError.RepositoryError("Failed to find repo: ${it.message}", it) }
                    .bind()
            if (record == null) raise(GitHubError.NotFound(id))
            record
        }
Enter fullscreen mode Exit fullscreen mode

GITHUB_REPO.ID is a typed column reference generated from the schema. .eq(id.value) is type-checked — passing a String where a Long is expected won't compile.

The error wrapping pattern is the same as before: Either.catch { } captures any thrown exception, mapLeft converts it to a GitHubError.RepositoryError, and .bind() short-circuits the either { } block on failure.

Upsert with jOOQ DSL

The save operation benefits from jOOQ's expressive SQL DSL for the INSERT ... ON CONFLICT upsert:

override suspend fun save(repo: GitHubRepo): Either<GitHubError, GitHubRepo> =
    Either
        .catch {
            Mono
                .from(
                    dsl
                        .insertInto(GITHUB_REPO)
                        .set(GITHUB_REPO.ID, repo.id.value)
                        .set(GITHUB_REPO.OWNER, repo.owner.value)
                        // ... remaining fields ...
                        .onConflict(GITHUB_REPO.ID)
                        .doUpdate()
                        .set(GITHUB_REPO.OWNER, excluded(GITHUB_REPO.OWNER))
                        // ... update fields ...
                        .returning(),
                ).awaitSingle()
                .toDomain()
        }.mapLeft { GitHubError.RepositoryError("Failed to save repo: ${it.message}", it) }
Enter fullscreen mode Exit fullscreen mode

.onConflict(GITHUB_REPO.ID).doUpdate() is standard SQL upsert semantics expressed as Kotlin. excluded(GITHUB_REPO.OWNER) references the value from the VALUES clause that was excluded by the conflict — the jOOQ equivalent of PostgreSQL's EXCLUDED.owner. The column references are compile-time constants. Mistype GITHUB_REPO.OWNR and the build fails.

The domain GitHubRepo entity is unchanged. The toDomain() mapping reads from GithubRepoRecord instead of the old Spring Data entity — but the output is the same domain object.


Presentation: adding GraphQL with Netflix DGS

Schema-first with DGS

Netflix DGS (Domain Graph Service) takes a schema-first approach. The GraphQL schema lives in presentation/src/main/resources/schema/github.graphqls:

scalar Long
scalar DateTime

type Query {
    githubRepo(id: Long!): GitHubRepo
    githubRepos(pageNumber: Int = 1, pageSize: Int = 20): GitHubRepoPage!
}

type Mutation {
    saveGitHubRepo(input: GitHubRepoInput!): GitHubRepo!
}

type GitHubRepo {
    id: Long!
    owner: String!
    name: String!
    fullName: String!
    description: String
    language: String
    stargazersCount: Int!
    forksCount: Int!
    isPrivate: Boolean!
    createdAt: DateTime!
    updatedAt: DateTime!
}

input GitHubRepoInput {
    id: Long!
    owner: String!
    # ...
}
Enter fullscreen mode Exit fullscreen mode

The ID type is replaced with Long — keeping it consistent with the domain's GitHubRepoId(Long). DateTime maps to OffsetDateTime in Kotlin. Both are custom scalars registered via graphql-java-extended-scalars:

@DgsComponent
class ScalarConfig {
    @DgsRuntimeWiring
    fun addScalar(builder: RuntimeWiring.Builder): RuntimeWiring.Builder =
        builder
            .scalar(ExtendedScalars.GraphQLLong)
            .scalar(ExtendedScalars.DateTime)
}
Enter fullscreen mode Exit fullscreen mode

DGS codegen generates Kotlin data classes from this schema at build time — GitHubRepo, GitHubRepoPage, GitHubRepoInput — parallel to how jOOQ generates classes from the SQL schema. The schema is the source of truth; the generated types are implementation details.

The DataFetcher: Either.fold as GraphQL adapter

GitHubRepoDataFetcher is the GraphQL equivalent of the REST controller. It receives requests, calls use cases, and translates Either results to GraphQL responses:

@DgsComponent
class GitHubRepoDataFetcher(
    private val gitHubRepoListUseCase: GitHubRepoListUseCase,
    private val gitHubRepoFindByIdUseCase: GitHubRepoFindByIdUseCase,
    private val gitHubRepoSaveUseCase: GitHubRepoSaveUseCase,
) {
    @DgsQuery(field = "githubRepo")
    suspend fun githubRepo(
        @InputArgument id: Long,
    ): GitHubRepo? =
        gitHubRepoFindByIdUseCase
            .execute(id)
            .fold(
                ifLeft = { error ->
                    when (error) {
                        is GitHubRepoFindByIdError.NotFound -> null
                        is GitHubRepoFindByIdError.InvalidId,
                        is GitHubRepoFindByIdError.FetchFailed,
                        -> throw GraphQLException(error.message)
                    }
                },
                ifRight = { repo -> repo.toGraphQL() },
            )

    @DgsQuery(field = "githubRepos")
    suspend fun githubRepos(
        @InputArgument pageNumber: Int = 1,
        @InputArgument pageSize: Int = 20,
    ): GitHubRepoPage =
        gitHubRepoListUseCase.execute(pageNumber, pageSize).fold(
            ifLeft = { error -> throw GraphQLException(error.message) },
            ifRight = { page -> page.toGraphQL() },
        )

    @DgsMutation(field = "saveGitHubRepo")
    suspend fun saveGitHubRepo(
        @InputArgument input: GitHubRepoInput,
    ): GitHubRepo =
        gitHubRepoSaveUseCase.execute(input.toDto()).fold(
            ifLeft = { error -> throw GraphQLException(error.message) },
            ifRight = { repo -> repo.toGraphQL() },
        )
}
Enter fullscreen mode Exit fullscreen mode

The pattern is identical to the REST controller: call the use case, fold on the Either, map Right to the response type, handle Left based on the error type. The only difference is the output format — ResponseEntity becomes a GraphQL type or a GraphQLException.

Because the schema types id as Long!, DGS handles the coercion automatically. There's no need to manually parse or validate the ID in the DataFetcher — the type system does it.

Compare githubRepo with the REST findById:

  • REST: NotFoundResponseEntity.notFound().build()
  • GraphQL: NotFoundnull (GraphQL convention for missing nullable fields)
  • REST: FetchFailedResponseEntity.badRequest()
  • GraphQL: FetchFailedthrow GraphQLException(error.message)

Different protocols, different conventions — same use case, same error types, same business logic.

The mapper: same pattern as REST DTOs

Type conversion between domain objects and GraphQL types follows the same extension function pattern as the REST DTOs:

fun DomainGitHubRepo.toGraphQL() =
    GitHubRepo(
        id = id.value,       // Long — matches scalar Long in schema
        owner = owner.value,
        name = name.value,
        fullName = fullName,
        // ...
    )

fun Page<DomainGitHubRepo>.toGraphQL() =
    GitHubRepoPage(
        items = items.map { it.toGraphQL() },
        totalCount = totalCount,
    )

fun GitHubRepoInput.toDto() =
    GitHubRepoDto(
        id = id,
        owner = owner,
        name = name,
        // ...
    )
Enter fullscreen mode Exit fullscreen mode

GitHubRepo here is the DGS-generated GraphQL type. DomainGitHubRepo is the domain entity — aliased to avoid the name collision. The mapper sits entirely within presentation, converting between what GraphQL needs and what the domain provides.

GitHubRepoInput.toDto() produces a GitHubRepoDto — a plain data class in the application layer. The DTO's toDomain() method then performs value-object construction with Arrow's either { } DSL, so validation errors propagate as Left through the use case rather than escaping as exceptions.


What didn't change: the proof

Here's the complete list of files modified across domain and application:

(none)
Enter fullscreen mode Exit fullscreen mode

Every use case interface and implementation: unchanged. Every domain entity and value object: unchanged. Every error type: unchanged. Every application test from Part 2: still passing, without modification. Every domain test from Part 2: still passing, without modification.

The GitHubRepoListUseCase doesn't know whether its results go to a REST response or a GraphQL query. GitHubRepoFindByIdUseCaseImpl doesn't know whether the repository behind it uses Spring Data or jOOQ. PageSize.of(101) returns Left(PageSizeError.AboveMaximum(101)) regardless of what database is running.

This is what the dependency rule buys you. Infrastructure and presentation know about the domain. The domain knows about neither.


The pattern that made this possible

Three design choices made both changes straightforward:

1. The repository interface is the only contract. Infrastructure implements GitHubRepoRepository. Whether the implementation uses jOOQ, Spring Data, or a flat file doesn't matter — as long as it returns the right Either<GitHubError, T>.

2. Presentation layers are pure adapters. Both the REST controller and the GraphQL DataFetcher do exactly the same thing: receive a request, call a use case, translate the Either result to the protocol's response format. Adding a new protocol means adding a new adapter — not changing anything that exists.

3. Either.fold is protocol-neutral. The use case returns Either<AppError, DomainResult>. What you do with Left and Right is entirely up to the presentation layer. REST maps them to HTTP status codes. GraphQL maps them to nullable fields and exceptions. The use case doesn't need to know which.

The architecture didn't just survive the change. It made the change straightforward.

The full source is on GitHub: https://github.com/wakita181009/clean-architecture-kotlin/tree/v2

Top comments (0)