Introduction

Lifecycle management ensures that application components like servers, databases, and background workers are correctly initialized, started in the right order, and gracefully stopped.

Go developers typically wire dependencies manually, instead of relying on heavy frameworks or reflection based dependency injection. This approach improves readability and reduces hidden behavior, but it also shifts the burden of managing component interactions and lifecycle sequencing onto the developer.

As applications scale, the lack of built-in orchestration increases the risk of subtle bugs and inconsistent shutdown behavior.

This post compares two approaches:

  • Manual lifecycle management using idiomatic Go
  • Structured lifecycle management using the component library

Understanding Dependency Lifecycle in Go

In Go applications, services such as HTTP servers, database connections, and background workers often have dependencies that must be managed explicitly. While Go provides primitives like context.Context and sync.WaitGroup, it is the developer’s responsibility to ensure that resources are constructed, started, and shut down in the correct order.

This coordination becomes more challenging as dependencies grow in number and interdependence. Without a structured lifecycle model, it’s easy to overlook teardown responsibilities, introduce race conditions, or mishandle cancellation flows.

Manual Dependency Wiring

In idiomatic Go, managing the lifecycle of an HTTP server involves handling OS signals, propagating context cancellations, and ensuring that all resources are released properly. Here’s an enhanced example:

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	httpCtx, httpCancel := context.WithCancel(context.Background())
	srv := &http.Server{
		Addr:    ":8080",
		Handler: http.DefaultServeMux,
		BaseContext: func(net.Listener) context.Context {
			return httpCtx
		},
	}

	var wg sync.WaitGroup

	// Start HTTP server
	wg.Add(1)
	go func() {
		defer wg.Done()
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("server failed: %v", err)
		}
	}()

	// Start a worker that simulates queue processing
	workerCtx, workerCancel := context.WithCancel(context.Background())
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
			case <-workerCtx.Done():
				log.Println("worker stopped")
				return
			default:
				log.Println("processing queue item")
				time.Sleep(1 * time.Second)
			}
		}
	}()

	log.Println("server and worker started")
	<-ctx.Done()
	log.Println("shutdown signal received")

	// Initiate graceful shutdown
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("graceful shutdown failed: %v", err)
	}

	httpCancel()
	workerCancel()
	wg.Wait()
	log.Println("shutdown complete")
}

This example demonstrates how to manage the startup and shutdown of multiple services, specifically an HTTP server and a background worker using idiomatic Go patterns:

  • Signal Handling
    The application listens for system interrupts (SIGINT, SIGTERM) using signal.NotifyContext. When a signal is received, the context is canceled, triggering shutdown logic.

  • HTTP Server Setup
    A standard http.Server is initialized with a base context (httpCtx) that can be canceled independently. This allows graceful termination of incoming requests.

  • Background Worker
    A simulated worker runs in a separate goroutine. It processes tasks (logged every second) until its context (workerCtx) is canceled.

  • WaitGroup Coordination
    A sync.WaitGroup tracks both the HTTP server and worker goroutines. This ensures the main function blocks until all background work completes during shutdown.

  • Shutdown Flow
    Upon receiving a signal:

    • A shutdown context (shutdownCtx) with a timeout is created for the HTTP server.
    • The HTTP server is gracefully shut down.
    • Both contexts (httpCancel, workerCancel) are canceled to signal termination.
    • The main function waits (wg.Wait()) until both goroutines finish cleanly.

Pros

  • No external dependencies
  • Full control over orchestration

Cons

  • Lifecycle logic is scattered
  • Harder to test in isolation
  • Cleanup sequencing becomes complex with multiple services

Challenges with Start and Shutdown Order

Coordinating startup and shutdown is deceptively complex. While Go offers simple primitives, their correct use requires consistent discipline, especially as systems scale.

Common Pitfalls

  • Deferred cleanup runs in reverse: This works well when initialization and teardown are co-located, but can lead to premature shutdown when resources depend on one another.
  • Context cancellation doesn’t wait: context.Context can signal termination, but it doesn’t block until goroutines exit. Without explicit coordination (e.g., sync.WaitGroup), shutdown may race ahead of in-flight tasks.
  • Detached lifecycle ownership: When components are initialized in helper functions without returning their cleanup responsibilities, it’s easy to overlook or misorder shutdown steps.

These problems compound in systems with interdependent services. For example, if a worker enqueues jobs to a queue and a processor persists them to a database, shutting down the processor first risks losing data. Such errors are subtle, hard to test for, and difficult to recover from.

Despite Go’s concurrency primitives, lifecycle correctness remains the developer’s responsibility and a source of subtle bugs if not handled carefully.

Using the component Library

The component library offers a minimal framework for structuring and orchestrating stateful services in Go. It builds on Go’s type system and concurrency primitives to manage component initialization and shutdown in a clear, predictable order.

Components are registered with explicit dependencies and organized into levels in a directed acyclic graph (DAG). The library starts and stops components in dependency respecting order, in parallel where safe. If startup fails, it automatically rolls back by stopping already started components.

