diff --git a/README.md b/README.md index 301f8b4..a9f13cb 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,129 @@ -# 📦 CompoApp - Lightweight DI Framework for Go +# compoapp -**CompoApp** is a zero-dependency, 400-line DI (Dependency Injection) framework for Go that makes building scalable applications easy. It automatically resolves dependencies, manages component lifecycle, and handles graceful shutdowns. +A small dependency injection container for Go. ~600 lines, no external dependencies. -## 🌟 Features +## Install -- **Zero Dependencies** - Pure Go, no external libraries -- **Ultra Lightweight** - Only ~400 lines of clean, readable code -- **Automatic Dependency Resolution** - Register constructors, we handle the rest -- **Type-Based Wiring** - Dependencies resolved by function parameter types -- **Topological Sorting** - Components created in correct dependency order -- **Circular Dependency Detection** - Prevents runtime deadlocks -- **Automatic Interface Implementation Injection** - Useful for testing -- **Thread-Safe** - Safe for concurrent use -- **Context-Based Lifecycle** - Graceful startup/shutdown +```bash +go get github.com/trofkm/compoapp +``` -## 🚀 Quick Start +## Basic usage + +Register constructors, resolve the root type. Dependencies are wired automatically by parameter types. ```go -package main +container := compoapp.NewContainer() +container.MustProvide(NewDatabase) +container.MustProvide(NewUserService) +container.MustProvide(NewHTTPServer) -import "github.com/trofkm/compoapp" +var server *HTTPServer +container.MustResolve(&server) +``` -// Define your types -type Database struct { - host string +The container builds a dependency graph, topologically sorts it, and constructs types in the correct order. Circular dependencies are detected and reported as errors. + +## Lifecycle + +For applications that need controlled startup and shutdown, use `ResolveLifecycle` instead of `MustResolve`. + +```go +var server *HTTPServer +if err := container.ResolveLifecycle(&server).Execute(ctx); err != nil { + log.Fatal(err) } +``` + +`Execute` runs three stages in order, then blocks until `ctx` is cancelled: + +``` +1. construct — all types built in dependency order +2. init — sequential, blocking, fail-fast +3. start — launched by lifecycle runner concurrently, each component waits for its dependencies to be ready +``` -type UserService struct { - db *Database +Each stage is opt-in via interfaces: + +```go +type Initer interface { + Init(ctx context.Context) error } -type HTTPServer struct { - userService *UserService +type Starter interface { + Start(ctx context.Context) error } -// Constructor functions -func NewDatabase() *Database { - return &Database{host: "localhost:5432"} +type Readier interface { + Ready() <-chan struct{} } +``` + +A component implements only what it needs. `Config` might implement none. `Database` might implement all three. -func NewUserService(db *Database) *UserService { - return &UserService{db: db} +**Ordering guarantee:** if `HTTPServer` depends on `Database`, then `Database.Init`, `Database.Start`, and `Database.Ready()` all complete before `HTTPServer.Start` is called. + +**Graceful shutdown** is the component's own responsibility via `ctx.Done()`: + +```go +type Database struct { + ready chan struct{} } -func NewHTTPServer(userService *UserService) *HTTPServer { - return &HTTPServer{userService: userService} +func NewDatabase() *Database { + // make it buffered + return &Database{ready: make(chan struct{}, 1)} } -func main() { - // Create container - container := di.NewContainer() +func (d *Database) Start(ctx context.Context) error { + // startup work... + d.ready <- struct{} + close(d.ready) - // Register constructors - container.MustProvide(NewDatabase) - container.MustProvide(NewUserService) - container.MustProvide(NewHTTPServer) + <-ctx.Done() + // shutdown work... + d.conn.Close() - // Resolve dependencies automatically - var server *HTTPServer - container.MustResolve(&server) + return nil +} - // server is now fully constructed with all dependencies! - fmt.Printf("Server created with database: %s\n", server.userService.db.host) +func (d *Database) Ready() <-chan struct{} { + return d.ready // same channel every time, created in constructor } ``` -## 🎯 How It Works +Full example with a realistic dependency tree: [samples/lifecycle](samples/lifecycle) -1. **Register Constructors** - Provide functions that create your components -2. **Automatic Analysis** - Container uses reflection to analyze parameters -3. **Dependency Graph** - Builds dependency relationships automatically -4. **Topological Sort** - Orders components for proper creation sequence -5. **Resolve Dependencies** - Container creates instances in correct order - -## 🛠️ API Reference +## API ```go -// Core functions func NewContainer() *Container func (c *Container) Provide(constructor interface{}) error func (c *Container) MustProvide(constructor interface{}) -func (c *Container) ProvideNamed(name string, constructor interface{}) error -func (c *Container) MustProvideNamed(name string, constructor interface{}) error func (c *Container) Resolve(target interface{}) error func (c *Container) MustResolve(target interface{}) +func (c *Container) Debug() +func (c *Container) Visualize(pathToDot string) error +func (c *Container) ResolveLifecycle(target interface{}) *LifecycleRunner +func (r *LifecycleRunner) Execute(ctx context.Context) error ``` -## 🛣️ Roadmap +## Roadmap -- [x] Basic dependency resolution with reflection +- [x] Dependency resolution with reflection - [x] Topological sorting and circular dependency detection - [x] Thread-safe container operations - [x] Interface binding support -- [x] Error handling -- [ ] Named dependency resolution -- [x] Dependency graph visualization -- [x] Error handling +- [x] Lifecycle support (Init, Start, Ready) +- [ ] Named/tagged dependencies - [ ] Scope support -- [ ] Lifecycle support - -## ⚠️ Current Limitations -- **Basic Named Dependencies** - No tags or name-based resolution -- **No Lifecycle Hooks** - Basic startup/shutdown only -- **Limited ctor return types** - Only support ctors which returns pointers (*T) or (*T, error) -- **Only types in ctor return** - Doesn't support interfaces as ctor return value - -## 📊 Benefits - -### Clean Architecture -```go -// Instead of manual wiring: -db := NewDatabase() -cache := NewCache() -userService := NewUserService(db, cache) -authService := NewAuthService(userService) -server := NewServer(userService, authService) - -// Use automatic resolution: -container.MustProvide(NewDatabase) -container.MustProvide(NewCache) -container.MustProvide(NewUserService) -container.MustProvide(NewAuthService) -container.MustProvide(NewServer) - -var server *Server -container.MustResolve(&server) -``` - -## 📦 Installation - -```bash -go get github.com/trofkm/compoapp -``` - -## 📄 License -MIT License - see LICENSE file for details. +## Limitations ---- +- Constructors must return `*T` or `(*T, error)` +- No interface return types from constructors +- No named/tagged dependencies -*"400 lines of code that solve dependency injection elegantly"* +## License -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +MIT diff --git a/container.go b/container.go index 51b7d9e..5331c04 100644 --- a/container.go +++ b/container.go @@ -26,6 +26,8 @@ type Container struct { debug bool // mark if container resolved resolved bool + // Resolved types topsorted + sorted []any } // Debug enables debug mode @@ -200,12 +202,20 @@ func (c *Container) Resolve(target any) error { } // Step 3: Resolve all dependencies in order + sorted := make([]any, 0, len(sortedTypes)) for _, name := range sortedTypes { // todo: here might be tagged instances too if err := c.resolveInstance(name); err != nil { return fmt.Errorf("failed to resolve %s: %w", name, err) } + if v, ok := c.instances[name]; ok { + // todo: in case this + sorted = append(sorted, v) + } else { + return fmt.Errorf("this is impossible, but this happens: we can't find the instance for registered type") + } } + c.sorted = sorted // Step 4: Set the target value if instance, exists := c.instances[targetType]; exists { diff --git a/go.mod b/go.mod index 33d652f..dad634a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/trofkm/compoapp -go 1.24.0 +go 1.25.0 + +require golang.org/x/sync v0.20.0 diff --git a/go.sum b/go.sum index e69de29..733d716 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= diff --git a/lifecycle.go b/lifecycle.go new file mode 100644 index 0000000..2dc1e33 --- /dev/null +++ b/lifecycle.go @@ -0,0 +1,120 @@ +package compoapp + +import ( + "context" + "fmt" + "sync" + + "golang.org/x/sync/errgroup" +) + +// Initer is a component which has some initialization logic before Start +type Initer interface { + Init(ctx context.Context) error +} + +// Starter is component which has Start method. +type Starter interface { + Start(ctx context.Context) error +} + +// Redier is a component that has some long Start logic, so he must explicitly say when he is ready +type Redier interface { + // Ready returns channel which must be closed when component is ready + Ready() <-chan struct{} +} + +// LifecycleRunner encapsulates the init and start logic. +// +// It launches the Init() and Start() with correct order and automatically waits for component to be started +type LifecycleRunner struct { + container *Container + target any + // responsible for logs + debug bool +} + +// ResolveLifecycle creates LifecycleRunner from container +func (c *Container) ResolveLifecycle(target any) *LifecycleRunner { + return &LifecycleRunner{container: c, target: target, debug: c.debug} +} + +func (r *LifecycleRunner) Execute(ctx context.Context) error { + if err := r.container.Resolve(r.target); err != nil { + return fmt.Errorf("resolve: %w", err) + } + + for _, component := range r.container.sorted { + if i, ok := component.(Initer); ok { + r.debugf("calling %T.Init(ctx)", i) + if err := i.Init(ctx); err != nil { + return fmt.Errorf("init %T: %w", component, err) + } + } + } + + // component -> ready channels of its dependencies + readiers := make(map[any][]<-chan struct{}) + + for typ, val := range r.container.instances { + componentVal := r.container.instances[typ] + depTypes, ok := r.container.graph.dependencies[typ] + if !ok { + continue + } + + r.debugf("collecting ready statuses for %T", val) + for _, depType := range depTypes { + depVal, ok := r.container.instances[depType] + if !ok { + continue + } + + if readier, ok := depVal.(Redier); ok { + r.debugf("found %T with Ready() method", depVal) + readiers[componentVal] = append(readiers[componentVal], readier.Ready()) + } + } + } + // todo: can be implement in more convenient way? + eg, ctx := errgroup.WithContext(ctx) + + for _, component := range r.container.sorted { + s, ok := component.(Starter) + if !ok { + continue + } + + eg.Go(func() error { + // waiting for dependency resolution (Start method called) + if depsChans, ok := readiers[component]; ok { + wg := sync.WaitGroup{} + for _, ch := range depsChans { + wg.Go(func() { + <-ch + }) + } + wg.Wait() + } + + if err := s.Start(ctx); err != nil { + return fmt.Errorf("start %T: %w", component, err) + } + return nil + }) + } + + if err := eg.Wait(); err != nil { + return err + } + + <-ctx.Done() + return nil +} + +func (r *LifecycleRunner) debugf(format string, args ...any) { + if r.debug { + fmtStr := "[LIFECYCLE] " + format + "\n" + fmt.Printf(fmtStr, args...) + } +} diff --git a/samples/lifecycle/production.go b/samples/lifecycle/production.go new file mode 100644 index 0000000..e41a9c1 --- /dev/null +++ b/samples/lifecycle/production.go @@ -0,0 +1,296 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/trofkm/compoapp" +) + +// --- Config --- +// no lifecycle, just a plain value + +type Config struct { + DSN string + Port string +} + +func NewConfig() *Config { + return &Config{ + DSN: "postgres://localhost:5432/myapp", + Port: ":8080", + } +} + +// --- Logger --- +// has Init, no Start/Ready (simple component) + +type Logger struct{} + +func NewLogger() *Logger { + return &Logger{} +} + +func (l *Logger) Init(ctx context.Context) error { + fmt.Println("[Logger] Init: setting up log output") + time.Sleep(50 * time.Millisecond) + return nil +} + +func (l *Logger) Log(msg string) { + fmt.Println("[LOG]", msg) +} + +// --- Database --- +// depends on Config, Logger +// has Init + Start + Ready + +type Database struct { + config *Config + logger *Logger + ready chan struct{} +} + +func NewDatabase(cfg *Config, log *Logger) *Database { + return &Database{ + config: cfg, + logger: log, + ready: make(chan struct{}), + } +} + +func (d *Database) Init(ctx context.Context) error { + d.logger.Log(fmt.Sprintf("Database] Init: connecting to %s", d.config.DSN)) + time.Sleep(100 * time.Millisecond) + return nil +} + +func (d *Database) Start(ctx context.Context) error { + go func() { + d.logger.Log("[Database] Start: running connection pool") + time.Sleep(200 * time.Millisecond) + d.logger.Log("[Database] Ready") + close(d.ready) + + <-ctx.Done() + d.logger.Log("[Database] shutting down") + }() + return nil +} + +func (d *Database) Ready() <-chan struct{} { + return d.ready +} + +// --- Cache --- +// depends on Config, Logger +// has Init + Start + Ready + +type Cache struct { + config *Config + logger *Logger + ready chan struct{} +} + +func NewCache(cfg *Config, log *Logger) *Cache { + return &Cache{ + config: cfg, + logger: log, + ready: make(chan struct{}), + } +} + +func (c *Cache) Init(ctx context.Context) error { + c.logger.Log("[Cache] Init: warming up") + time.Sleep(80 * time.Millisecond) + return nil +} + +func (c *Cache) Start(ctx context.Context) error { + go func() { + c.logger.Log("[Cache] Start: connecting to redis") + time.Sleep(150 * time.Millisecond) + c.logger.Log("[Cache] Ready") + close(c.ready) + + <-ctx.Done() + c.logger.Log("[Cache] shutting down") + }() + return nil +} + +func (c *Cache) Ready() <-chan struct{} { + return c.ready +} + +// --- UserRepository --- +// depends on Database, Cache +// has Start + Ready (no Init) + +type UserRepository struct { + db *Database + cache *Cache + logger *Logger + ready chan struct{} +} + +func NewUserRepository(db *Database, cache *Cache, log *Logger) *UserRepository { + return &UserRepository{ + db: db, + cache: cache, + logger: log, + ready: make(chan struct{}), + } +} + +func (r *UserRepository) Start(ctx context.Context) error { + go func() { + r.logger.Log("[UserRepository] Start: preparing queries") + time.Sleep(50 * time.Millisecond) + r.logger.Log("[UserRepository] Ready") + close(r.ready) + + <-ctx.Done() + r.logger.Log("[UserRepository] shutting down") + }() + return nil +} + +func (r *UserRepository) Ready() <-chan struct{} { + return r.ready +} + +// --- AuthService --- +// depends on UserRepository, Cache +// has Start + Ready + +type AuthService struct { + repo *UserRepository + cache *Cache + logger *Logger + ready chan struct{} +} + +func NewAuthService(repo *UserRepository, cache *Cache, log *Logger) *AuthService { + return &AuthService{ + repo: repo, + cache: cache, + logger: log, + ready: make(chan struct{}), + } +} + +func (a *AuthService) Start(ctx context.Context) error { + go func() { + a.logger.Log("[AuthService] Start: loading JWT keys") + time.Sleep(100 * time.Millisecond) + a.logger.Log("[AuthService] Ready") + close(a.ready) + + <-ctx.Done() + a.logger.Log("[AuthService] shutting down") + }() + return nil +} + +func (a *AuthService) Ready() <-chan struct{} { + return a.ready +} + +// --- MetricsCollector --- +// depends on Logger only +// has Start, no Ready (fire and forget background worker) + +type MetricsCollector struct { + logger *Logger +} + +func NewMetricsCollector(log *Logger) *MetricsCollector { + return &MetricsCollector{logger: log} +} + +func (m *MetricsCollector) Start(ctx context.Context) error { + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + m.logger.Log("[MetricsCollector] Start: collecting metrics") + for { + select { + case <-ticker.C: + m.logger.Log("[MetricsCollector] tick: collected metrics") + case <-ctx.Done(): + m.logger.Log("[MetricsCollector] shutting down") + return + } + } + }() + return nil +} + +// --- HTTPServer --- +// depends on AuthService, UserRepository, Config +// has Start + Ready — the root component + +type HTTPServer struct { + auth *AuthService + repo *UserRepository + config *Config + logger *Logger + ready chan struct{} +} + +func NewHTTPServer(auth *AuthService, repo *UserRepository, cfg *Config, log *Logger) *HTTPServer { + return &HTTPServer{ + auth: auth, + repo: repo, + config: cfg, + logger: log, + ready: make(chan struct{}), + } +} + +func (s *HTTPServer) Start(ctx context.Context) error { + go func() { + s.logger.Log(fmt.Sprintf("[HTTPServer] Start: listening on %s", s.config.Port)) + time.Sleep(100 * time.Millisecond) + s.logger.Log("[HTTPServer] Ready") + close(s.ready) + + <-ctx.Done() + s.logger.Log("[HTTPServer] graceful shutdown") + }() + return nil +} + +func (s *HTTPServer) Ready() <-chan struct{} { + return s.ready +} + +// --- main --- + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + container := compoapp.NewContainer() + container.Debug() + + container.MustProvide(NewConfig) + container.MustProvide(NewLogger) + container.MustProvide(NewDatabase) + container.MustProvide(NewCache) + container.MustProvide(NewUserRepository) + container.MustProvide(NewAuthService) + container.MustProvide(NewMetricsCollector) + container.MustProvide(NewHTTPServer) + + var server *HTTPServer + if err := container.ResolveLifecycle(&server).Execute(ctx); err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } +} diff --git a/tests/go.mod b/tests/go.mod index c0de936..728b1a6 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -19,7 +19,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.41.0 // indirect diff --git a/tests/go.sum b/tests/go.sum index 30d90c6..c5c3e95 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -46,16 +46,14 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/trofkm/compoapp v0.0.0-20250913174805-9596e84338a9 h1:1MeAlh7PCzDyaj2SPyJPejiJPaSjE4LVfAkl2bJF4y8= -github.com/trofkm/compoapp v0.0.0-20250913174805-9596e84338a9/go.mod h1:/IVlwmTZ0mZE7tTwgPBO8J2yXqLsiuIkrmkqOHcPiik= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=