gpdf API design: closure-callback builders over method chaining
Why gpdf's Go API uses closure-callback builders instead of chainable methods. The design decisions around Go's error handling, PDF tree structure, and how three different patterns split responsibility cleanly.
The conclusion first: gpdf doesn't chain, it nests with closures
When designing the gpdf API, the first option I dropped was method chaining.
// What chainable would have looked like (not adopted)
doc.AddPage().Row().Col(6).Text("Left").Done().Col(6).Text("Right")
Here is what gpdf v1.0.0 actually looks like.
doc := template.New(
template.WithPageSize(document.A4),
template.WithMargins(document.UniformEdges(document.Mm(15))),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) { c.Text("Left") })
r.Col(6, func(c *template.ColBuilder) { c.Text("Right") })
})
data, err := doc.Generate()
Builders that take a closure as an argument. No chaining. Styling alone is passed via functional options (template.FontSize(24) and friends). This post records how the three-layer structure landed where it did, and what was borrowed from libraries in other languages.
What I disliked about chainable
Early in the prototype phase (January 2026), I genuinely considered chainable APIs in the style of Java's iText or Python builder patterns. For short cases, chains read more cleanly.
doc.Page().Row().Col(6).Text("Hello").Bold().Color(red)
There were three problems.
One: error propagation doesn't fit Go. When a step fails mid-chain, you have two options. (a) Accumulate the error inside the builder and surface it later, or (b) panic. (a) creates the "forgot to check" problem on the caller side, where the next chain call silently does nothing because a flag was tripped earlier. (b) clashes with Go's culture and turns library bugs into runtime crashes. Both options pull error handling away from where it would naturally live. gpdf settled on returning an error from a single point: doc.Generate() ([]byte, error). Even if something fails midway, the aggregated error comes out of Generate. Chaining would have broken this single-point property, because every chained method would need to either silently no-op or signal failure on its own.
Two: PDF is fundamentally a tree, not a linear sequence. A page holds rows, a row holds columns, a column holds text/image/table. Writing a tree with chains forces calls like .Done() or .End() that climb back to a parent, and at deeper nesting it stops being clear which ancestor you're returning to. Reading the code becomes a counting exercise — "this .End() ends the column, this one ends the row, and this one…" — that no formatter can help you with. gofmt won't format the chain for you either; it preserves your line breaks but doesn't reflect the semantic depth back at you.
Three: variables don't have a lexical scope you can grab. With chaining, the c in c.Text("hi") flows through the whole function and it gets fuzzy where it came from. If you want to compute something, store it, and use it across two leaf calls in the same column, the natural place to put that variable doesn't exist mid-chain. Closure callbacks lock it down lexically as the func(c *ColBuilder) argument: each scope has its own c, and Go's lexical rules do the work of telling you which one is in play.
What it looks like with closure callbacks
What I went with is "builders that take a closure, with c/r/p variables made lexical." Implementation-wise, Row / Col / AutoRow / Absolute / Box in template/grid.go all take a func(...) argument.
page.AutoRow(func(r *template.RowBuilder) {
r.Col(8, func(c *template.ColBuilder) {
c.Text("Invoice", template.FontSize(24), template.Bold())
c.Spacer(document.Mm(4))
c.Table(headers, rows, template.Striped())
})
r.Col(4, func(c *template.ColBuilder) {
c.Box(func(c *template.ColBuilder) {
c.Text("Total", template.AlignRight())
c.Text("$1,234.00", template.FontSize(18), template.Bold())
}, template.BoxPadding(document.Mm(8)))
})
})
Indentation = nesting structure, and that mapping shows up directly in the code. Go's formatter (gofmt) preserves the meaning of indentation, so reshuffling layout doesn't get flattened. When a reviewer reads a Go file using gpdf, the page structure is visible at a glance from the indentation alone, before they have to parse any of the method names.
A side benefit: you can use local variables freely inside each Col. With chaining there's nowhere mid-chain to introduce a variable. Code that switches styles dynamically turned out to be drastically easier to write inside closures, in my case.
Splitting responsibility into three layers
The gpdf API ended up mixing three different construction styles.
| Layer | Style | Example |
|---|---|---|
| Document configuration | Functional Options | template.New(WithPageSize(A4), WithMargins(...)) |
| Structure (tree) | Closure callback | page.AutoRow(func(r) { r.Col(6, func(c) {...}) }) |
| Leaf-node styling | Variadic option args | c.Text("hi", FontSize(24), Bold(), TextColor(blue)) |
Functional Options for configuration. Order is arbitrary, and adding new options doesn't break existing code. This follows the convention Dave Cheney popularized for Go. Adding a new option six months from now means adding WithFoo(...) Option next to the others; existing callers don't have to update.
Closure callbacks for structure. For a nesting tree, callbacks express the hierarchy most directly. The closure body is the children, the function call is the parent. The lexical scope of the callback is the lexical scope of that subtree.
Variadic options for leaves. The number of options on Text varies per element, so accept them as variadic args. Some Texts get one option, some get five, and the call site doesn't have to lie about it by passing zero values.
When responsibility is split by style, the user only has to think in three granularities: "config → structure → style." That decides where to put what. Forcing a single pattern to do all three muddles configuration, structure, and styling so badly that nothing is findable. I've seen this happen in libraries that try to be "consistent" in the wrong way — where every concept is expressed through chaining, including configuration that should have been a struct literal.
What I borrowed from libraries in other languages
The references weren't only inside Go.
- gofpdf (Go, archived) — A flat command stream (
pdf.Cell(40, 10, "Hello")) with state held on the Pdf struct. I learned the low-level direct-manipulation idea but didn't copy it as a high-level API. Designs that rely on order-dependent commands against a stateful object get hard to test once they grow. - Maroto (Go) — A layout library with col/row structures close to closure callbacks, conceptually quite similar. The 12-column grid idea also came from here. That said, Maroto depends internally on gofpdf, and now that the dependency has been archived, I'm not actively adopting it.
- Flutter (Dart) — The widget tree of
Column(children: [Row(children: [Text(...)])]). Passing children as a slice/list is highly explicit, and along with gpdf's closure-passing it was a reference for "the natural way to write a tree." - Dave Cheney's Functional Options — Document-level config follows this. Standardized on the
WithXxx(value) Optionform.
By the way, I also tried JavaScript's jsPDF, but it's in the gofpdf family of imperative + stateful APIs. The only takeaway was "concentrate the final output at a single output() call site." That single piece reinforced gpdf's choice to terminate at Generate() rather than spreading output side effects across many calls.
One thing I deliberately did not borrow: HTML/CSS-style declarative props (the WeasyPrint or paged-media direction). Going that route would mean shipping a layout engine that interprets a string-based DSL, which is a much larger surface area than what gpdf is trying to cover. Direct Go calls keep the API small enough to be reviewed in a single sitting, and that matters more for a project I'm running on the side than expressive parity with web layout.
What's bothering me after six months
Honestly, the closure callback pattern has a downside: deep indentation. Putting a Box inside a Box inside a Row, you'll easily go six levels deep. The right edge of the screen starts to crowd the actual code, and the relationship between siblings at different levels gets visually noisy. I have to admit this, and right now I escape it by expecting users to extract a Component into a separate function when nesting threatens to get deep. The template package already has a Component type for this purpose, but the convention isn't documented as strongly as it should be — that's on me.
Another thing I've been feeling: the func(r *RowBuilder) signature is awkward for IDE completion in some cases. Some IDEs surface RowBuilder methods the moment you write the parameter name r, others give up on that and force you to type r. and wait. It's an IDE-side problem in principle, but it affects the user's lived experience. Chainable APIs are easier here because the receiver type at each link is already known to the IDE.
There's also the question of how the API reads to someone coming from a different ecosystem. A developer who has only used chainable PDF libraries (jsPDF, iText) sometimes asks "where's .bold()?" and has to learn that styling is a variadic option, not a method. The mental model transfer isn't free. I haven't found a great answer to this beyond examples in the README.
Even so, prioritizing the single-point error aggregation and the natural tree expression, I have no plans right now to revert to chainable. I might rewrite this in six months, but for now I'm going to keep going with closure callbacks.
Related
- Building a Pure Go zero-dependency PDF library — How gpdf was built without CGO
- Building Pure Go micro SaaS on the side — Where the nadai products including gpdf currently stand
- gpdf product page — Repo, docs, latest release