Learning Roadmap¶
This roadmap takes you from zero to advanced usage of pico-ioc.
Roadmap Overview¶
graph TD
A[Level 0: Python Basics] --> B[Level 1: What is DI?]
B --> C[Level 2: First Steps with pico-ioc]
C --> D[Level 3: Configuration]
D --> E[Level 4: Lifecycles]
E --> F[Level 5: Testing]
F --> G[Level 6: Advanced Features]
G --> H[Level 7: Architecture Patterns] Level 0: Prerequisites¶
What you need to know before starting: Basic Python (3.11+)
1. Classes and inheritance¶
class Animal:
def __init__(self, name: str):
self.name = name
def speak(self) -> str:
return "..."
class Dog(Animal):
def speak(self) -> str:
return f"{self.name} says: Woof!"
2. Type hints (critical for DI)¶
from typing import Protocol
class Sender(Protocol):
def send(self, msg: str) -> None: ...
class EmailSender:
def send(self, msg: str) -> None:
print(f"[EMAIL] {msg}")
Why this matters: pico-ioc resolves dependencies using type annotations.
Level 1: What is Dependency Injection?¶
The problem: when a class creates its own dependencies, it becomes tightly coupled and hard to test.
# Without DI: class creates its own dependency
class Notifier:
def __init__(self):
self._sender = EmailSender() # hardcoded
def notify(self, text: str) -> None:
self._sender.send(text)
The solution: receive dependencies from outside (inject them).
# With DI: dependency is injected
class Notifier:
def __init__(self, sender: Sender):
self._sender = sender
def notify(self, text: str) -> None:
self._sender.send(text)
Now Notifier works with any Sender implementation: EmailSender, SmsSender, or a FakeSender in tests. This is the core idea behind Inversion of Control (IoC): the class doesn't control how its dependencies are created.
An IoC container like pico-ioc automates this wiring. You declare what each class needs, and the container builds the full object graph for you.
Level 2: First Steps with pico-ioc¶
The three core APIs¶
| API | Purpose |
|---|---|
@component | Registers a class with the container |
init(modules=[...]) | Scans modules, validates wiring, builds the container |
container.get(T) | Resolves and returns a fully-wired instance of T |
Hello World¶
from pico_ioc import component, init
@component
class GreeterService:
def greet(self) -> str:
return "Hello, pico-ioc!"
@component
class App:
def __init__(self, greeter: GreeterService): # auto-injected
self.greeter = greeter
def run(self):
print(self.greeter.greet())
if __name__ == "__main__":
container = init(modules=[__name__])
app = container.get(App)
app.run() # "Hello, pico-ioc!"
Registering third-party classes¶
Use @factory + @provides when you can't decorate a class:
import redis
from pico_ioc import factory, provides
@factory
class InfraFactory:
@provides(redis.Redis)
def build_redis(self) -> redis.Redis:
return redis.Redis.from_url("redis://localhost")
See Getting Started for the complete 5-minute tutorial.
Level 3: Configuration¶
Environment-based configuration¶
from dataclasses import dataclass
from pico_ioc import configured, configuration, init, EnvSource
@configured(prefix="DB_")
@dataclass
class DBConfig:
host: str = "localhost"
port: int = 5432
# Reads DB_HOST, DB_PORT from environment
container = init(modules=[__name__], config=configuration())
Tree-based configuration (YAML/JSON)¶
from pico_ioc import configured, configuration, init
from pico_ioc.config_builder import FileSource
@configured(prefix="database", mapping="tree")
@dataclass
class DBConfig:
host: str = "localhost"
port: int = 5432
container = init(
modules=[__name__],
config=configuration(FileSource("config.yaml"))
)
See Configuration (Basic) and Configuration Binding.
Level 4: Lifecycles¶
Scopes¶
| Scope | Behavior |
|---|---|
singleton (default) | One instance for the container's lifetime |
prototype | New instance on every get() call |
request | One instance per request scope activation |
session | One instance per session scope activation |
transaction | One instance per transaction scope activation |
Lifecycle hooks¶
from pico_ioc import component, configure, cleanup
@component
class ConnectionPool:
@configure
def start(self):
self.pool = create_pool() # called after __init__
@cleanup
def close(self):
self.pool.close() # called on container.shutdown()
Lazy instantiation¶
Level 5: Testing¶
Pico-ioc makes testing easy by design. Use overrides to replace any component:
class FakeRepo:
def fetch(self):
return "test-data"
def test_service():
container = init(
modules=["myapp"],
overrides={Repo: FakeRepo()}
)
svc = container.get(Service)
assert svc.repo.fetch() == "test-data"
Use profiles to activate test-specific components:
@component(profiles=["test"])
class TestDatabase:
... # only registered when "test" profile is active
container = init(modules=["myapp"], profiles=["test"])
Since pico-ioc uses constructor injection exclusively, you can also test without the container at all:
def test_notifier():
fake = FakeSender()
n = Notifier(sender=fake)
n.notify("hello")
assert fake.messages == ["hello"]
See Testing.
Level 6: Advanced Features¶
AOP Interceptors¶
Add cross-cutting concerns (logging, caching, security) without modifying business logic:
from pico_ioc import component, intercepted_by, MethodInterceptor, MethodCtx
@component
class LogInterceptor(MethodInterceptor):
def invoke(self, ctx: MethodCtx, call_next):
print(f"Calling {ctx.name}")
result = call_next(ctx)
print(f"Done {ctx.name}")
return result
@component
class Service:
@intercepted_by(LogInterceptor)
def process(self):
return "result"
Event Bus¶
Decouple components with publish/subscribe:
from pico_ioc import Event, subscribe, component
class OrderCreated(Event):
def __init__(self, order_id: str):
self.order_id = order_id
@component
class EmailNotifier:
@subscribe(OrderCreated)
async def on_order(self, event: OrderCreated):
print(f"Sending email for order {event.order_id}")
Collection injection¶
from typing import List, Dict
@component
class Router:
def __init__(self, handlers: List[Handler]): # all Handler implementations
self.handlers = handlers
@component
class Registry:
def __init__(self, services: Dict[str, Service]): # keyed by name
self.services = services
See Advanced Features.
Level 7: Architecture Patterns¶
At this level, you use pico-ioc as the backbone for full application architecture:
- CQRS: Separate command/query handlers with
Dict[Type, Handler]injection - Multi-tenant: Use
scope="request"with per-tenant configuration - Feature toggles: Use AOP interceptors to enable/disable features at runtime
- Structured logging: Attach request context via interceptors
- Hot-reload configuration: Watch config files and refresh the container
See the Cookbook for complete, runnable examples of each pattern.
FAQ¶
If a component only requests Sender, how does the container know which one to inject? Mark one binding as primary via primary=True. See Conditional binding.
Can I use pico-ioc with FastAPI/Flask? Yes. See pico-fastapi for automatic integration including request scope management.
What if I have circular dependencies? pico-ioc detects them at startup and raises an error. Break the cycle using @configure methods or lazy=True. See ADR-008.