modulex

package module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 10 Imported by: 0

README

Modulex

CI OpenSSF Scorecard Go Reference Go Report Card

Modulex is a Go library that orchestrates the lifecycle of modular applications and encourages clean architectural boundaries. It helps teams build feature modules that can run together in a single process today and be extracted into standalone services tomorrow without changing core business logic.


What this Repository is Designed to Do

Modulex is built to solve a critical orchestration challenge: how to initialize, start, and stop a set of interdependent feature modules in a deterministic order, while keeping their wiring configurable at the composition root.

Specifically, it is designed to:

  1. Encourage Feature Decoupling: Make it easy to depend on interface contracts (ports) rather than concrete implementations, so features can be wired locally or remotely at the composition root.
  2. Automate Topological DAG Lifecycles: Analyze feature dependencies at startup, detect circular loops, and execute lifecycle stages (Init, Start, Stop) in strict topological order (and reverse order for teardown).
  3. Abstract Messaging Infrastructure: Provide a generic EventBus interface that decouples modules from specific messaging frameworks (e.g. NATS, RabbitMQ, Kafka).
  4. Provide OpenTelemetry-Ready Tracing: Automatically trace module initialization and startup cycles, and provide trace-safe concurrency helpers for background work.
  5. Enable Flexible Deployment Topologies:
    • Monolithic Run: Register all feature modules locally. The service registry wires interfaces directly to in-process service implementations.
    • Distributed Run (Microservices): Register only the target module in its own standalone binary. For modules it depends on, the composition root registers network client adapters (HTTP/gRPC/NATS) instead, pointing to the external service.

What Modulex Is (and Is Not)

Modulex is a runtime lifecycle orchestrator and service locator, not a compile-time dependency enforcer. It cannot prevent one Go package from importing another; that guarantee belongs to the Go compiler and, optionally, to a separate go/analysis static-analysis tool.

What it does provide:

  • A deterministic startup and shutdown order.
  • A typed, runtime service registry.
  • A consistent pattern for registering local or remote implementations of the same interface.
  • Trace-safe background task execution.

What it does not provide:

  • Compile-time prevention of cross-feature imports.
  • Automatic code generation for wiring.
  • A microservices runtime, service mesh, or deployment platform.

Pluggable Event Bus Interface

To prevent modules from tying themselves directly to a specific messaging broker, Modulex exposes a generic EventBus abstraction.

1. The EventBus Interface
// EventHandler is a generic callback for incoming event payloads.
type EventHandler func(ctx context.Context, payload []byte) error

// EventBus abstracts the underlying message broker.
type EventBus interface {
	Publish(ctx context.Context, topic string, payload []byte) error
	Subscribe(ctx context.Context, topic string, handler EventHandler) error
	Close(ctx context.Context) error
}
2. Built-In Adapters (Drivers)

Modulex includes reference adapters for popular brokers. They are usable as-is, but you should validate them against your own reliability and observability requirements before production use. The Watermill driver shown below uses the in-memory GoChannel implementation, which is ideal for local development and tests.

NATS Driver
import "github.com/mediusfy/modulex/nats"

// Wrap a standard *nats.Conn connection
eb := nats.NewEventBus(natsConn)
mgr, err := modulex.NewManager(modulex.WithEventBus(eb), modulex.WithLogger(logger), modulex.WithConfigLoader(configLoader))
if err != nil {
    // handle error
}
NATS JetStream Driver (publish-only)
import "github.com/mediusfy/modulex/nats"

// js is typically obtained via (*nats.Conn).JetStream()
eb := nats.NewJetStreamEventBus(js)
mgr, err := modulex.NewManager(modulex.WithEventBus(eb), modulex.WithLogger(logger), modulex.WithConfigLoader(configLoader))
if err != nil {
    // handle error
}

JetStreamEventBus is deliberately publish-only: Subscribe always returns nats.ErrJetStreamSubscribeUnsupported, since JetStream consumption needs substantially more configuration (durable vs ephemeral consumers, ack policies, delivery subjects, replay policy) than the EventBus interface's fire-and-forget Subscribe can express. Use it when a module only needs to publish (fire-and-confirm) to a JetStream stream; use the core NATS EventBus above, or a direct JetStream consumer, to consume messages.

RabbitMQ Driver
import "github.com/mediusfy/modulex/rabbitmq"

// Wrap a standard *amqp.Channel channel
eb := rabbitmq.NewEventBus(amqpChannel)
mgr, err := modulex.NewManager(modulex.WithEventBus(eb), modulex.WithLogger(logger), modulex.WithConfigLoader(configLoader))
if err != nil {
    // handle error
}
Watermill Driver
import watermilladapter "github.com/mediusfy/modulex/watermill"

// Initialize Watermill in-memory (Go Channel)
eb := watermilladapter.NewEventBus(100, false, false)
mgr, err := modulex.NewManager(modulex.WithEventBus(eb), modulex.WithLogger(logger), modulex.WithConfigLoader(configLoader))
if err != nil {
    // handle error
}
Chi Router Integration
import (
    gochi "github.com/go-chi/chi/v5"
    modulexchi "github.com/mediusfy/modulex/chi"
)

router := gochi.NewRouter()
mgr, err := modulex.NewManager(modulex.WithEventBus(eb), modulex.WithLogger(logger), modulex.WithConfigLoader(configLoader))
if err != nil {
    // handle error
}
if err := modulexchi.RegisterRouter(mgr, router); err != nil {
    // handle error
}

Modules that need the router resolve it in Init:

func (m *Module) Init(ctx context.Context, reg modulex.Registry) error {
    router, err := modulexchi.ResolveRouter(reg)
    if err != nil {
        return err
    }
    router.Get("/api/incidents", m.listIncidents)
    return nil
}
gRPC Adapter
import (
    googlegrpc "google.golang.org/grpc"
    modulexgrpc "github.com/mediusfy/modulex/grpc"
)

grpcServer := googlegrpc.NewServer(modulexgrpc.ServerOptions(myErrorMapping)...)
myservicepb.RegisterMyServiceServer(grpcServer, myServiceImpl)

listener, err := net.Listen("tcp", ":50051")
if err != nil {
    // handle error
}

server, err := modulexgrpc.NewServer(grpcServer, listener)
if err != nil {
    // handle error
}
// server implements modulex.Starter/modulex.Stopper — register it (or a
// module that delegates to it) so the Manager owns starting and gracefully
// stopping the gRPC listener.

grpc/ also provides OpenTelemetry trace-context propagation interceptors, a consistent domain-error-to-status mapping layer, and a health integration backed by the Manager's real registered health/readiness checks. See docs/planning/grpc-adapter-guide.md for the full design and a worked example that binds the same domain port to a local implementation and a remote gRPC client.

3. InMemory Event Bus (For Testing)

Using the EventBus interface, you can write an InMemoryEventBus backed by simple Go channels/maps to test your business logic completely offline:

type InMemoryEventBus struct {
	mu          sync.Mutex
	subscribers map[string][]modulex.EventHandler
}

func (eb *InMemoryEventBus) Publish(ctx context.Context, topic string, payload []byte) error {
	eb.mu.Lock()
	handlers := eb.subscribers[topic]
	eb.mu.Unlock()
	for _, h := range handlers {
		_ = h(ctx, payload)
	}
	return nil
}

func (eb *InMemoryEventBus) Subscribe(ctx context.Context, topic string, handler modulex.EventHandler) error {
	eb.mu.Lock()
	defer eb.mu.Unlock()
	eb.subscribers[topic] = append(eb.subscribers[topic], handler)
	return nil
}

func (eb *InMemoryEventBus) Close(ctx context.Context) error { return nil }

Telemetry and Context Propagation

Modulex has an optional, pluggable Tracer interface. The modulex/otel package adapts an OpenTelemetry TracerProvider so the core library does not force the OpenTelemetry dependency on consumers who do not need tracing.

import modulexotel "github.com/mediusfy/modulex/otel"

sr := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))

mgr, err := modulex.NewManager(eb, logger, configLoader,
    modulex.WithTracer(modulexotel.NewTracer(tp)),
)
if err != nil {
    // handle error
}
Preventing Telemetry Gaps

In Go, starting a background task using go func() breaks context propagation, leading to detached, orphaned spans (telemetry gaps). To prevent this, the Registry provides a Go helper that handles trace context propagation and panic safety automatically:

// Inside a module's Start method:
func (m *Module) Start(ctx context.Context, tracer trace.Tracer) error {
    _, err := m.registry.Go(ctx, "invoices.ProcessQueue", func(bgCtx context.Context) error {
        // bgCtx carries the parent span context from the caller.
        // Spans created here are correctly linked, preventing telemetry gaps.
        _, span := tracer.Start(bgCtx, "ProcessNextInvoice")
        defer span.End()

        // Do background work safely...
        return nil
    })
    return err
}

