Forge Extension
Mount Ledger as a Forge application extension with automatic tenant isolation.
Ledger integrates with Forge as a first-class extension. This gives you automatic lifecycle management, tenant context extraction from Forge scope, and the ability to share the billing engine across other extensions in the same application.
What is the Forge integration?
Forge is a Go application framework with a standardized extension system. Each extension implements lifecycle hooks (Register, Start, RegisterRoutes, Stop) that Forge calls in order during the application boot sequence. Mounting Ledger as a Forge extension means:
- The billing engine starts and stops with the application.
- Database migrations run automatically on startup.
- Tenant and app IDs are extracted from the Forge request scope -- no manual
context.WithValuecalls. - Other extensions can access
engineto check entitlements, meter usage, or generate invoices.
Registering Ledger as a Forge extension
Create a Ledger extension and register it with your Forge app:
package main
import (
"log/slog"
"os"
"time"
"github.com/xraph/forge"
"github.com/xraph/ledger"
"github.com/xraph/ledger/store/memory"
)
func main() {
app := forge.New()
// Create the store (use postgres.New(pool) in production)
store := memory.New()
// Create the Ledger extension
ledgerExt := NewLedgerExtension(store,
ledger.WithLogger(slog.Default()),
ledger.WithMeterConfig(100, 5*time.Second),
ledger.WithEntitlementCacheTTL(30*time.Second),
)
app.RegisterExtension(ledgerExt)
app.Run()
}Extension lifecycle
The Ledger extension participates in all four Forge lifecycle phases:
| Lifecycle event | Behaviour |
|---|---|
Register | Creates the *ledger.Ledger engine from the store and options |
Start | Calls engine.Start(ctx) which runs store.Migrate and starts the meter flush worker |
RegisterRoutes | Mounts billing HTTP endpoints under /v1/billing (plans, subscriptions, invoices, usage) |
Stop | Calls engine.Stop() which flushes remaining meter events and emits OnShutdown to all plugins |
Building a Ledger extension
Here is a complete implementation of a Ledger Forge extension:
package main
import (
"context"
"net/http"
"github.com/xraph/forge"
"github.com/xraph/ledger"
"github.com/xraph/ledger/store"
)
// LedgerExtension wraps Ledger as a Forge extension.
type LedgerExtension struct {
store store.Store
opts []ledger.Option
engine *ledger.Ledger
}
// NewLedgerExtension creates a new Forge extension for Ledger.
func NewLedgerExtension(s store.Store, opts ...ledger.Option) *LedgerExtension {
return &LedgerExtension{
store: s,
opts: opts,
}
}
// Name returns the extension name.
func (e *LedgerExtension) Name() string { return "ledger" }
// Register creates the billing engine.
func (e *LedgerExtension) Register(app *forge.App) error {
e.engine = ledger.New(e.store, e.opts...)
return nil
}
// Start runs migrations and starts the billing engine.
func (e *LedgerExtension) Start(ctx context.Context) error {
return e.engine.Start(ctx)
}
// RegisterRoutes mounts billing HTTP endpoints.
func (e *LedgerExtension) RegisterRoutes(router *forge.Router) {
billing := router.Group("/v1/billing")
// Plan endpoints
billing.POST("/plans", e.handleCreatePlan)
billing.GET("/plans/:id", e.handleGetPlan)
// Subscription endpoints
billing.POST("/subscriptions", e.handleCreateSubscription)
billing.GET("/subscriptions/:id", e.handleGetSubscription)
billing.DELETE("/subscriptions/:id", e.handleCancelSubscription)
// Usage endpoints
billing.POST("/usage", e.handleMeterUsage)
// Entitlement endpoints
billing.GET("/entitlements/:feature", e.handleCheckEntitlement)
// Invoice endpoints
billing.POST("/invoices/generate", e.handleGenerateInvoice)
billing.GET("/invoices/:id", e.handleGetInvoice)
}
// Stop shuts down the billing engine.
func (e *LedgerExtension) Stop() error {
return e.engine.Stop()
}
// Engine returns the underlying Ledger engine for use by other extensions.
func (e *LedgerExtension) Engine() *ledger.Ledger {
return e.engine
}Automatic tenant context extraction
In a Forge application, tenant scope is set by authentication middleware. Ledger extracts tenant_id and app_id from the Go context. In Forge, you bridge from the Forge scope to Ledger's context values:
// tenantMiddleware extracts tenant info from Forge scope and sets
// the context values that Ledger expects.
func tenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract from Forge scope (e.g., JWT claims, API key lookup)
tenantID := r.Header.Get("X-Tenant-ID")
appID := r.Header.Get("X-App-ID")
// Set the context values that Ledger reads
ctx := context.WithValue(r.Context(), "tenant_id", tenantID)
ctx = context.WithValue(ctx, "app_id", appID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Register the middleware on your Forge router:
func (e *LedgerExtension) RegisterRoutes(router *forge.Router) {
billing := router.Group("/v1/billing")
billing.Use(tenantMiddleware)
// ... routes as above
}Now every handler automatically has the correct tenant context. For example:
func (e *LedgerExtension) handleCheckEntitlement(w http.ResponseWriter, r *http.Request) {
featureKey := forge.Param(r, "feature")
// r.Context() already has tenant_id and app_id from the middleware
result, err := e.engine.Entitled(r.Context(), featureKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return JSON response
forge.JSON(w, http.StatusOK, result)
}
func (e *LedgerExtension) handleMeterUsage(w http.ResponseWriter, r *http.Request) {
var req struct {
FeatureKey string `json:"feature_key"`
Quantity int64 `json:"quantity"`
}
if err := forge.Bind(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := e.engine.Meter(r.Context(), req.FeatureKey, req.Quantity); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusAccepted)
}Accessing the engine from other extensions
After Forge calls Register, other extensions can obtain the Ledger engine to check entitlements or meter usage in their own handlers:
// In another Forge extension:
type APIExtension struct {
ledger *LedgerExtension
}
func (a *APIExtension) Register(app *forge.App) error {
// Get the Ledger extension by name
ext := app.Extension("ledger")
a.ledger = ext.(*LedgerExtension)
return nil
}
func (a *APIExtension) handleAPIRequest(w http.ResponseWriter, r *http.Request) {
// Check entitlement before processing the API request
result, err := a.ledger.Engine().Entitled(r.Context(), "api_calls")
if err != nil || !result.Allowed {
http.Error(w, "quota exceeded", http.StatusTooManyRequests)
return
}
// Process the request...
// Record the usage
a.ledger.Engine().Meter(r.Context(), "api_calls", 1)
}Adding plugins (audit, metrics)
Pass Ledger plugins through the options when creating the extension. The audit hook and observability plugins are built-in:
import (
audithook "github.com/xraph/ledger/audit_hook"
"github.com/xraph/ledger/observability"
)
// Create an audit recorder (bridges to your audit trail backend)
auditRecorder := audithook.RecorderFunc(func(ctx context.Context, event *audithook.AuditEvent) error {
slog.Info("audit",
"action", event.Action,
"resource", event.Resource,
"outcome", event.Outcome,
)
return nil
})
// Create the extension with plugins
ledgerExt := NewLedgerExtension(store,
ledger.WithLogger(slog.Default()),
ledger.WithMeterConfig(100, 5*time.Second),
ledger.WithEntitlementCacheTTL(30*time.Second),
ledger.WithPlugin(audithook.New(auditRecorder)),
ledger.WithPlugin(observability.NewMetricsExtension(metricsFactory)),
)The audit hook emits structured audit events for plan creation, subscription changes, invoice generation, and quota violations. The observability plugin tracks counters and histograms for all lifecycle events.
Grove database integration
When your Forge app uses the Grove extension to manage database connections, Ledger can automatically resolve a grove.DB from the DI container and construct the correct store backend (PostgreSQL, SQLite, or MongoDB) based on the driver type.
Using the default grove database
If the Grove extension registers a single database (or a default in multi-DB mode), use WithGroveDatabase with an empty name:
ext := extension.New(
extension.WithGroveDatabase(""),
)Using a named grove database
In multi-database setups, reference a specific database by name:
ext := extension.New(
extension.WithGroveDatabase("billing"),
)This resolves the grove.DB named "billing" from the DI container and auto-constructs the matching store. The driver type is detected automatically -- you do not need to import individual store packages.
Store resolution order
The extension resolves its store in this order:
- Explicit store -- if
WithStore(s)was called, it is used directly and grove is ignored. - Grove database -- if
WithGroveDatabase(name)was called (orgrove_databaseis set in YAML), the named or defaultgrove.DBis resolved from DI. - In-memory fallback -- if neither is configured, an in-memory store is used.
YAML configuration
The Ledger extension automatically loads configuration from your Forge app's YAML config files. It looks for the key extensions.ledger first, then falls back to ledger:
# forge.yaml (or app.yaml, config.yaml, etc.)
extensions:
ledger:
base_path: /billing
grove_database: billing
disable_routes: false
disable_migrate: false
meter_batch_size: 100
meter_flush_interval: 5s
entitlement_cache_ttl: 30sOr at the top level:
ledger:
base_path: /billing
grove_database: ""
meter_batch_size: 200
meter_flush_interval: 10s
entitlement_cache_ttl: 1mConfiguration reference
| Field | YAML key | Type | Default | Description |
|---|---|---|---|---|
BasePath | base_path | string | "/ledger" | URL prefix for all ledger HTTP routes |
GroveDatabase | grove_database | string | "" | Name of the grove.DB to resolve from DI; empty uses the default DB |
DisableRoutes | disable_routes | bool | false | Prevents HTTP route registration (engine-only mode) |
DisableMigrate | disable_migrate | bool | false | Prevents auto-migration on startup |
MeterBatchSize | meter_batch_size | int | 100 | Number of usage events buffered before flushing to the store |
MeterFlushInterval | meter_flush_interval | duration | 5s | Max time before the meter buffer is flushed |
EntitlementCacheTTL | entitlement_cache_ttl | duration | 30s | How long entitlement check results are cached in-process |
Merge behaviour
YAML config and programmatic options are merged at startup:
- If YAML config is found, it takes precedence for string and numeric fields.
- Programmatic
boolflags (e.g.WithDisableRoutes(true)) override whentrue. - Any zero-value fields after merging are filled with defaults from
DefaultConfig().
// Programmatic options still work alongside YAML:
ext := extension.New(
extension.WithStore(store),
extension.WithDisableRoutes(true), // overrides YAML
)Requiring YAML config
To make the extension fail if no config is found in YAML files:
ext := extension.New(
extension.WithStore(store),
extension.WithRequireConfig(true),
)This returns an error during Register if neither extensions.ledger nor ledger is present in the config.
Complete example
Putting it all together, here is a Forge application with Ledger as a billing extension:
package main
import (
"context"
"log/slog"
"os"
"time"
"github.com/xraph/forge"
"github.com/xraph/ledger"
audithook "github.com/xraph/ledger/audit_hook"
"github.com/xraph/ledger/observability"
"github.com/xraph/ledger/store/memory"
)
func main() {
app := forge.New()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Store setup
store := memory.New()
// Audit recorder
recorder := audithook.RecorderFunc(func(ctx context.Context, event *audithook.AuditEvent) error {
logger.Info("billing.audit",
"action", event.Action,
"resource", event.Resource,
"severity", event.Severity,
)
return nil
})
// Create the Ledger extension with full configuration
ledgerExt := NewLedgerExtension(store,
ledger.WithLogger(logger),
ledger.WithMeterConfig(100, 5*time.Second),
ledger.WithEntitlementCacheTTL(30*time.Second),
ledger.WithPlugin(audithook.New(recorder,
audithook.WithLogger(logger),
)),
ledger.WithPlugin(observability.NewMetricsExtension(app.Metrics())),
)
// Register and run
app.RegisterExtension(ledgerExt)
app.Run()
}Next steps
- End-to-End Billing Example -- see the full billing pipeline without Forge.
- Custom Store -- implement a production storage backend.
- Custom Plugin -- build notification, metrics, or webhook plugins.