A component implements Lifecycle:

type Lifecycle interface {
    Start(context.Context) error
    Stop(context.Context) error
}

Components are registered with a System, which handles lifecycle sequencing:

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/jacoelho/component"
)

// 1. Define components
type Logger struct{}

func (l *Logger) Start(ctx context.Context) error { l.Log("Logger Started"); return nil }
func (l *Logger) Stop(ctx context.Context) error  { l.Log("Logger Stopped"); return nil }
func (l *Logger) Log(message string)              { fmt.Println(message) }

type MainService struct{ logger *Logger }

func (s *MainService) Start(ctx context.Context) error {
	s.logger.Log("MainService Started")
	return nil
}
func (s *MainService) Stop(ctx context.Context) error {
	s.logger.Log("MainService Stopped")
	return nil
}

func main() {
	sys := new(component.System)
	ctx := context.Background()

	// Create Keys
	var (
		loggerKey  component.Key[*Logger]
		serviceKey component.Key[*MainService]
	)

	// Provide components
	if err := component.Provide(sys, loggerKey, func(_ *component.System) (*Logger, error) {
		return &Logger{}, nil
	}); err != nil {
		log.Fatalf("Failed to provide logger: %v", err)
	}

	if err := component.Provide(sys, serviceKey, func(s *component.System) (*MainService, error) {
		logger, err := component.Get(s, loggerKey) // Get dependency
		if err != nil {
			return nil, err
		}
		return &MainService{logger: logger}, nil
	}, loggerKey); err != nil { // Declare dependency on loggerKey
		log.Fatalf("Failed to provide main service: %v", err)
	}

	// Start system
	fmt.Println("Starting system...")
	startCtx, cancel := context.WithTimeout(ctx, time.Second*5)
	defer cancel()
	if err := sys.Start(startCtx); err != nil {
		log.Fatalf("System start failed: %v", err)
	}

	fmt.Println("System is UP.")

	// Stop system
	fmt.Println("Stopping system...")
	stopCtx, cancel := context.WithTimeout(ctx, time.Second*5)
	defer cancel()
	if err := sys.Stop(stopCtx); err != nil {
		log.Printf("System stop encountered errors: %v", err)
	}
	fmt.Println("System shut down.")
}

This example shows how to explicitly declare dependencies between services and let the library orchestrate their lifecycle.

Key Concepts

  • Component System (component.System)
    This is the central registry. It tracks all components and their dependencies. It is responsible for starting and stopping components in the correct order.

  • Component Keys
    component.Key[*Logger] and component.Key[*MainService] serve as unique identifiers for each registered component. These keys are used for dependency lookup and resolution.

  • Providing Components
    Components are registered using component.Provide, which takes:

    • The system
    • A key
    • A constructor function that may declare dependencies
    • Optional dependency keys (e.g., loggerKey for MainService)

    This allows the system to validate the dependency graph and determine start/stop order.

  • Dependency Declaration
    MainService depends on Logger, which is declared via the constructor and enforced by passing loggerKey to Provide.

  • Start and Stop Lifecycle
    sys.Start(ctx) starts all components in topological order based on their dependencies. In this case:

    1. Logger starts first.
    2. MainService starts second (after Logger is ready).

    sys.Stop(ctx) shuts down components in reverse order.

This pattern eliminates manual wiring of start/stop logic and enforces correct ordering via explicit dependency declarations. It’s scalable, testable, and safe, especially as the number of components grows.

While the example is minimal, it mirrors patterns used in production systems to manage databases, queues, servers, and background jobs with consistent lifecycle semantics.

Pros

  • Explicit Dependency Declaration: Each component clearly defines what it needs, reducing hidden coupling.
  • Automatic Lifecycle Ordering: Components start and stop respecting dependency order, reducing shutdown bugs.
  • Centralized Lifecycle Management: The System handles orchestration, improving maintainability and testability.
  • Parallel Execution: Components at the same dependency level start concurrently, improving efficiency.
  • Validation at Registration: The system catches cyclic dependencies early.

Cons

  • Extra Abstraction Layer: Adds structural overhead compared to manually wiring functions and structs.
  • Less Transparent for Simple Cases: For small programs, the indirection can obscure the flow of control and increase conceptual overhead.
  • Runtime Wiring Overhead: Dependencies are resolved at runtime, not at compile time, which may be unfamiliar to Go developers used to static wiring.

Conclusion

Manual lifecycle management in Go works well for smaller applications with simple dependencies. It keeps the control flow clear and aligns with Go’s preference for direct, explicit code. But as more components are added, each with their own startup and shutdown requirements, manual wiring becomes more difficult to maintain and more likely to break.

The component library helps manage this complexity by introducing a structure for declaring and orchestrating components. It handles dependency resolution, startup sequencing, and shutdown ordering automatically. This reduces boilerplate and improves the reliability of long running systems without hiding too much logic or requiring major changes to Go’s usual patterns.

For teams working on modular or service-heavy systems, component provides a practical way to scale lifecycle management while preserving the clarity and maintainability expected in Go projects.