How CQRS inside Clean Architecture gave reads a fast path while keeping writes honest
The read path is doing work it doesn't need to
In Part 3,
I swapped Spring Data R2DBC for jOOQ and added GraphQL alongside REST. The domain and application layers: zero files
changed.
But something bothered me about the read path. When a client calls GET /api/github-repos, here's what happens:
- The controller calls
gitHubRepoListUseCase.execute(pageNumber, pageSize) - The use case validates pagination parameters
- The repository queries the database
- The database rows are mapped into domain entities — constructing
GitHubRepoId,GitHubOwner,GitHubRepoNamevalue objects with full validation - The domain entities are mapped into REST response DTOs
- The DTOs are serialized to JSON
Steps 4 and 5 are pure overhead. The client asked for a list of repos. The read path doesn't modify anything, doesn't
enforce business invariants, doesn't trigger side effects. It just needs data — but it's constructing fully validated
domain objects, only to immediately unwrap them into flat DTOs.
Writes are different. When a client calls POST /api/github-repos, domain validation matters. The GitHubRepoId must
be positive. The GitHubOwner must not be blank. The GitHubRepoName must not be blank. Those invariants protect the
system's integrity. The domain model earns its keep on the write path.
This is the core insight behind CQRS: reads and writes have fundamentally different needs. Forcing them through the
same model means one path is always doing unnecessary work.
What CQRS changes in Clean Architecture
CQRS — Command Query Responsibility Segregation — separates the read model from the write model. In its simplest form (
no event sourcing, no separate databases), it means:
- Commands (writes) go through the full domain model: validation, invariants, business rules
- Queries (reads) bypass the domain and return DTOs directly from the database
The application layer splits into two sub-packages:
application/
├── command/ # Write path
│ ├── dto/github/
│ │ └── GitHubRepoDto.kt
│ ├── error/github/
│ │ └── GitHubRepoSaveError.kt
│ └── usecase/github/
│ ├── GitHubRepoSaveUseCase.kt
│ └── GitHubRepoSaveUseCaseImpl.kt
│
└── query/ # Read path
├── dto/
│ ├── PageDto.kt
│ └── github/
│ └── GitHubRepoQueryDto.kt
├── error/github/
│ ├── GitHubRepoFindByIdQueryError.kt
│ └── GitHubRepoListQueryError.kt
├── repository/github/
│ └── GitHubRepoQueryRepository.kt
└── usecase/github/
├── GitHubRepoFindByIdQueryUseCase.kt
├── GitHubRepoFindByIdQueryUseCaseImpl.kt
├── GitHubRepoListQueryUseCase.kt
└── GitHubRepoListQueryUseCaseImpl.kt
Separate DTOs, separate errors, separate repository interfaces. The write path and read path don't share
application-layer types. They share domain value objects for input validation — and nothing else.
The command side: unchanged domain flow
The write path keeps the full domain-driven approach from Parts 1-3. The save use case validates input by constructing
domain entities:
class GitHubRepoSaveUseCaseImpl(
private val gitHubRepoRepository: GitHubRepoRepository,
) : GitHubRepoSaveUseCase {
override suspend fun execute(dto: GitHubRepoDto): Either<GitHubRepoSaveError, GitHubRepo> =
either {
val repo = dto.toDomain().mapLeft(GitHubRepoSaveError::ValidationFailed).bind()
gitHubRepoRepository
.save(repo)
.mapLeft(GitHubRepoSaveError::SaveFailed)
.bind()
}
}
dto.toDomain() constructs every value object — GitHubRepoId.of(id), GitHubOwner.of(owner),
GitHubRepoName.of(name) — each returning Either. If any validation fails, the chain short-circuits with
ValidationFailed. If the repository call fails, it short-circuits with SaveFailed.
The domain repository interface is now write-only:
interface GitHubRepoRepository {
suspend fun save(repo: GitHubRepo): Either<GitHubError, GitHubRepo>
}
In Parts 1-3, this interface also had findById and list. Those methods are gone — reads no longer flow through the
domain. The domain repository does one thing: persist validated domain entities.
The query side: DTOs all the way
Here's the read path. No domain entities. No value object construction on the output path:
class GitHubRepoListQueryUseCaseImpl(
private val queryRepository: GitHubRepoQueryRepository,
) : GitHubRepoListQueryUseCase {
override suspend fun execute(
pageNumber: Int,
pageSize: Int,
): Either<GitHubRepoListQueryError, PageDto<GitHubRepoQueryDto>> =
either {
val validPageNumber =
PageNumber
.of(pageNumber)
.mapLeft(GitHubRepoListQueryError::InvalidPageNumber)
.bind()
val validPageSize =
PageSize
.of(pageSize)
.mapLeft(GitHubRepoListQueryError::InvalidPageSize)
.bind()
val limit = validPageSize.value
val offset = (validPageNumber.value - 1) * validPageSize.value
queryRepository.list(limit, offset).bind()
}
}
The return type tells the story: Either<GitHubRepoListQueryError, PageDto<GitHubRepoQueryDto>>. Not
Page<GitHubRepo>. The query returns a PageDto of GitHubRepoQueryDto — a flat data class with no domain types:
data class GitHubRepoQueryDto(
val id: Long,
val owner: String,
val name: String,
val fullName: String,
val description: String?,
val language: String?,
val stargazersCount: Int,
val forksCount: Int,
val isPrivate: Boolean,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime,
)
Plain types. Long, String, Int, Boolean. No GitHubRepoId, no GitHubOwner, no GitHubRepoName. The DTO is a
direct projection of the database row — no domain construction overhead.
The query repository: why it lives in the application layer
In Part 1, the
repository interface was defined in the domain layer — because the domain needs to express what persistence operations
it requires. That's still true for writes. GitHubRepoRepository.save() takes a domain entity and returns a domain
entity.
But the query repository is different:
interface GitHubRepoQueryRepository {
suspend fun findById(id: Long): Either<GitHubRepoFindByIdQueryError, GitHubRepoQueryDto>
suspend fun list(
limit: Int,
offset: Int,
): Either<GitHubRepoListQueryError, PageDto<GitHubRepoQueryDto>>
}
Two things stand out. First, the error types are the full sealed interfaces (GitHubRepoFindByIdQueryError,
GitHubRepoListQueryError), not just FetchFailed. The infrastructure implementation is responsible for resolving "not
found" — it queries the database and knows whether a row exists. The application layer handles input validation (
InvalidId). Each layer raises the errors within its scope.
Second, this interface lives in application/query/repository/, not domain/repository/. The reason is structural: it
takes primitives (Long, Int) and returns application-layer DTOs (GitHubRepoQueryDto, PageDto). It has no domain
types in its signature. Placing it in the domain layer would force the domain to know about GitHubRepoQueryDto — an
application-layer concept. That breaks the dependency rule.
The query repository is an application-layer port. Infrastructure implements it. The domain doesn't know it exists.
Pragmatic validation: reusing domain value objects
The query side doesn't construct domain entities — but it does reuse domain value objects for input validation. Look
at GitHubRepoFindByIdQueryUseCaseImpl:
class GitHubRepoFindByIdQueryUseCaseImpl(
private val queryRepository: GitHubRepoQueryRepository,
) : GitHubRepoFindByIdQueryUseCase {
override suspend fun execute(id: Long): Either<GitHubRepoFindByIdQueryError, GitHubRepoQueryDto> =
either {
val repoId = GitHubRepoId.of(id).mapLeft(GitHubRepoFindByIdQueryError::InvalidId).bind()
queryRepository.findById(repoId.value).bind()
}
}
GitHubRepoId.of(id) is a domain value object. The query uses it to validate that the ID is positive — the same rule
the write path enforces. But after validation, it extracts .value and passes the raw Long to the query repository.
The domain entity GitHubRepo is never constructed.
This is pragmatic CQRS. Domain validation rules (positive IDs, page size limits) are universal — they apply to reads and
writes equally. Duplicating that logic would be worse than reusing it. But domain entities — the full aggregate with
all its fields validated and wrapped — are only needed when writing.
The same pattern applies to pagination. PageNumber.of() and PageSize.of() enforce business constraints (minimum 1,
maximum 100). The query converts them to limit and offset — database concepts — and the query repository takes those
primitives directly.
Infrastructure: two repositories, one database
Both repositories read from and write to the same github_repo table. The split is logical, not physical.
The query repository implementation maps database records directly to DTOs:
@Repository
class GitHubRepoQueryRepositoryImpl(
private val dsl: DSLContext,
) : GitHubRepoQueryRepository {
override suspend fun findById(id: Long): Either<GitHubRepoFindByIdQueryError, GitHubRepoQueryDto> =
either {
val dto =
Either
.catch {
Mono
.from(
dsl
.selectFrom(GITHUB_REPO)
.where(GITHUB_REPO.ID.eq(id)),
).map { it.toQueryDto() }
.awaitSingleOrNull()
}.mapLeft { GitHubRepoFindByIdQueryError.FetchFailed(it.message ?: "Unknown error") }
.bind()
ensureNotNull(dto) { GitHubRepoFindByIdQueryError.NotFound(id) }
}
it.toQueryDto() maps a jOOQ GithubRepoRecord to GitHubRepoQueryDto. No domain entity involved. The database row
becomes a DTO in a single step. ensureNotNull from Arrow-kt handles the "not found" case — if the query returns
null, it raises NotFound directly. The application layer doesn't need to check for null; the infrastructure resolves
it.
Compare this with the write repository, which maps to a full domain entity through toDomain() — constructing value
objects, enforcing invariants. Two implementations of the same table, each optimized for its path.
Presentation: same pattern, different types
The controller now injects queries and commands as separate dependencies:
@RestController
@RequestMapping("/api/github-repos")
class GitHubRepoController(
private val gitHubRepoListQueryUseCase: GitHubRepoListQueryUseCase,
private val gitHubRepoFindByIdQueryUseCase: GitHubRepoFindByIdQueryUseCase,
private val gitHubRepoSaveUseCase: GitHubRepoSaveUseCase,
) {
Read endpoints call queries. The write endpoint calls the command use case. The Either.fold pattern is identical:
@GetMapping("/{id}")
suspend fun findById(@PathVariable id: Long): ResponseEntity<*> =
gitHubRepoFindByIdQueryUseCase.execute(id).fold(
ifLeft = { error ->
when (error) {
is GitHubRepoFindByIdQueryError.InvalidId ->
ResponseEntity.badRequest().body(ErrorResponse(error.message))
is GitHubRepoFindByIdQueryError.NotFound ->
ResponseEntity.notFound().build<Nothing>()
is GitHubRepoFindByIdQueryError.FetchFailed -> {
logger.error("Failed to fetch GitHub repo (id=$id): ${error.message}")
ResponseEntity.internalServerError().body(ErrorResponse("Internal server error"))
}
}
},
ifRight = { dto ->
ResponseEntity.ok(GitHubRepoResponse.fromQueryDto(dto))
},
)
The response DTO now has two factory methods: fromDomain() for commands (unwrapping value objects) and
fromQueryDto() for queries (direct field copy):
companion object {
fun fromDomain(repo: GitHubRepo) =
GitHubRepoResponse(
id = repo.id.value, // unwrap value object
owner = repo.owner.value,
// ...
)
fun fromQueryDto(dto: GitHubRepoQueryDto) =
GitHubRepoResponse(
id = dto.id, // already a Long
owner = dto.owner,
// ...
)
}
The same split applies to GraphQL. The mapper has GitHubRepoQueryDto.toGraphQL() for reads and
DomainGitHubRepo.toGraphQL() for writes. Different source types, same destination type. The presentation layer adapts
both paths to its protocol without knowing which path the data took to get there.
Tests: the specification still holds
Query tests mock the query repository. Command tests mock the domain repository. Each side has its own test fixtures:
class GitHubRepoListQueryUseCaseImplTest {
private val queryRepository = mockk<GitHubRepoQueryRepository>()
private val queryUseCase = GitHubRepoListQueryUseCaseImpl(queryRepository)
@Test
fun `execute returns Right with page when parameters are valid`() =
runTest {
val page = PageDto(totalCount = 1, items = listOf(sampleQueryDto()))
coEvery { queryRepository.list(offset = 0, limit = 20) } returns Either.Right(page)
queryUseCase.execute(1, 20).shouldBeRight(page)
}
@Test
fun `execute returns Left InvalidPageNumber when pageNumber is 0`() =
runTest {
val error = queryUseCase.execute(0, 20).shouldBeLeft()
error shouldBe GitHubRepoListQueryError.InvalidPageNumber(PageNumberError.BelowMinimum(0))
}
@Test
fun `execute calculates correct offset for page 2`() =
runTest {
val page = PageDto(totalCount = 30, items = listOf(sampleQueryDto()))
coEvery { queryRepository.list(offset = 20, limit = 20) } returns Either.Right(page)
queryUseCase.execute(2, 20).shouldBeRight(page)
}
}
Note the offset test. Page 2 with size 20 should produce offset = 20. This calculation —
(pageNumber - 1) * pageSize — happens in the query use case, not the repository. The repository receives limit and
offset as primitives. The business logic of pagination lives in the application layer; the database execution lives in
infrastructure.
The command tests are unchanged
from Part 2:
class GitHubRepoSaveUseCaseImplTest {
private val repository = mockk<GitHubRepoRepository>()
private val useCase = GitHubRepoSaveUseCaseImpl(repository)
@Test
fun `execute returns Left ValidationFailed when dto has invalid id`() =
runTest {
val dto = sampleDto().copy(id = 0L)
val error = useCase.execute(dto).shouldBeLeft()
error shouldBe GitHubRepoSaveError.ValidationFailed(GitHubError.InvalidId(0L))
}
}
Domain validation on the write path. DTO projection on the read path. Each test verifies the right behavior for its side
of the split.
The honest tradeoff
CQRS adds types. Specifically:
| Before (unified) | After (CQRS) |
|---|---|
| 1 repository interface | 2 repository interfaces |
| 1 DTO type | 2 DTO types (command + query) |
| 3 use cases, 1 error hierarchy per use case | 1 command + 2 queries, separate error hierarchies |
| 1 infrastructure implementation | 2 infrastructure implementations |
That's roughly double the application-layer surface area for the same feature set. For a project with one entity, this
feels like overkill. For a project with dozens of entities, where read and write patterns diverge significantly —
different fields projected, different joins, different caching strategies — the separation pays for itself.
The key question is: will your read and write patterns diverge? If yes, CQRS gives you the seam to evolve them
independently. If no — if every read returns exactly the same shape as the domain entity — the unified model from Parts
1-3 is simpler and sufficient.
In this project, the divergence is modest. But the architecture is now ready for it to grow. Adding a search query that
joins across tables, a dashboard aggregation that returns computed fields, or a denormalized read model for high-traffic
endpoints — none of these will touch the command side or the domain.
The full source is on GitHub: https://github.com/wakita181009/clean-architecture-kotlin/tree/v3
Top comments (0)