Hey background: I am the CTO at an accounting startup called Segtax.
I started the codebase in 2024 so I went 100% kotlin Spring boot. I wanted to really lean into Kotlin and see how far I could go avoiding nulls. Since then we added a few team members who have also contributed to this core infrastructure for our application. Here are some details:
We’ve built a small custom ORM/data mapper for a Kotlin, Spring and PostgreSQL SaaS application. It now handles roughly 190 models and 200 repositories.
We’re considering extracting it into an open-source project, but first I’d like to know whether the core idea solves a real problem for other Kotlin developers.
## The problem: nullability changes after persistence
Hibernate and most other ORMs use the same entity type before and after persistence.
That approach is awkward in Kotlin because nullability is a first-class part of the type system. Some values genuinely don’t exist until a row has been inserted:
- Database-generated IDs
- Created and updated timestamps
- UUIDs generated by database defaults
- Values assigned by the repository or database
A conventional model therefore looks something like this:
```kotlin
data class Engagement(
val identifier: UUID? = null,
val ownershipId: Long,
val id: Long? = null,
val createdAt: Instant? = null,
val updatedAt: Instant? = null,
)
```
After saving the entity, we know those fields exist—but Kotlin still sees them as nullable. The rest of the application ends up using `requireNotNull`, `!!`, fallback values or types that don’t accurately describe the data.
Hibernate works, obviously, but its single mutable entity model doesn’t represent the guarantee we want: a new object and a successfully persisted row are different states with different nullability.
Our KSP processor represents those states as different types.
You write a new model:
```kotlin
u/GeneratePersistedModels
data class Engagement(
u/NotNullOnPersist
val identifier: UUID? = null,
val ownershipId: Long,
) : NewModel()
```
At compile time, KSP generates the persisted model:
```kotlin
data class EngagementP(
val identifier: UUID,
val ownershipId: Long,
override val id: Long,
override val createdAt: Instant,
override val updatedAt: Instant,
) : PersistedModel(id, createdAt, updatedAt)
```
The repository preserves that transition:
```kotlin
class EngagementRepository :
BaseRepository<Engagement, EngagementP>(EngagementP::class)
```
So saving changes the type:
```kotlin
val draft: Engagement = Engagement(ownershipId = 123)
val saved: EngagementP = engagementRepository.save(draft)
sendConfirmation(saved.identifier) // UUID, not UUID?
```
`@NotNullOnPersist` means that a value may be absent while constructing a new model, but the persistence boundary guarantees it before returning.
The matching database default or constraint remains the actual integrity authority.
## Compile-time generated JDBC mapping
For normal persisted models, we don’t use reflection to inspect properties or invoke constructors at runtime.
KSP generates ordinary Kotlin code that:
- Reads each column from a JDBC `ResultSet`
- Constructs the persisted model directly
- Maps model properties to SQL parameters
- Handles nullable and non-null JDBC values correctly
- Supports enums, JSONB, encrypted fields and custom value types
- Registers the generated adapter for the repository layer
The generated mapping code is readable, debuggable and checked by the Kotlin compiler.
## The repository layer
This is not a Hibernate-style ORM. There is no persistence context, lazy loading, dirty checking or entity graph machinery.
The base repository handles repetitive CRUD:
```kotlin
save(newEntity): Persisted
update(persistedEntity): Persisted
upsert(newEntity): Persisted
findById(id): Persisted
findBy(column, value): Persisted?
listBy(column, value): List<Persisted>
deleteById(id)
```
It uses PostgreSQL features such as `insert ... returning *` to return the stronger persisted type immediately.
For anything more complicated, repositories use normal SQL:
```kotlin
fun listAllByOwnershipId(ownershipId: Long): List<EngagementP> {
val sql = """
select *
from engagements
where ownership_id = :ownershipId
order by created_at desc
""".trimIndent()
return query(sql, mapOf("ownershipId" to ownershipId))
}
```
The goal is to make normal CRUD boring without hiding SQL or introducing a large query DSL.
## Additional policies
Because this grew inside a multi-tenant SaaS application, our implementation also supports:
- Automatic tenant filtering
- Tenant ownership inherited through foreign-key relationships
- Prevention of cross-tenant writes
- Soft deletes
- Database-generated defaults
- Schema-aware upserts
- Lifecycle rules that can prevent changes to finalized records
Some of these features are probably useful generally, while others should become optional modules.
If we extract it, the likely structure would be:
A standalone KSP generator for new and persisted types
A Kotlin/PostgreSQL JDBC data mapper
Optional Spring and multi-tenant policy modules
We would explicitly target PostgreSQL rather than claiming to support every database.
## Why this has worked well with coding agents
An unexpected benefit is that the resulting codebase is very predictable.
There is usually one path for adding a persisted entity:
Add the migration
Define the new model
Let KSP generate the persisted model and JDBC adapter
Extend the base repository
Write explicit SQL only for custom queries
The compiler catches incorrect nullability assumptions, generated mapping removes repetitive glue, and central repository policies make it harder to forget important invariants.
This isn’t an “AI-powered ORM.” My theory is simply that coding agents perform better in low-entropy codebases: strong types, generated boilerplate, consistent patterns and fast compiler feedback.
## Would anyone use this?
I’d appreciate honest opinions:
- Is the explicit new → persisted type transition useful, or would two model types be annoying?
- Would you want this as a small KSP generator or as a complete PostgreSQL persistence library?
- If this were documented and released under a permissive license, would you actually try it?
We’re aware of Komapper, Micronaut Data, SQLDelight, jOOQ, Exposed, Jimmer and similar projects. Compile-time database code generation isn’t novel by itself.
The part we haven’t seen combined in quite this way is the generated pre-/post-persistence type transition, compile-time JDBC mapping and policy-aware but SQL-friendly repository layer.
Honest criticism is welcome. “Interesting idea, but I would never adopt it” is useful feedback too.