Interaction between
An Additive is isolated, but never alone. It runs inside your app, shares its runtime, talks to its neighbours through events and queries — never through imports — can depend on other Additives, contributes its own frontend to the build and, because it carries a manifest, can be packaged and handed to someone else. This chapter is about those seams.
One shared runtime
Additives reach the same batteries your main app does. The shared registry in
webfluid.core.ext is the same object everywhere, so an Additive talks to the
very same database, cache and event manager — no wiring required:
from webfluid.core.ext import db, events
from sqlalchemy import select, func
from fluid.models import MyModel
async def handle_request():
from .. import additive
async with db.async_executor(model=MyModel) as e:
result = await e.exec(select(func.count(MyModel.id)), scalars=False)
total = result.scalar()
return await additive.render("index.html", total=total)
Talking without imports
Reaching into fluid.models like that is fine for the main app's own models, but
it is exactly the kind of hard dependency that turns "modules" back into a monolith. The
moment one Additive imports another, they're welded together — you can't
ship one without the other, and disabling one breaks its neighbour.
So Additives talk over the EventManager instead. Events and queries are the
loose-coupling contract: a producer publishes under a name, and anyone interested reacts to
that name — with no shared Python symbol between them. The only thing that crosses the
boundary is a string.
Scoping contracts by id
For that to be safe, those names must not collide. This is what
additive.unique_name() is for: it prefixes a name with the Additive's id, giving
every contract a stable, predictable address. An Additive serves its contracts under
its own id namespace:
from webfluid.core.ext import db, events
from sqlalchemy import select, func
from .. import additive
from fluid.models import MyModel
# A query other modules may call by name. unique_name("model_count")
# scopes it to our id, so it is served as "portal_model_count".
@events.query(additive.unique_name("model_count"))
async def model_count(_):
async with db.async_executor(model=MyModel) as e:
result = await e.exec(select(func.count(MyModel.id)), scalars=False)
return result.scalar()
# A public broadcast, likewise scoped: "portal_model:created".
events.create_signal(additive.unique_name("model:created"), internal=False)
We register these from before_enable, right next to the routes, so the contracts
exist as soon as the Additive is enabled:
@additive.before_enable
def before_enable(_):
from .app import index
additive.app.get("/")(index)
from .events import contracts # noqa: registers the contracts above
Consuming by name
On the other side, a second Additive reacts to those contracts without importing
portal at all. It knows portal only by its id — the same id it
already declares as a dependency in its manifest — and addresses the contract by that
id-scoped name:
from webfluid.core.ext import events
# The id we depend on (declared under requires.additives in our manifest).
PORTAL = "portal"
async def overview():
# Ask portal for its model count without importing a line of it:
return await events.request(f"{PORTAL}_model_count")
# React to portal broadcasts, again only by name:
@events.event(f"{PORTAL}_model:created", internal=False)
async def on_created(data):
from webfluid.utils.logging import factory as log
log.log(f"[dashboard] portal created a model: {data}")
The id is the public contract. Because the consumer depends on portal by id and version in its manifest, the framework guarantees portal is present before either is enabled — so the string address is safe, and no Python import ever crosses the boundary. The same id-scoping powers url_for inside Additive templates: route names are run through unique_name too, so an Additive's reverse-url lookups never clash with anyone else's.
A local request lifecycle
Just like the main app, an Additive can hook its own request flow — but scoped to its
own routes only. It offers before_request, after_request,
context_processor and a route-level http_middleware. Use them to
give an Additive its own guards or template defaults without touching the rest of the app:
from webfluid import Additive
from webfluid.additives.core import import_base
additive = Additive(
__name__,
import_base("core"),
required_extensions=["sqlalchemy", "events"]
)
@additive.context_processor
def defaults():
return {"section": "portal"}
@additive.before_enable
def before_enable(_):
from .app import index
additive.app.get("/")(index)
required_extensions is the Additive's own safety net: if one of the named extensions isn't enabled in the app it is loaded into, enabling fails loudly instead of breaking at runtime. A base's requirements are merged into the child automatically.
Declaring requirements
The manifest's optional requires block is how an Additive states what it needs
from the world around it: a framework version range, other Additives (by id, optionally
pinned), and pip packages to install. These are checked when the Additive is enabled:
{
"id": "portal",
"version": "1.0.0",
"type": "default",
"frontend": { "type": "vite", "framework": "vue", "typescript": false },
"name": "Portal",
"requires": {
"wf": ">=1.0.0",
"additives": { "core": ">=1.0.0" },
"packages": ["httpx"]
}
}
Frontends that compose
Remember how we kept the workspace list tiny in the Frontend chapter? This is where it pays
off. Each Additive can carry its own Vite app in additives/<id>/frontend,
and the framework's orchestrator already globs every one of them into the build alongside
the main app:
{
"name": "myapp",
"private": true,
"workspaces": [
"additives/*/frontend",
"fluid/frontend"
],
"devDependencies": {
"vite": "^8.0.0"
}
}
Every Additive frontend is served under its own prefix —
/portal/frontend for the build, proxied through /vite-dev in debug
— and they all share one dev server with hot module replacement running across the lot.
The per-Additive vite.config.js only differs from the main app's in its base
path, which points at the Additive's prefix instead of the root. Static files behave the
same way, mounting under /<id>/static.
HMR across several frontends works, but more or less cleanly in this first alpha. If a reload gets confused, restarting the dev run sorts it out. It is good enough to develop against; just don't be surprised by the occasional hiccup.
Packaging and distribution
Because an Additive is fully described by its manifest, it can be shipped. Two methods make an installed Additive a good citizen of whatever project pulls it in:
-
install()— copies anything in the Additive'sextractfolder (templates and static the host app should own) into the main app, and installs the manifest's required packages. Existing files are never overwritten. -
configure()— lets an Additive contribute its own questions towf create appthrough asetupmapping in itsconfig.py, so its settings land in the right app config section interactively.
Together with the version requirements, that is the groundwork for distributing Additives through a Hub — publish once, depend on by id and version, install into any WebFluid project. The plumbing for that lands as the alpha matures.
Continue reading
From here you can continue straight with the Framework utility.