Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 84 additions & 99 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type Container struct {
debug bool
// mark if container resolved
resolved bool
// Resolved types topsorted
sorted []any
}

// Debug enables debug mode
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
120 changes: 120 additions & 0 deletions lifecycle.go
Original file line number Diff line number Diff line change
@@ -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...)
}
}
Loading
Loading