Generic Repository
Build a type-safe generic repository system that exercises Kotlin’s generics in
one place: declaration-site variance (out/in), type bounds with where,
reified type parameters, sealed result types, and class delegation with by.
If you’ve written a generic Repository<T> in TS or fought Go’s lack of
generics-with-variance, this is where Kotlin’s type system earns its keep.
What you’ll practice
Section titled “What you’ll practice”- Generic interface design:
Repository<T, ID>andReadOnlyRepository<out T, in ID> - Declaration-site variance:
outfor producers,infor consumers - Reified type parameters for a type-safe service locator (no
Class<T>tokens) - Sealed classes for operation results (
RepoResult<T>) - Class delegation with
byfor decorator patterns (logging, caching) whereclauses for multiple type bounds
Requirements
Section titled “Requirements”-
Define a covariant read interface
ReadOnlyRepository<out T, in ID>(find / count / exists) and an invariantRepository<T, ID>that adds writes (save / delete / update). -
Implement
InMemoryRepository<T, ID>backed by a map. It takes an ID extractor(T) -> IDso it knows how to pull the key off an entity, and it boundsTwithwhere T : Identifiable<ID>. -
Model operation outcomes as a sealed
RepoResult<out T>—Success,NotFound,ValidationError,Conflict— and wrap the repo in aSafeRepositorythat returns those instead of throwing. -
Add decorators via
bydelegation: aLoggingRepositoryand aCachingRepositorythat forward everything to a delegate and override only the methods they care about. -
Build a
ServiceLocatorusinginline fun <reified T>so callers fetch services by type alone —locator.get<Repository<User, String>>()— with noClass<T>argument.
Architecture
Section titled “Architecture”Repository<T, ID> extends the covariant read interface and is implemented by
InMemoryRepository. The logging and caching decorators wrap any Repository
by delegation, and the ServiceLocator resolves repositories by their reified
type.
flowchart TB RO["ReadOnlyRepository<out T, in ID><br/>findById · findAll · count · exists"] R["Repository<T, ID><br/>save · delete · update"] IM["InMemoryRepository<T, ID><br/>map-backed, where T : Identifiable"] LOG["LoggingRepository<br/>by delegation"] CACHE["CachingRepository<br/>by delegation"] SL["ServiceLocator<br/>reified register / get"] R --> RO IM --> R LOG -->|wraps| IM CACHE -->|wraps| IM SL -.resolves.-> R
The worked solution
Section titled “The worked solution”A single Gradle module, split into five focused files:
Directorygeneric-repository/
- build.gradle.kts deps + build config
- settings.gradle.kts project name
Directorysrc/main/kotlin/com/example/repository/
- Models.kt entities,
Identifiable, sealedRepoResult - Repository.kt the generic interfaces +
InMemoryRepository - Decorators.kt
LoggingRepository,CachingRepositoryviaby - Locator.kt
ServiceLocatorwith reified types - Main.kt the demo wiring it all together
- Models.kt entities,
build.gradle.kts
Section titled “build.gradle.kts”Nothing exotic — just the Kotlin JVM plugin and the application plugin so
./gradlew run works. No extra runtime dependencies; this is pure language
features.
plugins { kotlin("jvm") version "2.1.0" application}
group = "com.example"version = "1.0-SNAPSHOT"
repositories { mavenCentral()}
kotlin { jvmToolchain(21)}
dependencies { testImplementation(kotlin("test"))}
tasks.test { useJUnitPlatform()}
application { mainClass.set("com.example.repository.MainKt")}rootProject.name = "generic-repository"Models.kt
Section titled “Models.kt”The domain types. Identifiable<ID> is the contract every entity satisfies so
the repository can extract a key. RepoResult<out T> is a sealed hierarchy —
the out makes it covariant, which is why the no-payload cases
(NotFound, ValidationError, Conflict) can be RepoResult<Nothing> and
still slot in anywhere a RepoResult<T> is expected (Nothing is a subtype of
everything).
package com.example.repository
// --- Identifiable interface: every entity has an ID ---
interface Identifiable<ID> { val id: ID}
// --- Entity data classes ---
enum class Role { ADMIN, USER, MODERATOR }
data class User( override val id: String, val name: String, val email: String, val role: Role = Role.USER) : Identifiable<String>
data class Product( override val id: String, val name: String, val price: Double) : Identifiable<String>
// --- Animal hierarchy for variance demo ---
open class Animal(val name: String) { override fun toString() = "${this::class.simpleName}(name=$name)"}
class Dog(name: String) : Animal(name)class Cat(name: String) : Animal(name)
// --- Sealed result type for safe operations ---
sealed class RepoResult<out T> { data class Success<T>(val data: T) : RepoResult<T>() data class NotFound(val id: String) : RepoResult<Nothing>() data class ValidationError(val errors: List<String>) : RepoResult<Nothing>() data class Conflict(val message: String) : RepoResult<Nothing>()}
fun <T> RepoResult<T>.getOrNull(): T? = when (this) { is RepoResult.Success -> data else -> null}
fun <T> RepoResult<T>.getOrThrow(): T = when (this) { is RepoResult.Success -> data is RepoResult.NotFound -> throw NoSuchElementException("Entity with id '$id' not found") is RepoResult.ValidationError -> throw IllegalArgumentException("Validation failed: $errors") is RepoResult.Conflict -> throw IllegalStateException("Conflict: $message")}
fun <T> RepoResult<T>.describe(): String = when (this) { is RepoResult.Success -> "Success: $data" is RepoResult.NotFound -> "Not Found: Entity with id '$id' not found" is RepoResult.ValidationError -> "Validation Error: $errors" is RepoResult.Conflict -> "Conflict: $message"}Because RepoResult is sealed, the when blocks above are exhaustive — no
else branch needed, and the compiler will flag you if you add a fifth case and
forget to handle it.
Repository.kt
Section titled “Repository.kt”The heart of the exercise. ReadOnlyRepository<out T, in ID> is covariant in the
value type and contravariant in the key type. The one wrinkle: exists(id: ID)
takes ID as a parameter, which would normally forbid the in ID position from
also being read — here ID is in, so that’s fine, but exists is annotated
@UnsafeVariance to opt out of the variance check for that one spot (it’s safe
because nothing escapes).
package com.example.repository
// --- Read-only repository interface (covariant in T) ---// out T: ReadOnlyRepository<Dog> IS-A ReadOnlyRepository<Animal>// This is safe because we only READ values of type T (never accept T as input).
interface ReadOnlyRepository<out T, in ID> { fun findById(id: ID): T? fun findAll(): List<T> fun count(): Int fun exists(id: @UnsafeVariance ID): Boolean}
// --- Full repository interface (invariant -- read + write) ---
interface Repository<T, ID> : ReadOnlyRepository<T, ID> { fun save(entity: T): T fun delete(id: ID): Boolean fun update(entity: T): T}
// --- In-memory implementation ---// T must be Identifiable so we can extract the ID from entities.// Multiple type bounds using `where` clause.
class InMemoryRepository<T, ID>( private val idExtractor: (T) -> ID) : Repository<T, ID> where T : Identifiable<ID> {
private val store = mutableMapOf<ID, T>()
override fun findById(id: ID): T? = store[id]
override fun findAll(): List<T> = store.values.toList()
override fun count(): Int = store.size
override fun exists(id: ID): Boolean = store.containsKey(id)
override fun save(entity: T): T { val id = idExtractor(entity) store[id] = entity return entity }
override fun delete(id: ID): Boolean = store.remove(id) != null
override fun update(entity: T): T { val id = idExtractor(entity) require(store.containsKey(id)) { "Entity with id '$id' not found for update" } store[id] = entity return entity }}
// --- Safe repository operations returning sealed RepoResult ---
class SafeRepository<T, ID>( private val delegate: Repository<T, ID>, private val validate: (T) -> List<String> = { emptyList() }) where T : Identifiable<ID> {
fun safeFindById(id: ID): RepoResult<T> { val entity = delegate.findById(id) return if (entity != null) RepoResult.Success(entity) else RepoResult.NotFound(id.toString()) }
fun safeSave(entity: T): RepoResult<T> { val errors = validate(entity) if (errors.isNotEmpty()) return RepoResult.ValidationError(errors)
val id = entity.id if (delegate.exists(id)) return RepoResult.Conflict("Entity with id '$id' already exists")
return RepoResult.Success(delegate.save(entity)) }}
// --- Filtered read-only repository view ---// Demonstrates variance: returns ReadOnlyRepository<out T> which is covariant.
fun <T : Identifiable<ID>, ID> Repository<T, ID>.filteredView( predicate: (T) -> Boolean): ReadOnlyRepository<T, ID> { val parent = this return object : ReadOnlyRepository<T, ID> { override fun findById(id: ID): T? = parent.findById(id)?.takeIf(predicate) override fun findAll(): List<T> = parent.findAll().filter(predicate) override fun count(): Int = findAll().size override fun exists(id: ID): Boolean = findById(id) != null }}Three things worth pausing on:
- The
where T : Identifiable<ID>clause is the multi-bound form of a constraint. A single bound can go inline (<T : Identifiable<ID>>), butwherescales to several bounds (e.g.where T : Identifiable<ID>, T : Any) and reads cleaner. InMemoryRepositorytakes anidExtractor: (T) -> IDlambda instead of hard-codingentity.id. Even thoughTisIdentifiable, passing the extractor keeps the key logic explicit and lets you key on something other than the declared id if you ever want to.filteredViewis an extension function returning aReadOnlyRepository<T, ID>built from an anonymousobject— a lightweight, read-only filtered view over the live repository, no copying.
Decorators.kt
Section titled “Decorators.kt”This is class delegation, Kotlin’s killer feature for the decorator pattern.
: Repository<T, ID> by delegate auto-generates a forwarding implementation of
every Repository method that just calls delegate. You then override only the
methods you actually want to change — no boilerplate pass-through methods like
you’d hand-write in TS or Go.
package com.example.repository
// --- Logging decorator using class delegation (by) ---// `by delegate` forwards ALL Repository methods. We override only the ones we want to log.
class LoggingRepository<T, ID>( private val delegate: Repository<T, ID>) : Repository<T, ID> by delegate where T : Identifiable<ID> {
override fun findById(id: ID): T? { println("[LOG] findById: $id") return delegate.findById(id) }
override fun findAll(): List<T> { println("[LOG] findAll") return delegate.findAll() }
override fun save(entity: T): T { println("[LOG] save: $entity") return delegate.save(entity) }
override fun delete(id: ID): Boolean { println("[LOG] delete: $id") return delegate.delete(id) }
override fun update(entity: T): T { println("[LOG] update: $entity") return delegate.update(entity) }
override fun count(): Int { println("[LOG] count") return delegate.count() }
override fun exists(id: ID): Boolean { println("[LOG] exists: $id") return delegate.exists(id) }}
// --- Caching decorator using class delegation (by) ---// Only caches findById. Other methods delegate directly.
class CachingRepository<T, ID>( private val delegate: Repository<T, ID>, private val maxCacheSize: Int = 100) : Repository<T, ID> by delegate where T : Identifiable<ID> {
private val cache = LinkedHashMap<ID, T>(16, 0.75f, true) // LRU order
override fun findById(id: ID): T? { // Check cache first cache[id]?.let { return it }
// Cache miss -- fetch from delegate and cache return delegate.findById(id)?.also { entity -> if (cache.size >= maxCacheSize) { val oldest = cache.keys.first() cache.remove(oldest) } cache[id] = entity } }
override fun save(entity: T): T { val saved = delegate.save(entity) cache[entity.id] = saved // update cache return saved }
override fun delete(id: ID): Boolean { cache.remove(id) // invalidate cache return delegate.delete(id) }
override fun update(entity: T): T { val updated = delegate.update(entity) cache[entity.id] = updated // update cache return updated }
fun cacheStats(): String = "Cache size: ${cache.size}/$maxCacheSize"}CachingRepository uses a LinkedHashMap with access-order set to true, which
gives you LRU iteration for free — the cache keys are walked oldest-first, so
evicting cache.keys.first() drops the least-recently-used entry.
Locator.kt
Section titled “Locator.kt”The reified payoff. Without reified you’d have to thread a Class<T> token
through every call (the Java way). With inline fun <reified T>, the type
parameter survives into the function body, so T::class.qualifiedName works and
the cast services[key] as? T is a real runtime check.
package com.example.repository
// --- Service Locator with reified type parameters ---// Demonstrates: inline + reified to avoid passing Class<T> objects.
class ServiceLocator { // Store services keyed by their fully qualified class name private val services = mutableMapOf<String, Any>()
// Register a service instance by its type // `reified` lets us access T::class at runtime -- only works with `inline` inline fun <reified T : Any> register(service: T) { val key = T::class.qualifiedName ?: T::class.simpleName ?: throw IllegalArgumentException("Cannot determine type name") services[key] = service }
// Retrieve a service by type -- no Class<T> parameter needed inline fun <reified T : Any> get(): T { val key = T::class.qualifiedName ?: T::class.simpleName ?: throw IllegalArgumentException("Cannot determine type name") return services[key] as? T ?: throw IllegalStateException("Service not registered: $key") }
// Try to retrieve a service, return null if not found inline fun <reified T : Any> getOrNull(): T? { val key = T::class.qualifiedName ?: T::class.simpleName ?: throw IllegalArgumentException("Cannot determine type name") return services[key] as? T }
// Check if a service is registered inline fun <reified T : Any> isRegistered(): Boolean { val key = T::class.qualifiedName ?: T::class.simpleName ?: throw IllegalArgumentException("Cannot determine type name") return services.containsKey(key) }
fun registeredCount(): Int = services.size}
// --- Type-safe filtering with reified types ---// Demonstrates: reified for runtime type checks on collections
inline fun <reified T> List<Any>.filterByType(): List<T> { return this.filterIsInstance<T>()}
inline fun <reified T> typeNameOf(): String { return T::class.simpleName ?: "Unknown"}Main.kt
Section titled “Main.kt”The demo wires every piece together: basic CRUD, the variance assignment, sealed
results via SafeRepository, the logging decorator, the reified locator, and the
filtered view.
package com.example.repository
fun main() { println("=== Generic Repository Demo ===") println()
// 1. Basic CRUD with InMemoryRepository println("--- Basic CRUD ---")
val userRepo: Repository<User, String> = InMemoryRepository { it.id }
val alice = userRepo.save(User("u1", "Alice", "alice@corp.com", Role.ADMIN)) val bob = userRepo.save(User("u2", "Bob", "bob@corp.com", Role.USER)) val charlie = userRepo.save(User("u3", "Charlie", "charlie@corp.com", Role.USER))
println("Saved: $alice") println("Saved: $bob") println("Saved: $charlie") println("Find u1: ${userRepo.findById("u1")}") println("Find u999: ${userRepo.findById("u999")}") println("Count: ${userRepo.count()}") println("Exists u2: ${userRepo.exists("u2")}") println()
// 2. Variance demo -- ReadOnlyRepository is covariant println("--- Variance Demo ---")
data class DogEntity(override val id: String, val name: String) : Identifiable<String>
val dogRepo: Repository<DogEntity, String> = InMemoryRepository { it.id } dogRepo.save(DogEntity("d1", "Rex")) dogRepo.save(DogEntity("d2", "Buddy"))
// Because of `out T`, a Repository<DogEntity> can be read through a // ReadOnlyRepository view, and assigned to a wider read-only type. val readOnlyDogRepo: ReadOnlyRepository<DogEntity, String> = dogRepo println("All dogs (read-only, covariant): ${readOnlyDogRepo.findAll()}")
val readOnlyRepos: List<ReadOnlyRepository<Identifiable<String>, String>> = listOf( dogRepo as ReadOnlyRepository<Identifiable<String>, String>, ) println("Read-only view works with supertype: ${readOnlyRepos.isNotEmpty()}") println()
// 3. Sealed result types -- safe operations println("--- Sealed Result Types ---")
val safeRepo = SafeRepository(userRepo) { user: User -> buildList { if (user.name.isBlank()) add("Name must not be blank") if (!user.email.contains("@")) add("Email must contain @") } }
println(safeRepo.safeFindById("u1").describe()) println(safeRepo.safeFindById("u999").describe())
val invalidUser = User("u5", "", "invalid", Role.USER) println(safeRepo.safeSave(invalidUser).describe()) println()
// 4. Logging decorator using `by` delegation println("--- Logging Decorator (by delegation) ---")
val loggingRepo = LoggingRepository(userRepo) loggingRepo.save(User("u4", "Diana", "diana@corp.com", Role.USER)) val diana = loggingRepo.findById("u4") println("Found via logging repo: $diana") loggingRepo.findAll() loggingRepo.count() println()
// 5. Service Locator with reified types println("--- Service Locator (reified types) ---")
val locator = ServiceLocator()
val productRepo: Repository<Product, String> = InMemoryRepository { it.id } productRepo.save(Product("p1", "Widget", 9.99)) productRepo.save(Product("p2", "Gadget", 24.99))
locator.register(userRepo) locator.register(productRepo)
// Retrieve by type -- no string keys, no Class<T> val retrievedUserRepo = locator.get<Repository<User, String>>() val retrievedProductRepo = locator.get<Repository<Product, String>>()
println("User repo: ${retrievedUserRepo.count()} users") println("Product repo: ${retrievedProductRepo.count()} products") println("User from locator: ${retrievedUserRepo.findById("u1")}") println()
// 6. Filtered repository view println("--- Filtered Repository ---")
val adminsOnly = userRepo.filteredView { it.role == Role.ADMIN } val usersOnly = userRepo.filteredView { it.role == Role.USER }
println("Admins only: ${adminsOnly.findAll()}") println("Users only: ${usersOnly.findAll()}")}The line val readOnlyDogRepo: ReadOnlyRepository<DogEntity, String> = dogRepo
is the whole point of variance — a Repository is assignable to its read-only
covariant supertype with zero ceremony, and locator.get<Repository<User, String>>()
is the reified payoff: type-directed lookup with no Class<T> token in sight.
Run it
Section titled “Run it”-
From the project directory, run the demo:
Terminal window ./gradlew run -
You should see each section print in order — CRUD, variance, sealed results, the
[LOG]lines from the logging decorator, the locator lookups, and the filtered admin/user views:=== Generic Repository Demo ===--- Basic CRUD ---Saved: User(id=u1, name=Alice, email=alice@corp.com, role=ADMIN)...--- Logging Decorator (by delegation) ---[LOG] save: User(id=u4, name=Diana, email=diana@corp.com, role=USER)[LOG] findById: u4Found via logging repo: User(id=u4, name=Diana, email=diana@corp.com, role=USER)...--- Filtered Repository ---Admins only: [User(id=u1, name=Alice, email=alice@corp.com, role=ADMIN)]