go/docs/pkg/core.md
Snider 915816b3b5 docs: add pkg/core documentation, remove 12MB stale generated site
- Add comprehensive docs/pkg/core.md covering DI container, service
  pattern, message bus (ACTION/QUERY/TASK), error handling, runtime
- Remove pkg/core/docs/site/ (ancient MkDocs HTML with Lethean branding)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-06 14:29:04 +00:00

18 KiB

pkg/core -- Dependency Injection & Service Framework

pkg/core is the foundation of the Core application framework. It provides a dependency injection container, service lifecycle management, and a message bus for inter-service communication. Every other package in the ecosystem builds on top of it.

The package is designed for use with Wails v3 (desktop GUI) but is equally useful in CLI and headless applications.


Core Struct

Core is the central application object. It owns the service registry, the message bus, embedded assets, and feature flags.

type Core struct {
    App      any         // GUI runtime (e.g. Wails App), set by WithApp
    Features *Features   // Feature flags
    // unexported: svc *serviceManager, bus *messageBus, assets embed.FS
}

Creating a Core Instance

New() is the sole constructor. It accepts a variadic list of Option functions that configure the instance before it is returned. After all options are applied, the service lock is finalised.

c, err := core.New(
    core.WithService(mypackage.NewMyService),
    core.WithAssets(embeddedFS),
    core.WithServiceLock(),
)

If any option returns an error, New() returns nil and that error immediately.

Options

Option Purpose
WithService(factory) Register a service via factory function. Auto-discovers the service name from the factory's return type package path and auto-registers an IPC handler if the service has a HandleIPCEvents method.
WithName(name, factory) Register a service with an explicit name. Does not auto-discover IPC handlers.
WithApp(app) Inject a GUI runtime (e.g. Wails *application.App) into Core.App.
WithAssets(fs) Attach an embed.FS containing frontend assets.
WithServiceLock() Prevent any further service registration after New() completes. Calls to RegisterService after the lock is applied return an error.

The Option type is defined as:

type Option func(*Core) error

Service Retrieval

Services are retrieved by name. Two generic helpers provide type-safe access:

// Returns (T, error) -- safe version
svc, err := core.ServiceFor[*MyService](c, "myservice")

// Panics if not found or wrong type -- use in init paths
svc := core.MustServiceFor[*MyService](c, "myservice")

The untyped Service(name) method returns any (or nil if not found).

Convenience Accessors

Core provides shorthand methods for well-known services:

c.Config()     // returns Config interface
c.Display()    // returns Display interface
c.Workspace()  // returns Workspace interface
c.Crypt()      // returns Crypt interface

Each calls MustServiceFor internally and will panic if the named service is not registered.

Global Instance

For GUI runtimes that require global access, a singleton pattern is available:

core.SetInstance(c)       // store globally (thread-safe)
app := core.App()         // retrieve Core.App (panics if not set)
inst := core.GetInstance() // retrieve *Core (returns nil if not set)
core.ClearInstance()      // reset to nil (primarily for tests)

Feature Flags

The Features struct holds a simple string slice of enabled flags:

c.Features.Flags = []string{"dark-mode", "beta-api"}
c.Features.IsEnabled("dark-mode") // true

Service Pattern

Factory Functions

Services are created via factory functions that receive the *Core and return (any, error):

func NewMyService(c *core.Core) (any, error) {
    return &MyService{
        ServiceRuntime: core.NewServiceRuntime(c, MyOptions{BufferSize: 64}),
    }, nil
}

The factory is called during New() when the corresponding WithService or WithName option is processed.

ServiceRuntime[T]

ServiceRuntime[T] is a generic helper struct that services embed to gain access to the Core instance and typed options:

type ServiceRuntime[T any] struct {
    core *core.Core
    opts T
}

Constructor:

rt := core.NewServiceRuntime[MyOptions](c, MyOptions{BufferSize: 64})

Methods:

Method Returns
Core() *Core -- the parent container
Opts() T -- the service's typed options
Config() Config -- shorthand for Core().Config()

Example service:

type MyService struct {
    *core.ServiceRuntime[MyOptions]
    items map[string]string
}

