Structuring Repositories
A common pattern is to define queries on a repository object. Fixed queries become public vals returning OperationRead (name them for analysis reports); parameterized queries become methods that take their parameters and return OperationRead or Operation:
- Kotlin
- Java
- Scala
object UserRepo {
data class User(val id: Int, val name: String)
val userCodec: RowCodecNamed<User> =
RowCodec.namedBuilder<User>()
.field("id", PgTypes.int4, User::id)
.field("name", PgTypes.text, User::name)
.build(::User)
val selectAll: OperationRead<List<User>> =
sql { "SELECT ${userCodec.columnList} FROM users ORDER BY name" }
.query(userCodec.all())
.named("UserRepo.selectAll")
fun selectById(id: Int): OperationRead<User?> =
sql { "SELECT ${userCodec.columnList} FROM users WHERE id = " }
.value(PgTypes.int4, id)
.query(userCodec.maxOne())
}
public final class UserRepo {
record User(int id, String name) {}
static final RowCodecNamed<User> userCodec =
RowCodec.<User>namedBuilder()
.field("id", PgTypes.int4, User::id)
.field("name", PgTypes.text, User::name)
.build(User::new);
static final OperationRead<List<User>> selectAll =
Fragment.of("SELECT ")
.append(userCodec.columnList())
.append(" FROM users ORDER BY name")
.query(userCodec.all())
.named("UserRepo.selectAll");
static OperationRead<Optional<User>> selectById(int id) {
return Fragment.of("SELECT ")
.append(userCodec.columnList())
.append(" FROM users WHERE id = ")
.value(PgTypes.int4, id)
.query(userCodec.maxOne());
}
}
object UserRepo:
case class User(id: Int, name: String)
val userCodec: RowCodecNamed[User] = RowCodec
.namedBuilder[User]()
.field("id", PgTypes.int4)(_.id)
.field("name", PgTypes.text)(_.name)
.build(User.apply)
val selectAll: OperationRead[List[User]] =
sql"SELECT ${userCodec.columnList} FROM users ORDER BY name"
.query(userCodec.all())
.named("UserRepo.selectAll")
def selectById(id: Int): OperationRead[Option[User]] =
sql"SELECT ${userCodec.columnList} FROM users WHERE id = "
.value(PgTypes.int4, id)
.query(userCodec.maxOne())
Exposing OperationRead and Operation directly, rather than executing them inside the repository, gives callers full flexibility. They can compose, batch, name, or analyze these values however they like, without the repository dictating execution strategy.
This also means the repository stays in the database layer: it knows what to query, but not when or how to run it. The service layer owns the transaction boundary by calling .transactRead(tx) or .transact(tx):
- Kotlin
- Java
- Scala
class UserService(private val tx: Transactor) {
fun listUsers(): List<User> =
UserRepo.selectAll.transact(tx)
fun findUser(id: Int): User? =
UserRepo.selectById(id).transact(tx)
}
public final class UserService {
private final Transactor tx;
public UserService(Transactor tx) {
this.tx = tx;
}
public List<User> listUsers() {
return UserRepo.selectAll.transact(tx);
}
public Optional<User> findUser(int id) {
return UserRepo.selectById(id).transact(tx);
}
}
class UserService(tx: Transactor):
def listUsers(): List[User] =
UserRepo.selectAll.transact(tx)
def findUser(id: Int): Option[User] =
UserRepo.selectById(id).transact(tx)
Both OperationRead and Operation implement Analyzable, so AnalyzableScanner discovers them automatically, both fields and methods. See Query Analysis for details.