Persisted vs Unpersisted Types
Most domain entities exist in two forms: one before the database has seen them (no id, no generated timestamps), and one after (with an id assigned by the database). Modelling both forms as the same type forces you to invent placeholder values for fields that don't exist yet:
// The id here is meaningless — the database assigns it
var venue = new Venue(0, "Madison Square Garden", 20789);
Placeholder values are a data modelling smell. The type says "I have an id" when the value doesn't. A better model separates the two forms: Venue is what you create, and PersistedVenue is what the database gives back.
Defining the Types
- Kotlin
- Java
- Scala
data class VenueId(val value: Long)
val venueIdType: DuckDbType<VenueId> = DuckDbTypes.bigint.transform(::VenueId, VenueId::value)
data class Venue(val name: String, val capacity: Int)
data class PersistedVenue(val id: VenueId, val venue: Venue)
public record VenueId(Long value) {}
static DuckDbType<VenueId> venueIdType = DuckDbTypes.bigint.transform(VenueId::new, VenueId::value);
public record Venue(String name, int capacity) {}
public record PersistedVenue(VenueId id, Venue venue) {}
case class VenueId(value: Long)
val venueIdType: DuckDbType[VenueId] = DuckDbTypes.bigint.transform(VenueId.apply, _.value)
case class Venue(name: String, capacity: Int)
case class PersistedVenue(id: VenueId, venue: Venue)
Venue holds the data you provide. PersistedVenue wraps a Venue together with its database-assigned id. The nesting is intentional — it means PersistedVenue doesn't re-declare name and capacity, and you can always extract the original Venue from a persisted one.
Composing the Codecs
The write codec (venueCodec) maps Venue to the columns the INSERT targets. The read codec (persistedVenueCodec) maps the full row — including the id column — back to PersistedVenue.
Rather than re-declaring all the venue fields in the read codec, compose it from parts:
- Kotlin
- Java
- Scala
val venueCodec: RowCodecNamed<Venue> =
RowCodec.namedBuilder<Venue>()
.field("name", DuckDbTypes.varchar, Venue::name)
.field("capacity", DuckDbTypes.integer, Venue::capacity)
.build(::Venue)
val persistedVenueCodec: RowCodecNamed<PersistedVenue> =
RowCodec.ofNamed("id", venueIdType)
.join(venueCodec)
.to({ (id, venue) -> PersistedVenue(id, venue) }, { Pair(it.id, it.venue) })
static RowCodecNamed<Venue> venueCodec =
RowCodec.<Venue>namedBuilder()
.field("name", DuckDbTypes.varchar, Venue::name)
.field("capacity", DuckDbTypes.integer, Venue::capacity)
.build(Venue::new);
static RowCodecNamed<PersistedVenue> persistedVenueCodec =
RowCodec.<VenueId>ofNamed("id", venueIdType)
.join(venueCodec)
.to(Bijection.of(
t -> new PersistedVenue(t._1(), t._2()),
pv -> Tuple.of(pv.id(), pv.venue())));
val venueCodec: RowCodecNamed[Venue] = RowCodec.namedBuilder[Venue]()
.field("name", DuckDbTypes.varchar)(_.name)
.field("capacity", DuckDbTypes.integer)(_.capacity)
.build(Venue.apply)
val persistedVenueCodec: RowCodecNamed[PersistedVenue] =
RowCodec.ofNamed("id", venueIdType)
.join(venueCodec)
.to(PersistedVenue.apply, pv => (pv.id, pv.venue))
Three methods make this work:
RowCodec.ofNamed(name, type)— creates a single-column named codec.join(other)— concatenates two named codecs, producing a tuple (preserving all column names).to(forward, backward)— maps the tuple to your domain type via a bijection
The result is a RowCodecNamed<PersistedVenue> that knows about all three columns (id, name, capacity) — reusing the venueCodec definition for the last two.
Building a Repository
With both codecs defined, the repository becomes straightforward. The two-codec insertIntoReturning takes the write codec for the INSERT columns and the read codec for the RETURNING clause:
- Kotlin
- Java
- Scala
object VenueRepo {
private val venueIdType = DuckDbTypes.bigint.transform(::VenueId, VenueId::value)
private val venueCodec = RowCodec.namedBuilder<Venue>()
.field("name", DuckDbTypes.varchar, Venue::name)
.field("capacity", DuckDbTypes.integer, Venue::capacity)
.build(::Venue)
private val persistedVenueCodec =
RowCodec.ofNamed("id", venueIdType)
.join(venueCodec)
.to({ (id, venue) -> PersistedVenue(id, venue) }, { Pair(it.id, it.venue) })
val insert: RowTemplate.Query<Venue, PersistedVenue> =
Fragment.insertIntoReturning("venue", venueCodec, persistedVenueCodec)
val selectAll: Operation<List<PersistedVenue>> =
sql { "SELECT ${persistedVenueCodec.columnList} FROM venue" }
.query(persistedVenueCodec.all())
val selectById: Template<VenueId, PersistedVenue?> =
sql { "SELECT ${persistedVenueCodec.columnList} FROM venue WHERE id = " }
.param(venueIdType)
.query(persistedVenueCodec.maxOne())
}
class VenueRepo {
private static final DuckDbType<VenueId> venueIdType =
DuckDbTypes.bigint.transform(VenueId::new, VenueId::value);
private static final RowCodecNamed<Venue> venueCodec =
RowCodec.<Venue>namedBuilder()
.field("name", DuckDbTypes.varchar, Venue::name)
.field("capacity", DuckDbTypes.integer, Venue::capacity)
.build(Venue::new);
private static final RowCodecNamed<PersistedVenue> persistedVenueCodec =
RowCodec.<VenueId>ofNamed("id", venueIdType)
.join(venueCodec)
.to(Bijection.of(
t -> new PersistedVenue(t._1(), t._2()),
pv -> Tuple.of(pv.id(), pv.venue())));
static final RowTemplate.Query<Venue, PersistedVenue> insert =
Fragment.insertIntoReturning("venue", venueCodec, persistedVenueCodec);
static final Operation<List<PersistedVenue>> selectAll =
Fragment.of("SELECT ")
.append(persistedVenueCodec.columnList())
.append(" FROM venue")
.query(persistedVenueCodec.all());
static final Template<VenueId, Optional<PersistedVenue>> selectById =
Fragment.of("SELECT ")
.append(persistedVenueCodec.columnList())
.append(" FROM venue WHERE id = ")
.param(venueIdType)
.query(persistedVenueCodec.maxOne());
}
object VenueRepo:
private val venueIdType = DuckDbTypes.bigint.transform(VenueId.apply, _.value)
private val venueCodec = RowCodec.namedBuilder[Venue]()
.field("name", DuckDbTypes.varchar)(_.name)
.field("capacity", DuckDbTypes.integer)(_.capacity)
.build(Venue.apply)
private val persistedVenueCodec =
RowCodec.ofNamed("id", venueIdType)
.join(venueCodec)
.to(PersistedVenue.apply, pv => (pv.id, pv.venue))
val insert: RowTemplate.Query[Venue, PersistedVenue] =
Fragment.insertIntoReturning("venue", venueCodec, persistedVenueCodec)
val selectAll: Operation[List[PersistedVenue]] =
sql"SELECT ${persistedVenueCodec.columnList} FROM venue"
.query(persistedVenueCodec.all())
val selectById: Template[VenueId, Option[PersistedVenue]] =
sql"SELECT ${persistedVenueCodec.columnList} FROM venue WHERE id = "
.param(venueIdType)
.query(persistedVenueCodec.maxOne())
The types tell the full story: insert takes a Venue and returns a PersistedVenue. No placeholder ids, no ambiguity about what goes in and what comes out.
When to Use This
This pattern is most useful when the persisted form differs meaningfully from the input — typically when the database generates an id, a timestamp, or a version number.
For simple cases where you're happy to provide all fields up front (including the id), a single codec with insertIntoReturning(table, codec) works fine. Use whichever model fits your domain.