type MyOptions struct {
    BufferSize int
}

func NewMyService(c *core.Core) (any, error) {
    return &MyService{
        ServiceRuntime: core.NewServiceRuntime(c, MyOptions{BufferSize: 128}),
        items:          make(map[string]string),
    }, nil
}

Startable and Stoppable Interfaces

Services that need lifecycle hooks implement one or both of:

type Startable interface {
    OnStartup(ctx context.Context) error
}

type Stoppable interface {
    OnShutdown(ctx context.Context) error
}

The service manager detects these interfaces at registration time and stores references internally.

  • Startup: ServiceStartup() calls OnStartup on every Startable service in registration order, then broadcasts ActionServiceStartup{} via the message bus.
  • Shutdown: ServiceShutdown() first broadcasts ActionServiceShutdown{}, then calls OnShutdown on every Stoppable service in reverse registration order. This ensures that services which were started last are stopped first, respecting dependency order.

Errors from individual services are aggregated via errors.Join and returned together, so one failing service does not prevent others from completing their lifecycle.

Service Lock

When WithServiceLock() is passed to New(), the serviceManager sets lockEnabled = true during option processing. After all options have been applied, applyLock() sets locked = true. Any subsequent call to RegisterService returns an error:

core: service "late-service" is not permitted by the serviceLock setting

This prevents accidental late-binding of services after the application has been fully wired.

Service Name Discovery

WithService derives the service name from the Go package path of the returned struct. For a type myapp/services.Calculator, the name becomes services. For myapp/calculator.Service, it becomes calculator.

To control the name explicitly, use WithName("calc", factory).

IPC Handler Discovery

WithService also checks whether the service has a method named HandleIPCEvents with signature func(*Core, Message) error. If found, it is automatically registered as an ACTION handler via RegisterAction.

WithName does not perform this discovery. Register handlers manually if needed.


Message Bus

The message bus provides three distinct communication patterns, all thread-safe:

1. ACTION -- Fire-and-Forget Broadcast

ACTION dispatches a message to all registered handlers. Every handler is called; errors are aggregated.

// Define a message type
type OrderPlaced struct {
    OrderID string
    Total   float64
}

// Dispatch
err := c.ACTION(OrderPlaced{OrderID: "abc", Total: 42.50})

// Register a handler
c.RegisterAction(func(c *core.Core, msg core.Message) error {
    switch m := msg.(type) {
    case OrderPlaced:
        log.Printf("Order %s placed for %.2f", m.OrderID, m.Total)
    }
    return nil
})

Multiple handlers can be registered at once with RegisterActions(h1, h2, h3).

The Message type is defined as any, so any struct can serve as a message. Handlers use a type switch to filter messages they care about.

Built-in action messages:

Message Broadcast when
ActionServiceStartup{} After all Startable.OnStartup calls complete
ActionServiceShutdown{} Before Stoppable.OnShutdown calls begin
ActionTaskStarted{TaskID, Task} A PerformAsync task begins
ActionTaskProgress{TaskID, Task, Progress, Message} A background task reports progress
ActionTaskCompleted{TaskID, Task, Result, Error} A PerformAsync task finishes

2. QUERY -- Read-Only Request/Response

QUERY dispatches a query to handlers until the first one responds (returns handled = true). Remaining handlers are skipped.

type GetUserByID struct {
    ID string
}

// Register
c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) {
    switch req := q.(type) {
    case GetUserByID:
        user, err := db.Find(req.ID)
        return user, true, err
    }
    return nil, false, nil // not handled -- pass to next handler
})

// Dispatch
result, handled, err := c.QUERY(GetUserByID{ID: "u-123"})
if !handled {
    // no handler recognised this query
}
user := result.(*User)

QUERYALL dispatches the query to all handlers and collects every non-nil result:

results, err := c.QUERYALL(ListPlugins{})
// results is []any containing responses from every handler that responded

The Query type is any. The QueryHandler signature is:

type QueryHandler func(*Core, Query) (any, bool, error)

3. TASK -- Side-Effect Request/Response

