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:
// ❌ global variable — violates AGENTS.md rule
var myLogger *slog.Logger
func init() {
myLogger = slog.Default()
}You do this:
1. Registering (Host App)
app.Container().Singleton(new(contracts.Logger), myLogger)2. Resolving (Plugin)
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.
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.
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.
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
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:
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:
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:
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.
