29 KiB
Prabhupāda Says — BFF (Backend for Frontend)
What this project is
A Go-based Backend for Frontend (BFF) API server for prabhupadasays.org. It is the sole data provider for the production Next.js frontend (and future mobile apps). The knowledgebase frontend has removed all direct Prisma, MongoDB, and S3 dependencies — everything flows through this BFF. It aggregates content from MongoDB, user data from PostgreSQL, search from Typesense, caching from DragonflyDB, and media from S3 into a unified API surface.
Tech Stack
- Go 1.23 — API server
- Chi v5 — lightweight HTTP router with middleware support
- Zap — structured logging (uber-go/zap)
- MongoDB — content database (books, verses, letters, essays, glossary, disciples, transcriptions)
- PostgreSQL — relational data (users, bookmarks, collections, mentions, URL shortener)
- Typesense — typo-tolerant full-text search engine (https://typesense.org)
- DragonflyDB — Redis-compatible cache (API responses, rate limiting, sessions)
- AWS S3 — media storage (avatars, images, audio files)
- FCM + APNs — push notifications for Android and iOS
- JWT — stateless authentication
Architecture
Note: The BFF is now the ONLY data path for the frontend. The knowledgebase has no Prisma bypass, no direct MongoDB connections, and no direct S3 access. All content is served exclusively through this API.
┌──────────────┐ ┌─────────────────────────────────────────┐
│ Next.js │────▶│ Go BFF API (:8080) │
│ (frontend) │ │ │
│ (sole data │ │ cmd/api/main.go → server.go → handlers │
│ path ↑) │ │ │
├──────────────┤ │ ┌──────────┐ ┌───────────────┐ │
│ Mobile Apps │────▶│ │ Handlers │──│ Services │ │
│ (future) │ │ └──────────┘ └───────┬───────┘ │
└──────────────┘ │ │ │
│ ┌─────────────────────┼──────────┐ │
│ ▼ ▼ ▼ ▼ ▼ │
│ Mongo Postgres Typesense S3 Dragonfly│
│ (content)(users, (search)(media)(cache)│
│ mentions, │
│ URL short, │
│ relations) │
└─────────────────────────────────────────┘
Project Structure
spsays-bff/
├── cmd/
│ ├── api/
│ │ └── main.go → Entry point (config, logger, server, graceful shutdown)
│ └── indexer/
│ └── main.go → CLI tool for indexing MongoDB content into Typesense
│ • --full drop all collections and reindex
│ • --only=verses,letters index specific collections only
├── internal/
│ ├── config/
│ │ └── config.go → Environment config (all services: Mongo, Postgres, Typesense, S3, etc.)
│ ├── server/
│ │ └── server.go → Chi router setup, global middleware, route registration
│ ├── handler/ → HTTP handlers (one file per domain)
│ │ ├── health.go → Health check endpoint
│ │ ├── search.go → Federated search handler (multi-collection, typo-tolerant)
│ │ ├── book.go → Book routes (SB, CC, KB, BG, generic verse & prose)
│ │ ├── letter.go → Letter routes (by year, recipient, location)
│ │ ├── essay.go → Essay routes
│ │ ├── glossary.go → Glossary routes (letters, terms)
│ │ ├── disciple.go → Disciple routes (filter, search, aggregations)
│ │ ├── journal.go → Journal routes
│ │ ├── magazine.go → Magazine routes
│ │ ├── other_document.go → Other-document routes
│ │ └── transcription.go → Transcription routes (by year, place, type, slug)
│ ├── service/ → Business logic layer (orchestrates repositories)
│ ├── repository/ → Data access layer (Mongo, Postgres, Typesense, S3, Dragonfly)
│ │ ├── mongo.go → MongoDB connection + database handle
│ │ ├── book_repo.go → Book / verse queries
│ │ ├── letter_repo.go → Letter queries
│ │ ├── essay_repo.go → Essay queries
│ │ ├── glossary_repo.go → Glossary queries
│ │ ├── disciple_repo.go → Disciple queries
│ │ ├── journal_repo.go → Journal queries
│ │ ├── magazine_repo.go → Magazine queries
│ │ ├── other_document_repo.go → Other-document queries
│ │ ├── transcription_repo.go → Transcription queries (with contentJson fallback)
│ │ └── typesense.go → Typesense client wrapper (search, indexing helpers)
│ ├── middleware/
│ │ ├── logger.go → Zap-based request logging (method, path, status, duration)
│ │ ├── auth.go → JWT Bearer token authentication + context user extraction
│ │ └── ratelimit.go → In-memory token bucket rate limiter (→ DragonflyDB in production)
│ └── model/
│ ├── content.go → Content models (Book, Verse, Letter, Essay, GlossaryEntry,
│ │ Disciple, Transcription, ContentBlock, Synonym)
│ └── user.go → User models (User, Bookmark, Collection, Mention, ShortURL)
├── pkg/ → Public packages (importable by other projects)
│ ├── response/
│ │ └── response.go → Standardized API response helpers (JSON, Error, Envelope, Meta)
│ ├── validator/
│ │ └── validator.go → Field validation (Required, MaxLength, Email, In, etc.)
│ ├── push/ → Push notification sender (FCM + APNs) — placeholder
│ └── shorturl/ → URL shortener logic — placeholder
├── migrations/ → PostgreSQL migrations (goose or golang-migrate)
├── go.mod → Module: github.com/prabhupadasays/spsays-bff
├── go.sum
├── Makefile → build, run, dev, test, clean, lint, tidy
├── .env.example → Template for all environment variables
└── .gitignore
API Routes
All routes are prefixed with /api/v1.
Health
| Method | Route | Description |
|---|---|---|
GET |
/health |
Server health check |
Books
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/books |
List all books with full metadata (chapters, cantos, parts, volumes, etc.) |
GET |
/api/v1/books/sb |
SB book metadata |
GET |
/api/v1/books/sb/{canto} |
SB canto chapter listing |
GET |
/api/v1/books/sb/{canto}/{chapter} |
SB chapter verse index |
GET |
/api/v1/books/sb/{canto}/{chapter}/verses |
SB paginated verses |
GET |
/api/v1/books/sb/{canto}/{chapter}/{verse} |
SB single verse |
GET |
/api/v1/books/cc |
CC book metadata |
GET |
/api/v1/books/cc/{lila} |
CC līlā chapter listing |
GET |
/api/v1/books/cc/{lila}/{chapter} |
CC chapter verse index |
GET |
/api/v1/books/cc/{lila}/{chapter}/verses |
CC paginated verses |
GET |
/api/v1/books/cc/{lila}/{chapter}/{verse} |
CC single verse |
GET |
/api/v1/books/kb |
KB book metadata |
GET |
/api/v1/books/kb/{volume} |
KB volume chapter listing |
GET |
/api/v1/books/kb/{volume}/{slug} |
KB single chapter |
GET |
/api/v1/books/bg |
BG book metadata |
GET |
/api/v1/books/bg/{chapter} |
BG chapter verse index |
GET |
/api/v1/books/bg/{chapter}/verses |
BG paginated verses |
GET |
/api/v1/books/bg/{chapter}/{verse} |
BG single verse |
GET |
/api/v1/books/{code} |
Any other book metadata |
GET |
/api/v1/books/{code}/chapters |
Prose book chapter listing (no paragraphs) |
GET |
/api/v1/books/{code}/chapters/{slug} |
Prose book single chapter |
GET |
/api/v1/books/{code}/{chapter} |
Verse book chapter index |
GET |
/api/v1/books/{code}/{chapter}/verses |
Verse book paginated verses |
GET |
/api/v1/books/{code}/{chapter}/{verse} |
Verse book single verse |
Letters
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/letters |
Total letter count |
GET |
/api/v1/letters/years |
Year → count aggregation |
GET |
/api/v1/letters/recipients |
Recipient → count aggregation |
GET |
/api/v1/letters/locations |
Location → count aggregation |
GET |
/api/v1/letters/year/{year} |
Letters for a year |
GET |
/api/v1/letters/recipient/{name} |
Letters for a recipient |
GET |
/api/v1/letters/location/{name} |
Letters from a location |
GET |
/api/v1/letters/{slug} |
Single letter |
Essays
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/essays |
All essay summaries |
GET |
/api/v1/essays/{slug} |
Single essay |
Glossary
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/glossary |
All glossary entries |
GET |
/api/v1/glossary/letters |
Letter → count mapping |
GET |
/api/v1/glossary/letter/{letter} |
Entries for a letter (with diacritical grouping) |
GET |
/api/v1/glossary/letter/{letter}/terms |
Term names only for sidebar |
GET |
/api/v1/glossary/term/{term} |
Single entry by term |
Disciples
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/disciples |
Filtered list (?letter=&suffix=) |
GET |
/api/v1/disciples/count |
Total + filtered counts |
GET |
/api/v1/disciples/letters |
Letter → count aggregation |
GET |
/api/v1/disciples/suffixes |
Suffix → count aggregation |
GET |
/api/v1/disciples/search |
Search by name (?q=) |
Journals
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/journals |
All journal metadata |
GET |
/api/v1/journals/{book} |
Single journal meta |
GET |
/api/v1/journals/{book}/chapters |
Journal chapters |
GET |
/api/v1/journals/{book}/chapters/{slug} |
Single chapter |
Magazines
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/magazines |
All magazine metadata |
GET |
/api/v1/magazines/{book} |
Single magazine meta |
GET |
/api/v1/magazines/{book}/issues |
Magazine issues |
GET |
/api/v1/magazines/{book}/issues/{slug} |
Single issue |
Other Documents
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/other-documents |
All document metadata |
GET |
/api/v1/other-documents/{book} |
Single document meta |
GET |
/api/v1/other-documents/{book}/chapters |
Document chapters |
GET |
/api/v1/other-documents/{book}/chapters/{slug} |
Single chapter |
Transcriptions
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/transcriptions |
Total transcription count |
GET |
/api/v1/transcriptions/years |
Year → count aggregation |
GET |
/api/v1/transcriptions/places |
Place → count aggregation |
GET |
/api/v1/transcriptions/types |
Type distribution ({lectures, conversations}) |
GET |
/api/v1/transcriptions/year/{year} |
Transcriptions for a year (summaries) |
GET |
/api/v1/transcriptions/place/{place} |
Transcriptions for a place (summaries) |
GET |
/api/v1/transcriptions/type/{type} |
Transcriptions by type (lecture or conversation) |
GET |
/api/v1/transcriptions/adjacent/{slug} |
Prev/next within same year (?year=N) |
GET |
/api/v1/transcriptions/{slug} |
Single transcription (full content + audio URL) |
Search
| Method | Route | Description |
|---|---|---|
GET |
/api/v1/search?q=...&type=...&page=1&limit=20 |
Federated typo-tolerant search across all content |
Query params:
q— search query (required)type— content type filter, comma-separated. Valid values:all(default) — search all 9 collectionsverses,prose,letters,transcriptions,glossary,essays,journals,magazines,other_documents- Multiple:
verses,letters,transcriptions
page— page number (default 1)limit— results per page (default 20, max 100)
Response:
{
"success": true,
"data": {
"query": "surrender unto",
"type": "all",
"results": [
{
"collection": "verses",
"id": "...",
"title": "BG 18.66",
"snippet": "...just <mark>surrender unto</mark> Me...",
"url": "/books/bg/18/66",
"meta": { "book": "bg", "reference": "BG 18.66" }
}
],
"total": 150,
"page": 1,
"limit": 20,
"total_pages": 8
}
}
Authenticated (JWT required) — planned
| Method | Route | Description |
|---|---|---|
POST |
/api/v1/auth/register |
User registration |
POST |
/api/v1/auth/login |
User login (returns JWT) |
POST |
/api/v1/auth/refresh |
Refresh JWT token |
GET |
/api/v1/users/me |
Get current user profile |
PUT |
/api/v1/users/me |
Update current user profile |
GET |
/api/v1/collections |
List user's collections |
POST |
/api/v1/collections |
Create a collection |
POST |
/api/v1/collections/:id/items |
Add item to collection |
DELETE |
/api/v1/collections/:id/items/:itemId |
Remove item from collection |
GET |
/api/v1/mentions |
List user's mentions |
POST |
/api/v1/mentions |
Create a mention |
POST |
/api/v1/s |
Create a short URL |
POST |
/api/v1/notifications/register |
Register device for push notifications |
Typesense Indexing
The BFF includes a CLI tool (cmd/indexer/main.go) for managing Typesense indexes.
Collections (9 total, ~35,500 documents)
| Collection | MongoDB Source | Documents | Searchable Fields |
|---|---|---|---|
verses |
verses |
21,127 | translation, purport, transliteration, reference (+ ASCII variants) |
prose |
prose_chapters |
627 | title, content (+ ASCII variants) |
letters |
letters |
6,866 | content, recipient_display (+ ASCII variants) |
transcriptions |
transcriptions |
3,787 | content, name, reference (+ ASCII variants) |
glossary |
glossary |
2,749 | term, definition (+ ASCII variants) |
essays |
essays |
23 | title, content (+ ASCII variants) |
journals |
journal_chapters |
300 | title, content (+ ASCII variants) |
magazines |
magazine_issues |
59 | title, content (+ ASCII variants) |
other_documents |
other_document_chapters |
28 | title, content (+ ASCII variants) |
ASCII Shadow Fields
For diacritic-insensitive search, every searchable text field has a _ascii shadow field with all IAST diacritics stripped (ā→a, ṛ→r, ṣ→s, ṭ→t, ñ→n, etc.). This allows queries like "Krsna" to match "Kṛṣṇa", "Yashodanandana" to match "Yaśodanandana".
Token Separators
Every collection is configured with token_separators: ["-"] so hyphenated Sanskrit/Vedic terms are split into individual tokens at index time. For example, sarva-dharman is indexed as the two tokens sarva and dharman; Dīkṣā-guru indexes as dīkṣā + guru. This means queries like "sarva dharman" or "diksa guru" match cleanly even though the source text uses a hyphen.
A shared hyphenSeparators variable is defined once in cmd/indexer/main.go and applied to every schema. Earlier only verses had this configured — the fix was to extend it to glossary, prose, letters, transcriptions, essays, journals, magazines, and other_documents (a full --full reindex is required after enabling it on a collection because tokenisation happens at index time).
Stopwords
A server-side Typesense stopwords set named common strips conversational tokens before matching. The set is provisioned out-of-band via PUT /stopwords/common (one-time server config; not part of the indexer):
letter, letters, to, of, the, a, an, is, are, for, by, from,
in, on, with, and, or,
essay, essays, transcription, transcriptions,
magazine, magazines, journal, journals,
verse, verses, lecture, lectures, conversation, conversations,
document, documents
The search handler passes stopwords=common on every Typesense call (both multiSearch and singleSearch). Without this, a query like "letter to tusta krsna" would let "letter" and "to" match in the body content of many letters, drowning out the specific recipient match. With stopwords, the query effectively becomes "tusta krsna" and the actual letters to Tusta Krsna rise to the top.
Search Configuration
All Typesense search calls (both multiSearch and singleSearch in internal/handler/search.go) use this set of parameters:
prefix=true— partial word matching (e.g., "sarv" matches "sarva", "parit" matches "parityajya")num_typos=2— up to 2 character substitutions allowed per wordexhaustive_search=true— finds all matches across multi-field queries; without this, multi-field searches with mixed-length fields (like a verse's shorttransliterationvs. its longpurport) would miss valid matches because Typesense's default early-termination heuristics drop short-field hits when longer fields dominate the candidate setprioritize_exact_match=true— prioritises documents whose field value matches the query exactly over partial / typo matchesprioritize_token_position=true— matches earlier in a field score higher; this is what differentiates documents whose scores would otherwise tie (e.g. "karma" magazine results where every doc hadtokens_matched=1)stopwords=common— strips conversational tokens (see Stopwords above)text_match_type— left at Typesense's defaultmax_score. Earlier experiments withsum_score(for boosting letters matching both recipient + content) caused regressions on the "All" tab becausesum_scorefavours documents with matches across more fields, so transcriptions with the verse reference in theirnamefield would outrank the verses themselves on a query like "bg 18.66". Stopwords alone solved the letter-ranking problem without this trade-off.query_by_weights— per-collection weights to boost identifier/primary fields over secondary content (see below)- HTML stripping — all paragraph content has HTML tags removed before indexing
_asciishadow fields — every searchable text field is duplicated with diacritics stripped, and both the original and_asciifield are included inquery_by
Per-Collection Field Strategy
Each collection defines its own query_by field list and matching query_by_weights, ranking identifier/primary fields highest and secondary content lowest:
| Collection | query_by |
Weights |
|---|---|---|
verses |
reference, transliteration, transliteration_ascii, translation, translation_ascii |
10,8,8,4,4 |
prose |
title, title_ascii, content, content_ascii |
10,10,4,4 |
letters |
recipient_display, recipient_display_ascii, content, content_ascii |
8,8,4,4 |
transcriptions |
reference, name, name_ascii, content, content_ascii |
10,8,8,4,4 |
glossary |
term, term_ascii, definition, definition_ascii |
10,10,4,4 |
essays / journals / magazines / other_documents |
title, title_ascii, content, content_ascii |
10,10,4,4 |
Verses — purport is intentionally excluded from default search. The verse query_by list does not include purport or purport_ascii. If purport were searchable by default, verses where a phrase appears only in someone's commentary (e.g. a purport that quotes another verse) would frequently outrank the actual verse containing that phrase. For example, searching "sarva dharman parityajya" should rank BG 18.66 (the verse itself) above any commentary that cites it. Excluding purport from query_by ensures the canonical verse always wins.
The same identifier-first pattern applies across collections: references, names, terms, and titles are weighted highest; primary text (translation, definition, recipient) sits in the middle; long-form body content (purport, content) is either weighted lowest or excluded entirely.
CLI Usage
# Full reindex (drops all collections and rebuilds)
go run cmd/indexer/main.go --full
# Index specific collections only (drops + recreates them)
go run cmd/indexer/main.go --only=verses,letters
# Index all, but skip collections that already exist
go run cmd/indexer/main.go
Indexer Robustness
- Typesense connection timeout: 5 minutes. The indexer uses
typesense.WithConnectionTimeout(5*time.Minute)(vs the library default of 5s) because the magazines and other_documents collections ship multi-MB batches that exceeded the default. Without this, those collections silently ended up empty in Typesense — the indexer loggedcontext deadline exceededper batch but moved on. - Reduced batch size for heavy collections.
magazinesandother_documentsuseimportBatch(..., 25)instead of the default 500 because each document contains very large paragraph arrays (entire magazine issues / chapters concatenated). All other collections still use batch size 500. - Importing post-fix. If you have empty magazine / other_documents collections from before this fix, simply run
--full(or--only=magazines,other_documents) to rebuild.
Data Models
Content Models (MongoDB)
| Model | Collection | Description |
|---|---|---|
Book |
book_meta |
Book metadata (title, author, chapters, etc.) |
Verse |
verses |
Individual verses (BG, SB, CC, ISO, NOI, BS, MM, NBS) |
Letter |
letters |
6,800+ letters (1947–1977) |
Essay |
essays |
23 standalone essays |
GlossaryEntry |
glossary |
2,749 Sanskrit/Vedic terms |
Disciple |
disciples |
4,747 initiated disciples |
Transcription |
transcriptions |
3,787 lectures, conversations, philosophy discussions, SB dictations — with audio URLs |
User Models (PostgreSQL)
| Model | Table | Description |
|---|---|---|
User |
users |
User accounts (email, name, avatar, role) |
Bookmark |
bookmarks |
Saved content references with optional notes |
Collection |
collections |
User-created collections of bookmarks |
Mention |
mentions |
Content shared/tagged by users |
ShortURL |
short_urls |
Shortened URLs with click tracking |
API Response Format
All responses follow a consistent envelope:
{
"success": true,
"data": { ... },
"meta": {
"page": 1,
"limit": 20,
"total": 150,
"total_pages": 8
}
}
Error responses:
{
"success": false,
"error": {
"code": 400,
"message": "validation failed"
}
}
Middleware Stack
| Middleware | Description |
|---|---|
RequestID |
Generates unique request ID (chi built-in) |
RealIP |
Extracts real client IP from proxy headers |
Logger |
Zap-based structured request logging |
Recoverer |
Panic recovery (chi built-in) |
Compress |
Gzip compression (level 5) |
CORS |
Cross-origin resource sharing (frontend URL whitelisted) |
Auth |
JWT Bearer token validation (applied to protected routes) |
RateLimit |
Token bucket rate limiter (in-memory → DragonflyDB in production) |
Environment Variables
# Server
APP_ENV=development # "development" | "production"
PORT=8080
# MongoDB (content)
MONGO_URI=mongodb://... # required
MONGO_DB=spsays
# PostgreSQL (users, relations)
POSTGRES_URI=postgres://...
# Typesense
TYPESENSE_URL=https://search.prabhupadasays.org
TYPESENSE_API_KEY=
# DragonflyDB / Redis
REDIS_URI=redis://localhost:6379
# S3
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_ENDPOINT= # optional — for MinIO or S3-compatible services
# Push Notifications
FCM_CREDENTIALS_FILE= # path to Firebase service account JSON
APNS_KEY_FILE= # path to APNs auth key (.p8)
APNS_KEY_ID=
APNS_TEAM_ID=
APNS_BUNDLE_ID=
# JWT
JWT_SECRET= # required in production
JWT_EXPIRATION=24h
# Frontend (CORS)
FRONTEND_URL=http://localhost:3000
How to Run
# Setup
cp .env.example .env # then fill in values
# Development (with live reload — requires air)
make dev
# Build + run
make run
# Build only
make build
# Run tests
make test
# Lint (requires golangci-lint)
make lint
# Tidy deps
make tidy
Relationship to Other Projects
| Project | Path | Relationship |
|---|---|---|
| knowledgebase | ../knowledgebase/ |
Next.js frontend — depends entirely on this BFF for all data (books, letters, essays, glossary, disciples, journals, magazines, other documents, transcriptions). Has NO direct database (Prisma/MongoDB) or S3 connections. The BFF must be running for the frontend to function. |
| extractor | ../extractor/ |
Python/TS extraction scripts + raw data + seed scripts |
| spsays-bff | ./ (this project) |
Go BFF API server — single source of truth for all content served to clients |
Migration status (Prisma → BFF):
- ✅ Done — BFF API routes matching all former Prisma queries
- ✅ Done — Frontend migrated from Prisma to BFF API calls (Prisma, MongoDB driver, and S3 SDK removed from knowledgebase)
- ✅ Done — Typesense indexing + federated search endpoints (
cmd/indexer/main.go,/api/v1/search) - Add PostgreSQL for user features (auth, bookmarks, collections, mentions)
- Add mobile app support (same API, push notifications)
Known Issues
-
Verse purport content is not searchable by default. Because
purportandpurport_asciiare excluded from the versesquery_bylist (see Per-Collection Field Strategy above), phrases that appear only inside a purport — and not in the verse'sreference,transliteration, ortranslation— will not match a normal verse search. Surfacing those matches would require a separate "deep search" toggle that addspurportandpurport_ascii(with lower weights) toquery_by. This is not yet implemented. -
Stopwords set is an external Typesense resource. The
commonstopwords set is configured server-side viaPUT /stopwords/commonand is not managed by the indexer or the BFF code. If Typesense is rebuilt from scratch or restored from a snapshot that predates the stopwords config, the set must be reprovisioned manually. The BFF passesstopwords=commonon every search; if the set doesn't exist, Typesense simply ignores the parameter (rather than erroring), so symptoms would be “searches like 'letter to tusta krsna' suddenly behave worse,” not hard failures. -
Indexer is destructive on
--fullor--only=. Both flags drop the named collection(s) before recreating them. There is currently no incremental indexer — if a single document changes in MongoDB, the whole collection has to be reindexed. Acceptable today (largest collection is verses at ~21k docs, takes seconds), but worth keeping in mind.
Design Principles
- Single source of truth — the BFF is the sole data provider for all content consumed by the frontend and future mobile apps. No client should bypass the BFF to access MongoDB, S3, or any other backing service directly.
internal/— private to this module (handlers, services, repositories, middleware, models, config)pkg/— public packages that could be imported by other projects (response helpers, validators, push sender)- Repository pattern — data access isolated behind interfaces (easy to swap Mongo ↔ Postgres, mock for tests)
- Service layer — business logic between handlers and repositories (orchestration, validation, caching)
- Handler layer — thin HTTP handlers that parse requests, call services, return responses
- No ORM — use native drivers (
mongo-go-driver,pgx) for full control - Explicit book routing — SB, CC, and KB have dedicated route groups to avoid URL ambiguity with generic
{code}/{chapter}/{verse}patterns - Search gateway — search is provided by Typesense, indexed from MongoDB via the indexer CLI; the BFF acts as the search gateway with multi-collection federated queries.