PERFORM dispatches a task to handlers until the first one executes it (returns handled = true). Semantically identical to QUERY but intended for operations with side effects.

type SendEmail struct {
    To      string
    Subject string
    Body    string
}

c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) {
    switch task := t.(type) {
    case SendEmail:
        err := mailer.Send(task.To, task.Subject, task.Body)
        return nil, true, err
    }
    return nil, false, nil
})

result, handled, err := c.PERFORM(SendEmail{
    To:      "user@example.com",
    Subject: "Welcome",
    Body:    "Hello!",
})

The Task type is any. The TaskHandler signature is:

type TaskHandler func(*Core, Task) (any, bool, error)

Background Tasks

PerformAsync runs a PERFORM dispatch in a background goroutine and returns a task ID immediately:

taskID := c.PerformAsync(BuildProject{Path: "/src"})
// taskID is "task-1", "task-2", etc.

The framework automatically broadcasts:

  1. ActionTaskStarted -- when the goroutine begins
  2. ActionTaskCompleted -- when the task finishes (includes Result and Error)

If the task implements TaskWithID, the framework injects the assigned ID before execution:

type TaskWithID interface {
    Task
    SetTaskID(id string)
    GetTaskID() string
}

Services can report progress during long-running tasks:

c.Progress(taskID, 0.5, "Compiling 50%...", task)
// Broadcasts ActionTaskProgress{TaskID: taskID, Progress: 0.5, Message: "..."}

Thread Safety

The message bus uses sync.RWMutex for each handler slice (IPC, query, task). Handler registration acquires a write lock; dispatch acquires a read lock and copies the handler slice before iterating, so dispatches never block registrations.


Error Handling

The Error struct provides contextual error wrapping:

type Error struct {
    Op  string // operation, e.g. "config.Load"
    Msg string // human-readable description
    Err error  // underlying error (optional)
}

E() Helper

E() is the primary constructor:

return core.E("config.Load", "failed to read config file", err)
// Output: "config.Load: failed to read config file: <underlying error>"

return core.E("auth.Login", "invalid credentials", nil)
// Output: "auth.Login: invalid credentials"

When err is nil, the resulting Error has no wrapped cause.

Error Chain Compatibility

Error implements Unwrap(), so it works with errors.Is() and errors.As():

var coreErr *core.Error
if errors.As(err, &coreErr) {
    log.Printf("Operation: %s, Message: %s", coreErr.Op, coreErr.Msg)
}

Convention

The Op field should follow package.Function or service.Method format. The Msg field should be a human-readable sentence suitable for display to end users.


Runtime (Wails Integration)

The Runtime struct wraps Core for use as a Wails service. It implements the Wails service interface (ServiceName, ServiceStartup, ServiceShutdown).

type Runtime struct {
    app  any    // GUI runtime
    Core *Core
}

NewRuntime

Creates a minimal runtime with no custom services:

rt, err := core.NewRuntime(wailsApp)

NewWithFactories

Creates a runtime with named service factories. Factories are called in sorted (alphabetical) order to ensure deterministic initialisation:

rt, err := core.NewWithFactories(wailsApp, map[string]core.ServiceFactory{
    "calculator": func() (any, error) { return &Calculator{}, nil },
    "logger":     func() (any, error) { return &Logger{}, nil },
})

ServiceFactory is defined as func() (any, error) -- note it does not receive *Core, unlike the WithService factory. The Runtime wraps each factory result with WithName internally.

Lifecycle Delegation

Runtime.ServiceStartup and Runtime.ServiceShutdown delegate directly to Core.ServiceStartup and Core.ServiceShutdown. The Wails runtime calls these automatically.

func (r *Runtime) ServiceStartup(ctx context.Context, options any) {
    _ = r.Core.ServiceStartup(ctx, options)
}

func (r *Runtime) ServiceShutdown(ctx context.Context) {
    if r.Core != nil {
        _ = r.Core.ServiceShutdown(ctx)
    }
}

Interfaces

pkg/core defines several interfaces that services may implement or consume. These decouple services from concrete implementations.

Lifecycle Interfaces

