Simple guides introducing Fabrica with hands-on examples.
Fabrica uses template-based code generation to create consistent, production-ready code for REST APIs, storage backends, and client libraries. This document explains how the code generation system works and how to use it effectively.
Fabrica uses a two-phase code generation approach:
fabrica codegen init)apis/<group>/<version>/ for resource definitions using AST parsing driven by apis.yamlapis/<group>/<version>/register_generated.go with importsfabrica generate)go fmt on all generated filesAll generated files have the suffix _generated.go and include a header comment warning not to edit them directly.
Why two phases? Go doesn’t support dynamic type loading at runtime. The registration file bridges the gap by importing and instantiating resource types, which the generator then introspects via reflection.
# Two-phase workflow:
# 1. Initialize code generation (after adding/modifying resources)
fabrica codegen init
# 2. Generate everything (recommended)
fabrica generate
# Or generate specific components
fabrica generate --handlers # Handlers plus routes, models, and middleware
fabrica generate --storage # Storage layer
fabrica generate --client # Client library and CLI client
fabrica generate --openapi # OpenAPI spec plus request/response models
# Or use the Makefile for the complete workflow
make dev # Clean, init, generate, and build
The code generator consists of several key parts:
//go:embedThe generator automatically discovers resources using Go’s AST parser:
// Scans apis/<group>/<version>/ and looks for:
type MyResource struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata resource.Metadata `json:"metadata"`
Spec MyResourceSpec `json:"spec"`
Status MyResourceStatus `json:"status,omitempty"`
}
Discovery process:
apis.yaml to get groups, versions, and resourcesapis/<group>/<version>/ directory tree_types.go file into an ASTAll templates are located in pkg/codegen/templates/:
| Template | Purpose | Output Location | Used By |
|---|---|---|---|
handlers.go.tmpl |
REST API CRUD handlers | cmd/server/*_handlers_generated.go |
Server |
storage.go.tmpl |
File-based storage operations | internal/storage/storage_generated.go |
Server (file backend) |
storage_ent.go.tmpl |
Ent database storage operations | internal/storage/storage_generated.go |
Server (ent backend) |
routes.go.tmpl |
HTTP route registration | cmd/server/routes_generated.go |
Server |
models.go.tmpl |
Request/response types | cmd/server/models_generated.go |
Server |
openapi.go.tmpl |
OpenAPI 3.0 specification (generated resource paths) | cmd/server/openapi_generated.go |
Server |
openapi_extensions.go.tmpl |
User-editable hook for custom/non-generated routes | cmd/server/openapi_extensions.go (create-once, never overwritten) |
Server |
client.go.tmpl |
HTTP client library | pkg/client/client_generated.go |
Client |
client-models.go.tmpl |
Client-side types | pkg/client/models_generated.go |
Client |
client-cmd.go.tmpl |
CLI application (Cobra-based) | cmd/cli/main_generated.go |
CLI |
reconciler.go.tmpl |
Resource reconciliation logic | pkg/reconcile/*_reconciler_generated.go |
Reconcile |
reconciler-registration.go.tmpl |
Reconciler registration | pkg/reconcile/registration_generated.go |
Reconcile |
event-handlers.go.tmpl |
Cross-resource event handlers | pkg/reconcile/event_handlers_generated.go |
Reconcile |
When using Ent storage (--storage ent), additional templates are used:
| Template | Purpose | Output Location |
|---|---|---|
ent/schema/resource.go.tmpl |
Generic resource schema | internal/storage/ent/schema/resource.go |
ent/schema/label.go.tmpl |
Resource label schema | internal/storage/ent/schema/label.go |
ent/schema/annotation.go.tmpl |
Resource annotation schema | internal/storage/ent/schema/annotation.go |
ent_adapter.go.tmpl |
Adapter between Fabrica and Ent | internal/storage/ent_adapter.go |
generate.go.tmpl |
Ent code generation directive | internal/storage/generate.go |
Fabrica generates several middleware templates for common functionality:
| Template | Purpose | Output Location |
|---|---|---|
validation_middleware.go.tmpl |
Request validation | internal/middleware/validation_middleware_generated.go |
versioning_middleware.go.tmpl |
API versioning | internal/middleware/versioning_middleware_generated.go |
conditional_middleware.go.tmpl |
Conditional requests (ETags) | internal/middleware/conditional_middleware_generated.go |
For custom authorization, implement your own middleware in internal/middleware/.
Templates have access to resource metadata:
type ResourceMetadata struct {
Name string // "Device"
PluralName string // "devices"
Package string // "github.com/user/project/apis/example.fabrica.dev/v1"
PackageAlias string // "device"
TypeName string // "*device.Device"
SpecType string // "device.DeviceSpec"
StatusType string // "device.DeviceStatus"
URLPath string // "/devices"
StorageName string // "Device"
}
Template usage example:
// In handlers.go.tmpl
func Get(c fuego.ContextWithBody[GetRequest]) (, error) {
id := c.PathParam("id")
return storage.Load(c.Context(), id)
}
Generates:
// In device_handlers_generated.go
func GetDevice(c fuego.ContextWithBody[GetRequest]) (*device.Device, error) {
id := c.PathParam("id")
return storage.LoadDevice(c.Context(), id)
}
Templates can use these helper functions:
| Function | Purpose | Example |
|---|---|---|
toLower |
Convert to lowercase | `` → device |
toUpper |
Convert to uppercase | `` → DEVICE |
title |
Capitalize first letter | `` → Devices |
camelCase |
Convert to camelCase | `` → device |
trimPrefix |
Remove prefix | `` → 1 |
The generator operates in three modes based on the PackageName:
PackageName: "main")Generates complete server-side code:
GenerateHandlers() - REST API endpointsGenerateStorage() - Data persistence layerGenerateRoutes() - URL routing configurationGenerateModels() - Request/response typesGenerateOpenAPI() - OpenAPI specification for all Fabrica-managed resources, plus a one-time openapi_extensions.go stub for custom routes (see OpenAPI Extensions)GenerateMiddleware() - Validation, versioning, conditional requestsOutput: Files in cmd/server/, internal/storage/, and internal/middleware/
PackageName: "client")Generates client library code:
GenerateClient() - HTTP client with CRUD methodsGenerateClientModels() - Client-side data typesOutput: Files in pkg/client/
PackageName: "reconcile")Generates reconciliation code for eventual consistency:
GenerateReconcilers() - Resource reconciliation logicGenerateReconcilerRegistration() - Registration codeGenerateEventHandlers() - Cross-resource event handlingOutput: Files in pkg/reconcile/
The generator adapts output based on storage type:
gen := codegen.NewGenerator(outputDir, "main", modulePath)
gen.SetStorageType("file") // or omit, it's the default
gen.GenerateStorage()
Uses: storage.go.tmpl
Creates: JSON file-based persistence in ./data/
gen := codegen.NewGenerator(outputDir, "main", modulePath)
gen.SetStorageType("ent")
gen.SetDBDriver("postgres") // or "mysql", "sqlite"
gen.GenerateStorage()
gen.GenerateEntSchemas()
gen.GenerateEntAdapter()
Uses: storage_ent.go.tmpl + Ent templates
Creates: Database-backed storage with migrations
Templates are embedded in the binary using Go’s embed directive:
// pkg/codegen/generator.go
//go:embed templates/*
var embeddedTemplates embed.FS
Why this matters:
go install (templates travel with binary)../../templates)func (g *Generator) LoadTemplates() error {
// Read from embedded filesystem (not disk!)
content, err := embeddedTemplates.ReadFile("templates/handlers.go.tmpl")
// Parse with helper functions
tmpl, err := template.New("handlers").Funcs(templateFuncs).Parse(string(content))
g.Templates["handlers"] = tmpl
return nil
}
func (g *Generator) GenerateHandlers() error {
for _, resource := range g.Resources {
var buf bytes.Buffer
// Execute template with resource metadata
err := g.Templates["handlers"].Execute(&buf, resource)
// Format with go fmt
formatted, err := format.Source(buf.Bytes())
// Write to file
filename := fmt.Sprintf("%s_handlers_generated.go", strings.ToLower(resource.Name))
os.WriteFile(filepath.Join(g.OutputDir, filename), formatted, 0644)
}
return nil
}
Resources are registered using a two-phase approach:
Phase 1: Generate Registration File
fabrica codegen init
This scans apis/<group>/<version>/ (as listed in apis.yaml) and creates apis/<group>/<version>/register_generated.go:
// Code generated by fabrica codegen init. DO NOT EDIT.
package v1
import (
"fmt"
"github.com/openchami/fabrica/pkg/codegen"
"github.com/user/project/apis/example.fabrica.dev/v1"
)
func RegisterAllResources(gen *codegen.Generator) error {
if err := gen.RegisterResource(&v1.Device{}); err != nil {
return fmt.Errorf("failed to register Device: %w", err)
}
return nil
}
Phase 2: Use Registration for Generation
When you run fabrica generate, it:
apis/RegisterAllResources() to register discovered types via reflectionThe generated Makefile provides convenient targets for development:
# Complete development workflow (clean, init, generate, build)
make dev
# Individual targets
make codegen-init # Initialize code generation
make generate # Generate handlers, storage, and OpenAPI
make build # Build the server
make run # Build and run the server
make test # Run tests
make clean # Remove all generated files and binaries
What make dev does:
make clean)fabrica codegen init)fabrica generate --handlers --storage --openapi)This is the recommended workflow when adding or modifying resources.
# 1. Create resource definition
fabrica add resource Product
# 2. Customize the resource
vim apis/example.fabrica.dev/v1/product_types.go
# 3. Initialize code generation (register the new resource)
fabrica codegen init
# 4. Generate code
fabrica generate
# 5. Build and run
go build -o bin/server cmd/server/*.go
./bin/server
# Or use the Makefile for steps 3-5:
make dev
Don’t edit generated files directly! Instead:
# 1. Find the template
# Generated file says: "Generated from: pkg/codegen/templates/handlers.go.tmpl"
# 2. Edit the template
vim pkg/codegen/templates/handlers.go.tmpl
# 3. Regenerate
fabrica generate
# 4. Verify changes
git diff cmd/server/
That works when you control fabrica. When you don’t — you consume it as a dependency and want different output for your own resources — register a custom emitter instead of forking. See Custom Emitters.
A service can replace what fabrica emits for a resource without forking it or editing generated files.
type ResourceEmitter interface {
Name() string
Emit(req EmitRequest) ([]EmittedFile, error)
}
Emit receives one resource and returns the files to write. Fabrica’s own
dedicated-Ent-schema generator implements this interface, so custom emitters run
through exactly the same path as the built-in — including gofmt, the idempotent
write, and the “✓ Generated” output.
EmitRequest carries the resource, its parsed annotations, the storage
type/driver, the module path, the path the built-in would have written, and —
importantly — the built-in template and its data. That lets an emitter
render fabrica’s own template and post-process the result rather than
reimplementing it.
| Kind | Constant | What it renders |
|---|---|---|
ent-schema |
codegen.EmitterKindEntSchema |
The dedicated Ent schema for one resource (+fabrica:storage=dedicated) |
codegen.KnownEmitterKinds() returns the current list.
Generation does not run in-process. fabrica generate writes a throwaway
program at cmd/.fabrica-codegen/main.go and runs it, so you cannot pass an
emitter in on the command line. That program does import your project’s
pkg/resources, so register from an init() there:
// pkg/resources/emitters.go
package resources
import (
"fmt"
"github.com/openchami/fabrica/pkg/codegen"
)
type auditedSchemaEmitter struct{}
func (auditedSchemaEmitter) Name() string { return "myservice/audited-ent-schema" }
func (e auditedSchemaEmitter) Emit(req codegen.EmitRequest) ([]codegen.EmittedFile, error) {
// Only customize resources that ask for it; defer to fabrica otherwise.
if req.Annotations.Migration != "additive-only" {
return codegen.BuiltinEmitter(req.Kind).Emit(req)
}
files, err := codegen.BuiltinEmitter(req.Kind).Emit(req)
if err != nil {
return nil, err
}
banner := fmt.Sprintf("// AUDITED SCHEMA — %s is append-only.\n", req.Resource.Name)
files[0].Content = append([]byte(banner), files[0].Content...)
return files, nil
}
func init() {
if err := codegen.RegisterEmitter(codegen.EmitterKindEntSchema, auditedSchemaEmitter{}); err != nil {
panic(err)
}
}
Then fabrica generate picks it up with no further wiring.
When you own the Generator — tests, or your own tooling — prefer the
instance-scoped form, which avoids process-wide state:
gen := codegen.NewGenerator(outputDir, "main", modulePath)
if err := gen.SetEmitter(codegen.EmitterKindEntSchema, myEmitter{}); err != nil {
return err
}
Resolution order, most specific first:
(*Generator).SetEmitter — this generator onlycodegen.RegisterEmitter — process-wideEmittedFile with an empty Path is an error..go output is gofmt’d; unparseable content fails
generation with the emitter’s name in the error.codegen.BuiltinEmitter(kind) returns fabrica’s
implementation, so you can customize the resources you care about and hand the
rest back unchanged.Example: Add a count endpoint for each resource
Edit handlers.go.tmpl:
// Add this function to the template
func GetCount(c fuego.ContextNoBody) (map[string]int, error) {
resources, err := storage.LoadAlls(c.Context())
if err != nil {
return nil, err
}
return map[string]int{"count": len(resources)}, nil
}
Edit routes.go.tmpl:
// Add this route registration
fuego.Get(server, "/count", GetCount)
Regenerate:
fabrica generate
Result: Every resource now has a /resources/count endpoint!
From file to database:
# 1. Regenerate with ent storage
# (Edit your project's generation code to set storage type)
# 2. Create database
createdb myapp
# 3. Generate Ent schemas
fabrica generate
# 4. Run migrations
cd internal/storage && go generate ./ent
# 5. Update main.go to use Ent backend
After running fabrica codegen init and fabrica generate on a project with a Device resource:
myproject/
├── cmd/server/
│ ├── main.go # Server entry point (user-maintained)
│ ├── device_handlers_generated.go # CRUD handlers for Device
│ ├── routes_generated.go # Route registration
│ ├── models_generated.go # Request/response types + helpers
│ └── openapi_generated.go # OpenAPI spec
├── internal/storage/
│ └── storage_generated.go # Storage wrappers using fabrica/pkg/storage
├── pkg/client/
│ ├── client_generated.go # HTTP client
│ └── models_generated.go # Client types
├── apis/
│ └── example.fabrica.dev/
│ └── v1/
│ ├── register_generated.go # Resource registration (from codegen init)
│ └── device/
│ └── device.go # Resource definition (user-maintained)
└── Makefile # Build automation with dev workflow
For Ent-backed projects, Fabrica owns only the files it generates under
internal/storage/ent/schema/: resource.go, label.go, annotation.go, and
dedicated resource schemas named after generated resources. Services may place
additional Ent schema files in the same package for auxiliary tables such as
audit records, token/session internals, or security history tables.
Regeneration preserves supplemental schema files and project-owned migration
files. Keep database-specific DDL, triggers, row-level security, partial indexes,
and append-only enforcement in service-owned migrations, commonly under
migrations/. Do not reuse Fabrica-owned schema filenames for supplemental
tables; collisions are treated as generated-file ownership conflicts.
Use features.storage.type: custom when a resource needs persistence semantics
that Fabrica cannot safely generate. Custom storage keeps Fabrica-owned API,
route, model, client, OpenAPI, and version artifacts, but suppresses generated
persistence files: no Ent schemas, no Ent adapter, no Ent helpers, and no
internal/storage/storage_generated.go.
In custom mode, the service owns the internal/storage package and must provide
the functions/types referenced by generated handlers. This is the intended path
for security-sensitive stores that need project-owned repositories, custom SQL,
triggers, row-level security, append-only audit records, or vendor-specific DDL.
Fabrica does not generate migrations for custom storage.
When converting an existing file- or Ent-backed project to custom storage,
remove or replace Fabrica-generated storage artifacts before regenerating. The
generator fails fast if files such as internal/storage/storage_generated.go,
internal/storage/ent/, or generated import/export commands are still present,
so a service cannot accidentally continue running the previous generated backend
after declaring storage.type: custom.
Resources can have multiple schema versions:
gen := codegen.NewGenerator("cmd/server", "main", modulePath)
gen.RegisterResource(&device.Device{})
// Add a new version
gen.AddResourceVersion("Device", codegen.SchemaVersion{
Version: "v2",
IsDefault: false,
Stability: "beta",
Deprecated: false,
SpecType: "device.DeviceV2Spec",
})
Add custom authentication/authorization middleware:
// In internal/middleware/auth.go
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Implement your auth logic
next.ServeHTTP(w, r)
})
}
Apply middleware in your routes or main.go.
Extend template capabilities:
// In pkg/codegen/generator.go
var templateFuncs = template.FuncMap{
"toLower": strings.ToLower,
"myCustomFunc": func(s string) string {
// Your logic here
},
}
Error: failed to read embedded template templates/handlers.go.tmpl: no such file or directory
Cause: Templates not embedded properly (usually during development)
Fix:
pkg/codegen/templates///go:embed templates/* directive existsgo build -o bin/fabrica cmd/fabrica/*.goError: No resources found in apis/<group>/<version>/
Cause: Resource doesn’t have flattened envelope structure or file doesn’t parse
Fix:
// Make sure your resource looks like this:
type MyResource struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata Metadata `json:"metadata"`
Spec MyResourceSpec `json:"spec"`
Status MyResourceStatus `json:"status,omitempty"`
}
Error: registration file not found: run 'fabrica codegen init' first
Cause: You ran fabrica generate without first running fabrica codegen init
Fix:
# Run codegen init to create the registration file
fabrica codegen init
# Then generate code
fabrica generate
# Or use the Makefile which does both
make dev
Error: undefined: device.Device
Cause: Missing import or incorrect package path
Fix:
go mod tidygo.mod module path matches template’s ModulePathError: failed to format generated code: expected ';', found '}'
Cause: Template produces invalid Go syntax
Fix:
go run cmd/fabrica/main.go generateformat.Source() temporarily)/devices, /products)validate:"required,email"# 1. Generate code
fabrica generate
# 2. Verify it compiles
go build ./cmd/server
# 3. Run tests
go test ./...
# 4. Check formatting
go fmt ./...
# 5. Lint
golangci-lint run
Instead of editing generated files:
main.goTemplates are parsed once during LoadTemplates():
For a project with 10 resources:
Generation is fast enough to run on every code change.
Generated code size (per resource):
This is acceptable for generated code and provides good readability.
To improve code generation:
.tmpl fileFabrica’s code generation system:
go fmtmake dev workflowThe result: Write resource definitions once, get complete CRUD APIs automatically.
✅ Working:
Note: The core infrastructure is complete and production-ready. For authentication/authorization, implement custom middleware in internal/middleware/.