Go returns a *TaskHandle that can be awaited, and the manager guarantees that all supervised tasks are cancelled and awaited before modules are stopped during shutdown. Panic recovery is configurable via WithPanicPolicy.

OTLP Provider from Environment

Constructing an OTLP-exporting TracerProvider (exporter protocol/endpoint selection, resource attributes, sampling) is generic boilerplate that's otherwise hand-rolled per service. modulex/otel.NewProviderFromEnv factors it out, reading the standard OTEL_EXPORTER_OTLP_* environment variables:

tp, shutdown, err := modulexotel.NewProviderFromEnv("my-service")
if err != nil {
    // handle error
}
defer shutdown(context.Background())

tracer := modulexotel.NewTracer(tp)
mgr, err := modulex.NewManager(modulex.WithTracer(tracer), modulex.WithLogger(logger))

Set the exporter protocol to "none" (via WithExporterProtocol("none") or OTEL_EXPORTER_OTLP_PROTOCOL=none) to disable span export entirely, useful for local development. WithSpanProcessor attaches an extra sdktrace.SpanProcessor alongside (or, with "none", instead of) the OTLP batch processor — a tracetest.SpanRecorder in tests, or a console/debug exporter in development.

Verifying Spans (Asserting No Gaps)

To guarantee that your tracing pipeline is intact and that no developers are introducing gaps in spans, you can write unit tests using the OTel tracetest package to verify parent-child relations:

func TestTracesNoGaps(t *testing.T) {
	// Set up memory span exporter
	sr := tracetest.NewSpanRecorder()
	tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))

	manager, err := modulex.NewManager(nil, logger, nil,
	    modulex.WithTracer(modulexotel.NewTracer(tp)),
	)
	require.NoError(t, err)
	manager.RegisterModule(&myModule{})

	// Initialize
	err = manager.InitModules(context.Background())
	require.NoError(t, err)

	// Fetch captured spans and verify parent-child lineage
	spans := sr.Ended()
	var parentSpan, childSpan sdktrace.ReadOnlySpan
	for _, s := range spans {
		if s.Name() == "InitModules" {
			parentSpan = s
		} else if s.Name() == "InitModule:my-module" {
			childSpan = s
		}
	}

	// Verify child span points directly to parent, ensuring no orphaned spans/gaps
	assert.Equal(t, parentSpan.SpanContext().SpanID(), childSpan.Parent().SpanID())
	assert.Equal(t, parentSpan.SpanContext().TraceID(), childSpan.SpanContext().TraceID())
}

Architectural Decision Record (ADR-0029)

This section embeds the official architectural decision that defines the creation, standard, and deployment constraints of the modulex framework.

Context & Problem Statement

As applications grow within a monorepo, features that start as simple internal modules often need to scale, compile, and deploy independently. However, developers commonly fall into the trap of tight coupling by importing concrete structures and adapters from other packages (e.g., calling a database helper directly or importing a controller).

If Feature A directly imports Feature B's service or adapters packages, compilation boundaries are broken:

  • Feature A cannot be compiled without pulling in Feature B's dependencies (causing bloated binaries).
  • Circular package imports occur frequently.
  • Extracting a feature into a standalone service requires a major rewrite.

We need a standardized framework and layout rules to enforce linear execution paths, clean interface segregation, and dynamic runtime wiring.

Options Considered
  • Option 1: Compile-time Dependency Injection (e.g., Wire, Dig)
    • Pros: Type-safe at compile time.
    • Cons: Requires highly complex setup configurations in main.go. Changing target topologies requires maintaining distinct, cumbersome compile-time configuration sets.
  • Option 2: Service Locator and Module Registry Pattern (modulex)
    • Pros: Extremely low coupling. The core business logic is completely insulated. Topologies are selected at the composition root (the entry point main.go) by choosing to register either local modules or network proxy clients under the same interface names.
    • Cons: Registry resolution type-checks are performed at startup rather than compile-time.
Confirming the Design

We confirm the selection of Option 2 (the Service Locator and Module Registry Pattern). Modulex encourages clean hexagonal segregation and supports runtime topology mapping:

graph TD
    subgraph "Monolithic Execution"
        MA[Service A] -->|1. Resolve Port B| MR[Registry]
        MR -->|2. Local In-Memory| MB[Service B]
    end

    subgraph "Standalone Execution"
        DA[Service A] -->|1. Resolve Port B| DR[Registry]
        DR -->|2. Network Proxy Client| DC[Client Adapter]
        DC -->|3. TCP/NATS/gRPC| DS[Service B Standalone Process]
    end

    style MA fill:#1e1e24,stroke:#333,stroke-width:2px,color:#fff
    style MB fill:#1e1e24,stroke:#333,stroke-width:2px,color:#fff
    style DA fill:#2e1e24,stroke:#444,stroke-width:2px,color:#fff
    style DS fill:#2e1e24,stroke:#444,stroke-width:2px,color:#fff
Consequences
  • Positive:
    • Zero Code Modification: Splitting a monolith to microservices involves changing only the registration block in the application's entrypoint (main.go).
    • Strict Clean Deletion: If a feature is deprecated, deleting its package directory does not break the compilation of other modules, since no other module imported its code.
    • No Resource Leakage: Reverse-order shutdowns ensure downstream DB connectors/event lines are terminated only after upstream services have stopped consuming them.
  • Negative:
    • Type Assertions: Developers must cast resolved interfaces (val.(ports.Service)). Missing service registrations surface when a module calls ResolveService/Resolve during Init.

Directory Standards

Every feature module using modulex must reside in its own package and strictly adhere to this layout:

  • domain/: Entities, pure values, core business rules. Zero dependencies on other features.
  • ports/: Clean interface contracts. Defines how the service can be called (inbound) and what it requires (outbound).
  • service/: Core logic implementing the inbound ports using pure business logic (no DB/network drivers).
  • adapters/: Houses all infrastructure mappings:
    • Inbound: HTTP controllers, NATS listeners.
    • Outbound: SQL repositories, API integrations.
    • Client: Client adapter (HTTP/NATS proxy) implementing ports/ interfaces for standalone deployment mode.
  • module.go: Implements the modulex.Module interface. Acts as the module's localized composition root.

Getting Started

Installation
go get github.com/mediusfy/modulex
Usage Example
1. Define a Module
package database

import (
	"context"
	"github.com/mediusfy/modulex"
)

type Module struct {
	dbConn *Connection
}

func (m *Module) Name() string      { return "database" }
func (m *Module) DependsOn() []string { return nil } // No dependencies

func (m *Module) Init(ctx context.Context, reg modulex.Registry) error {
	m.dbConn = ConnectDB() // your database constructor
	// Register service for other modules to resolve
	return reg.RegisterService("database.Connection", m.dbConn)
}

// Stop is optional; implement it only when the module owns resources that must
// be released during shutdown.
func (m *Module) Stop(ctx context.Context) error { return m.dbConn.Close() }
2. Declare a Dependent Module
package incident

import (
	"context"
	"github.com/mediusfy/modulex"
)

type Module struct {
	svc Service
}

func (m *Module) Name() string      { return "incident" }
func (m *Module) DependsOn() []string { return []string{"database"} } // Boot database first

func (m *Module) Init(ctx context.Context, reg modulex.Registry) error {
	// Resolve dependency without importing any concrete implementation
	conn, err := reg.ResolveService("database.Connection")
	if err != nil {
		return err
	}

	m.svc = NewService(conn)
	return reg.RegisterService("incident.Service", m.svc)
}

Typed Service Wiring

String-based service keys require unchecked type assertions. Modulex provides compile-time typed keys and generic helpers:

package ports

import "github.com/mediusfy/modulex"

type Service interface { /* ... */ }

var ServiceKey = modulex.NewKey[Service]("incident.Service")
func (m *Module) Init(ctx context.Context, reg modulex.Registry) error {
    m.svc = service.New(repo)
    return modulex.Provide(reg, ports.ServiceKey, m.svc)
}
func (m *OtherModule) Init(ctx context.Context, reg modulex.Registry) error {
    svc, err := modulex.Resolve(reg, ports.ServiceKey)
    if err != nil {
        return err
    }
    // svc is already typed as ports.Service
    m.incidentSvc = svc
    return nil
}

Provide and Resolve wrap the underlying string-keyed registry and return ErrServiceTypeMismatch when the registered value does not match the key's compile-time type.

