From OOP to Functional Programming with Haiway¶
This guide is a concise map for OOP developers adopting Haiway. It focuses on key differences and the core patterns you need day‑to‑day, aligned with Haiway’s guidelines.
1) Immutability First (State)¶
Traditional OOP encourages mutating objects. Haiway centers on immutable State objects and pure
transformations.
from datetime import datetime
from haiway import State
class User(State):
name: str
email: str | None = None
login_count: int = 0
last_login: datetime | None = None
def with_login(self) -> "User":
return self.updated(
login_count=self.login_count + 1,
last_login=datetime.now(),
)
u = User(name="Alice", email="alice@example.com")
u2 = u.with_login()
print(f"User1: {u}") # u remains unchanged
print(f"User2: {u2}")
Why it matters:
- Thread‑safe sharing by default; easy reasoning and testing
.updated()copies with structural sharing for performance- Collections: prefer
Sequence/Mapping/Set(sequences/sets become immutable; mappings stay dicts)
2) Composition over Inheritance (Protocols)¶
Replace deep hierarchies with small protocol contracts and compose behaviors as data.
from typing import Protocol, runtime_checkable
from haiway import State
@runtime_checkable
class Barking(Protocol):
def __call__(self, name: str) -> str: ...
class Dog(State):
name: str
bark: Barking
def loud_bark(name: str) -> str:
return f"{name} BARKS!"
dog = Dog(name="Rex", bark=loud_bark)
print(dog.bark("Reksio"))
Why it matters:
- Loose coupling and easy swapping in tests/production
- Clear, minimal interfaces; no fragile base classes
3) Context‑Driven Access and statemethod¶
Haiway’s context provides scoped execution, dependency resolution by type, and observability. Access
state using its type only, and expose behavior with @statemethod.
import asyncio
from typing import Protocol, runtime_checkable
from haiway import State, ctx, statemethod
@runtime_checkable
class EmailSending(Protocol):
async def __call__(self, to: str, subject: str, body: str) -> bool: ...
class NotificationService(State):
email_sending: EmailSending
@statemethod
async def notify(self, user_id: str, message: str) -> bool:
return await self.email_sending(
to=f"user-{user_id}@example.com",
subject="Notification",
body=message,
)
async def smtp_send(to: str, subject: str, body: str) -> bool:
# ... real send
return True
async def main():
service = NotificationService(email_sending=smtp_send)
async with ctx.scope("app", service):
# Class call resolves instance from context; instance call uses itself
await NotificationService.notify("123", "Welcome")
if __name__ == "__main__":
asyncio.run(main())
Key rules:
ctx.state(T)resolves by type only@statemethodalways works on an instance: class calls resolve from context, instance calls use that instance- Prefer
@statemethodover@classmethodfor helpers that need contextual state
State Resolution & Variables¶
When multiple states of the same type exist, Haiway resolves using:
- Explicit state passed to
ctx.scope(...)(highest) - Disposables (resources yielded into the scope)
- Presets
- Parent context (lowest)
Context variables are always isolated per spawned task (ctx.spawn wraps tasks with isolated
VariablesContext), so variables set in the parent are not visible inside spawned tasks.
4) Structured Concurrency (Scoped Tasks)¶
Start tasks within the active scope; Haiway manages their lifecycle and cleanup.
import asyncio
from haiway import ctx
async def worker():
# Inherits current scope (state, observability, variables)
await asyncio.sleep(0.5)
print("ZZZZzzzzz....")
async def main():
async with ctx.scope("app"):
t = ctx.spawn(worker)
await t # or gather/spawn more
if __name__ == "__main__":
asyncio.run(main())
Why it matters:
- Automatic cancellation/cleanup with the scope
- Consistent access to contextual state and observability
5) Testing the Functional Way¶
Tests become wiring exercises: build small states and protocols, enter a scope, and call
@statemethods.
import asyncio
from typing import Sequence
from haiway import State, ctx, statemethod
# This class defines the "interface" for the dependency.
class UsersFetching(State):
async def __call__(self) -> Sequence[str]: ...
# This service depends on an instance of UsersFetching.
class UsersService(State):
fetching: UsersFetching
@statemethod
async def all(self) -> Sequence[str]:
return await self.fetching()
class FakeUsersFetching(UsersFetching):
async def __call__(self) -> Sequence[str]:
return ("alice", "bob")
svc = UsersService(fetching=FakeUsersFetching())
async def main():
async with ctx.scope("test", svc):
users = await UsersService.all()
print(f"Fetched users: {users}")
if __name__ == "__main__":
asyncio.run(main())
Guidelines:
- Keep business logic in pure functions; pass dependencies explicitly
- Use protocols for seams; swap implementations in tests
- Avoid hidden globals/singletons; rely on
ctx.scope(...)
6) Tips¶
- Extract pure functions from stateful methods; return new
Stateinstead of mutating - Introduce protocol boundaries for external effects (I/O, HTTP, DB)
- Wrap work in
ctx.scope(...); inject only what’s needed by type - Replace
@classmethodhelpers with@statemethodto ensure instance‑based execution - Use abstract collection types (
Sequence,Mapping,Set) and modern unions (T | None)
Quick Reference¶
- State access:
ctx.state(T)by type only - Priority: explicit > disposables > presets > parent context
- Methods: use
@statemethodfor helpers that may be called from class or instance - Concurrency: use
ctx.spawn(...)inside an activectx.scope(...) - Observability:
ctx.record_info(event=..., attributes={...})orctx.log_info(...)
This functional, immutable, and context‑driven approach yields predictable, testable, and concurrent‑safe systems with minimal ceremony.