Skip to content

Service Container

The Service Container is the backbone of Tako. Everything — loggers, config, the event bus, your own services — lives in the container. Nothing is a global variable. If you need something, you ask the container for it.

This pattern is called Inversion of Control (IoC), and it's the reason plugins in Tako are naturally testable and replaceable.

The Core Idea

Instead of this:

go
// ❌ global variable — violates AGENTS.md rule
var myLogger *slog.Logger

func init() {
    myLogger = slog.Default()
}

You do this:

1. Registering (Host App)

go
app.Container().Singleton(new(contracts.Logger), myLogger)

2. Resolving (Plugin)

go
var logger contracts.Logger
ctx.Container().Make(&logger)

The plugin depends on contracts.Logger — an interface — not on any concrete implementation. This means you can swap the logger in tests without changing a single line of plugin code.

The Three Registration Scopes

The container supports three binding modes. Choosing the right one matters.

Singleton — register a pre-built instance

Use this when you already have the object and want everyone to share the same instance.

go
bus := event.NewBus()
container.Singleton(new(contracts.EventBus), bus)

The same bus is returned every time Make is called for contracts.EventBus. The factory is never called — you're registering the finished product.

When to use: Services that are cheap to create upfront and naturally shared (event bus, logger, config, key router).

Lazy — defer construction until first use

Use this when construction is expensive or requires services that aren't registered yet.

go
container.Lazy(new(*MyService), func() (any, error) {
    var logger contracts.Logger
    if err := container.Make(&logger); err != nil {
        return nil, err
    }
    return NewMyService(logger), nil
})

The factory runs exactly once, on the first Make call. After that, the result is cached just like Singleton. Thread-safe — the double-check pattern ensures only one goroutine runs the factory even under concurrent access.

When to use: Services that need other services to be built, or services that are only needed conditionally.

Transient — new instance on every call

Use this for objects that should not be shared — like request-scoped contexts or throw-away builders.

go
container.Transient(new(*QueryBuilder), func() (any, error) {
    return NewQueryBuilder(), nil
})

Every Make call creates a fresh instance.

When to use: Rare in framework code. More useful in application-level services where shared state would cause bugs.

Resolving Services

go
var logger contracts.Logger
if err := ctx.Container().Make(&logger); err != nil {
    return fmt.Errorf("plugin: failed to resolve logger: %w", err)
}

Make takes a pointer to the interface variable you want to fill. The container uses reflection to match the interface type to its registered implementation.

Always check the error. If a service isn't registered, Make returns an error — it doesn't panic. Handle it gracefully.

The ctx.Logger() Shortcut

For the six core framework services, tako.Context provides cached shortcuts so you don't need to call Make every time:

go
ctx.Logger()    // contracts.Logger
ctx.Config()    // contracts.Config
ctx.EventBus()  // contracts.EventBus
ctx.Hooks()     // hook.Registry
ctx.Storage()   // contracts.KVStore
ctx.Container() // contracts.Container (for everything else)

These cache on first access under a mutex. Safe to call from multiple goroutines.

Resolution Flow

Registering Your Own Services

Plugins can register their own services into the container during OnInit. This is how you make a service available to other plugins:

go
func (p *Plugin) OnInit(ctx *tako.Context) error {
    // Build the service
    svc := NewMySearchService()

    // Register it so other plugins can resolve it
    ctx.Container().Singleton(new(*MySearchService), svc)

    return nil
}

Another plugin that depends on MySearchService declares the dependency in its manifest and resolves it in its own OnInit:

go
var Manifest = plugin.Manifest{
    ID:       "results-view",
    Requires: []string{"search"}, // boot order guaranteed
}

func (p *Plugin) OnInit(ctx *tako.Context) error {
    var svc *MySearchService
    if err := ctx.Container().Make(&svc); err != nil {
        return fmt.Errorf("results-view: search service not available: %w", err)
    }
    p.searchSvc = svc
    return nil
}

The Requires field in the manifest guarantees that search is booted before results-view. The DAG sort handles this automatically.


Up next: 03.02 — Event Bus — async pub/sub between plugins. We'll cover Publish, Subscribe, context-based cleanup, and when to use events vs. direct service calls.