Typed Configuration

WithConfigLoader takes a func(target interface{}) error, which normally means hand-writing the same type-assert-and-copy closure in every service:

configLoader := func(target interface{}) error {
    out, ok := target.(*Config)
    if !ok {
        return errors.New("invalid config type")
    }
    *out = cfg
    return nil
}
mgr, err := modulex.NewManager(modulex.WithConfigLoader(configLoader))

modulex.WithTypedConfig removes that boilerplate:

mgr, err := modulex.NewManager(modulex.WithTypedConfig(cfg))

GetConfig then returns ErrConfigTypeMismatch if called with a target that isn't *T.

Capability interfaces

modulex.Registry is a composite of smaller capability interfaces. The framework still passes the full Registry to Module.Init, but internal helpers and constructors can depend on only the capabilities they need:

  • modulex.ServiceRegistry – register and resolve services.
  • modulex.ServiceRegisterer – register services only.
  • modulex.ServiceResolver – resolve services only.
  • modulex.EventBusProvider – access the event bus.
  • modulex.ConfigProvider – load configuration.
  • modulex.LoggerProvider – access the logger.
  • modulex.TaskSpawner – start supervised background tasks.

For example, a constructor that only needs to register a service and read the logger can accept the narrower interfaces:

func NewService(reg modulex.ServiceRegisterer, log modulex.LoggerProvider) *Service {
    svc := &Service{logger: log.Logger()}
    _ = reg.RegisterService("my.Service", svc)
    return svc
}

func (m *Module) Init(ctx context.Context, reg modulex.Registry) error {
    m.svc = NewService(reg, reg)
    return nil
}

Health Checks, Readiness, and HTTP Exposure

modulex.Registry embeds two independent check namespaces that modules register named check functions against:

  • Health (liveness) checks answer "is this process functioning correctly?" A failing health check means the process is broken and should be restarted.
  • Readiness checks answer "should this process currently receive traffic?" A failing readiness check means the instance should be pulled from load balancing — its database pool isn't warm yet, a dependency is unreachable, a cache hasn't primed — while the process itself keeps running and should not be restarted.

Modulex only abstracts registration, aggregation, and (via modulex/httpx) HTTP exposure; the consumer defines what "healthy" and "ready" mean for their service:

func (m *Module) Init(ctx context.Context, reg modulex.Registry) error {
    if err := reg.RegisterHealthCheck("db-ping", func(ctx context.Context) error {
        return m.db.PingContext(ctx)
    }); err != nil {
        return err
    }

    return reg.RegisterReadinessCheck("db-pool-warm", func(ctx context.Context) error {
        if m.db.Stats().OpenConnections == 0 {
            return errors.New("connection pool not yet warm")
        }
        return nil
    })
}

HealthChecks() and ReadinessChecks() return a defensive copy of every check registered under their respective namespace so callers can safely aggregate or expose them without holding an internal lock.

Exposing checks over HTTP

The core modulex package stays free of net/http, the same way it stays free of Chi (see modulex/chi). HTTP-serving consumers instead use modulex/httpx:

import "github.com/mediusfy/modulex/httpx"

mux := http.NewServeMux()
mux.HandleFunc("/healthz", httpx.HealthHandler(manager))
mux.HandleFunc("/readyz", httpx.ReadinessHandler(manager))

server := &http.Server{Addr: ":8080", Handler: mux}
handle, err := httpx.Serve(ctx, manager, "http-server", server, 10*time.Second)
if err != nil {
    return err
}
// handle.Wait() blocks until the server has shut down (or failed).
  • httpx.HealthHandler / httpx.ReadinessHandler run every registered check concurrently, each bounded by the caller's request deadline (or a 5-second default), and respond with a JSON body listing every check by name: {"status":"ok","checks":{"db-ping":"ok"}} (200) or {"status":"unhealthy","checks":{"db-ping":"connection refused"}} (503). ReadinessHandler uses "ready" / "not-ready" in place of "ok" / "unhealthy".
  • httpx.Serve spawns server.ListenAndServe() as a supervised task via modulex.TaskSpawner.Go and shuts the server down gracefully with the given timeout when either the passed context or the manager's own shutdown fires first, treating http.ErrServerClosed as a clean exit. This removes the "spawn ListenAndServe, select on ctx.Done, Shutdown with a timeout" boilerplate every HTTP-serving consumer would otherwise hand-write.

See httpx/README.md for the full adapter reference.

Visualizing the module graph

Manager.ExportDAG() returns a Mermaid-compatible graph of the registered modules and their DependsOn() edges:

fmt.Println(manager.ExportDAG())
graph TD
    notifications[notifications]
    incidents[incidents]
    incidents --> notifications

Paste the output into any Mermaid renderer (GitHub Markdown, the Mermaid Live Editor, etc.) to visualize dependency ordering during design or debugging.


Quickstart

See examples/quickstart for a minimal, runnable application that registers two modules, resolves a typed service, and runs the full lifecycle.

go run ./examples/quickstart

Application Bootstrap (modulex/app)

Every service entrypoint otherwise hand-writes the same ~30-line skeleton: construct a Manager, register modules, derive a signal-aware context, drive InitModules -> StartModules -> wait -> StopModules, and report the first failing step. modulex/app.Run owns that skeleton:

import "github.com/mediusfy/modulex/app"

func main() {
    logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

    err := app.Run(logger, configLoader, []modulex.Module{
        &notification.Module{},
        &incident.Module{},
    })
    if err != nil {
        logger.Error("application failed", slog.Any("error", err))
        os.Exit(1)
    }
}

Run blocks until os.Interrupt/SIGTERM (or a context passed via app.WithContext, useful in tests) triggers shutdown, then stops the manager within a bounded timeout (app.WithShutdownTimeout, 15s default). app.WithManagerOptions passes extra options through to modulex.NewManager (WithTracer, WithEventBus, WithPanicPolicy, WithTypedConfig, ...); app.WithSetup runs a hook against the constructed Manager before modules are registered and initialized — for wiring that must happen first, such as modulexchi.RegisterRouter. See examples/bootstrap for a complete, runnable example combining Run with WithTypedConfig.

go run ./examples/bootstrap

Deployment Topologies

The same domain interfaces can be wired as a monolith or as separate processes. See examples/deployment:

  • examples/deployment/monolith registers the notification module and a consumer module in the same process. The consumer resolves the local service implementation via typed key.
  • examples/deployment/remote runs the notification service and the consumer as two separate processes. The consumer binary registers a notification.RemoteModule that provides an HTTP client adapter under the same typed key, so the consumer module itself does not change.
# Monolith
go run ./examples/deployment/monolith

# Remote (two processes)
go run ./examples/deployment/remote/notification-server
NOTIFICATION_URL=http://localhost:8080 go run ./examples/deployment/remote/consumer

How Modulex Compares to Alternatives

Approach What it does best Where Modulex differs
Plain constructor injection Simple, type-safe, no dependencies Modulex adds deterministic lifecycle ordering and runtime topology switching for large monorepos.
Wire Compile-time dependency graphs Modulex wires at runtime, so topology can change without re-running code generation.
Fx (Uber) Rich dependency injection and lifecycle hooks Modulex is smaller and exposes an explicit state machine; it does not use reflection for DI.
Dig Runtime container with parameter objects Modulex favors explicit Init/Start/Stop methods and typed service keys over a generic container.

Modulex is a good fit when you need a small, predictable orchestrator that encourages clean boundaries without taking over your entire application.

See docs/planning/comparison-with-alternatives.md for a detailed comparison with plain constructor injection, Wire, Fx, and Dig.


Documentation


License

This project is licensed under the MIT License - see the LICENSE file for details.

Documentation

Overview

Package modulex provides deterministic lifecycle orchestration for modular Go applications.

A Manager coordinates feature modules through an explicit lifecycle (configuring → initializing → initialized → starting → running → stopping → stopped), validates the dependency graph, rolls back partial failures, and supervises background tasks. Modules receive a Registry they can use to register services, access the router, tracer, logger, event bus, config, and start lifecycle-owned tasks.

Typed service registration helpers (Key, Provide, Resolve) are also provided.

EventBus adapter sub-packages such as github.com/mediusfy/modulex/nats, github.com/mediusfy/modulex/rabbitmq, and github.com/mediusfy/modulex/watermill keep framework dependencies out of the core package.

