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
72 changes: 72 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Needle Context

Needle is a generic dependency-injection container for Go. The vocabulary below is what the codebase uses; pick these terms over their aliases.

## Language

**Container**:
The owner of registrations, lifecycle, and resolution. The thing users construct with `needle.New()`.
_Avoid_: registry (that's an internal sub-component), context (overloaded with `context.Context`).

**Spec[T]**:
The configuration a caller hands to `Register[T]` to register a service of type `T`. Sum-typed: carries either a `Provider` or a `Value`, plus optional name, dependencies, scope, hooks, pool size, and lazy flag. The user-facing description of a service.
_Avoid_: definition, registration, config, builder.

**ServiceEntry**:
The internal runtime state of a registered service: the spec's contents plus `sync.Once`, init error, pool channel, instantiation flag. Lives inside the registry; users never see it.
_Avoid_: service record, entry (when ambiguous).

**Provider**:
A function `func(ctx, Resolver) (T, error)` that constructs an instance of `T`. One of the two things a `Spec[T]` can carry.
_Avoid_: factory, constructor (constructor refers to plain Go constructor functions used by the autowire path).

**Hook**:
A function `func(ctx) error` called at a service's lifecycle transition. A `Spec[T]` carries at most one `OnStart` and one `OnStop`; multiple hooks compose via `needle.Compose(h1, h2)`.
_Avoid_: callback, listener, observer (observer is reserved for container-wide observation hooks like `WithStartObserver`).

**Scope**:
When and how a service instance is reused: Singleton, Transient, Request, Pooled. A property of the spec; resolved differently per scope.
_Avoid_: lifetime (overloaded with lifecycle), strategy.

**Resolver**:
The lookup interface a `Provider` uses to fetch dependencies during construction. In the deepened design this is the Container itself, not a separate adapter.
_Avoid_: locator, injector.

**Module**:
A deferred recorder of typed registrations. `ModuleRegister[T](m, spec)` captures a closure that calls `Register[T]` against the container at `Apply` time. Modules carry no semantics beyond batched registration.
_Avoid_: bundle, package, group.

**Decorator**:
A function that wraps a resolved instance of `T` to add cross-cutting behaviour. Registered separately from specs (cross-cutting; not per-service config).
_Avoid_: middleware, interceptor.

**Binding**:
A spec where the provider resolves another key and returns it as the registered type. Built via `SpecFromBinding[I, T]()`. Lets an interface `I` be served by an implementation `T` already registered under a different key.
_Avoid_: alias, link.

**Observer**:
A container-wide callback fired on `Resolve`/`Provide`/`Start`/`Stop`. Distinct from per-service Hooks: observers see every service, hooks fire on one service.
_Avoid_: listener, hook (hook is reserved for per-service lifecycle).

## Relationships

- A **Container** holds many **ServiceEntries**, one per registered key.
- A **Spec[T]** is the input to `Register`; the **Container** turns it into a **ServiceEntry**.
- A **ServiceEntry** carries at most one **Provider** (or a pre-built value), at most one **OnStart Hook**, at most one **OnStop Hook**, and exactly one **Scope**.
- A **Module** records typed `Spec[T]` closures and replays them against a **Container** at apply time.
- A **Binding** is a **Spec** whose **Provider** delegates to another key's resolution.
- **Decorators** attach to a key independently of the **Spec** for that key; one key can have many decorators.
- **Observers** attach to the **Container**, not to a **Spec**.

## Example dialogue

> **Dev:** "If I want a service to start lazily and run an `OnStart` hook the first time it resolves, what do I put on the **Spec**?"
> **Maintainer:** "Set `Lazy: true` and `OnStart: myHook`. The **Container** holds the **Spec** as a **ServiceEntry**; on first `Resolve` it constructs the instance via the **Provider** and runs the **OnStart Hook** because the container is already in the running state."

> **Dev:** "Can I add two `OnStart` hooks to one service?"
> **Maintainer:** "A **Spec** carries one **Hook** per slot. Compose them with `needle.Compose(h1, h2)` and assign the composite. **Modules** wanting to layer extra behaviour register a **Decorator** instead — that's the cross-cutting path."

## Flagged ambiguities

- "service" was used loosely for both the runtime instance and the registration. Resolved: the registration is a **Spec[T]** (user-facing) or **ServiceEntry** (internal); the runtime instance is just "the resolved value" or "instance."
- "hook" previously named both per-service lifecycle callbacks and container-wide callbacks. Resolved: per-service is **Hook**; container-wide is **Observer**.
95 changes: 69 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ A modern, type-safe dependency injection framework for Go.

## Features

Needle uses Go generics for compile-time type safety (`Provide[T]`, `Invoke[T]`) and has zero external dependencies.
Needle uses Go generics for compile-time type safety (`Register[T]`, `Invoke[T]`) and has zero external dependencies.

It supports constructor auto-wiring, struct tag injection, multiple scopes (singleton, transient, request, pooled), and lifecycle hooks that run in dependency order. Services can start in parallel, be lazily initialized, or be replaced at runtime without restarting the container.
A single `Register[T]` entry point takes a typed `Spec[T]` carrying provider, dependencies, scope, hooks, pool size, and lazy flag. Constructor helpers (`SpecFromConstructor`, `SpecFromStruct`, `SpecFromBinding`, `SpecValue`) cover auto-wiring, struct-tag injection, interface binding, and pre-built values. Lifecycle hooks run in dependency order, services can start in parallel, and any spec can be replaced at runtime.

You can group providers into modules, bind interfaces to implementations, wrap services with decorators, and resolve optional dependencies with a built-in `Optional[T]` type. Health and readiness checks are supported out of the box.
You can group specs into modules, attach decorators for cross-cutting concerns, and resolve optional dependencies with the built-in `Optional[T]` type. Health and readiness checks are supported out of the box.

## Installation

Expand All @@ -24,9 +24,11 @@ go get github.com/danpasecinic/needle
```go
c := needle.New()

needle.ProvideValue(c, &Config{Port: 8080})
needle.Provide(c, func(ctx context.Context, r needle.Resolver) (*Server, error) {
return &Server{Config: needle.MustInvoke[*Config](c)}, nil
needle.Register(c, needle.SpecValue(&Config{Port: 8080}))
needle.Register(c, needle.Spec[*Server]{
Provider: func(ctx context.Context, r needle.Resolver) (*Server, error) {
return &Server{Config: needle.MustInvoke[*Config](c)}, nil
},
})

server := needle.MustInvoke[*Server](c)
Expand All @@ -37,7 +39,7 @@ server := needle.MustInvoke[*Server](c)
See the [examples](examples/) directory:

- [basic](examples/basic/) - Simple dependency chain
- [autowire](examples/autowire/) - Struct-based injection
- [autowire](examples/autowire/) - Constructor and struct-tag injection
- [httpserver](examples/httpserver/) - HTTP server with lifecycle
- [modules](examples/modules/) - Modules and interface binding
- [scopes](examples/scopes/) - Singleton, Transient, Request, Pooled
Expand All @@ -47,6 +49,40 @@ See the [examples](examples/) directory:
- [optional](examples/optional/) - Optional dependencies with fallbacks
- [parallel](examples/parallel/) - Parallel startup/shutdown

## The Spec Type

Every registration goes through one type. The defaults (zero values) cover the common case: singleton scope, eager initialization, no hooks.

```go
type Spec[T any] struct {
Name string // optional, for named services
Provider Provider[T] // factory function (mutually exclusive with SpecValue)
Dependencies []string // explicit dependency keys
Scope Scope // Singleton (default), Transient, Request, Pooled
OnStart Hook // lifecycle hook on container Start
OnStop Hook // lifecycle hook on container Stop
PoolSize int // pool size when Scope is Pooled
Lazy bool // defer instantiation until first Resolve
}
```

Constructor helpers fill the spec for common patterns:

```go
needle.SpecValue(&Config{Port: 8080}) // pre-built value
needle.SpecFromConstructor[*Database](NewDatabase) // auto-wire from func params
needle.SpecFromStruct[*UserService]() // auto-wire from `needle:""` tags
needle.SpecFromBinding[UserRepo, *PostgresRepo]() // bind interface to impl
```

Each helper returns a `Spec[T]` that you can field-tweak or chain via `WithName`, `WithScope`, `WithLazy`, etc:

```go
needle.Register(c, needle.SpecFromConstructor[*Server](NewServer).
WithName("primary").
WithLazy())
```

## Choosing a Scope

| Scope | Lifetime | Use When |
Expand All @@ -57,10 +93,10 @@ See the [examples](examples/) directory:
| **Pooled** | Reusable instances from a fixed-size pool | Expensive-to-create, stateless-between-uses resources: gRPC connections, worker objects |

```go
needle.Provide(c, NewService) // Singleton (default)
needle.Provide(c, NewHandler, needle.WithScope(needle.Transient))
needle.Provide(c, NewRequestLogger, needle.WithScope(needle.Request))
needle.Provide(c, NewWorker, needle.WithPoolSize(10)) // Pooled with 10 slots
needle.Register(c, needle.SpecFromConstructor[*Service](NewService)) // Singleton (default)
needle.Register(c, needle.SpecFromConstructor[*Handler](NewHandler).WithScope(needle.Transient)) // Transient
needle.Register(c, needle.SpecFromConstructor[*RequestLogger](NewRequestLogger).WithScope(needle.Request))
needle.Register(c, needle.SpecFromConstructor[*Worker](NewWorker).WithPoolSize(10)) // Pooled
```

Pooled services must be released by the caller via `c.Release(key, instance)`. If the pool is full, the instance is dropped and a warning is logged.
Expand All @@ -70,28 +106,35 @@ Pooled services must be released by the caller via `c.Release(key, instance)`. I
Replace services at runtime without restarting the container. Useful for feature flags, A/B testing, test doubles, or configuration updates.

```go
// Replace with a new value
needle.ReplaceValue(c, &Config{Port: 9090})
needle.Replace(c, needle.SpecValue(&Config{Port: 9090}))

// Replace with a new provider
needle.Replace(c, func(ctx context.Context, r needle.Resolver) (*Server, error) {
return &Server{Config: needle.MustInvoke[*Config](c)}, nil
needle.Replace(c, needle.Spec[*Server]{
Provider: func(ctx context.Context, r needle.Resolver) (*Server, error) {
return &Server{Config: needle.MustInvoke[*Config](c)}, nil
},
})

// Replace with auto-wired constructor
needle.ReplaceFunc[*Service](c, NewService)
needle.Replace(c, needle.SpecFromConstructor[*Service](NewService))
needle.Replace(c, needle.SpecFromStruct[*Service]())

// Replace with struct injection
needle.ReplaceStruct[*Service](c)

// Named variants
needle.ReplaceNamedValue(c, "primary", &Config{Port: 5432})
needle.ReplaceNamed(c, "primary", provider)
needle.Replace(c, needle.SpecValue(&Config{Port: 5432}).WithName("primary"))
```

All Replace functions accept the same options as Provide (`WithScope`, `WithOnStart`, `WithOnStop`, `WithLazy`, `WithPoolSize`). If the service does not exist yet, Replace creates it. If it does exist, the old entry is removed from both the registry and the dependency graph before re-registering.
`Register` errors on a duplicate key. `Replace` overwrites if the key exists, or registers if it does not. The same `Spec[T]` type is the input to both -- only the intent differs. `MustRegister` and `MustReplace` panic on error.

## Multiple Hooks

`Spec[T]` carries one `OnStart` and one `OnStop` Hook. Combine multiple hooks with `Compose`:

```go
needle.Register(c, needle.Spec[*Server]{
Provider: NewServer,
OnStart: needle.Compose(installRoutes, openListener),
OnStop: needle.Compose(stopGracefully, flushLogs),
})
```

`Must` variants (`MustReplace`, `MustReplaceValue`, `MustReplaceFunc`, `MustReplaceStruct`) panic on error.
`Compose` runs hooks in argument order and returns the first error.

## Benchmarks

Expand Down
48 changes: 12 additions & 36 deletions autowire.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ func InvokeStruct[T any](c *Container) (T, error) {
}

func InvokeStructCtx[T any](ctx context.Context, c *Container) (T, error) {
return resolveStruct[T](ctx, c.resolver)
}

func resolveStruct[T any](ctx context.Context, r Resolver) (T, error) {
var zero T

t := reflectPkg.TypeOf(zero)
Expand Down Expand Up @@ -42,14 +46,14 @@ func InvokeStructCtx[T any](ctx context.Context, c *Container) (T, error) {
key = field.TypeKey
}

if !c.internal.Has(key) {
if !r.Has(key) {
if field.Optional {
continue
}
return zero, errServiceNotFound(key)
}

instance, err := c.internal.Resolve(ctx, key)
instance, err := r.Resolve(ctx, key)
if err != nil {
if field.Optional {
continue
Expand Down Expand Up @@ -82,7 +86,7 @@ func InvokeStructCtx[T any](ctx context.Context, c *Container) (T, error) {
return structVal.Interface().(T), nil
}

func buildFuncProvider[T any](c *Container, constructor any) (Provider[T], []ProviderOption, error) {
func buildFuncProvider[T any](constructor any) (Provider[T], []string, error) {
params, returnType, err := reflect.FuncParams(constructor)
if err != nil {
return nil, nil, err
Expand Down Expand Up @@ -112,7 +116,7 @@ func buildFuncProvider[T any](c *Container, constructor any) (Provider[T], []Pro

args := make([]reflectPkg.Value, len(params))
for i, p := range params {
instance, err := c.internal.Resolve(ctx, p.TypeKey)
instance, err := r.Resolve(ctx, p.TypeKey)
if err != nil {
return zero, fmt.Errorf("failed to resolve parameter %d (%s): %w", i, p.TypeKey, err)
}
Expand All @@ -128,12 +132,12 @@ func buildFuncProvider[T any](c *Container, constructor any) (Provider[T], []Pro
return results[0].Interface().(T), nil
}

return provider, []ProviderOption{WithDependencies(deps...)}, nil
return provider, deps, nil
}

func buildStructProvider[T any](c *Container) (Provider[T], []ProviderOption) {
func buildStructProvider[T any]() (Provider[T], []string) {
provider := func(ctx context.Context, r Resolver) (T, error) {
return InvokeStructCtx[T](ctx, c)
return resolveStruct[T](ctx, r)
}

fields, _ := reflect.StructFields[T](TagKey)
Expand All @@ -148,33 +152,5 @@ func buildStructProvider[T any](c *Container) (Provider[T], []ProviderOption) {
}
}

return provider, []ProviderOption{WithDependencies(deps...)}
}

func ProvideFunc[T any](c *Container, constructor any, opts ...ProviderOption) error {
provider, depOpts, err := buildFuncProvider[T](c, constructor)
if err != nil {
return err
}

opts = append(depOpts, opts...)
return Provide(c, provider, opts...)
}

func MustProvideFunc[T any](c *Container, constructor any, opts ...ProviderOption) {
if err := ProvideFunc[T](c, constructor, opts...); err != nil {
panic(err)
}
}

func ProvideStruct[T any](c *Container, opts ...ProviderOption) error {
provider, depOpts := buildStructProvider[T](c)
opts = append(depOpts, opts...)
return Provide(c, provider, opts...)
}

func MustProvideStruct[T any](c *Container, opts ...ProviderOption) {
if err := ProvideStruct[T](c, opts...); err != nil {
panic(err)
}
return provider, deps
}
Loading
Loading