Interface Method Purpose
Startable OnStartup(ctx) error Initialisation on app start
Stoppable OnShutdown(ctx) error Cleanup on app shutdown

Well-Known Service Interfaces

Interface Service name Key methods
Config "config" Get(key, out) error, Set(key, v) error
Display "display" OpenWindow(opts...) error
Workspace "workspace" CreateWorkspace, SwitchWorkspace, WorkspaceFileGet, WorkspaceFileSet
Crypt "crypt" CreateKeyPair, EncryptPGP, DecryptPGP

These interfaces live in interfaces.go and define the contracts that concrete implementations must satisfy.

Contract

The Contract struct configures resilience behaviour:

type Contract struct {
    DontPanic      bool // recover from panics, return errors instead
    DisableLogging bool // suppress all logging
}

Complete Example

Putting it all together -- a service that stores items, broadcasts actions, and responds to queries:

package inventory

import (
    "context"
    "sync"

    "forge.lthn.ai/core/go/pkg/core"
)

// Options configures the inventory service.
type Options struct {
    MaxItems int
}

// Service manages an inventory of items.
type Service struct {
    *core.ServiceRuntime[Options]
    items map[string]string
    mu    sync.RWMutex
}

// NewService creates a factory for Core registration.
func NewService(opts Options) func(*core.Core) (any, error) {
    return func(c *core.Core) (any, error) {
        if opts.MaxItems == 0 {
            opts.MaxItems = 1000
        }
        return &Service{
            ServiceRuntime: core.NewServiceRuntime(c, opts),
            items:          make(map[string]string),
        }, nil
    }
}

// OnStartup registers query and task handlers.
func (s *Service) OnStartup(ctx context.Context) error {
    s.Core().RegisterQuery(s.handleQuery)
    s.Core().RegisterTask(s.handleTask)
    return nil
}

// -- Query: look up an item --

type GetItem struct{ ID string }

func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
    switch req := q.(type) {
    case GetItem:
        s.mu.RLock()
        val, ok := s.items[req.ID]
        s.mu.RUnlock()
        if !ok {
            return nil, true, core.E("inventory.GetItem", "not found", nil)
        }
        return val, true, nil
    }
    return nil, false, nil
}

// -- Task: add an item --

type AddItem struct {
    ID   string
    Name string
}

type ItemAdded struct {
    ID   string
    Name string
}

func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
    switch task := t.(type) {
    case AddItem:
        s.mu.Lock()
        s.items[task.ID] = task.Name
        s.mu.Unlock()

        _ = c.ACTION(ItemAdded{ID: task.ID, Name: task.Name})
        return task.ID, true, nil
    }
    return nil, false, nil
}

// -- Wiring it up --

func main() {
    c, err := core.New(
        core.WithName("inventory", NewService(Options{MaxItems: 500})),
        core.WithServiceLock(),
    )
    if err != nil {
        panic(err)
    }

    // Start lifecycle
    _ = c.ServiceStartup(context.Background(), nil)

    // Use the bus
    _, _, _ = c.PERFORM(AddItem{ID: "item-1", Name: "Widget"})
    result, _, _ := c.QUERY(GetItem{ID: "item-1"})
    // result == "Widget"

    // Shutdown
    _ = c.ServiceShutdown(context.Background())
}

File Map

File Responsibility
core.go New(), options (WithService, WithName, WithApp, WithAssets, WithServiceLock), ServiceFor[T], MustServiceFor[T], lifecycle dispatch, global instance, bus method delegation
interfaces.go Core struct definition, Option, Message, Query, Task, QueryHandler, TaskHandler, Startable, Stoppable, Contract, Features, well-known service interfaces (Config, Display, Workspace, Crypt), built-in action message types
message_bus.go messageBus struct, action(), query(), queryAll(), perform(), handler registration
service_manager.go serviceManager struct, service registry, Startable/Stoppable tracking, service lock mechanism
runtime_pkg.go ServiceRuntime[T] generic helper, Runtime struct (Wails integration), NewRuntime(), NewWithFactories()
e.go Error struct, E() constructor, Unwrap() for error chain compatibility