github.com/mediusfy/modulex/app provides an opinionated Run helper that owns the construct-manager/register-modules/signal-context/Init-Start-wait-Stop bootstrap skeleton every service entrypoint otherwise repeats.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrCircularDependency is returned when registered modules contain a circular dependency.
	ErrCircularDependency = errors.New("circular dependency detected")

	// ErrServiceNotFound is returned when a requested service is not registered in the locator.
	ErrServiceNotFound = errors.New("service not found")

	// ErrRegistryLocked is returned when a module attempts to register a service after registry initialization has completed.
	ErrRegistryLocked = errors.New("registry is locked: cannot register services after initialization")

	// ErrModuleNil is returned when a nil module is passed to RegisterModule.
	ErrModuleNil = errors.New("module must not be nil")

	// ErrInvalidModuleName is returned when a module name is empty or whitespace-only.
	ErrInvalidModuleName = errors.New("module name must not be empty")

	// ErrDuplicateModule is returned when RegisterModule is called with a module name that is already registered.
	ErrDuplicateModule = errors.New("module already registered")

	// ErrDuplicateService is returned when RegisterService is called with a service key that is already registered.
	ErrDuplicateService = errors.New("service already registered")

	// ErrDuplicateTask is returned when Go is called with a task name that is already in use.
	ErrDuplicateTask = errors.New("task already exists")

	// ErrInvalidTaskName is returned when Go is called with an empty or whitespace-only task name.
	ErrInvalidTaskName = errors.New("task name must not be empty")

	// ErrDependencyNotFound is returned when a module depends on a module that has not been registered.
	ErrDependencyNotFound = errors.New("module dependency not found")

	// ErrSelfDependency is returned when a module declares itself as a dependency.
	ErrSelfDependency = errors.New("module cannot depend on itself")

	// ErrInvalidDependencyName is returned when a module declares a dependency with an empty or whitespace-only name.
	ErrInvalidDependencyName = errors.New("module dependency name must not be empty")

	// ErrInvalidServiceName is returned when a service is registered with an empty or whitespace-only key.
	ErrInvalidServiceName = errors.New("service name must not be empty")

	// ErrServiceTypeMismatch is returned when a resolved service cannot be type-asserted to the requested type.
	ErrServiceTypeMismatch = errors.New("service type mismatch")

	// ErrInvalidLifecycleState is returned when a lifecycle operation is requested while the manager is in an incompatible state.
	ErrInvalidLifecycleState = errors.New("invalid lifecycle state")

	// ErrNoConfigLoader is returned by GetConfig when no config loader was
	// configured at construction time or via WithConfigLoader.
	ErrNoConfigLoader = errors.New("no config loader configured")

	// ErrInvalidPanicPolicy is returned by NewManager when WithPanicPolicy is
	// given a value outside the defined PanicPolicy enum.
	ErrInvalidPanicPolicy = errors.New("invalid panic policy")

	// ErrInvalidHealthCheckName is returned when a health check name is empty.
	ErrInvalidHealthCheckName = errors.New("health check name must not be empty")

	// ErrInvalidReadinessCheckName is returned when a readiness check name is empty.
	ErrInvalidReadinessCheckName = errors.New("readiness check name must not be empty")

	// ErrHealthCheckNil is returned when RegisterHealthCheck is given a nil
	// check function. A registered nil check would panic if any caller
	// invoked it directly rather than defensively nil-checking first (as
	// the httpx package does); rejecting it at registration means a nil
	// check function can never reach that map in the first place.
	ErrHealthCheckNil = errors.New("health check function must not be nil")

	// ErrReadinessCheckNil is returned when RegisterReadinessCheck is given
	// a nil check function. See ErrHealthCheckNil for why this is rejected
	// at registration rather than left to each caller to guard against.
	ErrReadinessCheckNil = errors.New("readiness check function must not be nil")
)
View Source
var ErrConfigTypeMismatch = errors.New("config target type mismatch")

ErrConfigTypeMismatch is returned by the config loader installed via WithTypedConfig when GetConfig's target is not a pointer to the configured type.

Functions

func Provide

func Provide[T any](reg Registry, key Key[T], svc T) error

Provide registers a typed service implementation in the registry. It is a type-safe wrapper around Registry.RegisterService.

Example
package main

import (
	"fmt"
	"io"
	"log/slog"

	"github.com/mediusfy/modulex"
)

type greetingService struct {
	greeting string
}

func (s greetingService) Greet(name string) string {
	return fmt.Sprintf("%s, %s!", s.greeting, name)
}

func main() {
	manager := newExampleManager()

	key := modulex.NewKey[greetingService]("example.GreetingService")
	if err := modulex.Provide(manager, key, greetingService{greeting: "Hello"}); err != nil {
		panic(err)
	}

	svc, err := modulex.Resolve(manager, key)
	if err != nil {
		panic(err)
	}

	fmt.Println(svc.Greet("Modulex"))
}

func newExampleManager() *modulex.Manager {
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	mgr, err := modulex.NewManager(modulex.WithLogger(logger))
	if err != nil {
		panic(err)
	}
	return mgr
}
Output:
Hello, Modulex!

func Resolve

func Resolve[T any](reg Registry, key Key[T]) (T, error)

Resolve retrieves a typed service from the registry. It returns ErrServiceNotFound if the key is missing, ErrServiceTypeMismatch if the registered value cannot be asserted to T, or ErrInvalidServiceName if the key is empty.

Types

type AckDecision added in v0.6.0

type AckDecision int

AckDecision is the disposition a DurableHandler assigns to a message it was given, replacing the bare "error or nil" signal EventHandler uses. EventHandler's plain error return cannot distinguish "please retry this message" from "give up on this message without retrying" from "this message can never succeed, route it to a dead letter" — a durable consumer with real ack/nack/dead-letter semantics needs to express all three, so DurableHandler returns AckDecision instead of error.

const (
	// Ack acknowledges the message as successfully processed. A conforming
	// DurableConsumer will not redeliver it.
	Ack AckDecision = iota

	// Nack indicates processing failed but should be retried. A conforming
	// DurableConsumer redelivers the message, subject to whatever
	// retry/backoff/max-attempts policy it documents.
	Nack

	// DeadLetter indicates processing failed terminally: the message must
	// not be redelivered again. A conforming DurableConsumer routes it to
	// whatever dead-letter mechanism it documents (a separate subject or
	// stream, a broker-native DLQ, or simply marking it permanently failed)
	// instead of retrying it.
	DeadLetter
)

func (AckDecision) String added in v0.6.0

func (d AckDecision) String() string

String returns a lower_snake_case name for d, or "unknown" for an out-of-range value.

type ConfigProvider

type ConfigProvider interface {
	// GetConfig unmarshals configuration values into the target structure.
	// This abstract config retrieval prevents features from directly reading global configurations.
	GetConfig(target interface{}) (err error)
}

ConfigProvider is the capability to unmarshal configuration values into a target structure.

type Diagnostics added in v0.6.0

type Diagnostics struct {
	State           string           `json:"state"`
	Modules         ModuleContract   `json:"modules"`
	Services        []string         `json:"services"`
	Tasks           []TaskDiagnostic `json:"tasks"`
	HealthChecks    []string         `json:"health_checks"`
	ReadinessChecks []string         `json:"readiness_checks"`
	Timings         LifecycleTimings `json:"timings,omitempty"`
}

Diagnostics is a point-in-time, machine-readable snapshot of the manager's internal state: lifecycle state, the module dependency graph, registered service names, supervised task status, health/readiness check names, and lifecycle timings.

Diagnostics is safe to log, export, or attach to a support ticket: it deliberately never includes registered service values (only their sorted names), health/readiness check function bodies (only their sorted names), task closures, or any other internal implementation detail such as mutexes or the internal task cancellation context. Only names, booleans, error strings, and durations are exposed.

type DurableConsumer added in v0.6.0

type DurableConsumer interface {
	// SubscribeDurable registers handler as the durable consumer for topic
	// under the identity and options given. opts must include
	// WithConsumerName; an adapter rejects a call without one.
	SubscribeDurable(ctx context.Context, topic string, handler DurableHandler, opts ...DurableSubscribeOption) (err error)
}

DurableConsumer is the capability of consuming a topic with the stronger guarantees Subscriber deliberately does not promise: explicit acknowledgement, redelivery, replay, consumer identity, and dead-letter routing. Not every EventBus adapter implements DurableConsumer — callers that need these guarantees should type-assert for it rather than assuming any EventBus or Subscriber provides them.

DurableConsumer deliberately does not embed Subscriber. A durable handler needs to express more than "error or nil" (see AckDecision), so it uses the distinct DurableHandler signature rather than EventHandler; the two therefore cannot share one Subscribe method identity. An adapter is free to implement both Subscriber and DurableConsumer (e.g. by also embedding a plain EventBus), but the capabilities are independent and are checked independently via type assertion.

