Chapter 19: Documents Across Frameworks
Most teams write in several languages at once, and any reasonable team will wonder whether a single document API can attend to all of them with equal grace. Allow me to set your mind at ease.
Twenty-one methods. Seven languages. One surface.
The document API was designed framework-agnostic from its first commit — which is to say, the decision was made before there was anything to decide against. Every binding — Python, Node.js, Ruby, Java, PHP, Go, .NET — is a thin pass-through to the same SQL generator. The method you call in a Django view produces the same query plan as the method you call in a Spring @Service or a Laravel controller. If you learned doc_find, doc_insert, doc_update, doc_delete, and doc_aggregate in the Python chapters, you already know the library in every language it ships in.
This chapter attends to three questions the earlier chapters politely set aside:
- How does a document table live alongside your relational models?
- Where do
doc_*calls fit in the request lifecycle? - How do you test code that uses them?
The answers are short. The integration story is, I am pleased to report, deliberately boring — which is precisely the point. A document API that requires its own service, its own connection pool, or its own testing apparatus is a second database wearing a library's clothes. Gold Lapel is a set of functions that run against the PostgreSQL connection you already have. No staff to hire. No room to prepare.
The Universal Pattern: Two Lines, Seven Languages
Every binding observes the same contract:
- Call the
startfactory with your upstream Postgres URL. It spawns the proxy, opens an internal connection, and returns a readyGoldLapelinstance. - Call
doc_*methods directly on the instance, or handgl.urlto any Postgres driver for raw SQL.
In pseudocode:
gl = GoldLapel.start("postgresql://user:pass@host/db")
rows = gl.doc_find("orders", {"status": "open"}) Two lines. The first runs once in the lifetime of your process.
The factory signatures across all seven bindings:
| Language | Factory |
|---|---|
| Python | goldlapel.start(url) / await goldlapel.asyncio.start(url) |
| Node.js | await goldlapel.start(url) |
| Ruby | GoldLapel.start(url) / GoldLapel::Async.start(url) |
| Java | GoldLapel.start(url, opts -> {}) |
| PHP | GoldLapel::start($url, [...]) |
| Go | goldlapel.Start(ctx, url, opts...) |
| .NET | await GoldLapel.StartAsync(url, opts => {}) |
start is the canonical name in every binding — not init, not bootstrap, not migrate. A rose by any other name would still do the same thing, but consistency across seven languages is a courtesy I take seriously. It spawns the proxy subprocess, waits for it to accept connections, and creates the documents metadata table, the per-collection tables referenced by subsequent doc_* calls, and their GIN indexes. Running it never means the first doc_* call has nothing to target; that is why the factory is the entry point.
What follows is one section per language, showing where the factory fires, and what a call site looks like next to the framework's own ORM. I shall not keep you long in any one of them.
Python: psycopg, asyncpg, Django, SQLAlchemy
Raw psycopg and asyncpg. Call the factory with your upstream URL. No framework magic, and none needed.
import goldlapel
gl = goldlapel.start("postgresql://user:pass@localhost:5432/mydb")
orders = gl.doc_find("orders", {"status": "open"}) For async code, goldlapel.asyncio.start() returns a native asyncpg-backed instance — every doc_* method is awaitable, and IO yields cooperatively to the event loop:
from goldlapel.asyncio import start
gl = await start("postgresql://user:pass@localhost:5432/mydb")
orders = await gl.doc_find("orders", {"status": "open"}) The sync path uses psycopg under the hood; the async path uses asyncpg. Both surface the same doc_* methods with the same semantics. The library, in other words, minds its own business about which flavor of Python you brought to the table.
Django (bundled in goldlapel; configure by setting "ENGINE": "goldlapel.django" in DATABASES). The bundled Django backend wraps django.db.backends.postgresql — it spawns the proxy during first connect and routes the ORM's traffic through it. In a view:
from django.shortcuts import get_object_or_404
from myapp.goldlapel_instance import gl # created at app boot via goldlapel.start(DSN)
from myapp.models import Order
def order_detail(request, order_id):
order = get_object_or_404(Order, pk=order_id)
events = gl.doc_find("order_events", {"order_id": order.id})
return render(request, "order.html", {"order": order, "events": events}) The ORM call and the document call target the same proxy in front of the same PostgreSQL. For a shared transaction, scope the doc_* calls to the ORM's connection with gl.using(conn) — see "Where doc_* Calls Fit in the Request Lifecycle" below.
A word about Django's own JSONField — the feature most teams discover when they search "django jsonb field." It is excellent. It does what it does very well, and nothing in this library replaces it. A model can declare a JSONField column that the ORM manages, while a separate document table holds documents the doc API manages. Different tables for different access patterns. Both are welcome in the same application.
SQLAlchemy (bundled in goldlapel; pip install sqlalchemy separately). The goldlapel.sqlalchemy module provides a drop-in create_engine / create_async_engine that spawns the proxy and wires the SQLAlchemy engine through it:
import goldlapel
from goldlapel.sqlalchemy import create_async_engine
from sqlalchemy.ext.asyncio import AsyncSession
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost:5432/mydb")
async with AsyncSession(engine) as session:
# session.execute() runs SQL through the proxy engine;
# goldlapel.doc_* methods share the same proxy process.
prefs = goldlapel.doc_find("user_prefs", {"user_id": 42}) Both paths resolve to the same proxy in front of the same PostgreSQL. The same cordial coexistence applies to SQLAlchemy's JSONB column type: you may have both, on different tables, for different reasons. I can think of no good reason to choose.
Node.js: pg, Prisma, Drizzle
pg (npm install goldlapel pg). The goldlapel package no longer bundles a Postgres driver — pg is a peer dependency, so you install it alongside. Call the factory with your upstream URL:
import express from "express";
import * as goldlapel from "goldlapel";
const gl = await goldlapel.start(process.env.DATABASE_URL);
const app = express();
app.get("/orders/:id/events", async (req, res) => {
const events = await gl.docFind("order_events", { order_id: req.params.id });
res.json(events);
}); Method names are camelCase per JavaScript convention: docFind, docInsert, docUpdate, docDelete, docAggregate. The Waiter always dresses for the occasion.
Prisma (npm install goldlapel @goldlapel/prisma). Prisma is a thoughtful ORM — its type generation alone has saved countless hours across the industry, and I would not dream of speaking ill of it. The JSONB query surface is currently narrower than PostgreSQL's own: beyond equality and path access, the native operators — containment (@>), existence (?), path queries — are not yet wrapped. Issue #16125 tracks the work; it has been open for some time, as feature requests on a well-used library tend to be. Teams in the meantime reach for $queryRaw to recover the operators earlier chapters took for granted. Gold Lapel is the typed alternative, offered in the same spirit.
A word on how the adapter works, since Prisma does not by default hand you a raw pg pool — its query engine is a separate process and its connections are, from Node's side, opaque. The @goldlapel/prisma package calls goldlapel.start() under the hood, rewriting DATABASE_URL so Prisma's query engine connects through the proxy. Document calls go through a parallel connection the wrapper maintains against the same proxy. Both sides share the same proxy process; both sides hit the same PostgreSQL.
import { withGoldLapel } from "@goldlapel/prisma";
import * as goldlapel from "goldlapel";
const prisma = await withGoldLapel();
const gl = await goldlapel.start(process.env.DATABASE_URL);
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: { userId: 1, total: 99 } });
await gl.docInsert("order_events", {
order_id: order.id,
type: "created",
payload: { total: 99 },
});
}); Both Prisma and the document call target the same proxy in front of the same PostgreSQL. There is no second database to coordinate — because there is no second database.
Drizzle (npm install goldlapel @goldlapel/drizzle). Drizzle has earned its audience for good reason. Its JSONB support at present is column-type-only — issue #1690 tracks the absence of query helpers — and so the same complementary arrangement applies. The @goldlapel/drizzle adapter exposes a drizzle() factory that spawns the proxy and returns a ready Drizzle instance:
import { drizzle } from "@goldlapel/drizzle";
import * as goldlapel from "goldlapel";
import { eq } from "drizzle-orm";
import { orders } from "./schema";
const db = await drizzle({ schema: { orders } });
const gl = await goldlapel.start(process.env.DATABASE_URL);
const openOrders = await db.select().from(orders).where(eq(orders.status, "open"));
const revenue = await gl.docAggregate("order_events", [
{ $match: { type: "payment_succeeded" } },
{ $group: { _id: null, total: { $sum: "$amount" } } },
]); Relational select() for the typed columns Drizzle handles gracefully; docAggregate for the JSONB work Drizzle has not yet arrived at. The two tools in the same file, working in concert. I find this rather pleasing.
Ruby: pg Gem and Rails ActiveRecord
pg gem. Standalone, no framework:
require "pg"
require "goldlapel"
gl = GoldLapel.start("postgresql://user:pass@localhost/app")
gl.doc_find("users", { active: true }).each do |user|
puts user["email"]
end Method names are snake_case per Ruby convention — doc_find, doc_insert, doc_update, doc_delete, doc_aggregate. One tries to blend in.
For fiber-based concurrency via the async gem, GoldLapel::Async.start returns an instance whose every wrapper call uses pg's native non-blocking variants (async_exec_params and friends) so Postgres IO yields cooperatively to the fiber scheduler:
require "async"
require "goldlapel/async"
Async do
gl = GoldLapel::Async.start("postgresql://user:pass@localhost/app")
recent = gl.doc_find("articles", { "status" => "published" })
gl.stop
end Rails (gem "goldlapel", then require "goldlapel/rails"). The railtie prepends an extension onto the PostgreSQL adapter so every AR connection is spawned through the proxy — the ORM's queries hit the L1 native cache automatically. For document calls, start a GoldLapel::Instance once at boot (typically in an initializer) and reach for it from controllers:
# config/initializers/goldlapel.rb
Rails.application.config.after_initialize do
GL = GoldLapel.start(
ActiveRecord::Base.connection_db_config.configuration_hash[:url],
)
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
@preferences = GL.doc_find("user_preferences", { user_id: @user.id })
end
end Rails' existing jsonb column type — the feature returned for "rails postgresql jsonb" and "activerecord jsonb" — is untouched, unmolested, and entirely welcome. An ActiveRecord model may have a jsonb column the ORM serializes; a separate document table may hold documents the doc API serializes. Different tables, different jobs, no quarrel between them.
Java: JDBC and Spring Boot
Raw JDBC. Call the factory with your upstream URL — the returned GoldLapel is auto-closeable, so try-with-resources stops the proxy on scope exit:
try (GoldLapel gl = GoldLapel.start(
"postgresql://user:pass@localhost:5432/mydb",
opts -> {})) {
List<Map<String, Object>> open = gl.docFind(
"orders",
Map.of("status", "open")
);
} Method names follow Java convention: docFind, docInsert, docUpdate, docDelete, docAggregate. Input documents are Map<String, Object>; return values are List<Map<String, Object>>. The types are as straightforward as Java's type system permits, which is a phrase I offer with affection.
Spring Boot (com.goldlapel:goldlapel-spring-boot on Maven Central). The starter auto-configures a GoldLapel bean wired to the application's primary DataSource, calling GoldLapel.start(...) as part of the context refresh.
@Service
public class OrderService {
private final OrderRepository repo;
private final GoldLapel gl;
public OrderService(OrderRepository repo, GoldLapel gl) {
this.repo = repo;
this.gl = gl;
}
public OrderView load(Long id) {
Order order = repo.findById(id).orElseThrow();
var events = gl.docFind("order_events", Map.of("order_id", id));
return new OrderView(order, events);
}
} A brief and grateful word about the Baeldung archive. If you search "spring boot jsonb" or "hibernate jsonb postgresql" today, the top results are Baeldung articles that walk through writing a custom Hibernate UserType to map a JSONB column to a POJO. Those articles are correct, thorough, and have taught a generation of Java developers how to work with JSONB in Hibernate. They remain the right choice when you want the ORM to manage the column. The doc API is an alternative, not a replacement — for teams that want @>, ?, path extraction, and aggregation without writing a UserType at all.
PHP: PDO and Laravel Eloquent
PDO. Plain, unadorned, exactly as advertised:
use GoldLapel\GoldLapel;
$gl = GoldLapel::start("postgresql://user:pass@localhost:5432/app", [
"port" => 7932,
"log_level" => "info",
]);
$events = $gl->docFind("order_events", ["order_id" => 42]); Method names are camelCase: docFind, docInsert, docUpdate, docDelete, docAggregate.
Laravel (bundled in goldlapel/goldlapel; no separate package). The package supports Laravel's service provider auto-discovery, so no manual registration is required. The GoldLapel\Laravel\GoldLapelServiceProvider calls GoldLapel::start() during boot and binds the live instance into the container. A controller resolves it by type-hint:
use GoldLapel\GoldLapel;
class UserController extends Controller
{
public function show(int $id, GoldLapel $gl)
{
$user = User::findOrFail($id);
$events = $gl->docFind("user_events", ["user_id" => $user->id]);
return view("users.show", compact("user", "events"));
}
} Laravel's familiar pattern of declaring protected $casts = ['meta' => 'array'] on an Eloquent model — which stores JSON in a column and hydrates it to a PHP array — still works, still has its place, and stays precisely where it is. The doc API addresses a different case: a dedicated document table, queryable by the full JSONB operator set, without Eloquent involvement. Two tools. Same application. No argument between them.
Go: pgx and Native JSONB
Go is the one language in this chapter without a dominant ORM, and the Go community would, I suspect, consider that a feature rather than an oversight. The idiomatic pattern is pgx plus sqlc, or pgx plus hand-written SQL. The doc API slots in beside either without the slightest fuss.
Call goldlapel.Start with your upstream URL. It spawns the proxy and returns a ready *GoldLapel:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"github.com/goldlapel/goldlapel-go"
)
func main() {
ctx := context.Background()
gl, err := goldlapel.Start(ctx, os.Getenv("DATABASE_URL"),
goldlapel.WithPort(7932),
goldlapel.WithLogLevel("info"),
)
if err != nil {
log.Fatal(err)
}
defer gl.Stop(ctx)
http.HandleFunc("/orders/", func(w http.ResponseWriter, r *http.Request) {
events, err := gl.DocFind(r.Context(), "order_events",
map[string]any{"order_id": r.PathValue("id")})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(events)
})
http.ListenAndServe(":8080", nil)
} Method names are exported PascalCase per Go convention — DocFind, DocInsert, DocUpdate, DocDelete, DocAggregate — and every method takes context.Context as its first argument. Go has opinions about cancellation, and we are not here to contradict them.
.NET: Npgsql and EF Core
Call GoldLapel.StartAsync with your upstream URL at application startup and register the resulting instance as a singleton. await using disposal stops the proxy on application shutdown:
using GoldLapel;
var builder = WebApplication.CreateBuilder(args);
var gl = await GoldLapel.StartAsync(
builder.Configuration.GetConnectionString("Default"),
opts => { opts.Port = 7932; opts.LogLevel = "info"; });
builder.Services.AddSingleton(gl);
var app = builder.Build();
app.MapGet("/orders/{id}/events", async (long id, GoldLapel gl) =>
{
var events = await gl.DocFindAsync("order_events",
new Dictionary<string, object> { ["order_id"] = id });
return Results.Ok(events);
});
app.Run(); Method names are PascalCase and async — DocFindAsync, DocInsertAsync, DocUpdateAsync, DocDeleteAsync, DocAggregateAsync. The instance manages its own internal NpgsqlConnection; gl.Url is available for any code that wants a bespoke Npgsql connection of its own.
EF Core's jsonb column mapping — the feature returned for "ef core jsonb" — is a separate facility on a separate table. Both coexist. I believe I have said this in five languages now, which is a measure of how reluctant I am to be misunderstood on the point.
The Comparison Table
One table for the things that differ. Method behavior is identical across the board; only the wrapping varies.
| Language | Framework | Package | Auto-start | Method casing |
|---|---|---|---|---|
| Python | — | goldlapel | No | doc_find |
| Python (async) | — | goldlapel | No | doc_find |
| Python | Django | goldlapel (bundled) | Yes (first connect) | doc_find |
| Python | SQLAlchemy | goldlapel (bundled) | Yes (engine init) | doc_find |
| Node.js | — | goldlapel (+ pg peer) | No | docFind |
| Node.js | Prisma | @goldlapel/prisma | Yes (factory) | docFind |
| Node.js | Drizzle | @goldlapel/drizzle | Yes (factory) | docFind |
| Ruby | — | goldlapel | No | doc_find |
| Ruby (async) | — | goldlapel | No | doc_find |
| Ruby | Rails | goldlapel + railtie | Yes (railtie) | doc_find |
| Java | — | com.goldlapel:goldlapel | No | docFind |
| Java | Spring Boot | goldlapel-spring-boot | Yes (starter) | docFind |
| PHP | — | goldlapel/goldlapel | No | docFind |
| PHP | Laravel | goldlapel/goldlapel (bundled) | Yes (provider) | docFind |
| Go | — | github.com/goldlapel/goldlapel-go | No | DocFind |
| .NET | — | GoldLapel (NuGet) | No | DocFindAsync |
The rows that matter for migrating code between languages are the last two columns: method casing follows the target language's convention, and auto-start fires wherever the framework has a natural "application has booted" hook. Everything else, I am pleased to say, is the same query against the same table, translated by the same SQL generator.
Where doc_* Calls Fit in the Request Lifecycle
Three rules, true in every binding. Permit me to state them plainly.
One GoldLapel per process, not per request. Call the factory at boot. The instance is long-lived — it owns the proxy subprocess and an internal connection; creating one per request would spawn and tear down a proxy on every request, which is expensive and unnecessary.
The factory runs once at boot. It spawns the proxy, waits for it to accept connections, opens the internal connection, and returns. The auto-start bindings (Django, Rails, Spring Boot, Laravel) handle this for you via their normal initialization hooks. In the others, put the call next to wherever your pool or data source is initialized.
doc_* calls participate in the surrounding transaction when you scope them to it. This is the load-bearing integration claim of the entire chapter, so I shall make it carefully. Every binding exposes a scoping helper — gl.using(conn) in Python/Ruby/JS/PHP, gl.UsingAsync(conn, ...) in .NET, a conn:/connection: kwarg on every wrapper method — that routes calls inside its block through the connection you supply. Open a transaction on that connection; both ORM writes and document writes commit or roll back together:
import goldlapel
import psycopg2
from myapp.goldlapel_instance import gl
from myapp.models import Order
def create_order(request):
conn = psycopg2.connect(gl.url)
with conn: # transaction
with gl.using(conn):
order = Order.objects.create(user=request.user, total=request.POST["total"])
gl.doc_insert("order_events", {
"order_id": order.id,
"type": "created",
"payload": {"total": float(order.total)},
})
if not validate(order):
raise ValueError("rollback both")
return redirect("order_detail", order.id) If validate() fails, both the INSERT into the orders table and the doc_insert into order_events roll back. The doc call uses the same connection the transaction was opened on; PostgreSQL sees one transaction. Not two coordinated. One.
The same guarantee holds for Rails' ActiveRecord::Base.transaction (via gl.using(conn)), Spring's @Transactional, Laravel's DB::transaction() (via $gl->using($pdo, ...)), pgx's BeginTx, and EF Core's SaveChangesAsync inside a transaction scope. Every binding is documented to enroll in the active transaction on the connection it is scoped to. This is not a happy coincidence. It is the feature.
Testing doc_* Code Across Frameworks
Three patterns cover the cases worth covering. I should be forthcoming about the fact that there is no fourth pattern I am concealing.
Real PostgreSQL in CI. The doc API generates real SQL that depends on real PostgreSQL behavior — GIN indexes, JSONB containment semantics, jsonb_path_query paths. Mocks drift. A mock of PostgreSQL is a small fiction you are telling yourself, and small fictions compound. Run tests against a real database: Testcontainers in JVM and Node.js, pytest-postgresql in Python, the standard rails db:test:prepare flow in Rails, docker-compose up postgres for anything else.
Per-test transactional rollback. Every mainstream framework has a test base class that wraps each test in a transaction and rolls back at teardown. Scope your doc_* calls to the same connection the test is holding open — via gl.using(conn) — and they roll back with everything else:
from django.test import TransactionTestCase
from myapp.goldlapel_instance import gl
class OrderEventsTest(TransactionTestCase):
def test_event_is_recorded(self):
conn = self.connection # AR/Django test connection
with gl.using(conn):
gl.doc_insert("order_events", {"order_id": 1, "type": "created"})
found = gl.doc_find("order_events", {"order_id": 1})
self.assertEqual(len(found), 1)
# at teardown, the transaction rolls back; the next test sees an empty table The same pattern works unchanged with Rails' transactional fixtures, Spring's @Transactional on test classes, Laravel's RefreshDatabase trait, and SQLAlchemy's savepoint-per-test pattern. The test framework cleans up the table for you. You may focus on the assertion.
truncate_documents() for the rare non-transactional case. Some tests span processes — a browser driver in one process, an application server in another. Transactional rollback cannot reach across the connection boundary. I'm afraid no amount of wishing makes it otherwise. Every binding exposes a helper for this case:
gl.truncate_documents() # python
gl.truncateDocuments() # java/node/php/.net
gl.truncate_documents # ruby
gl.TruncateDocuments(ctx) # go It runs TRUNCATE against every document table registered with this GoldLapel instance. Use it in a test fixture that runs between scenarios, not per test — TRUNCATE is not free, and the per-scenario cadence is the appropriate granularity.
Honest Boundary: What ORM Integration Does Not Give You
I should be candid about the gaps. A waiter who overstates his case is no waiter at all, and I would rather you know the shape of the thing before you order.
Three things doc_* calls do not do, even inside a framework binding.
Document tables are not ORM models. There is no Order.events relation that lazy-loads from a document table. If you want that shape — a parent model with a collection of child documents accessed by dot notation — you project documents into a relational view the ORM can map, or you denormalize the documents into a jsonb column on the parent model the ORM already manages. Chapter 16 walks through when each approach is right. Both are supported by the same database, under the same transaction, with the same backup.
Document tables are not in your ORM's migration graph. The factory creates them on demand. Alembic, Django migrations, ActiveRecord migrations, Flyway, EF Core migrations — none of them see the DDL the library runs. Teams choose one of two approaches:
- Let the library own document DDL. Simple. The schema is the union of what the ORM manages and what the library creates. Good for small teams and greenfield projects.
- Write the
CREATE TABLEstatements by hand into your migration tool. The library's DDL becomes a no-op because the tables already exist. Good for teams with strict migration review — the DBA sees every schema change in the same place, which is the arrangement the DBA has every right to expect.
Both are supported. The second is recommended for any team where a schema change goes through a review process that expects to see the DDL. One does not surprise the review process.
Cross-table joins between an ORM model and a document table go through SQL, not the ORM. Every binding exposes a raw_sql() escape hatch for the query planner cases where you need a join:
gl.raw_sql("""
SELECT o.id, o.total, e.data->>'type' AS event_type
FROM orders o
JOIN gl_order_events e ON e.data->>'order_id' = o.id::text
WHERE o.user_id = %s
""", [user_id]) You have left the ORM's object graph. You have not left the database's transaction. I consider this a fair exchange.
A word, finally, about MongoDB Atlas. It is a serious product built by serious engineers, and it offers things this library does not: Compass, Charts, a unified document model with no relational impedance, change streams, a global cluster topology, and a great deal more besides. If those capabilities are what your application needs, Atlas is well worth its keep.
Gold Lapel makes a different offer: documents inside the database you already operate, under the transaction you already have, backed up by the backup you already take. Both are real trade-offs. Pick the one whose costs you would rather pay — and kindly do not let anyone, myself included, tell you one is universally correct.
Chapter 20 is the one chapter in this book that attends to the product itself — Gold Lapel as a wire-level PostgreSQL proxy sitting between your application and the database, quietly observing what the seven SDKs above are actually doing. The argument there is narrower than the argument of this book: this book claims you do not need MongoDB; the product chapter claims that if you are running PostgreSQL for documents at any real scale, something ought to be watching the traffic. What follows is a description of the auto-indexing that creates GIN indexes from observed query shapes, the N+1 detector that surfaces the rewrite rather than only the symptom, the materialized-view lifecycle for repeated aggregations, and the dashboard that keeps every decision auditable and reversible. You now have the API in your language; the next chapter is what the proxy in front of your database does with it.
If you'll follow me to the next room, I shall draw the curtains. The last course of a twenty-one-chapter dinner deserves a quiet table.