|
| 1 | +# Extension Patterns — "Extend, Don't Modify Core" |
| 2 | + |
| 3 | +## The Golden Rule |
| 4 | + |
| 5 | +Karrio is modular by design: `modules/core`, `modules/graph`, `modules/admin` form the server core; `modules/connectors/*` are plugins; everything else (`modules/orders`, `modules/data`, `modules/documents`, `modules/events`, `modules/pricing`, `modules/manager`, ...) is an auto-discovered extension module. |
| 6 | + |
| 7 | +**Prefer adding a new extension module over modifying an existing one.** Modify a core module only when the feature is genuinely generic and benefits all karrio deployments. |
| 8 | + |
| 9 | +## Decision Tree |
| 10 | + |
| 11 | +``` |
| 12 | +Is this feature specific to one domain (orders, documents, pricing, …)? |
| 13 | + YES -> Extend or create the relevant modules/<name>/ package |
| 14 | + NO -> Is it a bug fix in an existing module? |
| 15 | + YES -> Fix in that module, isolate to a clean commit |
| 16 | + NO -> Is it a new generic capability the server always needs? |
| 17 | + YES -> Add to modules/core or modules/graph |
| 18 | + NO -> Create a new extension module in modules/<name>/ |
| 19 | +``` |
| 20 | + |
| 21 | +## The Extension Module Pattern |
| 22 | + |
| 23 | +### Architecture |
| 24 | + |
| 25 | +``` |
| 26 | + ┌────────────────────────────────────────┐ |
| 27 | + │ karrio/ │ |
| 28 | + │ │ |
| 29 | + │ modules/ │ |
| 30 | + │ ├── core/ (OSS core) │ |
| 31 | + │ ├── graph/ (OSS graph) │ |
| 32 | + │ ├── admin/ (OSS admin) │ |
| 33 | + │ ├── manager/ │ |
| 34 | + │ ├── orders/ │ |
| 35 | + │ ├── data/ │ |
| 36 | + │ ├── documents/ │ |
| 37 | + │ ├── events/ │ |
| 38 | + │ ├── pricing/ │ |
| 39 | + │ ├── connectors/*/ (carrier plugins) │ |
| 40 | + │ └── <your-module>/ │ |
| 41 | + │ │ │ |
| 42 | + │ │ hooks via │ |
| 43 | + │ │ AppConfig.ready() │ |
| 44 | + │ ▼ │ |
| 45 | + │ ┌────────────────────┐ │ |
| 46 | + │ │ core hook points │ │ |
| 47 | + │ │ @pre_processing │ │ |
| 48 | + │ │ pkgutil discover │ │ |
| 49 | + │ │ INSTALLED_APPS │ │ |
| 50 | + │ └────────────────────┘ │ |
| 51 | + └────────────────────────────────────────┘ |
| 52 | +``` |
| 53 | + |
| 54 | +### Canonical Examples |
| 55 | + |
| 56 | +Study these before creating new extensions: |
| 57 | + |
| 58 | +- `modules/orders/karrio/server/orders/` — domain module with REST + GraphQL + signals |
| 59 | +- `modules/events/karrio/server/events/` — webhook/event delivery module with Huey task registration |
| 60 | +- `modules/documents/karrio/server/documents/` — module that registers its own auto-discovered URLs |
| 61 | +- `modules/graph/karrio/server/graph/schemas/base/` — reference GraphQL module layout (base schemas for the whole server) |
| 62 | + |
| 63 | +### Module File Organization |
| 64 | + |
| 65 | +Keep `__init__.py` as a **thin interface definition** — just `Query` field declarations and `Mutation` one-liner delegations. All resolver logic belongs in `types.py` and `mutations.py`. |
| 66 | + |
| 67 | +**Canonical reference:** `modules/graph/karrio/server/graph/schemas/base/__init__.py` |
| 68 | + |
| 69 | +- **`__init__.py`** — Interface only. `Query` uses `strawberry.field(resolver=types.XType.resolve)`. `Mutation` methods are one-liners that delegate to `mutations.XMutation.mutate()`. No business logic, no imports of domain modules. |
| 70 | +- **`types.py`** — Strawberry types with `resolve` / `resolve_list` static methods containing query logic. |
| 71 | +- **`inputs.py`** — Strawberry input types and filters. |
| 72 | +- **`mutations.py`** — Mutation classes with `mutate()` static methods containing mutation logic. |
| 73 | +- **`datatypes.py`** — `@attr.s(auto_attribs=True)` dataclasses for structured data flowing through the module. Prefer typed attributes over raw dicts. |
| 74 | +- **`utils.py`** — Reusable helper functions (payload builders, transformers, formatters, availability decorators). **Must not import from `types.py` or `mutations.py`** — see dependency rule below. |
| 75 | + |
| 76 | +``` |
| 77 | +modules/<name>/karrio/server/graph/schemas/<name>/ |
| 78 | +├── __init__.py # Thin interface: Query fields + Mutation delegators |
| 79 | +├── types.py # Strawberry types + resolve/resolve_list methods |
| 80 | +├── inputs.py # Strawberry input types and filters |
| 81 | +├── mutations.py # Mutation classes + mutate() methods |
| 82 | +├── datatypes.py # @attr.s dataclasses for typed data |
| 83 | +└── utils.py # Business logic, payload builders, decorators |
| 84 | +``` |
| 85 | + |
| 86 | +### Dependency Direction (one-way only) |
| 87 | + |
| 88 | +Imports between schema files must flow in one direction. Circular imports between these files cause silent schema registration failures (`schema.py` catches the error and skips the module). |
| 89 | + |
| 90 | +``` |
| 91 | +__init__.py ──→ types.py ──→ utils.py |
| 92 | + ──→ mutations.py ──→ utils.py |
| 93 | + ──→ inputs.py |
| 94 | +``` |
| 95 | + |
| 96 | +- **`utils.py`** must never import `types.py` or `mutations.py`. |
| 97 | +- **`types.py`** must never import `mutations.py` (or vice versa). |
| 98 | +- Factory methods that construct a GraphQL type belong as **static methods on the type itself** (e.g., `ShipmentType.parse(...)`), not in `utils.py`. |
| 99 | + |
| 100 | +```python |
| 101 | +# ✅ Good — type knows how to construct itself |
| 102 | +@strawberry.type |
| 103 | +class ItemType: |
| 104 | + id: int |
| 105 | + name: str |
| 106 | + |
| 107 | + @staticmethod |
| 108 | + def parse(raw: dict) -> "ItemType": |
| 109 | + return ItemType(id=raw["id"], name=raw.get("name", "")) |
| 110 | + |
| 111 | +# ❌ Bad — utils.py imports types.py, creating circular dependency |
| 112 | +# utils.py |
| 113 | +import karrio.server.graph.schemas.items.types as types |
| 114 | +def enrich_item(raw: dict) -> types.ItemType: # circular! |
| 115 | + return types.ItemType(...) |
| 116 | +``` |
| 117 | + |
| 118 | +```python |
| 119 | +# ✅ Good __init__.py — thin interface |
| 120 | +@strawberry.type |
| 121 | +class Query: |
| 122 | + items: typing.List[types.ItemType] = strawberry.field( |
| 123 | + resolver=types.ItemType.resolve_list |
| 124 | + ) |
| 125 | + item: typing.Optional[types.ItemType] = strawberry.field( |
| 126 | + resolver=types.ItemType.resolve |
| 127 | + ) |
| 128 | + |
| 129 | +@strawberry.type |
| 130 | +class Mutation: |
| 131 | + @strawberry.mutation |
| 132 | + def create_item(self, info: Info, input: inputs.CreateItemInput) -> mutations.CreateItemMutation: |
| 133 | + return mutations.CreateItemMutation.mutate(info, **input.__dict__) |
| 134 | + |
| 135 | +# ❌ Bad __init__.py — inline resolver logic |
| 136 | +@strawberry.type |
| 137 | +class Query: |
| 138 | + @strawberry.field |
| 139 | + @staticmethod |
| 140 | + def items(info: Info) -> typing.List[types.ItemType]: |
| 141 | + # 50 lines of business logic... |
| 142 | +``` |
| 143 | + |
| 144 | +### Hook Registration Pattern |
| 145 | + |
| 146 | +```python |
| 147 | +# apps.py |
| 148 | +from django.apps import AppConfig |
| 149 | + |
| 150 | +class OrdersConfig(AppConfig): |
| 151 | + name = "karrio.server.orders" |
| 152 | + |
| 153 | + def ready(self): |
| 154 | + from karrio.server.orders import signals # noqa: registers signal handlers |
| 155 | + # Append validation hooks to a core serializer's pre_process_functions: |
| 156 | + # from karrio.server.manager.serializers import ShipmentSerializer |
| 157 | + # ShipmentSerializer.pre_process_functions.append(validators.validate_order_link) |
| 158 | +``` |
| 159 | + |
| 160 | +### Settings Auto-Discovery |
| 161 | + |
| 162 | +```python |
| 163 | +# modules/<name>/karrio/server/settings/<name>.py |
| 164 | +from karrio.server.settings.base import * # noqa |
| 165 | + |
| 166 | +INSTALLED_APPS += ["karrio.server.<name>"] |
| 167 | +KARRIO_URLS += ["karrio.server.<name>.urls"] # if module has REST endpoints |
| 168 | +``` |
| 169 | + |
| 170 | +Karrio discovers settings modules via `importlib.util.find_spec()` at startup. Your settings file runs only if the module is installed via `-e ./modules/<name>` in `requirements.build.txt`. |
| 171 | + |
| 172 | +### GraphQL Extension (Auto-Discovery) |
| 173 | + |
| 174 | +```python |
| 175 | +# modules/<name>/karrio/server/graph/schemas/<name>/__init__.py |
| 176 | +import strawberry |
| 177 | +import typing |
| 178 | +from strawberry.types import Info |
| 179 | + |
| 180 | +@strawberry.type |
| 181 | +class Query: |
| 182 | + # Fields auto-merged into root Query via pkgutil.iter_modules() |
| 183 | + ... |
| 184 | + |
| 185 | +@strawberry.type |
| 186 | +class Mutation: |
| 187 | + # Fields auto-merged into root Mutation |
| 188 | + ... |
| 189 | + |
| 190 | +extra_types: typing.List = [] # required even if empty |
| 191 | +``` |
| 192 | + |
| 193 | +### Namespace Packages — NEVER Add `__init__.py` to Shared Paths |
| 194 | + |
| 195 | +Karrio uses **implicit namespace packages** (`pkgutil.extend_path`) so that multiple installed packages can contribute to the same Python namespace (e.g., `karrio.server.graph.schemas`). The core packages (`modules/graph/`, `modules/admin/`, etc.) already define the namespace roots. |
| 196 | + |
| 197 | +**NEVER add `__init__.py` files to namespace paths that are owned by another package.** Doing so converts the implicit namespace package into a regular package, which shadows the core package and breaks `pkgutil.iter_modules()` discovery. |
| 198 | + |
| 199 | +``` |
| 200 | +# ❌ WRONG — adding __init__.py to a namespace owned by core graph |
| 201 | +modules/<yourmod>/karrio/server/graph/__init__.py # breaks karrio.server.graph |
| 202 | +modules/<yourmod>/karrio/server/graph/schemas/__init__.py # breaks schema discovery |
| 203 | +
|
| 204 | +# ✅ CORRECT — only add __init__.py inside your module's own leaf directory |
| 205 | +modules/<yourmod>/karrio/server/graph/schemas/<yourmod>/__init__.py # leaf: your schema module |
| 206 | +modules/<yourmod>/karrio/server/<yourmod>/__init__.py # leaf: your module root |
| 207 | +``` |
| 208 | + |
| 209 | +**Rule of thumb:** if a directory path already exists in another installed module (e.g., `modules/graph/karrio/server/graph/`), do NOT create an `__init__.py` at that same path in your extension module. Only the **leaf directory** unique to your module gets `__init__.py`. |
| 210 | + |
| 211 | +### Core Hook Points |
| 212 | + |
| 213 | +| Hook | Location | Purpose | |
| 214 | +|------|----------|---------| |
| 215 | +| `@pre_processing` | `karrio.server.core.utils` | Append validators to serializer pipelines | |
| 216 | +| `AppConfig.ready()` | Django app startup | Register hooks, signal handlers | |
| 217 | +| `pkgutil.iter_modules()` | `graph/schema.py`, `admin/schema.py` | Auto-discover GraphQL schemas | |
| 218 | +| `importlib.util.find_spec()` | `settings/base.py` | Auto-discover settings modules | |
| 219 | +| `KARRIO_URLS` | `settings/base.py` | Register REST URL patterns | |
| 220 | +| `huey` task registry | `karrio.server.events.task_definitions` | Auto-discovered background tasks | |
| 221 | + |
| 222 | +## Creating a New Extension Module |
| 223 | + |
| 224 | +1. Create directory: `modules/<name>/karrio/server/<name>/`. |
| 225 | +2. Add `apps.py` with `AppConfig` and optional `ready()` hook. |
| 226 | +3. Add `karrio/server/settings/<name>.py` for auto-discovery. |
| 227 | +4. Add GraphQL schemas under `karrio/server/graph/schemas/<name>/` or `karrio/server/admin/schemas/<name>/` (admin is OSS-side; NOT `ee/insiders/modules/admin`). |
| 228 | +5. **Do NOT add `__init__.py` to shared namespace paths** (e.g., `karrio/server/graph/`, `karrio/server/admin/`) — only the leaf directory unique to your module gets `__init__.py` (see "Namespace Packages" above). |
| 229 | +6. Add tests under `modules/<name>/karrio/server/<name>/tests/`. |
| 230 | +7. **Add `karrio.server.<name>.tests` to `bin/run-server-tests`** — without this, tests pass locally but never run in CI. |
| 231 | +8. **Add `-e ./modules/<name>` to `requirements.build.txt`** — without this the module is not installed in Docker images and schema discovery silently skips it. |
| 232 | +9. Use the `create-extension-module` skill for scaffolding. |
| 233 | + |
| 234 | +## Connectors vs Extension Modules |
| 235 | + |
| 236 | +Carrier connectors under `modules/connectors/*/` follow a separate structure documented in [`carrier-integration.md`](./carrier-integration.md) — use `./bin/cli sdk add-extension` to scaffold. Do NOT use the extension-module pattern for carriers. |
0 commit comments