DurableConsumer documents the six semantics named by MOD-54, three as the SubscribeDurable method contract and three as documented properties a correct implementation must uphold:

  • Acknowledgement (method contract): DurableHandler returns an AckDecision — Ack, Nack, or DeadLetter — for every message it is given. The adapter is responsible for translating that decision into its broker's native ack mechanism.
  • Consumer identity (method contract, via DurableSubscribeOptions): ConsumerName names a durable consumer/consumer-group. Reusing the same ConsumerName resumes from its last acknowledged position rather than starting over, including across process restarts. Multiple concurrent subscriptions sharing one ConsumerName load-balance messages across them (a competing-consumers group) rather than each receiving every message.
  • Replay (method contract, via DurableSubscribeOptions): ReplayPolicy selects where a brand-new ConsumerName starts reading from — from the oldest retained message (ReplayAll) or only new ones (ReplayNew).
  • Retry (documented property): on Nack, the adapter redelivers the message subject to its own documented retry/backoff/max-attempts policy. This is adapter-defined rather than a method because retry configuration (backoff curves, max attempts, poison-message thresholds) varies too much across brokers to usefully standardize at this interface's level; see the implementing adapter's doc comment for its specific policy.
  • Ordering (documented property): within a single SubscribeDurable call (one ConsumerName, one subscription), an implementation must process and resolve (ack/nack/dead-letter) messages in the order the broker delivered them before fetching the next one, so relative order is preserved for that subscription. Ordering across multiple concurrent subscriptions sharing one ConsumerName (a competing-consumers group) is NOT guaranteed, since the broker may deliver to whichever subscription is next available.
  • Dead-letter (documented property): on DeadLetter, the adapter must never redeliver the message again through the normal retry path. How it is routed instead (a separate subject/stream, a broker-native DLQ, or simply discarded after being marked permanently failed) is adapter-defined; see the implementing adapter's doc comment.

type DurableHandler added in v0.6.0

type DurableHandler func(ctx context.Context, msg DurableMessage) (decision AckDecision)

DurableHandler processes one message delivered by a DurableConsumer and returns the AckDecision it should receive. See AckDecision for why this differs from EventHandler's bare error return.

type DurableMessage added in v0.6.0

type DurableMessage struct {
	// Payload is the message body.
	Payload []byte

	// Redelivered reports whether this delivery attempt is a retry of a
	// message previously delivered (to this consumer or an earlier attempt
	// by the same durable consumer identity).
	Redelivered bool

	// DeliveryCount is the number of times this message has been delivered
	// to this durable consumer identity, starting at 1 for the first
	// delivery. An adapter that cannot track delivery count reports 0.
	DeliveryCount int
}

DurableMessage carries the payload and delivery metadata for one message given to a DurableHandler.

type DurableSubscribeOption added in v0.6.0

type DurableSubscribeOption func(*DurableSubscribeOptions)

DurableSubscribeOption configures a DurableSubscribeOptions value.

func WithConsumerName added in v0.6.0

func WithConsumerName(name string) DurableSubscribeOption

WithConsumerName sets the durable consumer/consumer-group identity for a DurableConsumer subscription. See the "Consumer identity" semantic on DurableConsumer.

func WithReplayPolicy added in v0.6.0

func WithReplayPolicy(p ReplayPolicy) DurableSubscribeOption

WithReplayPolicy sets where a brand-new consumer identity starts reading from. See ReplayPolicy and the "Replay" semantic on DurableConsumer.

type DurableSubscribeOptions added in v0.6.0

type DurableSubscribeOptions struct {
	// ConsumerName identifies the durable consumer/consumer-group identity
	// (see the "Consumer identity" semantic on DurableConsumer). Required;
	// an adapter rejects an empty ConsumerName.
	ConsumerName string

	// Replay selects where a brand-new ConsumerName starts reading from.
	// See ReplayPolicy.
	Replay ReplayPolicy
}

DurableSubscribeOptions configures a DurableConsumer subscription. Use the With* option functions below to set fields; the zero value is not a valid configuration (ConsumerName is required).

type EventBus

type EventBus interface {
	// Publish sends a payload to a specific topic/subject.
	Publish(ctx context.Context, topic string, payload []byte) (err error)

	// Subscribe listens to a topic and invokes the handler when an event is
	// received. The adapter determines how handler errors affect
	// acknowledgment, retry, and redelivery; see the adapter documentation for
	// its policy.
	Subscribe(ctx context.Context, topic string, handler EventHandler) (err error)

	// Close gracefully disconnects from the broker, shutting down active subscribers.
	Close(ctx context.Context) (err error)
}

EventBus abstracts the underlying message broker (NATS, Kafka, RabbitMQ, etc.).

type EventBusProvider

type EventBusProvider interface {
	EventBus() (eb EventBus)
}

EventBusProvider is the capability to access the pluggable event bus.

type EventHandler

type EventHandler func(ctx context.Context, payload []byte) error

EventHandler processes an event delivered by an EventBus. The meaning of a returned error depends on the concrete adapter: some adapters ack/nack based on the error, others only log it. Callers coding against the EventBus abstraction should not assume specific retry or redelivery semantics.

type HealthCheckProvider added in v0.3.0

type HealthCheckProvider interface {
	HealthChecks() (checks map[string]func(context.Context) error)
}

HealthCheckProvider exposes the registered health (liveness) checks.

type HealthCheckRegisterer added in v0.6.0

type HealthCheckRegisterer interface {
	// RegisterHealthCheck registers a health (liveness) check function under a
	// unique name.
	RegisterHealthCheck(name string, check func(context.Context) error) (err error)
}

HealthCheckRegisterer is the capability to register a module health (liveness) check.

