Skip to content

Multi-App Framework

The multiapp package enables multiple apps to share backend infrastructure while maintaining complete data isolation. Apps can be deployed in two modes:

  • Multi-app mode: Multiple apps share a server, routed by X-App-ID header
  • Single-app mode: One app runs on dedicated infrastructure

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                           HTTP Request                          │
│                     X-App-ID: app1                         │
│                     Authorization: Bearer <jwt>                 │
└─────────────────────────────────┬───────────────────────────────┘
┌─────────────────────────────────▼───────────────────────────────┐
│                          multiapp.Server                        │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  appContextMiddleware                                     │  │
│  │  - Extracts X-App-ID header                               │  │
│  │  - Looks up registered app                                │  │
│  │  - Sets AppContext in request context                     │  │
│  │  - Routes to app's router                                 │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────┬───────────────────────────────┘
          ┌───────────────────────┼───────────────────────┐
          │                       │                       │
          ▼                       ▼                       ▼
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   App1     │     │   App2     │     │   App3    │
│   AppBackend    │     │   AppBackend    │     │   AppBackend    │
├─────────────────┤     ├─────────────────┤     ├─────────────────┤
│ Schema:         │     │ Schema:         │     │ Schema:         │
│ app_app1   │     │ app_app2   │     │ app_app3  │
├─────────────────┤     ├─────────────────┤     ├─────────────────┤
│ Own users       │     │ Own users       │     │ Own users       │
│ Own orgs        │     │ Own orgs        │     │ Own orgs        │
│ Own data        │     │ Own data        │     │ Own data        │
└─────────────────┘     └─────────────────┘     └─────────────────┘

Quick Start

Multi-App Server

package main

import (
    "os"

    "github.com/grokify/coreforge/multiapp"
    app1 "github.com/grokify/app1/multiapp"
    app2 "github.com/plexusone/app2/multiapp"
)

func main() {
    server, err := multiapp.NewServer(multiapp.Config{
        Mode:        multiapp.MultiAppMode,
        DatabaseURL: os.Getenv("DATABASE_URL"),
        RedisURL:    os.Getenv("REDIS_URL"),  // optional
    })
    if err != nil {
        panic(err)
    }

    // Register apps - each gets schema isolation
    server.RegisterApp(app1.NewBackend(nil))
    server.RegisterApp(app2.NewBackend(nil))

    server.Run(":8080")
}

Making Requests

# Request to App1
curl -H "X-App-ID: app1" http://localhost:8080/api/courses

# Request to App2
curl -H "X-App-ID: app2" http://localhost:8080/api/dashboards

# Requests without X-App-ID return 404 (security)
curl http://localhost:8080/api/health
# 404 Not Found

Schema-Per-App Isolation

Each app gets its own PostgreSQL schema (e.g., app_app1). This provides:

  • Complete data isolation between apps
  • Independent migrations per app
  • Shared database infrastructure
PostgreSQL Database
├── app_app1/          # App1 schema
│   ├── users
│   ├── organizations
│   ├── courses
│   └── ...
├── app_app2/          # App2 schema
│   ├── users
│   ├── organizations
│   ├── dashboards
│   └── ...
└── app_app3/         # App3 schema
    ├── users
    ├── organizations
    ├── proofs
    └── ...

AppBackend Interface

Apps integrate by implementing the AppBackend interface:

type AppBackend interface {
    // Slug returns unique app identifier (used in X-App-ID header)
    Slug() string

    // Name returns human-readable app name
    Name() string

    // Routes returns the app's HTTP routes with injected dependencies
    Routes(deps Dependencies) chi.Router

    // Migrations returns database migrations (optional)
    Migrations() []Migration

    // OnRegister is called after app registration
    OnRegister(ctx context.Context, cfg *AppConfig) error

    // OnShutdown is called during graceful shutdown
    OnShutdown(ctx context.Context) error
}

Dependencies

The Routes method receives injected dependencies:

type Dependencies struct {
    DB     *SchemaDB      // Schema-isolated database
    Cache  Cache          // App-prefixed cache
    Logger *slog.Logger   // App-tagged logger
    Config *AppConfig     // App configuration
}

Implementing AppBackend

Step 1: Refactor Existing Server

Extract shared initialization to support external database:

// internal/server/server.go

// New - standalone mode, creates own database
func New(cfg Config) (*Server, error) {
    db, err := sql.Open("postgres", cfg.DatabaseURL)
    if err != nil {
        return nil, err
    }
    return newServerInternal(cfg, db)
}

// NewServerWithDatabase - multi-app mode, uses provided database
func NewServerWithDatabase(cfg Config, db *sql.DB) (*Server, error) {
    return newServerInternal(cfg, db)
}

// newServerInternal - shared initialization logic
func newServerInternal(cfg Config, db *sql.DB) (*Server, error) {
    // JWT service, router, middleware, storage, routes...
}

// Router returns the HTTP handler
func (s *Server) Router() http.Handler {
    return s.mux
}

Step 2: Create AppBackend Adapter

Create a public multiapp/ package (not internal/multiapp/):

// multiapp/backend.go
package multiapp

