ForestLynk got its first commits on a Saturday. Three apps in one
repo: a mobile app, an API, and a small package in between carrying
the type definitions both sides agree on.
The middle package matters more than it looks. ForestLynk uses its
own words on purpose — a post is a node, a reply is a branch, a
deep reply is a leaf, a thread is a tree, a place is a forest. The
generated types are the contract. If the two sides ever drift on
what a node is, the build doesn’t compile. That keeps the
vocabulary honest before any UI is involved.
Everything ran from realistic data behind a mock client. Nothing
real on the wire yet. The point was to feel the shape — not yet to
host anything.
A lot. No identity. No realtime. The mobile app ran on mocks while
the API was being scaffolded in parallel. The next several posts
are about wiring the two together.
Tech notes ForestLynk runs three apps from one repo: a mobile client,
an HTTP API, and a small package of type definitions both
sides read.
Backend. Scala 3 on the JVM, with http4s as the
HTTP layer, Tapir for endpoint definitions, Doobie
for database access, and Cats Effect as the effect
runtime. Scala has a deeper functional-typing toolkit than
the more common backend languages (Go, Node, Python, Rust),
which makes it well-suited to modelling a domain where the
meaning of every noun is load-bearing — a node, a branch,
a leaf, a tree, a forest. Tapir is the keystone: endpoint
contracts are written once in code, and the same
definitions generate an OpenAPI schema, which an
openapi-typescript codegen step converts into
TypeScript types the mobile app imports directly. A
backwards-incompatible API change fails the mobile build
before it ever runs — the vocabulary is enforced by the
type system on both sides.
Database. Postgres 16 with the ltree
extension. Postgres is the obvious default — open source,
mature, very well-documented, and ours to operate. ltree
is the part fewer people reach for: a built-in column type
for hierarchical paths, with indexed ancestor / descendant
/ sibling queries. Conversations in a forest are trees of
reply-to-reply chains — exactly the shape ltree was
designed for. Postgres also gives us full-text search (used
for the directory’s leaves bucket), LISTEN/NOTIFY (used
to wake the push-dispatch worker, covered elsewhere on this
blog), and a generally rich enough toolbox that we haven’t
needed to bolt on a second database for a specialised job.
Mobile. Expo (React Native), with TypeScript ,
Expo Router for file-based routing,
react-native-reanimated for UI-thread animations,
and @shopify/react-native-skia for any per-frame
graphics. Expo’s value is the build pipeline and the
development experience — it handles native builds,
dependency upgrades, and OTA updates without a custom CI
pipeline. TypeScript closes the loop with the backend: the
codegen step above means a screen calling an endpoint can
autocomplete every field of the response, and a missing
field fails the build.
Why this combination. Type-sharing across the wire is
the thing that wouldn’t be feasible in most other stack
pairings — it needs both sides to be in statically-typed
languages with good codegen tooling. Once you have it, a
whole class of “the client expected forestId but the
server sent forest_id” bugs becomes structurally
impossible. Everything else is consequence: Postgres
because it’s the boring choice that always works; http4s
Where this stops being enough. Each piece has a scale
beyond which the easy moves run out.
Scala on the JVM has cold starts measured in tens of
seconds, not milliseconds. That’s fine for a long-running
server, irrelevant to most read latency once warm, but it
rules out serverless-function-per-request patterns and
pushes the minimum useful instance size up. The alternative
— a Go or Node / TypeScript backend — buys faster cold
starts and a leaner deployment target, at the cost of the
type-sharing story this section described. Whether the
trade is worth it depends on whether you’re optimising for
request-burst latency (Go / Node win) or for the
refactor-the-API-without-breaking-anyone-else discipline
the type sharing enforces (Scala wins).
Postgres has a single primary today. At the
write-throughput bottleneck — somewhere between tens of
thousands and low millions of writes per day depending on
workload — the standard moves are read replicas (easy:
Postgres ships with streaming replication; reads from
Yours / Discover / Search route to followers, writes stay
on the primary) and then application-level sharding (much
harder: it means moving from one logical database to
several, with all the cross-shard query gymnastics that
implies). Beyond that lies a different class of database
entirely — distributed-by-default systems like CockroachDB
or YugabyteDB that present a Postgres-compatible interface
but run multi-region natively. They trade operational
complexity and higher per-query latency for horizontal
write scaling. The ltree extension also doesn’t exist on
every Postgres-compatible system, so a move to one of those
would mean replacing it with a different hierarchical-index
strategy (materialised paths, nested sets, or a graph
database as the secondary store).
Expo is opinionated about a lot of things, which is
exactly why it’s productive at this stage. The opinions
can become walls later: custom native modules outside
Expo’s catalogue require either ejecting to a bare React
Native project (full native flexibility, no managed-update
story) or maintaining a custom development build via
Expo’s config-plugin system. At a hundred-thousand-user
scale, the OTA-update path matters more than at private
beta — the difference between fixing a serious bug in
hours versus pushing through both app stores’ review
queues. That’s exactly when the cost-benefit of staying
managed vs. going bare gets reassessed.
None of these constraints kick in soon. Each is a known
direction to look when the load justifies the operational
complexity it would buy.
And all of it is infrastructure we operate ourselves — the
thread that runs through the rest of the stack choices on
this blog, and the one Designing for the day this
works was about.