Skip to content

Repository files navigation

GOscade

Tests Go Reference Go Report Card codecov License: MIT

GOscade is a library for managing the lifecycle and dependencies of concurrent components in Go.

Installation

go get github.com/ognick/goscade/v2

Component Interface

To be managed by GOscade, a type must implement the Component interface:

type Component interface {
    // Run starts the component.
    // It must call readinessProbe when the component is ready to serve.
    // It should block until ctx is canceled or a fatal error occurs.
    Run(ctx context.Context, readinessProbe func(cause error)) error
}

Usage

Basic Example

package main

import (
    "context"
    "log"
    "time"

    "github.com/ognick/goscade/v2"
)

type Worker struct {
    Name string
}

func (w *Worker) Run(ctx context.Context, readinessProbe func(error)) error {
    log.Printf("Worker %s starting...", w.Name)
    
    // Simulate startup initialization
    time.Sleep(100 * time.Millisecond)
    readinessProbe(nil) // Signal readiness
    
    log.Printf("Worker %s ready", w.Name)

    <-ctx.Done() // Wait for shutdown signal
    
    log.Printf("Worker %s stopped", w.Name)
    return nil
}

// Simple logger adapter
type myLogger struct{}
func (l myLogger) Infof(f string, a ...interface{}) { log.Printf(f, a...) }
func (l myLogger) Errorf(f string, a ...interface{}) { log.Printf("ERROR: "+f, a...) }

func main() {
    // 1. Create Lifecycle manager
    lc := goscade.NewLifecycle(myLogger{}, goscade.WithShutdownHook())

    // 2. Register components
    goscade.Register(lc, &Worker{Name: "Core"})
    
    // 3. Run
    // Blocks until SIGINT/SIGTERM or error
    err := goscade.Run(context.Background(), lc, func() {
        log.Println("System is fully up and running!")
    })
    
    if err != nil {
        log.Fatal(err)
    }
}

Dependency Injection

GOscade automatically detects dependencies between registered components if they are struct pointers or interfaces.

type Database struct { /* ... */ }

// Service depends on Database via interface
type Service struct {
    DB interface{} // or specific interface like Storer
}

func main() {
    lc := goscade.NewLifecycle(log)

    db := &Database{}
    service := &Service{DB: db}

    // Order of registration doesn't matter
    goscade.Register(lc, service)
    goscade.Register(lc, db) 
    
    // GOscade detects that Service depends on Database.
    // It ensures Database is ready before Service starts.
}

To exclude a struct field from reflection-based dependency detection, tag it with goscade:"ignore". The field (and anything reachable through it) will not be traversed.

This is useful for externally-managed dependencies that GOscade does not own. Such third-party clients may spawn their own goroutines, so letting GOscade treat them as part of the managed lifecycle can lead to race conditions. Tagging them as ignored keeps them out of the dependency graph:

type Service struct {
    DB         *Database
    PaymentAPI *stripe.Client `goscade:"ignore"` // externally-managed, may run its own goroutines
}

If you need to declare dependencies that cannot be detected via reflection (e.g. hidden inside closures or non-struct fields), use explicit declaration:

// Register service and declare it depends on db explicitly
goscade.Register(lc, service, db)

Linking dependencies through an arbitrary struct

When a component has no reflectable field path to the components it depends on (the link runs through a wiring struct, closures, etc.), use Link to point it at an arbitrary struct. The struct is reflection-walked during graph building and every registered component reachable inside it becomes a parent — as if the component had a field referencing that struct. The struct itself is never a node and is never run.

// wiring is a plain struct, not a Component.
type wiring struct {
    DB    *Database
    Cache *Cache
}

w := &wiring{DB: db, Cache: cache}

// service depends on every registered component reachable inside w (db, cache),
// even though service has no field referencing them.
goscade.Link(lc, service, w)

Adapter Pattern

Use NewAdapter to wrap existing types (like http.Server) without defining a new struct.

srv := &http.Server{Addr: ":8080"}

adapter := goscade.NewAdapter(srv, func(ctx context.Context, s *http.Server, probe func(error)) error {
    // Start server in background
    errChan := make(chan error, 1)
    go func() {
        if err := s.ListenAndServe(); err != http.ErrServerClosed {
            errChan <- err
        }
    }()

    // Signal ready (optionally verify port is listening first)
    probe(nil)

    // Wait for context cancellation or server error
    select {
    case <-ctx.Done():
        // Graceful shutdown
        return s.Shutdown(context.Background())
    case err := <-errChan:
        return err
    }
})

lc.Register(adapter)

Configuration Options

lc := goscade.NewLifecycle(logger,
    // Handle system signals (SIGINT, SIGTERM)
    goscade.WithShutdownHook(),
    
    // Set timeout for components to become ready
    goscade.WithStartTimeout(30 * time.Second),

    // Set timeout for components to stop
    goscade.WithShutdownTimeout(30 * time.Second),
    
    // Allow circular dependencies (use with caution)
    goscade.WithCircularDependency(),
    
    // Export dependency graph to DOT file on startup
    goscade.WithGraphOutput("graph.dot"),
)

Errors

Lifecycle.Run returns the cause that initiated shutdown together with any independent component and cleanup errors. Inspect the result with errors.Is; multiple failures may be joined with errors.Join.

err := lc.Run(ctx, nil)
if errors.Is(err, context.Canceled) {
    // Shutdown was requested by the caller.
}

Dependency Graph Export

GOscade can export the component dependency graph in DOT format (Graphviz).

Get Graph Programmatically

// Build graph structure
graph := lc.BuildGraph()

// Convert to DOT format
dotString := graph.ToDOT()
fmt.Println(dotString)

Auto-save to File

// Graph will be saved to file when lifecycle starts
lc := goscade.NewLifecycle(logger, 
    goscade.WithGraphOutput("graph.dot"),
)

Visualize with Graphviz

# Generate PNG image
dot -Tpng graph.dot -o graph.png

# Generate SVG
dot -Tsvg graph.dot -o graph.svg

# Generate PDF
dot -Tpdf graph.dot -o graph.pdf

Example DOT output:

digraph G {
  rankdir=TB;

  "Database" [label="Database", shape=box];
  "Cache" [label="Cache", shape=box];
  "APIServer" [label="APIServer", shape=box];

  "Database" -> "Cache";
  "Cache" -> "APIServer";
}

Visual Examples

Basic Workflow
Basic Workflow
Components start in dependency order. Parents (dependencies) start first. Children start only after parents become ready. Shutdown happens in reverse order.
Startup Error
Startup Error
If a component fails to start (returns error or fails probe), the lifecycle cancels the startup sequence and shuts down already started components gracefully.
Unexpected Shutdown
Unexpected Shutdown
If a running component stops unexpectedly (returns from Run), it triggers a system-wide shutdown to ensure inconsistent state is not maintained.

License

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

About

Lightweight Go library for launching and coordinating components with dependency tracking, readiness probes, and graceful shutdown.

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages