Architecture overview¶
Learning path · step 7 of 8 — Design notes, limitations, future work. Prev: API reference. Next: BUGS.md. Use this when you want to know why the codebase is shaped the way it is; the surface-tab Architecture pages cover individual plugins.
dhis2w is designed around three orthogonal axes of extensibility. Extending one should never force edits to another — that's how we keep this codebase maintainable as it grows.
The three axes¶
1. Workspace members (shipping units)¶
Each shippable unit of code is a uv workspace member under packages/:
| Member | Role | PyPI |
|---|---|---|
dhis2w-client |
Async DHIS2 API client + Profile model + open_client(profile) for PAT/Basic/session auth. |
dhis2w-client |
dhis2w-core |
TOML profile resolution, OAuth2 token store, plugin registry, first-party plugins. | dhis2w-core |
dhis2w-cli |
Thin Typer console-script shell. | dhis2w-cli |
dhis2w-mcp |
Thin FastMCP server shell. | dhis2w-mcp |
dhis2w-mcp-bridge |
Single-tool MCP bridge exposing the d2w CLI to small local models. |
dhis2w-mcp-bridge |
dhis2w-browser |
Playwright helpers for UI automation. | dhis2w-browser |
dhis2w-codegen |
Version-aware client generator. | workspace-only |
dhis2w-bench |
Local-LLM benchmark harness (coding, mcp-bridge, full-mcp suites). | workspace-only |
dhis2w-mcp-router |
Domain-neutral MCP router: search + dispatch meta-tools over upstream MCP servers. | dhis2w-mcp-router |
dhis2w-fhir |
FHIR IG generation from DHIS2 metadata. Builds on dhis2w-core and mounts d2w fhir through the dhis2w.plugins.v1 entry point. |
dhis2w-fhir |
dhis2w-fhir-serve |
FastAPI FHIR facade over a generated IG: serves its resources and receives QuestionnaireResponse captures. Runs behind d2w fhir serve, installed through the dhis2w-cli[serve] extra. |
dhis2w-fhir-serve |
New surfaces land as new members, with no edits required to existing ones. dhis2w-fhir-serve is the worked example: d2w fhir serve needs FastAPI and uvicorn, the generator needs neither, so the HTTP surface is its own member and an API-only install of dhis2w-fhir stays free of both.
2. Plugins inside dhis2w-core¶
Each DHIS2 domain (metadata, tracker, analytics, screenshots, indicator validation, …) is a self-contained plugin package in dhis2w-core/src/dhis2w_core/v43/plugins/<name>/. Every plugin is a folder with this shape:
<name>/
├── __init__.py # exports `plugin = _MyPlugin()`, whose `contribute()` returns a `Contribution`
├── models.py # plugin-internal pydantic view-models (reports, summaries, job state)
├── service.py # async pure functions — single source of truth for the domain
├── cli.py # Typer sub-app wrapping service.py
├── mcp.py # FastMCP tool registrations wrapping service.py
└── tests/
The CLI and MCP surfaces both call into the same service.py. They never drift out of parity because neither is primary.
The plugin machinery is pluginkit: load_plugin_host(version_key) collects a Contribution from every plugin it can find, from two sources:
- Built-ins — iterate the plugin tree
resolve_startup_version()picks at startup,dhis2w_core.v43.plugins.*on the default. - External — the
dhis2w.plugins.v1entry-point group. An external package (likedhis2w-fhir) can add commands/tools without a PR.
3. Auth providers inside dhis2w-client¶
dhis2w-client defines an AuthProvider Protocol. The client never touches auth internals — it just asks for headers. Three providers ship with the package: BasicAuth, PatAuth, OAuth2Auth. Future providers (service-account JWT, OIDC federation, proxy-injected headers) land as new files in dhis2w-client/auth/ without touching client.py.
Dependency arrows¶
graph LR
bench["dhis2w-bench"]
bridge["dhis2w-mcp-bridge"]
cli["dhis2w-cli"]
mcp["dhis2w-mcp"]
router["dhis2w-mcp-router"]
core["dhis2w-core"]
browser["dhis2w-browser"]
codegen["dhis2w-codegen"]
client["dhis2w-client"]
fhir["dhis2w-fhir"]
fhirserve["dhis2w-fhir-serve"]
cli --> core
mcp --> core
cli --> fhir
mcp --> fhir
fhir --> core
fhirserve --> fhir
bridge --> cli
bench --> cli
bench --> router
core --> client
browser --> client
codegen --> client
cli -.->|"optional [browser] extra"| browser
mcp -.->|"optional [browser] extra"| browser
cli -.->|"optional [serve] extra"| fhirserve
No cycles. dhis2w-client is the foundation everything builds on, which is what lets it ship to PyPI independently.
Per-version subpackages¶
dhis2w-client and dhis2w-core are organised into per-major subpackages so each DHIS2 version (v41, v42, v43) can evolve its own hand-written code without entangling the others:
dhis2w_client/{v41,v42,v43}/ # hand-written client surface per major
dhis2w_client/generated/{v41,v42,v43}/ # auto-generated wire types per major
dhis2w_core/{v41,v42,v43}/plugins/ # plugin tree per major
v43 is the canonical baseline: new behaviour is written against the v43 tree first and copied to v41 and v42, and the trees diverge per-file as version-specific quirks land (CategoryCombo COC regeneration on v43, the categorys -> categories rename, v41's missing OAuth2ClientCredentialsAuthScheme, etc.). The version-neutral packages (dhis2w-fhir, dhis2w-fhir-serve, dhis2w_core.security_core) import their generated models from dhis2w_client.generated.v43.*.
When you add, rename, or remove anything, apply the change to all three trees. New plugin commands ship as three plugin files; bug fixes that aren't version-specific land in all three. Examples are the exception — they ship as one file under examples/{cli,client,mcp}/, because the wire is the same for almost everything they touch; only an example that exists for a single major lives under that major's subdirectory (examples/client/v43/). The CLAUDE.md hard requirements section spells this out at "Per-version subpackages" — the codebase enforces three-tree symmetry by convention, not by tooling, so the diff is the only check.
Why this matters¶
Every time a new requirement comes in, we should be able to say "that's a plugin", "that's a new auth provider", or "that's a new workspace member" — and build it in isolation. If a new requirement forces edits across three members, the architecture is wrong.