---
title: Forge Extension
description: Mount Ledger as a Forge application extension with automatic tenant isolation.
---

Ledger integrates with [Forge](https://github.com/xraph/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.WithValue` calls.
- Other extensions can access `engine` to check entitlements, meter usage, or generate invoices.

## Registering Ledger as a Forge extension

Create a Ledger extension and register it with your Forge app:

```go
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:

```go
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:

```go
// 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:

```go
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:

```go
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:

```go
// 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:

```go
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](https://github.com/xraph/grove) 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:

```go
ext := extension.New(
    extension.WithGroveDatabase(""),
)
```

### Using a named grove database

In multi-database setups, reference a specific database by name:

```go
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:

1. **Explicit store** -- if `WithStore(s)` was called, it is used directly and grove is ignored.
2. **Grove database** -- if `WithGroveDatabase(name)` was called (or `grove_database` is set in YAML), the named or default `grove.DB` is resolved from DI.
3. **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`:

```yaml
# 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: 30s
```

Or at the top level:

```yaml
ledger:
  base_path: /billing
  grove_database: ""
  meter_batch_size: 200
  meter_flush_interval: 10s
  entitlement_cache_ttl: 1m
```

### Configuration 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:

1. If YAML config is found, it takes precedence for string and numeric fields.
2. Programmatic `bool` flags (e.g. `WithDisableRoutes(true)`) override when `true`.
3. Any zero-value fields after merging are filled with defaults from `DefaultConfig()`.

```go
// 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:

```go
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:

```go
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](/docs/ledger/guides/full-example) -- see the full billing pipeline without Forge.
- [Custom Store](/docs/ledger/guides/custom-store) -- implement a production storage backend.
- [Custom Plugin](/docs/ledger/guides/custom-plugin) -- build notification, metrics, or webhook plugins.