A health check answers "is this process functioning correctly?" A failing health check means the process is broken and should be restarted (e.g. by an orchestrator's liveness probe). Contrast this with ReadinessRegisterer, which answers "should this process currently receive traffic?" — a failing readiness check means the instance should be pulled from load balancing, not restarted.

type Key

type Key[T any] struct {
	// contains filtered or unexported fields
}

Key is a typed service locator key. It couples a string identifier with a compile-time type so that Provide and Resolve can be type-safe.

func NewKey

func NewKey[T any](name string) Key[T]

NewKey creates a typed service key. Leading and trailing whitespace is trimmed.

func (Key[T]) Name

func (k Key[T]) Name() string

type LifecycleState

type LifecycleState int

LifecycleState represents the current phase of the Manager's lifecycle.

const (
	StateConfiguring LifecycleState = iota
	StateInitializing
	StateInitialized
	StateStarting
	StateRunning
	StateStopping
	StateStopped
)

func (LifecycleState) String

func (s LifecycleState) String() string

type LifecycleTimings added in v0.6.0

type LifecycleTimings struct {
	InitModules  time.Duration  `json:"init_modules_ns,omitempty"`
	StartModules time.Duration  `json:"start_modules_ns,omitempty"`
	ModuleInit   []ModuleTiming `json:"module_init,omitempty"`
	ModuleStart  []ModuleTiming `json:"module_start,omitempty"`
}

LifecycleTimings captures how long the InitModules and StartModules phases took, in total and (when available) per module. Durations are time.Duration values, which marshal to JSON as an integer count of nanoseconds. All fields are zero-valued/omitted until the corresponding phase has run at least once; they are never fabricated.

type LoggerProvider

type LoggerProvider interface {
	Logger() (logger *slog.Logger)
}

LoggerProvider is the capability to access the system logger.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager implements the Registry interface and orchestrates the module lifecycles.

func NewManager

func NewManager(opts ...ManagerOption) (*Manager, error)

NewManager creates a new instance of Manager.

eb may be nil, in which case a no-op EventBus is used. logger may be nil, in which case slog.Default() is used. NewManager returns ErrInvalidPanicPolicy if WithPanicPolicy is given a value outside the defined PanicPolicy enum.

func (*Manager) Diagnostics added in v0.6.0

func (m *Manager) Diagnostics() Diagnostics

Diagnostics returns a snapshot of the manager's current state. See the Diagnostics type doc for the safety guarantees this method provides.

func (*Manager) EventBus

func (m *Manager) EventBus() EventBus

EventBus implements Registry.

func (*Manager) ExportDAG added in v0.3.0

func (m *Manager) ExportDAG() string

ExportDAG returns a Mermaid-compatible DAG visualization of the registered modules.

func (*Manager) GetConfig

func (m *Manager) GetConfig(target interface{}) error

GetConfig implements Registry.

func (*Manager) Go

func (m *Manager) Go(ctx context.Context, taskName string, fn func(ctx context.Context) error) (*TaskHandle, error)

Go implements Registry. It spawns a supervised background routine while preserving trace ancestry when a Tracer is configured, and returns a handle for awaiting completion.

func (*Manager) HealthChecks added in v0.3.0

func (m *Manager) HealthChecks() map[string]func(context.Context) error

HealthChecks returns all registered health (liveness) checks.

func (*Manager) InitModules

func (m *Manager) InitModules(ctx context.Context) error

InitModules sorts the modules topologically based on dependencies, then initializes them sequentially in dependency order inside trace spans.

If a module fails to initialize, all previously initialized modules are stopped in reverse order and the manager moves to the stopped state.

Example
package main

import (
	"context"
	"fmt"
	"io"
	"log/slog"

	"github.com/mediusfy/modulex"
)

func main() {
	manager := newExampleManager()

	mod := &exampleModule{}
	if err := manager.RegisterModule(mod); err != nil {
		panic(err)
	}

	if err := manager.InitModules(context.Background()); err != nil {
		panic(err)
	}

	fmt.Println("initialized")
}

type exampleModule struct{}

func (m *exampleModule) Name() string                                 { return "example" }
func (m *exampleModule) DependsOn() []string                          { return nil }
func (m *exampleModule) Init(context.Context, modulex.Registry) error { return nil }

func newExampleManager() *modulex.Manager {
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	mgr, err := modulex.NewManager(modulex.WithLogger(logger))
	if err != nil {
		panic(err)
	}
	return mgr
}
Output:
initialized

func (*Manager) Logger

func (m *Manager) Logger() *slog.Logger

Logger implements Registry.

func (*Manager) ModuleContract added in v0.6.0

func (m *Manager) ModuleContract() ModuleContract

ModuleContract returns a deterministic, JSON-marshalable description of the registered modules and their declared dependencies. Modules are sorted alphabetically by name, and each module's DependsOn list is sorted alphabetically as well, so that two calls against the same manager state produce byte-identical JSON.

func (*Manager) ReadinessChecks added in v0.4.1

func (m *Manager) ReadinessChecks() map[string]func(context.Context) error

ReadinessChecks returns all registered readiness checks.

func (*Manager) RegisterHealthCheck added in v0.3.0

func (m *Manager) RegisterHealthCheck(name string, check func(context.Context) error) error

RegisterHealthCheck registers a health (liveness) check function under a unique name. check must not be nil.

func (*Manager) RegisterModule

func (m *Manager) RegisterModule(mod Module) error

RegisterModule registers a feature module in the manager. Modules should be registered before calling InitModules.

Registration is rejected if the module is nil, its name is empty, another module with the same name is already registered, or initialization has already started. Independent modules preserve their registration order as the deterministic tie-break during topological sorting.

func (*Manager) RegisterReadinessCheck added in v0.4.1

func (m *Manager) RegisterReadinessCheck(name string, check func(context.Context) error) error

RegisterReadinessCheck registers a readiness check function under a unique name. check must not be nil.

func (*Manager) RegisterService

func (m *Manager) RegisterService(name string, svc interface{}) error

RegisterService implements Registry. It registers a service instance to the service locator. Registration is only permitted before InitModules has completed.

func (*Manager) ResolveService

func (m *Manager) ResolveService(name string) (interface{}, error)

ResolveService implements Registry. It retrieves a registered service by its identifier.

func (*Manager) StartModules

func (m *Manager) StartModules(ctx context.Context) error

StartModules starts all registered modules in topological dependency order inside trace spans.

If a module fails to start, all previously started modules are stopped in reverse order and the manager moves to the stopped state.

Example
package main

import (
	"context"
	"fmt"
	"io"
	"log/slog"

	"github.com/mediusfy/modulex"
)

func main() {
	manager := newExampleManager()

	mod := &exampleModule{}
	if err := manager.RegisterModule(mod); err != nil {
		panic(err)
	}
	if err := manager.InitModules(context.Background()); err != nil {
		panic(err)
	}
	if err := manager.StartModules(context.Background()); err != nil {
		panic(err)
	}

	fmt.Println("started")
}

type exampleModule struct{}

func (m *exampleModule) Name() string                                 { return "example" }
func (m *exampleModule) DependsOn() []string                          { return nil }
func (m *exampleModule) Init(context.Context, modulex.Registry) error { return nil }

func newExampleManager() *modulex.Manager {
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	mgr, err := modulex.NewManager(modulex.WithLogger(logger))
	if err != nil {
		panic(err)
	}
	return mgr
}
Output:
started

func (*Manager) State

func (m *Manager) State() LifecycleState

State returns the current lifecycle state of the manager.

func (*Manager) StopModules

func (m *Manager) StopModules(ctx context.Context) error

StopModules cancels supervised tasks, stops registered modules in reverse topological order when they were running, and closes the EventBus.

StopModules is idempotent: calling it multiple times returns nil without re-executing shutdown logic. It is context-aware and joins all shutdown errors so that no failure is silently dropped.

StopModules returns ErrInvalidLifecycleState if called while InitModules or StartModules is concurrently in progress on another goroutine (i.e. the manager is in StateInitializing or StateStarting). Those phases iterate modules without holding the manager's state lock, so racing a concurrent StopModules against them cannot be done safely: it would let StopModules tear down tasks and the event bus while a module's Init/Start is still running, and could have the in-progress phase overwrite StateStopped with StateInitialized/StateRunning once it completes, silently breaking the idempotency guarantee above. To cancel an in-flight InitModules or StartModules call, cancel the context passed to it instead; call StopModules once it returns.

Example
package main

import (
	"context"
	"fmt"
	"io"
	"log/slog"

	"github.com/mediusfy/modulex"
)

func main() {
	manager := newExampleManager()

	mod := &exampleModule{}
	if err := manager.RegisterModule(mod); err != nil {
		panic(err)
	}
	if err := manager.InitModules(context.Background()); err != nil {
		panic(err)
	}
	if err := manager.StartModules(context.Background()); err != nil {
		panic(err)
	}
	if err := manager.StopModules(context.Background()); err != nil {
		panic(err)
	}

	fmt.Println("stopped")
}

type exampleModule struct{}

func (m *exampleModule) Name() string                                 { return "example" }
func (m *exampleModule) DependsOn() []string                          { return nil }
func (m *exampleModule) Init(context.Context, modulex.Registry) error { return nil }

func newExampleManager() *modulex.Manager {
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	mgr, err := modulex.NewManager(modulex.WithLogger(logger))
	if err != nil {
		panic(err)
	}
	return mgr
}
Output:
stopped

type ManagerOption

type ManagerOption func(*Manager)

ManagerOption configures a Manager during construction.

func WithConfigLoader

func WithConfigLoader(loader func(target interface{}) error) ManagerOption

WithConfigLoader configures the config loader after construction. This is useful when using the options pattern and keeps the positional configLoader argument nil-safe.

func WithEventBus added in v0.3.0

func WithEventBus(eb EventBus) ManagerOption

WithEventBus configures the pluggable event bus. If nil, a no-op event bus is used so event publishing remains optional.

func WithLogger added in v0.3.0

func WithLogger(logger *slog.Logger) ManagerOption

WithLogger configures the logger used by the manager and its modules. If nil, slog.Default() is used.

func WithPanicPolicy

func WithPanicPolicy(policy PanicPolicy) ManagerOption

WithPanicPolicy sets the panic policy for supervised background tasks.

func WithTracer

func WithTracer(tracer Tracer) ManagerOption

WithTracer injects a Tracer implementation into the Manager. If nil, a no-op tracer is used so tracing remains optional for core consumers.

func WithTypedConfig added in v0.5.1

func WithTypedConfig[T any](cfg T) ManagerOption

Example:

modulex.NewManager(modulex.WithTypedConfig(cfg))

type Module

type Module interface {
	// Name returns the unique, kebab-case name of the feature module.
	Name() (name string)

	// DependsOn returns the names of other modules that this module depends on.
	// The Manager uses this list to sort the modules topologically before initialization.
	DependsOn() (deps []string)

	// Init initializes the module with the registry.
	// This is where modules register their services, register routes, and resolve dependencies.
	Init(ctx context.Context, reg Registry) (err error)
}

Module represents a self-contained feature module that complies with Hexagonal Architecture. It acts as the composition root of the feature, instantiating services and adapters, and wiring them through the central registry.

Start and Stop are optional lifecycle capabilities. A module that needs to run background work during startup implements Starter; a module that owns resources that must be released implements Stopper. The manager skips modules that do not implement these interfaces, so simple modules do not require no-op methods.

type ModuleContract added in v0.6.0

type ModuleContract struct {
	Modules []ModuleContractEntry `json:"modules"`
}

ModuleContract is a machine-readable description of the registered modules and their declared dependency edges, independent of the Mermaid-oriented ExportDAG. It is intended for diffing between two versions of a running application's module topology (e.g. in CI, or between deployments), so its JSON encoding is fully deterministic: two calls to Manager.ModuleContract against the same manager state always marshal to byte-identical JSON.

type ModuleContractEntry added in v0.6.0

type ModuleContractEntry struct {
	Name      string   `json:"name"`
	DependsOn []string `json:"depends_on"`
}

ModuleContractEntry describes a single registered module and the sorted names of the modules it declares as dependencies.

type ModuleTiming added in v0.6.0

type ModuleTiming struct {
	Name       string        `json:"name"`
	DurationNs time.Duration `json:"duration_ns"`
}

ModuleTiming records how long a single module took during an InitModules or StartModules phase.

type PanicPolicy

type PanicPolicy int

PanicPolicy controls how the manager reacts to a panic in a supervised task.

const (
	// PanicPolicyLog recovers from the panic, records it as a task error, and logs it.
	PanicPolicyLog PanicPolicy = iota

	// PanicPolicyPropagate allows the panic to crash the application.
	PanicPolicyPropagate
)

type Publisher added in v0.6.0

type Publisher interface {
	Publish(ctx context.Context, topic string, payload []byte) (err error)
}

Publisher is the narrow capability of publishing a payload to a topic. It makes no promise about delivery durability beyond what the concrete adapter documents in its own doc comments: a Publisher may be backed by an at-most-once fire-and-forget transport (core NATS) or an acknowledged, durable one (JetStream) — the interface itself does not distinguish them.

Every EventBus implementation already satisfies Publisher for free, since Go interfaces are structural and EventBus.Publish has this exact signature. Publisher exists so code that only needs to publish can depend on the narrower capability instead of the full EventBus.

type ReadinessProvider added in v0.4.1

type ReadinessProvider interface {
	ReadinessChecks() (checks map[string]func(context.Context) error)
}

ReadinessProvider exposes the registered readiness checks.

type ReadinessRegisterer added in v0.6.0

type ReadinessRegisterer interface {
	// RegisterReadinessCheck registers a readiness check function under a
	// unique name.
	RegisterReadinessCheck(name string, check func(context.Context) error) (err error)
}

ReadinessRegisterer is the capability to register a module readiness check.

A readiness check answers "should this process currently receive traffic?" A failing readiness check means the instance is temporarily unable to serve requests (e.g. its database pool isn't warm yet, a dependency is unreachable, a cache hasn't primed) and should be pulled from the load balancer — the process itself is otherwise healthy and should not be restarted. Contrast this with HealthCheckRegisterer, whose checks answer "is this process functioning correctly?" and whose failures indicate the process should be restarted.

The consumer defines what "ready" means for their service by registering named check functions; Modulex only abstracts registration, aggregation, and HTTP exposure (see modulex/httpx).

type Registry

Registry manages the collection of features and cross-cutting platform components. It acts as a service locator and event bus hub, preventing features from importing each other directly or coupling to specific messaging architectures.

Registry is a composite of smaller capability interfaces. Modules that only need a subset of these capabilities can depend on the narrower interfaces (ServiceRegistry, EventBusProvider, ConfigProvider, LoggerProvider, or TaskSpawner) instead of the full Registry.

type ReplayPolicy added in v0.6.0

type ReplayPolicy int

ReplayPolicy selects where a brand-new DurableConsumer subscription starts reading from a topic. It only affects the first time a given ConsumerName (see DurableSubscribeOptions) is used; a durable consumer identity with prior acknowledged progress resumes from that position instead of replaying, regardless of ReplayPolicy.

const (
	// ReplayAll starts from the oldest message the adapter has retained.
	ReplayAll ReplayPolicy = iota

	// ReplayNew delivers only messages published after the subscription is
	// established; nothing previously retained is replayed.
	ReplayNew
)

func (ReplayPolicy) String added in v0.6.0

func (p ReplayPolicy) String() string

String returns a lower_snake_case name for p, or "unknown" for an out-of-range value.

type ServiceRegisterer added in v0.6.0

type ServiceRegisterer interface {
	// RegisterService registers a service implementation under a unique key (e.g. "incidents.Service").
	// Returns ErrRegistryLocked if the registry has already finished initialization.
	RegisterService(name string, svc interface{}) (err error)
}

ServiceRegisterer is the capability to register a service instance under a unique name. Registrations are only permitted before the registry has finished initialization.

type ServiceRegistry

type ServiceRegistry interface {
	ServiceRegisterer
	ServiceResolver
}

ServiceRegistry combines service registration and resolution.

type ServiceResolver

type ServiceResolver interface {
	// ResolveService resolves a registered service implementation by name.
	// If the service is not found, it returns ErrServiceNotFound.
	ResolveService(name string) (svc interface{}, err error)
}

ServiceResolver is the capability to resolve a previously registered service by name.

type Span

type Span interface {
	// End completes the span.
	End()
	// RecordError attaches an error to the span.
	RecordError(err error)
	// SetAttributes attaches key-value pairs to the span.
	SetAttributes(attrs map[string]any)
}

Span is a minimal lifecycle span created by a Tracer.

type SpanContext

type SpanContext interface {
	IsValid() bool
	TraceID() string
	SpanID() string
}

SpanContext is an opaque span context used for trace propagation.

type Starter added in v0.6.0

type Starter interface {
	Start(ctx context.Context) (err error)
}

Starter is an optional lifecycle capability for modules that begin background work or listeners during startup. The manager calls Start after all modules have been initialized successfully.

type Stopper added in v0.6.0

type Stopper interface {
	Stop(ctx context.Context) (err error)
}

Stopper is an optional lifecycle capability for modules that release resources during shutdown. The manager calls Stop in reverse topological order when stopping the application or rolling back a failed init/start.

type Subscriber added in v0.6.0

type Subscriber interface {
	Subscribe(ctx context.Context, topic string, handler EventHandler) (err error)
}

Subscriber is the narrow capability of registering a fire-and-forget handler for a topic. Subscriber makes NO durability guarantee: whether a delivered message is retried, redelivered, or silently dropped on handler error is entirely adapter-defined (see each adapter's Subscribe doc comment for its specific policy). A caller that needs at-least-once delivery, explicit acknowledgement, replay, or dead-letter semantics must use DurableConsumer instead — Subscriber alone never implies any of that.

Every EventBus implementation already satisfies Subscriber for free, since Go interfaces are structural and EventBus.Subscribe has this exact signature.

type TaskDiagnostic added in v0.6.0

type TaskDiagnostic struct {
	Name string `json:"name"`
	Done bool   `json:"done"`
	Err  string `json:"error,omitempty"`
}

TaskDiagnostic is a safe, name-only snapshot of a supervised task's completion status. It surfaces exactly the information TaskHandle.Wait already exposes to callers today (a name, whether the task has finished, and its final error, if any) so including it in Diagnostics does not leak anything new.

type TaskHandle

type TaskHandle struct {
	// contains filtered or unexported fields
}

TaskHandle identifies a supervised background task started by Manager.Go. Callers can use it to wait for completion or inspect the final error.

func (*TaskHandle) Done added in v0.6.0

func (h *TaskHandle) Done() bool

Done reports whether the task has finished, without blocking. It is safe to call concurrently with Wait and from diagnostics code that must not block on a long-running task.

func (*TaskHandle) Err added in v0.6.0

func (h *TaskHandle) Err() error

Err returns the task's final error. It only reflects a meaningful value once Done reports true; a task that is still running always reports a nil error here, regardless of what it eventually returns.

func (*TaskHandle) Name

func (h *TaskHandle) Name() string

func (*TaskHandle) Wait

func (h *TaskHandle) Wait() error

Wait blocks until the task finishes and returns its final error.

type TaskSpawner

type TaskSpawner interface {
	// Go spawns a supervised goroutine to execute background work. It creates a
	// child span when a Tracer is configured, recovers from panics according to
	// the manager's panic policy, and returns a handle that can be used to wait
	// for the task to finish. Tasks are cancelled and awaited during manager
	// shutdown. Returns ErrRegistryLocked if the manager is stopping or stopped,
	// or ErrDuplicateTask if a task with the same name already exists.
	Go(ctx context.Context, taskName string, fn func(ctx context.Context) error) (handle *TaskHandle, err error)
}

TaskSpawner is the capability to start a supervised background task. Tasks receive a lifecycle-owned context and are cancelled during shutdown.

type Tracer

type Tracer interface {
	// Start creates a new span and returns a context that carries it.
	Start(ctx context.Context, spanName string, attrs map[string]any) (context.Context, Span)
	// SpanContextFromContext extracts the current span context from ctx.
	SpanContextFromContext(ctx context.Context) SpanContext
	// ContextWithSpanContext returns a new context carrying the provided span
	// context. It is used to propagate trace ancestry to manager-owned task
	// goroutines that run on a different base context.
	ContextWithSpanContext(ctx context.Context, sc SpanContext) context.Context
}

Tracer abstracts span creation so the core package does not depend on a concrete OpenTelemetry implementation. A nil Tracer defaults to a no-op implementation.

Directories

Path Synopsis
Package agentdocs renders a contract.Contract into provider-specific agent instruction documents, per ADR-0032 ("Agent-First Development Experience", docs/adr/adr-0032-agent-first-development-experience.md), P1: "Generate portable agent instruction files and repository templates" (Jira MOD-67).
Package agentdocs renders a contract.Contract into provider-specific agent instruction documents, per ADR-0032 ("Agent-First Development Experience", docs/adr/adr-0032-agent-first-development-experience.md), P1: "Generate portable agent instruction files and repository templates" (Jira MOD-67).
Package approval implements a live, in-memory approval broker for elevated agent actions (network access, external mutations, push/release, deletion, infrastructure changes, database migrations, and Jira/PR operations), per ADR-0032 ("Agent-First Development Experience"), P2: "Add an approval broker for elevated agent tools" (Jira MOD-69).
Package approval implements a live, in-memory approval broker for elevated agent actions (network access, external mutations, push/release, deletion, infrastructure changes, database migrations, and Jira/PR operations), per ADR-0032 ("Agent-First Development Experience"), P2: "Add an approval broker for elevated agent tools" (Jira MOD-69).
Package contract defines a versioned, YAML-marshalable schema for a repository's agent contract (`modulex.agent.yaml`), per ADR-0032 ("Agent-First Development Experience"), P0: "Define and validate the Modulex agent repository contract" (Jira MOD-62).
Package contract defines a versioned, YAML-marshalable schema for a repository's agent contract (`modulex.agent.yaml`), per ADR-0032 ("Agent-First Development Experience"), P0: "Define and validate the Modulex agent repository contract" (Jira MOD-62).
Package discovery scans a repository directory and reports what an AI coding agent needs to select a project and begin useful work, per ADR-0032 ("Agent-First Development Experience"), P0: "Add Modulex agent project discovery and command classification" (Jira MOD-64), step 1 of the ADR's "Standard agent workflow": `modulex agent discover` identifies the repository root, projects, modules, composition roots, instruction files, Make targets, CI workflows, and available indexes.
Package discovery scans a repository directory and reports what an AI coding agent needs to select a project and begin useful work, per ADR-0032 ("Agent-First Development Experience"), P0: "Add Modulex agent project discovery and command classification" (Jira MOD-64), step 1 of the ADR's "Standard agent workflow": `modulex agent discover` identifies the repository root, projects, modules, composition roots, instruction files, Make targets, CI workflows, and available indexes.
examples
bootstrap command
Package main demonstrates modulex/app's Run helper together with modulex.WithTypedConfig.
Package main demonstrates modulex/app's Run helper together with modulex.WithTypedConfig.
deployment/consumer
Package consumer is a dependent feature module that consumes the notification service.
Package consumer is a dependent feature module that consumes the notification service.
deployment/monolith command
Package main demonstrates a monolithic Modulex deployment: every feature module is registered in-process and dependencies are wired directly to local implementations.
Package main demonstrates a monolithic Modulex deployment: every feature module is registered in-process and dependencies are wired directly to local implementations.
deployment/notification
Package notification is the composition root for the notification feature.
Package notification is the composition root for the notification feature.
deployment/notification/adapters
Package adapters provides infrastructure adapters for the notification feature.
Package adapters provides infrastructure adapters for the notification feature.
deployment/notification/ports
Package ports defines the inbound and outbound contracts for the notification feature.
Package ports defines the inbound and outbound contracts for the notification feature.
deployment/notification/service
Package service contains the core business logic for the notification feature.
Package service contains the core business logic for the notification feature.
deployment/remote/consumer command
Package main runs the consumer process in a remote deployment.
Package main runs the consumer process in a remote deployment.
deployment/remote/grpc-consumer command
Package main runs the consumer process against a remote gRPC notification service.
Package main runs the consumer process against a remote gRPC notification service.
deployment/remote/notification-grpc-server command
Package main runs the notification service as a standalone gRPC process.
Package main runs the notification service as a standalone gRPC process.
deployment/remote/notification-server command
Package main runs the notification service as a standalone process.
Package main runs the notification service as a standalone process.
hexagonal command
quickstart command
Package main is a minimal runnable example of Modulex lifecycle orchestration.
Package main is a minimal runnable example of Modulex lifecycle orchestration.
scaffolded-sample
Package scaffoldedsample was generated by tools/scaffold.
Package scaffoldedsample was generated by tools/scaffold.
Package grpc provides an optional gRPC topology adapter for Modulex: a Modulex-managed server lifecycle, OpenTelemetry context propagation interceptors, a consistent domain-error-to-status mapping layer, and a health integration that reports a modulex.Manager's real registered health/readiness checks over the standard gRPC health-checking protocol.
Package grpc provides an optional gRPC topology adapter for Modulex: a Modulex-managed server lifecycle, OpenTelemetry context propagation interceptors, a consistent domain-error-to-status mapping layer, and a health integration that reports a modulex.Manager's real registered health/readiness checks over the standard gRPC health-checking protocol.
internal
eventbustest
Package eventbustest provides shared test helpers for modulex.EventBus adapter implementations.
Package eventbustest provides shared test helpers for modulex.EventBus adapter implementations.
Package modtest provides reusable, composable test helpers that verify a modulex.Module (or a small group of them) against Modulex's lifecycle contract: Init/Start ordering, rollback on failure, cancellation and deadline handling, health/readiness registration, and resource ownership.
Package modtest provides reusable, composable test helpers that verify a modulex.Module (or a small group of them) against Modulex's lifecycle contract: Init/Start ordering, rollback on failure, cancellation and deadline handling, health/readiness registration, and resource ownership.
Package nats provides a Modulex EventBus adapter backed by NATS.
Package nats provides a Modulex EventBus adapter backed by NATS.
Package patchapply implements atomic, content-based file mutation with rollback journaling for a single target directory, per ADR-0032 ("Agent-First Development Experience"), P2: "Add atomic patch application and rollback journaling" (Jira MOD-70).
Package patchapply implements atomic, content-based file mutation with rollback journaling for a single target directory, per ADR-0032 ("Agent-First Development Experience"), P2: "Add atomic patch application and rollback journaling" (Jira MOD-70).
Package provenance defines a versioned, JSON-marshalable schema for recording what an AI coding agent did to a repository and why, per ADR-0032 ("Agent-First Development Experience"), P1: "Add provenance and handoff JSON".
Package provenance defines a versioned, JSON-marshalable schema for recording what an AI coding agent did to a repository and why, per ADR-0032 ("Agent-First Development Experience"), P1: "Add provenance and handoff JSON".
Package rabbitmq provides a Modulex EventBus adapter backed by RabbitMQ.
Package rabbitmq provides a Modulex EventBus adapter backed by RabbitMQ.
Package semindex diagnoses whether a semantic code index (CodeGraph, TokenSave, or any similar tool that builds an offline index of a repository) actually belongs to the git worktree an agent is currently working in, per ADR-0032 ("Agent-First Development Experience"), P2: "Add CodeGraph/TokenSave index-root validation and diagnostics" (Jira MOD-71).
Package semindex diagnoses whether a semantic code index (CodeGraph, TokenSave, or any similar tool that builds an offline index of a repository) actually belongs to the git worktree an agent is currently working in, per ADR-0032 ("Agent-First Development Experience"), P2: "Add CodeGraph/TokenSave index-root validation and diagnostics" (Jira MOD-71).
tools
modboundary module
Package verify maps a set of changed repository paths to focused verification checks, and pairs them with the repository's always-required full gates, per ADR-0032 ("Agent-First Development Experience"), P0: "Add focused agent verification with explicit skipped statuses" (Jira MOD-63), step 5 of the ADR's "Standard agent workflow":
Package verify maps a set of changed repository paths to focused verification checks, and pairs them with the repository's always-required full gates, per ADR-0032 ("Agent-First Development Experience"), P0: "Add focused agent verification with explicit skipped statuses" (Jira MOD-63), step 5 of the ADR's "Standard agent workflow":

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL