feat(01-03): create Repository pattern with typed errors

- Add User domain model (summer_users table mapping)
- Add RepositoryError ADT (NotFound, Conflict, ValidationError, DatabaseError)
- Implement UserRepository trait with CRUD operations
- Add UserRepositoryLive using Quill with compile-time SQL validation
- Handle SQL exceptions with refineOrDie for typed error channel
This commit is contained in:
Jakub Zych
2026-02-04 22:41:11 +01:00
parent d2399de260
commit 12e738f1b5
3 changed files with 237 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
package model
import java.time.Instant
/** User domain model
*
* Represents a registered user in the system. Maps to the summer_users table.
*
* @param id
* Auto-generated primary key
* @param email
* Unique email address, used for login
* @param passwordHash
* Bcrypt-hashed password (never store plain text)
* @param createdAt
* Timestamp when user was created
* @param updatedAt
* Timestamp of last update
*/
case class User(
id: Long,
email: String,
passwordHash: String,
createdAt: Instant,
updatedAt: Instant
)

View File

@@ -0,0 +1,39 @@
package repository
/** Typed error ADT for repository operations
*
* Provides compile-time checked errors for all data access operations. Each
* error type includes context for proper error handling and user-friendly
* messages.
*
* Usage:
* {{{
* def findUser(id: Long): IO[RepositoryError, User] = ???
*
* // Handle specific error types
* result.catchSome {
* case RepositoryError.NotFound(entity, id) =>
* ZIO.fail(HttpError.NotFound(s"$entity $id not found"))
* }
* }}}
*/
sealed trait RepositoryError
object RepositoryError:
/** Entity with given ID was not found */
case class NotFound(entity: String, id: Long) extends RepositoryError
/** Entity with given field value was not found */
case class NotFoundByField(entity: String, field: String, value: String)
extends RepositoryError
/** Operation would violate uniqueness constraint */
case class Conflict(entity: String, message: String) extends RepositoryError
/** Input validation failed */
case class ValidationError(errors: List[String]) extends RepositoryError
/** Database-level error occurred */
case class DatabaseError(message: String, cause: Option[Throwable] = None)
extends RepositoryError

View File

@@ -0,0 +1,172 @@
package repository
import io.getquill.*
import io.getquill.jdbczio.Quill
import model.User
import zio.*
import java.time.Instant
/** User repository trait defining CRUD operations
*
* All methods return typed IO with RepositoryError for proper error handling.
* Implementations use Quill for compile-time SQL validation.
*
* Usage:
* {{{
* ZIO.serviceWithZIO[UserRepository](_.findById(1L))
* .provide(UserRepository.live, QuillContext.quillLive)
* }}}
*/
trait UserRepository:
/** Find user by ID
*
* @param id
* User ID
* @return
* Some(user) if found, None if not found
*/
def findById(id: Long): IO[RepositoryError, Option[User]]
/** Find user by email address
*
* @param email
* Email to search for (case-sensitive)
* @return
* Some(user) if found, None if not found
*/
def findByEmail(email: String): IO[RepositoryError, Option[User]]
/** Create new user
*
* @param email
* Unique email address
* @param passwordHash
* Pre-hashed password (use bcrypt)
* @return
* Created user with generated ID
* @throws RepositoryError.Conflict
* if email already exists
*/
def create(email: String, passwordHash: String): IO[RepositoryError, User]
/** Update existing user
*
* @param user
* User with updated fields (ID must exist)
* @return
* Updated user
* @throws RepositoryError.NotFound
* if user doesn't exist
*/
def update(user: User): IO[RepositoryError, User]
/** Delete user by ID
*
* @param id
* User ID to delete
* @return
* Unit on success
* @throws RepositoryError.NotFound
* if user doesn't exist
*/
def delete(id: Long): IO[RepositoryError, Unit]
object UserRepository:
/** Live implementation layer requiring Quill.Postgres
*
* Provides UserRepository backed by PostgreSQL via Quill.
*/
val live: ZLayer[Quill.Postgres[SnakeCase], Nothing, UserRepository] =
ZLayer.fromFunction(UserRepositoryLive(_))
// Accessor methods for ZIO service pattern
def findById(id: Long): ZIO[UserRepository, RepositoryError, Option[User]] =
ZIO.serviceWithZIO(_.findById(id))
def findByEmail(
email: String
): ZIO[UserRepository, RepositoryError, Option[User]] =
ZIO.serviceWithZIO(_.findByEmail(email))
def create(
email: String,
passwordHash: String
): ZIO[UserRepository, RepositoryError, User] =
ZIO.serviceWithZIO(_.create(email, passwordHash))
def update(user: User): ZIO[UserRepository, RepositoryError, User] =
ZIO.serviceWithZIO(_.update(user))
def delete(id: Long): ZIO[UserRepository, RepositoryError, Unit] =
ZIO.serviceWithZIO(_.delete(id))
/** Live UserRepository implementation using Quill
*
* Provides compile-time validated SQL queries against PostgreSQL. Handles SQL
* exceptions and converts them to typed RepositoryError values.
*
* Table mapping: User -> summer_users (snake_case columns)
*
* @param quill
* Quill PostgreSQL context with SnakeCase naming
*/
class UserRepositoryLive(quill: Quill.Postgres[SnakeCase])
extends UserRepository:
import quill.*
/** Table schema mapping to summer_users table */
inline def users = quote(querySchema[User]("summer_users"))
override def findById(id: Long): IO[RepositoryError, Option[User]] =
run(users.filter(_.id == lift(id)))
.map(_.headOption)
.refineOrDie { case e: java.sql.SQLException =>
RepositoryError.DatabaseError(e.getMessage, Some(e))
}
override def findByEmail(email: String): IO[RepositoryError, Option[User]] =
run(users.filter(_.email == lift(email)))
.map(_.headOption)
.refineOrDie { case e: java.sql.SQLException =>
RepositoryError.DatabaseError(e.getMessage, Some(e))
}
override def create(
email: String,
passwordHash: String
): IO[RepositoryError, User] =
val now = Instant.now()
val newUser = User(0, email, passwordHash, now, now)
run(users.insertValue(lift(newUser)).returningGenerated(_.id))
.map(generatedId => newUser.copy(id = generatedId))
.refineOrDie {
case e: java.sql.SQLException if e.getSQLState == "23505" =>
RepositoryError.Conflict("User", s"Email $email already exists")
case e: java.sql.SQLException =>
RepositoryError.DatabaseError(e.getMessage, Some(e))
}
override def update(user: User): IO[RepositoryError, User] =
val updatedUser = user.copy(updatedAt = Instant.now())
run(
users
.filter(_.id == lift(user.id))
.updateValue(lift(updatedUser))
).refineOrDie { case e: java.sql.SQLException =>
RepositoryError.DatabaseError(e.getMessage, Some(e))
}.flatMap { rowsUpdated =>
if rowsUpdated == 0 then
ZIO.fail(RepositoryError.NotFound("User", user.id))
else ZIO.succeed(updatedUser)
}
override def delete(id: Long): IO[RepositoryError, Unit] =
run(users.filter(_.id == lift(id)).delete)
.refineOrDie { case e: java.sql.SQLException =>
RepositoryError.DatabaseError(e.getMessage, Some(e))
}
.flatMap { rowsDeleted =>
if rowsDeleted == 0 then ZIO.fail(RepositoryError.NotFound("User", id))
else ZIO.unit
}