Awesome Go

goscade

CategoryGoroutines
SubcategoryGoroutines
Stars24

Minimalistic lifecycle orchestrator for Go components with dependency graphs, startup sequencing, readiness coordination, and graceful shutdown

About goscade

GOscade

Tests Go Reference Go Report Card codecov Go Version Latest Release Mentioned in Awesome Go License: MIT

GOscade is a lightweight lifecycle orchestrator for Go applications.

It coordinates startup, readiness, failure propagation, and graceful shutdown of dependent components.

  • Dependency-aware startup
  • Readiness coordination
  • Failure propagation
  • Graceful shutdown in reverse dependency order
  • Automatic dependency graph discovery

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 Discovery

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

License

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

Frequently Asked Questions

What is goscade?

goscade is a Goroutines library for the Go programming language. Minimalistic lifecycle orchestrator for Go components with dependency graphs, startup sequencing, readiness coordination, and graceful shutdown

How do I install goscade?

Install goscade with the Go module system using `go get ognick/goscade`. Check the repository for the current installation instructions.

What category does goscade belong to?

goscade is listed under Goroutines, specifically Goroutines.

← Back to Goroutines