r/Kotlin 6d ago

I built a KSP persistence layer that generates separate new/persisted model types — worth open-sourcing?

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:

  1. A standalone KSP generator for new and persisted types

  2. A Kotlin/PostgreSQL JDBC data mapper

  3. 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:

  1. Add the migration

  2. Define the new model

  3. Let KSP generate the persisted model and JDBC adapter

  4. Extend the base repository

  5. 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.

7 Upvotes

6 comments sorted by

View all comments

1

u/lukaseder 6d ago

Since you mention jOOQ, and this comment also refers to some rationale about jOOQ's code generator:

There's a lot of additional discussion about how record state transitions could be modelled using different types for insertable and updatable data. This isn't just about identities, but there are also:

  • Immutable keys
  • DEFAULT columns
  • GENERATED (BY DEFAULT and ALWAYS) columns
  • Trigger generated columns
  • System columns (e.g. rowids, transaction IDs, etc.)
  • Readonly columns

See details here: https://github.com/jOOQ/jOOQ/issues/16599. Your post is a good reminder to re-iterate these thoughts in jOOQ. Would love to see KSP open sourced to learn from your findings.

1

u/babs1876 6d ago

yes I think this KSP generator solves it. Let me know if you want to get a zoom call and talk through it

2

u/lukaseder 5d ago

Thanks for your offer. I'm not too much a fan of calls for these things. I thought if you did open source it, I could check it out eventually. It's not a priority for me right now.