Appearance
Task Repository
With the database connection wired through the server, we'll now introduce the domain model and a thin layer that maps raw SQL rows to typed Gleam values — plus a small shell helper that makes testing each function in gleam shell much more convenient.
sh
doable/
├── shared/
│ └── src/
│ ├── shared.gleam # placeholder removed
│ └── task.gleam # Task and TaskInput domain types
└── server/
└── src/
├── console.gleam # debug helper for gleam shell
├── error.gleam # DatabaseError type
└── task/
└── repository.gleam # CRUD operations over the tasks tableShared Task Types
shared/src/task.gleam
shared.gleam was a placeholder from project setup. Replace it with task.gleam, which defines the domain types shared across the entire app:
gleam
// shared/src/task.gleam
pub type Task {
Task(id: Int, name: String, description: String, completed: Bool)
}
pub fn to_task_input(task: Task) -> TaskInput {
TaskInput(
name: task.name,
description: task.description,
completed: task.completed,
)
}
pub type TaskInput {
TaskInput(name: String, description: String, completed: Bool)
}
pub fn to_task(input: TaskInput, id: Int) -> Task {
Task(
id: id,
name: input.name,
description: input.description,
completed: input.completed,
)
}Task— the full record as stored and returned; includes anid.TaskInput— the create/update payload; noidbecause it's either generated by the database (on insert) or provided separately (on update).to_task/to_task_input— convenience conversions between the two.
The shared package is imported by both server and (eventually) client, so placing domain types here ensures they're defined once and used everywhere.
Error Types
error.gleam
gleam
// server/src/error.gleam
import pog
pub type DatabaseError {
UnexpectedNoRows
RecordNotFound
QueryError(pog.QueryError)
}Three variants cover everything that can go wrong at the database repository:
UnexpectedNoRows— a query that must return a row didn't.INSERT ... RETURNINGreturning nothing signals a bug rather than a user-visible condition.RecordNotFound— a lookup by ID found no matching record; maps to a404response.QueryError(pog.QueryError)— the driver reported a database-level error (connection failure, constraint violation, etc.).
Keeping errors as a plain Gleam type means route handlers can pattern-match on them directly when building HTTP responses.
Database Repository
task/repository.gleam sits between the generated SQL functions and the rest of the application. Each function follows the same shape: call an SQL function, map the error, unwrap the returned rows, and build a domain value:
gleam
// server/src/task/repository.gleam
import error.{type DatabaseError, QueryError, RecordNotFound, UnexpectedNoRows}
import gleam/bool
import gleam/list
import gleam/result
import pog
import task.{type Task, type TaskInput, Task}
import task/sql
pub fn all_tasks(db_conn: pog.Connection) -> Result(List(Task), DatabaseError) {
let query_result =
db_conn
|> sql.all_tasks
|> result.map_error(QueryError)
use pog.Returned(_, rows) <- result.map(query_result)
use row <- list.map(rows)
Task(
id: row.id,
name: row.name,
description: row.description,
completed: row.completed,
)
}
pub fn create_task(
db_conn: pog.Connection,
input: TaskInput,
) -> Result(Task, DatabaseError) {
let query_result =
sql.create_task(db_conn, input.name, input.description, input.completed)
|> result.map_error(QueryError)
use pog.Returned(_, rows) <- result.try(query_result)
let row =
rows
|> list.first
|> result.replace_error(UnexpectedNoRows)
use row <- result.map(row)
Task(
id: row.id,
name: row.name,
description: row.description,
completed: row.completed,
)
}
pub fn get_task(db_conn: pog.Connection, id: Int) -> Result(Task, DatabaseError) {
let query_result =
sql.get_task(db_conn, id)
|> result.map_error(QueryError)
use pog.Returned(_, rows) <- result.try(query_result)
let row =
rows
|> list.first
|> result.replace_error(RecordNotFound)
use row <- result.map(row)
Task(
id: row.id,
name: row.name,
description: row.description,
completed: row.completed,
)
}
pub fn update_task(
db_conn: pog.Connection,
task: Task,
) -> Result(Task, DatabaseError) {
let query_result =
sql.update_task(
db_conn,
task.id,
task.name,
task.description,
task.completed,
)
|> result.map_error(QueryError)
use pog.Returned(_, rows) <- result.try(query_result)
let row =
rows
|> list.first
|> result.replace_error(RecordNotFound)
use row <- result.map(row)
Task(
id: row.id,
name: row.name,
description: row.description,
completed: row.completed,
)
}
pub fn delete_task(
db_conn: pog.Connection,
id: Int,
) -> Result(Nil, DatabaseError) {
let query_result =
sql.delete_task(db_conn, id)
|> result.map_error(QueryError)
use pog.Returned(count, _) <- result.try(query_result)
use <- bool.guard(count == 0, Error(RecordNotFound))
Ok(Nil)
}A few things worth noting:
result.mapvsresult.try—result.tryis used when the next step can also fail (e.g. extracting the first row withlist.first);result.mapis used for the final pure transformation. Both work withusefor a consistent pipeline style.UnexpectedNoRowsvsRecordNotFound—create_taskusesUnexpectedNoRowsbecauseRETURNINGguarantees a row on success; an empty result is a bug.get_taskandupdate_taskuseRecordNotFoundbecause an empty result simply means the record doesn't exist.update_tasktakes a fullTask— the caller is responsible for merging any partial update with the existing record before calling this.pog.Returned(count, _)— destructures theReturnedtype directly in the pattern.countis the number of affected rows;_discards the empty row list.DELETEhas noRETURNING, so this is the only way to detect that nothing was deleted.bool.guard—bool.guard(condition, fallback)evaluatesfallbackifconditionis true and continues otherwise. It reads more naturally than anif/elsefor early-return guards.
Shell Helper
The previous chapter required six Erlang expressions just to get a pog.Connection in gleam shell. console.gleam wraps that setup into a single Gleam function.
gleam
// server/src/console.gleam
import config
import context.{Context}
import database
import gleam/dynamic.{type Dynamic}
import gleam/erlang/atom
import pog
@external(erlang, "shell", "strings")
fn shell_strings(enabled: Bool) -> Dynamic
@external(erlang, "application", "ensure_all_started")
fn ensure_all_started(app: atom.Atom) -> Dynamic
pub fn init() -> pog.Connection {
let _ = shell_strings(True)
let _ = ensure_all_started(atom.create("pgo"))
let config = config.load()
let db_pool_name = database.start(config)
let context = Context(config:, db_pool_name:)
context.db_conn(context)
}@external(erlang, ...) binds a Gleam function to an existing Erlang function. The two FFI calls here are the same setup steps from before — enabling string display and starting the pog driver — wrapped so they happen automatically on console:init().
Verifying Database Repository
With the database running, open a shell from the server/ directory:
sh
docker compose up -d
cd server
gleam shellA single call to console:init() sets everything up and returns a connection:
erlang
1> DbConn = console:init().
% Then exercise each database function directly:
2> 'task@repository':all_tasks(DbConn).
% {ok,[]}
3> 'task@repository':create_task(DbConn, {task_input, "Buy milk", "2% fat", false}).
% {ok,{task,1,<<"Buy milk">>,<<"2% fat">>,false}}
4> 'task@repository':get_task(DbConn, 1).
% {ok,{task,1,<<"Buy milk">>,<<"2% fat">>,false}}
5> 'task@repository':update_task(DbConn, {task, 1, "Buy milk", "Whole milk", false}).
% {ok,{task,1,<<"Buy milk">>,<<"Whole milk">>,false}}
6> 'task@repository':delete_task(DbConn, 1).
% {ok,nil}
7> 'task@repository':get_task(DbConn, 1).
% {error,record_not_found}A few things worth noting:
console:init()— compiled fromconsole.gleam, callable with Erlang module syntax just like any other Gleam module.- Gleam constructors in Erlang —
{task_input, "Buy milk", "2% fat", false}is how Gleam'sTaskInput("Buy milk", "2% fat", false)looks in Erlang. Custom types become tuples with a lowercase atom tag matching the constructor name. <<"...">>— Gleam'sStringtype compiles to an Erlang binary.shell:strings(true)prints bare binaries as quoted strings, but when a binary is nested inside a tuple the shell still uses the<<"...">>binary literal notation.
What's Next
The repository exposes a clean API over the database, returning Task values and typed errors. Next, we'll wire the five handler stubs to it — decoding JSON bodies, translating DatabaseError into HTTP status codes, and finally returning real task data over the wire.