| Metadata | Value |
|---|---|
| Status | Active |
| Version | 1.1.0 |
| Last Updated | 2026-09-10 |
| Author | Sangeetha Grantha Team |
| Document Type | Evidence record |
[!NOTE] Historical evidence: results, counts, commands, and observations below belong to the original work described here. The editorial update date is not a new test or corpus verification. For present behavior, use current quality checks.
Date: 2026-01-27
Scope: modules/backend/ and modules/shared/
Related: kotlin-code-review.md
Each item includes:
Mark items with:
[ ] Not started[~] In progress[x] Complete[-] Won’t do (with reason)Priority: P0 | Effort: L
Extract interfaces for all service classes to enable testing and flexibility.
KrithiService → IKrithiService / KrithiServiceImplImportService → IImportService / ImportServiceImplReferenceDataService → IReferenceDataService / ReferenceDataServiceImplBulkImportWorkerService → IBulkImportWorker / BulkImportWorkerServiceImplEntityResolutionService → IEntityResolver / EntityResolutionServiceImplQualityScoringService → IQualityScorer / QualityScoringServiceImplTransliterationService → ITransliterator / TransliterationServiceImplWebScrapingService → IWebScraper / WebScrapingServiceImplExample:
// Before (KrithiService.kt)
class KrithiService(private val dal: SangitaDal) {
suspend fun search(request: KrithiSearchRequest, publishedOnly: Boolean = true): KrithiSearchResult
}
// After
interface IKrithiService {
suspend fun search(request: KrithiSearchRequest, publishedOnly: Boolean = true): KrithiSearchResult
suspend fun getKrithi(id: Uuid): KrithiDto?
suspend fun createKrithi(request: KrithiCreateRequest): KrithiDto
suspend fun updateKrithi(id: Uuid, request: KrithiUpdateRequest): KrithiDto
// ...
}
class KrithiServiceImpl(private val dal: SangitaDal) : IKrithiService {
// Implementation unchanged
}
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/*.ktPriority: P1 | Effort: XL
Split the 847-line god class into focused components.
ManifestParser class for CSV parsing logicRateLimiter class for throttling logicTaskDispatcher class for channel managementWorkerPool class for worker lifecycleBatchCompletionHandler for stage transitionsTaskErrorBuilder for error payload constructionTarget Structure:
services/
├── bulkimport/
│ ├── BulkImportOrchestrator.kt # Main coordinator
│ ├── ManifestParser.kt # CSV parsing
│ ├── RateLimiter.kt # Domain rate limiting
│ ├── TaskDispatcher.kt # Channel management
│ ├── workers/
│ │ ├── ManifestWorker.kt
│ │ ├── ScrapeWorker.kt
│ │ └── ResolutionWorker.kt
│ └── handlers/
│ ├── BatchCompletionHandler.kt
│ └── TaskErrorHandler.kt
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/BulkImportWorkerService.ktPriority: P2 | Effort: L
Replace manual DI in App.kt with Koin modules.
build.gradle.ktsdi/AppModule.kt for service bindingsdi/DalModule.kt for repository bindingsApp.kt to use startKoin()Example:
// di/AppModule.kt
val appModule = module {
single { SangitaDal() }
single<IKrithiService> { KrithiServiceImpl(get()) }
single<IImportService> { ImportServiceImpl(get()) }
// ...
}
// App.kt
fun main() {
startKoin { modules(appModule, dalModule) }
embeddedServer(Netty, ...) {
// Dependencies injected via Koin
}.start(wait = true)
}
Files:
modules/backend/api/build.gradle.ktsmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/App.ktmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/di/Priority: P2 | Effort: S
200 → MAX_PAGE_SIZE15 * 60 * 1000 → CACHE_TTL_MS60, 120 → PER_DOMAIN_RATE_LIMIT, GLOBAL_RATE_LIMIT1, 3, 2 → named constants10_000, 60_000 → named constantsExample:
// Before (KrithiRepository.kt:598)
val safeSize = pageSize.coerceIn(1, 200)
// After
companion object {
const val MIN_PAGE_SIZE = 1
const val MAX_PAGE_SIZE = 200
}
val safeSize = pageSize.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE)
Files:
modules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/repositories/KrithiRepository.ktmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/BulkImportWorkerService.ktmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/EntityResolutionService.ktPriority: P2 | Effort: S
throw IllegalArgumentException with require()throw IllegalStateException with check()Example:
// Before (KrithiService.kt:236-241)
private fun parseUuidOrThrow(value: String, label: String): UUID =
try {
UUID.fromString(value)
} catch (ex: IllegalArgumentException) {
throw IllegalArgumentException("Invalid $label")
}
// After
private fun parseUuidOrThrow(value: String, label: String): UUID {
require(value.isNotBlank()) { "$label must not be blank" }
return runCatching { UUID.fromString(value) }
.getOrElse { throw IllegalArgumentException("Invalid $label: must be a valid UUID") }
}
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/KrithiService.ktmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/RouteHelpers.ktPriority: P3 | Effort: S
Create a reusable extension function for UUID parsing.
UuidParsing.kt with toJavaUuidOrNull() / toJavaUuidOrThrow() helpersExample:
// support/UuidParsing.kt
fun String?.toJavaUuidOrNull(label: String): UUID? = runCatching { this?.let(UUID::fromString) }.getOrNull()
fun String.toJavaUuidOrThrow(label: String): UUID {
require(this.isNotBlank()) { "$label must not be blank" }
return UUID.fromString(this)
}
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/support/UuidParsing.ktparseUuid* functionsPriority: P1 | Effort: M
KrithiService.kt:165, KrithiService.kt:194AdminKrithiRoutes.ktExample:
// Before
createdByUserId = null, // TODO: Extract from auth context
// After - create UserContextService
class UserContextService(private val call: ApplicationCall) {
fun currentUserId(): Uuid? = call.principal<JwtPrincipal>()?.userId?.toUuid()
}
// In service
createdByUserId = userContext.currentUserId()
Priority: P3 | Effort: M
Use builder pattern or parameter objects for functions with many parameters.
KrithiRepository.create() - 18 parameters → KrithiCreateParams data classKrithiRepository.update() - 16 parameters → KrithiUpdateParams data classDatabaseFactory.connect() - 8 parameters → ConnectionConfig data classExample:
// Before
suspend fun create(
title: String,
titleNormalized: String,
incipit: String?,
// ... 15 more parameters
): KrithiDto
// After
data class KrithiCreateParams(
val title: String,
val titleNormalized: String,
val incipit: String? = null,
// ... structured with defaults
)
suspend fun create(params: KrithiCreateParams): KrithiDto
Files:
modules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/repositories/KrithiRepository.ktmodules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/DatabaseFactory.ktPriority: P0 | Effort: L
Replace single admin token with proper JWT.
io.ktor:ktor-server-auth-jwt dependencyJwtConfig for token generation/validationSecurity.kt to use JWT authenticationExample:
// config/JwtConfig.kt
object JwtConfig {
val secret = System.getenv("JWT_SECRET") ?: error("JWT_SECRET required")
val issuer = "sangita-grantha"
val audience = "sangita-users"
val realm = "Sangita Grantha API"
fun generateToken(userId: Uuid, roles: List<String>): String {
return JWT.create()
.withIssuer(issuer)
.withAudience(audience)
.withClaim("userId", userId.toString())
.withClaim("roles", roles)
.withExpiresAt(Date(System.currentTimeMillis() + 3600_000))
.sign(Algorithm.HMAC256(secret))
}
}
// plugins/Security.kt
authentication {
jwt("admin-auth") {
realm = JwtConfig.realm
verifier(JWT.require(Algorithm.HMAC256(JwtConfig.secret))
.withIssuer(JwtConfig.issuer)
.build())
validate { credential ->
if (credential.payload.audience.contains(JwtConfig.audience)) {
JwtPrincipal(credential.payload)
} else null
}
}
}
Files:
modules/backend/api/build.gradle.ktsmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/plugins/Security.ktmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/config/JwtConfig.ktPriority: P1 | Effort: S
Remove API key from URL query parameters.
Example:
// Before (GeminiApiClient.kt:51-52)
url("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=$apiKey")
// After
url("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent")
header("x-goog-api-key", apiKey)
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/clients/GeminiApiClient.ktPriority: P1 | Effort: M
RequestValidation Ktor pluginExample:
// plugins/RequestValidation.kt
fun Application.configureRequestValidation() {
install(RequestValidation) {
validate<KrithiCreateRequest> { request ->
if (request.title.isBlank()) {
ValidationResult.Invalid("Title must not be blank")
} else {
ValidationResult.Valid
}
}
}
install(RateLimit) {
global {
rateLimiter(limit = 100, refillPeriod = 1.minutes)
}
}
}
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/plugins/RequestValidation.ktmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/App.ktPriority: P1 | Effort: M
Reduce search query from 3 queries to 1 using proper JOINs.
KrithiRepository.search() to use single JOIN queryExample:
// Before: 3 separate queries
val krithiDtos = baseQuery.map { it.toKrithiDto() }
val composersMap = ComposersTable.selectAll()...
val ragasMap = KrithiRagasTable.join(RagasTable)...
// After: Single query with JOINs
val query = KrithisTable
.join(ComposersTable, JoinType.INNER, KrithisTable.composerId, ComposersTable.id)
.leftJoin(KrithiRagasTable, { KrithisTable.id }, { KrithiRagasTable.krithiId })
.leftJoin(RagasTable, { KrithiRagasTable.ragaId }, { RagasTable.id })
.select(
KrithisTable.columns + ComposersTable.name + RagasTable.id + RagasTable.name + KrithiRagasTable.orderIndex
)
Files:
modules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/repositories/KrithiRepository.ktPriority: P2 | Effort: M
Cache frequently accessed reference data.
io.ktor:ktor-server-caching-headers plugin/composers, /ragas, /talas, etc.Example:
// plugins/Caching.kt
fun Application.configureCaching() {
install(CachingHeaders) {
options { call, outgoingContent ->
when (call.request.uri) {
in listOf("/v1/composers", "/v1/ragas", "/v1/talas") ->
CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 3600))
else -> null
}
}
}
}
Files:
modules/backend/api/build.gradle.ktsmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/plugins/Caching.ktPriority: P3 | Effort: S
Enable query logging for performance analysis.
Example:
// DatabaseFactory.kt
fun enableQueryLogging() {
TransactionManager.current().warnLongQueriesDuration = 100 // ms
addLogger(StdOutSqlLogger)
}
Files:
modules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/DatabaseFactory.ktPriority: P1 | Effort: M
TestDatabaseFactory with H2 in-memory databaseExample:
// test/support/TestDatabaseFactory.kt
object TestDatabaseFactory {
fun connectTestDb() {
DatabaseFactory.connect(
databaseUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL",
username = "sa",
password = "",
driverClassName = "org.h2.Driver"
)
// Run migrations
transaction {
SchemaUtils.create(
ComposersTable, RagasTable, TalasTable, KrithisTable, // ...
)
}
}
}
Files:
modules/backend/api/src/test/kotlin/com/sangita/grantha/backend/api/support/TestDatabaseFactory.ktmodules/backend/api/src/test/kotlin/com/sangita/grantha/backend/api/support/TestFixtures.ktPriority: P1 | Effort: L
KrithiServiceImportServiceEntityResolutionServiceQualityScoringServiceTest Structure:
class KrithiServiceTest {
private lateinit var dal: SangitaDal
private lateinit var service: KrithiService
@BeforeEach
fun setup() {
TestDatabaseFactory.connectTestDb()
dal = SangitaDal()
service = KrithiService(dal)
}
@Test
fun `search returns paginated results`() = runTest {
// Given
val request = KrithiSearchRequest(query = "test", page = 0, pageSize = 10)
// When
val result = service.search(request)
// Then
assertThat(result.page).isEqualTo(0)
assertThat(result.pageSize).isEqualTo(10)
}
}
Files:
modules/backend/api/src/test/kotlin/com/sangita/grantha/backend/api/services/KrithiServiceTest.ktmodules/backend/api/src/test/kotlin/com/sangita/grantha/backend/api/services/ImportServiceTest.ktPriority: P2 | Effort: M
Example:
/**
* Searches for krithis matching the given criteria.
*
* @param request Search parameters including filters and pagination
* @param publishedOnly If true, only returns PUBLISHED krithis (default for public API)
* @return Paginated search results with krithi summaries
* @throws IllegalArgumentException if page or pageSize are invalid
*/
suspend fun search(request: KrithiSearchRequest, publishedOnly: Boolean = true): KrithiSearchResult
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/modules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/repositories/Priority: P3 | Effort: S
Files:
application_documentation/02-architecture/adr/Priority: P2 | Effort: M
Example:
// config/LogbackConfig.kt
fun configure(env: ApiEnvironment) {
if (env.environment == "PROD") {
val encoder = LogstashEncoder()
// Configure JSON output
}
}
// MDC in routes
withContext(MDCContext("requestId" to call.callId.toString())) {
// Request handling
}
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/config/LogbackConfig.kt (programmatic configuration)Priority: P3 | Effort: M
Example:
// plugins/Metrics.kt
fun Application.configureMetrics() {
install(MicrometerMetrics) {
registry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)
meterBinders = listOf(
JvmMemoryMetrics(),
JvmGcMetrics(),
ProcessorMetrics()
)
}
}
Files:
modules/backend/api/build.gradle.ktsmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/plugins/Metrics.ktPriority: P2 | Effort: S
@Contextual for types requiring custom serialization (current DTOs use explicit serializers)Example:
@Serializable
data class KrithiSearchRequest(
val query: String? = null,
@SerialName("page")
val page: Int = 0, // Add: @Min(0)
@SerialName("pageSize")
val pageSize: Int = 50, // Add: @Min(1) @Max(200)
)
Files:
modules/shared/domain/src/commonMain/kotlin/com/sangita/grantha/shared/domain/model/*.ktPriority: P3 | Effort: S
KrithiDtos.kt (195 lines) into logical groupsImportDtos.kt (151 lines) into workflow and orchestrationTarget Structure:
domain/model/
├── krithi/
│ ├── KrithiDto.kt
│ ├── KrithiSearchDto.kt
│ ├── KrithiNotationDto.kt
│ └── KrithiEnums.kt
└── import/
├── ImportDto.kt
├── BatchDto.kt
└── ImportEnums.kt
Files:
modules/shared/domain/src/commonMain/kotlin/com/sangita/grantha/shared/domain/model/| Item | Status | Assignee | Notes | |——|——–|———-|——-| | 3.1 JWT Authentication | [x] | Sangeetha Grantha Architect | JWT + refresh + claims wired | | 3.2 API Key Headers | [x] | Sangeetha Grantha Architect | Header-based auth + redacted logging | | 1.1 Service Interfaces | [x] | Sangeetha Grantha Architect | Interfaces + impls + DI wiring | | 2.4 Complete TODOs | [x] | Sangeetha Grantha Architect | Auth user context + validation endpoint |
| Item | Status | Assignee | Notes | |——|——–|———-|——-| | 4.1 Optimize Search | [x] | Sangeetha Grantha Architect | JOIN rewrite + indexes (materialized view deferred) | | 2.1 Magic Numbers | [x] | Sangeetha Grantha Architect | Constants centralized | | 2.2 Preconditions | [x] | Sangeetha Grantha Architect | Require/check helpers applied | | 5.1 Test Database | [x] | Sangeetha Grantha Architect | H2 factory + fixtures |
| Item | Status | Assignee | Notes | |——|——–|———-|——-| | 1.2 Decompose Workers | [x] | Sangeetha Grantha Architect | Bulk import split into components | | 1.3 Koin DI | [x] | Sangeetha Grantha Architect | Koin modules + App.kt wiring | | 6.1 KDoc Comments | [x] | Sangeetha Grantha Architect | Services + repositories + packages | | 4.2 Response Caching | [x] | Sangeetha Grantha Architect | Cache headers + ETag |
A refactoring item is considered complete when:
Checklist generated from automated code review. Update status as items are completed.