import (
    "context"

    "github.com/go-chi/chi/v5"
    cfmultiapp "github.com/grokify/coreforge/multiapp"
    "github.com/yourapp/internal/server"
)

type Backend struct {
    cfg    *server.Config
    server *server.Server
}

func NewBackend(cfg *server.Config) *Backend {
    if cfg == nil {
        cfg = server.DefaultConfig()
    }
    return &Backend{cfg: cfg}
}

func (b *Backend) Slug() string { return "yourapp" }
func (b *Backend) Name() string { return "Your App" }

func (b *Backend) Routes(deps cfmultiapp.Dependencies) chi.Router {
    // Create server with schema-isolated database
    var err error
    b.server, err = server.NewServerWithDatabase(b.cfg, deps.DB.Pool())
    if err != nil {
        deps.Logger.Error("failed to create server", "error", err)
        return chi.NewRouter()  // Return empty router
    }

    // Wrap http.Handler as chi.Router if needed
    r := chi.NewRouter()
    r.Mount("/", b.server.Router())
    return r
}

func (b *Backend) Migrations() []cfmultiapp.Migration {
    return nil  // Let Ent handle migrations
}

func (b *Backend) OnRegister(ctx context.Context, cfg *cfmultiapp.AppConfig) error {
    return nil
}

func (b *Backend) OnShutdown(ctx context.Context) error {
    if b.server != nil {
        return b.server.Close()
    }
    return nil
}

Step 3: Schema-Isolated Ent Client

For apps using Ent ORM:

func createEntClient(schemaDB *cfmultiapp.SchemaDB) *ent.Client {
    pool := schemaDB.Pool()
    config := pool.Config().ConnConfig.Copy()

    // Set search_path to app's schema
    if config.RuntimeParams == nil {
        config.RuntimeParams = make(map[string]string)
    }
    config.RuntimeParams["search_path"] = schemaDB.Schema() + ", public"

    // Create standard DB connection
    connStr := stdlib.RegisterConnConfig(config)
    db, _ := sql.Open("pgx", connStr)

    // Create Ent client
    drv := entsql.OpenDB(dialect.Postgres, db)
    return ent.NewClient(ent.Driver(drv))
}

Context Helpers

Access app and auth context in handlers:

func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // App context (from X-App-ID routing)
    appCtx := multiapp.AppContextFromContext(ctx)
    appCtx.AppID          // "app1"
    appCtx.AppSlug        // "app1"
    appCtx.AppName        // "App1"
    appCtx.DatabaseSchema // "app_app1"
    appCtx.Features       // ["feature1", "feature2"]
    appCtx.Settings       // map[string]any

    // JWT claims (from auth middleware)
    claims := middleware.ClaimsFromContext(ctx)
    claims.PrincipalID    // user UUID
    claims.OrganizationID // org UUID
    claims.Email          // user email

    // Full context (combines all)
    fc := multiapp.FullContextFromContext(ctx)
    fc.HasApp()           // true in multi-app mode
    fc.IsAuthenticated()  // true if JWT valid
}

Server Modes

Multi-App Mode

Multiple apps share infrastructure, routed by header:

server, _ := multiapp.NewServer(multiapp.Config{
    Mode:        multiapp.MultiAppMode,
    DatabaseURL: "postgres://localhost:5432/platform",
})

server.RegisterApp(app1.NewBackend(nil))
server.RegisterApp(app2.NewBackend(nil))
server.RegisterApp(app3.NewBackend(nil))

server.Run(":8080")

Single-App Mode

One app on dedicated infrastructure (no header routing):

server, _ := multiapp.NewServer(multiapp.Config{
    Mode:        multiapp.SingleAppMode,
    DatabaseURL: "postgres://localhost:5432/myapp",
})

server.RegisterApp(myapp.NewBackend(nil))

server.Run(":8080")

Caching

The multiapp framework provides app-scoped caching:

// In Routes(), deps.Cache is pre-configured with app prefix
func (b *Backend) Routes(deps multiapp.Dependencies) chi.Router {
    // Cache keys are automatically prefixed: "app1:user:123"
    deps.Cache.Set(ctx, "user:123", userData, 5*time.Minute)

    userData, err := deps.Cache.Get(ctx, "user:123")
}

Cache Implementations

  • Redis: Production-ready with connection pooling
  • Memory: In-memory for development/testing
// Redis cache (from RedisURL config)
server, _ := multiapp.NewServer(multiapp.Config{
    RedisURL: "redis://localhost:6379",
})

// Memory cache (default when RedisURL empty)
server, _ := multiapp.NewServer(multiapp.Config{
    // No RedisURL = memory cache
})

Security

Generic 404 Responses

Missing or invalid X-App-ID returns generic 404 to prevent:

  • Routing mechanism disclosure
  • App ID enumeration attacks
  • Information leakage about valid apps

Self-Contained Apps

Each app manages its own:

  • Users and authentication
  • Organizations and memberships
  • Business data and logic
  • API routes and middleware

No external dependencies required. CoreControl integration is optional for SSO.

Optional: CoreControl Integration

Apps can optionally integrate with CoreControl for:

  • Single Sign-On (SSO) across apps
  • Centralized user management
  • Cross-app analytics

This is enabled by setting a federation_id on users when they authenticate via CoreControl.