| Metadata | Value |
|---|---|
| Status | Active |
| Version | 1.1.0 |
| Last Updated | 2026-09-10 |
| Author | Sangeetha Grantha Team |
| Document Type | Decision record |
[!NOTE] Decision record: preserve the original rationale and check its decision/supersession status. Current runtime guidance is in system architecture and Flyway migrations.
This document provides a detailed comparison between Exposed ORM’s DAO (Data Access Object) and DSL (Domain-Specific Language) approaches to persistence. Our codebase currently uses the DSL approach, which provides type-safe SQL generation but requires careful implementation to avoid inefficient operations like DELETE+INSERT when UPDATE would suffice.
JetBrains Exposed provides two primary approaches for database operations:
Entity<T>Table objects, not entity instances// Table definition
object UsersTable : UUIDTable("users") {
val name = varchar("name", 255)
val email = varchar("email", 255)
val createdAt = timestampWithTimeZone("created_at")
}
// Entity class
class User(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<User>(UsersTable)
var name by UsersTable.name
var email by UsersTable.email
var createdAt by UsersTable.createdAt
}
// Create
val user = User.new {
name = "John Doe"
email = "john@example.com"
createdAt = OffsetDateTime.now(ZoneOffset.UTC)
}
// Update (automatic change tracking)
user.name = "Jane Doe"
user.email = "jane@example.com"
// Only modified fields are updated in SQL
// Delete
user.delete()
object PostsTable : UUIDTable("posts") {
val userId = uuid("user_id").references(UsersTable.id)
val title = varchar("title", 255)
}
class Post(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<Post>(PostsTable)
var userId by PostsTable.userId
var title by PostsTable.title
// Automatic relationship
var user by User referencedOn PostsTable.userId
}
// Usage
val post = Post.new {
user = existingUser // Automatic foreign key handling
title = "My Post"
}
// Find by ID
val user = User.findById(userId)
// Query with conditions
val users = User.find { UsersTable.email like "%@example.com" }
// Eager loading
val posts = Post.find { PostsTable.userId eq userId }
.with(Post::user) // Loads related user in single query
✅ Automatic Optimization
✅ Type Safety
✅ Less Boilerplate
✅ Change Tracking
updated_at handling (if configured)❌ Memory Overhead
❌ Learning Curve
❌ Less Explicit Control
❌ Limited Query Flexibility
// Table definition (same as DAO)
object UsersTable : UUIDTable("users") {
val name = varchar("name", 255)
val email = varchar("email", 255)
val createdAt = timestampWithTimeZone("created_at")
}
// No entity class needed
// Create
UsersTable.insert {
it[id] = UUID.randomUUID()
it[name] = "John Doe"
it[email] = "john@example.com"
it[createdAt] = OffsetDateTime.now(ZoneOffset.UTC)
}
// Update (explicit)
UsersTable.update({ UsersTable.id eq userId }) {
it[name] = "Jane Doe"
it[email] = "jane@example.com"
}
// Delete
UsersTable.deleteWhere { UsersTable.id eq userId }
// Manual foreign key handling
PostsTable.insert {
it[id] = UUID.randomUUID()
it[userId] = existingUserId
it[title] = "My Post"
}
// Joins are explicit
(PostsTable innerJoin UsersTable)
.selectAll()
.where { PostsTable.userId eq UsersTable.id }
.map { row ->
PostDto(
id = row[PostsTable.id].value,
title = row[PostsTable.title],
userName = row[UsersTable.name]
)
}
// Find by ID
val userRow = UsersTable
.selectAll()
.where { UsersTable.id eq userId }
.singleOrNull()
// Query with conditions
val users = UsersTable
.selectAll()
.where { UsersTable.email like "%@example.com" }
.map { it.toUserDto() }
✅ Explicit Control
✅ Lower Memory Footprint
✅ SQL-Like Syntax
✅ Flexibility
❌ Manual Change Tracking
❌ No Automatic Optimization
❌ More Verbose
❌ Type Safety Limitations
| Operation | DAO Approach | DSL Approach |
|---|---|---|
| Single Row Insert | Similar performance | Similar performance |
| Batch Insert | Entity.batchInsert() |
Table.batchInsert() - Both similar |
| Update Single Field | ✅ Only updates changed field | ⚠️ Must manually specify fields |
| Update Multiple Fields | ✅ Only updates changed fields | ⚠️ Must manually specify all fields |
| Update Collection (1-to-Many) | ✅ Automatic diff and update | ❌ Often DELETE+INSERT (inefficient) |
| Complex Queries | ⚠️ May require DSL fallback | ✅ Excellent |
| Large Result Sets | ⚠️ Entity overhead | ✅ Direct to DTO mapping |
| Scenario | DAO Approach | DSL Approach |
|---|---|---|
| Simple CRUD | ✅ Less code | ⚠️ More verbose |
| Complex Queries | ⚠️ May need DSL | ✅ Natural fit |
| Collection Updates | ✅ Automatic | ❌ Manual implementation |
| Relationship Navigation | ✅ Automatic | ⚠️ Manual JOINs |
| Bulk Operations | ⚠️ Entity overhead | ✅ Efficient |
| Aspect | DAO Approach | DSL Approach |
|---|---|---|
| Readability | ✅ OOP style, intuitive | ✅ SQL-like, familiar |
| Testability | ✅ Easy to mock entities | ✅ Easy to test queries |
| Debugging | ⚠️ Hidden SQL generation | ✅ Explicit SQL |
| Refactoring | ✅ IDE support for entities | ⚠️ String-based column names |
Our current implementation in KrithiRepository.saveSections() uses a delete-then-insert pattern:
suspend fun saveSections(krithiId: Uuid, sections: List<Pair<String, Int>>) = DatabaseFactory.dbQuery {
val now = OffsetDateTime.now(ZoneOffset.UTC)
val javaKrithiId = krithiId.toJavaUuid()
// ❌ PROBLEM: Deletes ALL sections, even if only one changed
KrithiSectionsTable.deleteWhere { KrithiSectionsTable.krithiId eq javaKrithiId }
// Then re-inserts everything
if (sections.isNotEmpty()) {
KrithiSectionsTable.batchInsert(sections.withIndex()) { (index, section) ->
val (sectionType, orderIndex) = section
this[KrithiSectionsTable.id] = UUID.randomUUID()
this[KrithiSectionsTable.krithiId] = javaKrithiId
this[KrithiSectionsTable.sectionType] = sectionType
this[KrithiSectionsTable.orderIndex] = orderIndex
this[KrithiSectionsTable.label] = null
this[KrithiSectionsTable.notes] = null
this[KrithiSectionsTable.createdAt] = now // ❌ Loses original created_at
this[KrithiSectionsTable.updatedAt] = now
}
}
}
created_at timestampskrithi_lyric_sections references krithi_sections.id, foreign keys may breakThe DELETE+INSERT pattern is common in DSL implementations because:
However, it’s not optimal for scenarios where:
Implement proper diff logic to use UPDATE where possible:
suspend fun saveSections(krithiId: Uuid, sections: List<Pair<String, Int>>) = DatabaseFactory.dbQuery {
val now = OffsetDateTime.now(ZoneOffset.UTC)
val javaKrithiId = krithiId.toJavaUuid()
// Get existing sections
val existingSections = KrithiSectionsTable
.selectAll()
.where { KrithiSectionsTable.krithiId eq javaKrithiId }
.associateBy { it[KrithiSectionsTable.orderIndex] }
// Build map of new sections by order_index
val newSectionsMap = sections.associateBy { it.second }
// Determine what to update, insert, and delete
val toUpdate = mutableListOf<Pair<UUID, Pair<String, Int>>>()
val toInsert = mutableListOf<Pair<String, Int>>()
val toDelete = mutableListOf<UUID>()
// Find sections to update (same order_index, different type)
existingSections.forEach { (orderIndex, row) ->
val existingId = row[KrithiSectionsTable.id].value
val existingType = row[KrithiSectionsTable.sectionType]
val newSection = newSectionsMap[orderIndex]
if (newSection != null) {
if (newSection.first != existingType) {
// Section type changed - update it
toUpdate.add(existingId to newSection)
}
// If same, no change needed
} else {
// Section removed
toDelete.add(existingId)
}
}
// Find sections to insert (new order_index)
newSectionsMap.forEach { (orderIndex, section) ->
if (!existingSections.containsKey(orderIndex)) {
toInsert.add(section)
}
}
// Execute updates
toUpdate.forEach { (id, section) ->
KrithiSectionsTable.update({ KrithiSectionsTable.id eq id }) {
it[KrithiSectionsTable.sectionType] = section.first
it[KrithiSectionsTable.updatedAt] = now
// Preserve created_at, label, notes
}
}
// Execute inserts
if (toInsert.isNotEmpty()) {
KrithiSectionsTable.batchInsert(toInsert.withIndex()) { (index, section) ->
val (sectionType, orderIndex) = section
this[KrithiSectionsTable.id] = UUID.randomUUID()
this[KrithiSectionsTable.krithiId] = javaKrithiId
this[KrithiSectionsTable.sectionType] = sectionType
this[KrithiSectionsTable.orderIndex] = orderIndex
this[KrithiSectionsTable.label] = null
this[KrithiSectionsTable.notes] = null
this[KrithiSectionsTable.createdAt] = now
this[KrithiSectionsTable.updatedAt] = now
}
}
// Execute deletes
if (toDelete.isNotEmpty()) {
KrithiSectionsTable.deleteWhere {
KrithiSectionsTable.id inList toDelete
}
}
}
Benefits:
created_at timestampsDrawbacks:
Use DSL for queries, but create lightweight entity-like wrappers for updates:
data class KrithiSectionEntity(
val id: UUID,
val krithiId: UUID,
var sectionType: String,
var orderIndex: Int,
var label: String?,
var notes: String?,
val createdAt: OffsetDateTime,
var updatedAt: OffsetDateTime
) {
fun save() = DatabaseFactory.dbQuery {
KrithiSectionsTable.update({ KrithiSectionsTable.id eq id }) {
it[KrithiSectionsTable.sectionType] = sectionType
it[KrithiSectionsTable.orderIndex] = orderIndex
it[KrithiSectionsTable.label] = label
it[KrithiSectionsTable.notes] = notes
it[KrithiSectionsTable.updatedAt] = OffsetDateTime.now(ZoneOffset.UTC)
}
}
}
For entities with frequent collection updates, consider migrating to DAO:
class KrithiSection(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<KrithiSection>(KrithiSectionsTable)
var krithiId by KrithiSectionsTable.krithiId
var sectionType by KrithiSectionsTable.sectionType
var orderIndex by KrithiSectionsTable.orderIndex
var label by KrithiSectionsTable.label
var notes by KrithiSectionsTable.notes
var createdAt by KrithiSectionsTable.createdAt
var updatedAt by KrithiSectionsTable.updatedAt
}
// Usage - automatic change tracking
val sections = KrithiSection.find { KrithiSectionsTable.krithiId eq krithiId }
sections.forEach { it.sectionType = newType }
// Only changed sections are updated automatically
✅ Good for:
❌ Avoid when:
✅ Good for:
❌ Avoid when:
Our codebase can benefit from a hybrid approach:
krithi_sections)saveSections() with diff logic (Option 1)The DSL approach is powerful and flexible, but requires careful implementation to avoid inefficient patterns. The DELETE+INSERT anti-pattern is common but can be avoided with proper diff logic. For our use case, improving the DSL implementation is the most pragmatic solution that maintains our current architecture while fixing the performance issue.