# Recipe — Separating a project into a federation library + consumer app(s)

> **Audience:** any Magogi Foundation project that began as a single
> monolithic application and now needs to become a reusable `mgf-*`
> **domain library** plus one or more thin **consumer apps** (CLI / GUI).
> **Worked example:** vm-vmanager-app → `mgf-vm` (library) + `vmanager`
> (CLI + GUI app). Referenced throughout. **Note:** the `mgf-vm` library
> was later decomposed into the granular siblings `mgf-vm-core` /
> `mgf-vm-sdk` / `mgf-vm-libvirt` / … and the umbrella **retired** (see
> [`umbrella-drain.md`](umbrella-drain.md)). The separation *method* below
> stands unchanged; only that one end-state moved on.
> **Status:** living playbook — generalised from the vm-vmanager-app
> extraction; updated as that extraction completes and as other
> projects follow it.

This is a **how-to playbook**, not a specification. The normative
rules it leans on already live in `mgf-fed standards` and
[`../../FEDERATION.md`](../../FEDERATION.md); this recipe shows
how to *apply* them to one specific, high-stakes task: cutting a
monolith into a library and its apps without breaking anything.

---

## Why this exists

The federation model wants domain logic in reusable `mgf-*`
libraries and apps as thin consumers (see
[`../design/federation.md`](../design/federation.md)). But real
projects rarely start that way — they start as one app that grows
a valuable domain core. Retrofitting the split is delicate: it
touches every import in the codebase, and a careless cut leaves
cycles, hidden coupling, or a library that drags app concerns into
every future consumer.

vm-vmanager-app is the first project to do this end-to-end. This recipe is
its method, generalised — so the next project (and the one after)
follows a proven, verifiable path instead of improvising.

---

## 1. The concept

A monolith mixes three things that want to be separated:

| Layer | Examples | Belongs in |
|---|---|---|
| **Domain logic** | the actual capability — VM lifecycle, USB bus access, parsing, orchestration | a `mgf-*` **library** |
| **Presentation** | turning domain results into output; JSON/settings I/O; the facade both front-ends call | the app's **shared presentation layer** |
| **Front-ends** | Click/Rich command tree; Qt widgets and windows | the app's **`cli/`** and **`gui/`** |

The separation moves the domain layer into its own versioned,
independently-published `mgf-*` library. What's left — presentation
+ front-ends — is the **consumer app**, which keeps the product name.

**Why bother:** the domain logic becomes usable by *other* projects
(a second app, a service, a sibling tool) without copy-paste; the
app shrinks to a thin shell; and the library gets its own test
suite, semver, and release cadence.

---

## 2. The end state — target architecture

```
mgf-common      federation foundation — AppError, logging, bootstrap,
   ▲            BaseAppSettings, the standards corpus
   │
mgf-<name>      NEW library — your domain logic. import `mgf.<name>`,
   │            layout src/mgf/<name>/. Depends ONLY on mgf-common.
   ▲
<product>       consumer app — keeps the product name. Depends on
   │            mgf-<name> + mgf-common.
   ├─ presentation/   shared: canonical JSON + settings I/O, the
   │                  facade over the library, render-agnostic
   │                  view-models
   ├─ cli/            Click + Rich   — no GUI toolkit
   └─ gui/            Qt / PySide6   — shipped behind an optional extra
```

Dependency flow is strictly one-directional: `mgf-common ←
mgf-<name> ← app`. Nothing flows back up.

---

## 3. Decide the repo topology

Two viable shapes. Decide **before** Phase 1.

**Two repos (recommended default):** `mgf-<name>` library + one
app repo that ships the CLI by default and the GUI behind an
optional extra (`pip install <product>` = CLI; `<product>[gui]` =
adds the GUI toolkit). One product identity, one CHANGELOG, atomic
cross-surface commits. Best for a solo/small team and an early
project.

**Three repos:** `mgf-<name>` library + a CLI app repo + a GUI app
repo. Hard isolation and independent release cadence; correct when
the two front-ends are genuinely separate products with diverging
audiences. Costs more scaffolding and a 3-way version-pin dance.

Whichever you pick, **build the internal boundary as if it were
three repos**: `cli/` and `gui/` are separate top-level packages
that never import each other; both depend only on `presentation/`.
That makes a later 2→3 promotion a mechanical `git filter-repo`.

vm-vmanager-app chose **two repos** — see
[`../reference-consumers/vm-vmanager-app.md`](../reference-consumers/vm-vmanager-app.md).

---

## 4. Requirements — what every separation MUST honour

Each rule below is non-negotiable; the *why* is what keeps a future
engineer from "simplifying" it away.

| # | Rule | Why |
|---|---|---|
| R1 | The library depends on **`mgf-common` only** — never a sibling `mgf-*` library. | Federation contract ([`FEDERATION.md`](../../FEDERATION.md)). Sibling imports create a tangle no consumer can reason about. import-linter enforced. |
| R2 | Dependency direction is one-way: `mgf-common ← library ← app`. The library **never** imports the app. | A library that imports its consumer cannot be reused or tested in isolation. |
| R3 | The library carries **no presentation, no UI toolkit, no app-config object**. | Any consumer must be able to use it. The SCOPE standard SP-04 test (`mgf-fed standards SCOPE`): if a second project would need it, it's library; if it's one app's presentation, it stays in the app. |
| R4 | **Identity:** hyphenated repo + distribution slug (`mgf-<name>`); dotted PEP 420 import (`mgf.<name>`); wheel layout `src/mgf/<name>/` (no `src/mgf/__init__.py`). | Federation-wide consistency — every consumer pins and imports the same shape. |
| R5 | `cli/` and `gui/` **never import each other**; both consume the shared `presentation/` layer. | Lets you ship either front-end alone, and lets one presentation-layer test cover both. |
| R6 | The GUI toolkit is an **optional extra**, not a core dependency. | The CLI must install clean on headless servers and in CI. |
| R7 | Every step passes the **four green gates** (`ruff`, `mypy --strict`, tests + coverage, `lint-imports`) and lands as its **own commit**. | Each commit is an independently-verified revert point — "no deep trouble at later stages". |
| R8 | The layering is encoded as **`import-linter` contracts**. | Drift protection — the boundary is enforced mechanically, not by memory. |
| R9 | The new library is a federation **sibling**: it ships its own `SCOPE.md`, `DEPS.md`, `CHANGELOG.md`, declares a conformance level, and is carved out of the consumer-only `MGF_ADOPTION.md` discipline (ADP-04). | It is a library, not a consumer — its adoption story is its own CHANGELOG. |
| R10 | No domain module imports the app's config object. Config reaches the library as **injected primitive values**. | Rule **CF-08** — config is injected, not imported. A library typed against the app's `AppConfig` is coupled to the app. |

---

## 5. Preconditions — before you cut anything

- [ ] `mgf-common` already adopted (typed exceptions, logging,
  settings on `BaseAppSettings`).
- [ ] The test suite is green and a coverage gate is enforced.
- [ ] `import-linter` is installed and at least one layering
  contract exists (UI-is-a-leaf at minimum). Add it if missing —
  it is the safety net for everything that follows.
- [ ] A written, approved extraction plan exists in-repo (see §13).

---

## 6. The method — four phases

| Phase | Goal | Where |
|---|---|---|
| **0 — Prep refactors** | Make the cut mechanical: kill cycles, inject config, evict UI-coupled code from domain packages. | current repo, tests stay green |
| **1 — Extract the library** | Create `mgf-<name>`, move the domain packages, rewrite imports, scaffold CI/docs, publish `0.1.0`. | new repo |
| **2 — App consumes the library** | App depends on `mgf-<name>`; create the `presentation/` seam; add the new import-linter contracts. | app repo |
| **3 — Build the presentation layer** | Consolidate duplicated formatters; canonical JSON + settings I/O. | app repo, separate effort |

Do them **in order**. Phase 0 is the one most projects under-budget
and the one that prevents the most pain — give it its due.

---

## 7. Phase 0 — prep refactors (in depth)

Phase 0 happens entirely in the existing repo. Tests stay green
throughout. Its definition of done: **no domain package imports the
app's config, the CLI, the GUI, or has an import cycle** — at which
point Phase 1 becomes a near-pure file move.

### 7.1 Verify before you refactor

Do **not** trust a hand-wave (or an LLM agent's estimate) about
where the coupling is. Build an import-graph fact base first.
`grimp` (ships with `import-linter`) makes this a few lines:

```python
import grimp

g = grimp.build_graph("<your_package>", include_external_packages=True)

# package-level edges
def top_pkg(m): 
    p = m.split("."); return p[1] if len(p) > 1 and p[0] == "<your_package>" else None

edges = {}
for m in g.modules:
    sp = top_pkg(m)
    for imp in g.find_modules_directly_imported_by(m):
        dp = top_pkg(imp)
        if sp and dp and sp != dp:
            edges.setdefault((sp, dp), (m, imp))

# cycles: (a,b) and (b,a) both present
# config coupling: g.find_modules_that_directly_import("<your_package>.config")
```

Run it before Phase 0, and re-run it after every chunk. On
vm-vmanager-app this caught a wrong assumption immediately — an agent's
estimate claimed eleven `configurator` files imported the app
config; the graph proved the real number was **zero**, and the
coupling was five `environment` modules instead. A refactor was
saved from chasing the wrong files. **Verification is the cheapest
phase; skipping it is the most expensive mistake.**

### 7.2 Break import cycles

A cycle between two packages means neither can move cleanly. For
each cycle, relocate the *shared* type to a leaf module both sides
can depend on one-directionally:

- A data type both packages need → move it to the domain-types
  leaf module.
- A helper that conceptually belongs nearer one side → move the
  whole module there.

Keep churn minimal: leave a **re-export** at the old location so
the dozens of unaffected importers don't all need editing — change
only the one import that closes the cycle.

> vm-vmanager-app broke three cycles this way: `VMCredential` →
> `domain.py`, `guest_agent.py` → `providers/`, `IsolationLevel` →
> `domain.py`. Each was one commit, each gate-verified.

### 7.3 Config dependency-injection (rule CF-10 / CF-08)

A library function MUST NOT import or be typed against the app's
config object. Convert each domain function to take the **specific
primitive values** it needs (paths, timeouts, flags); the caller —
which is app-side and *does* hold the config — injects them.

Sort the coupled modules into two kinds, because they need
different treatment:

- **Config readers** — modules that only read a value or two off
  config. Parameterize them: `run_checks(image_store)` instead of
  `run_checks(config)`. They stay in the library.
- **App-config modules** — modules that *operate on the app's own
  config* (load it, write it, validate it). These are app
  self-checks, not domain logic — they belong app-side. Move them
  out of the domain packages.

### 7.4 Evict UI-coupled code from domain packages

Anything in a would-be-library package that imports a UI toolkit
or a CLI framework is misplaced. Move it into the front-end that
owns it (an interactive prompt wizard → `cli/`; a Qt helper →
`gui/`).

### Phase 0 — definition of done

- [ ] `lint-imports` shows zero cycles among domain packages (a
  single documented, accepted intra-library edge is allowed if
  breaking it is disproportionate — record the exception).
- [ ] No domain package imports the app config, `cli`, or `gui`.
- [ ] Domain packages are free of UI-toolkit / CLI-framework
  imports.
- [ ] Four green gates pass; no test behaviour changed.

---

## 8. Phase 1 — extract the library

1. **Create the repo** — fresh `mgf-<name>` (decide fresh-start vs
   history-preserving up front; fresh-start is simpler).
2. **Layout** — `src/mgf/<name>/`; PEP 420 namespace (no
   `src/mgf/__init__.py`).
3. **Move the domain packages** and rewrite imports
   `<your_package>.X` → `mgf.<name>.X` (scripted codemod).
4. **Move the matching tests** 1:1 (rule TS-01).
5. **`pyproject.toml`** — depend on `mgf-common`; carry over any
   system-package caveats; build with hatchling over `src/mgf`.
6. **CI + gates** — `.importlinter`, the four green gates.
7. **Federation docs** — `SCOPE.md` (explicit in/out scope),
   `DEPS.md`, `CHANGELOG.md`, `docs/claude/CLAUDE.md`, a declared
   conformance level. Run `mgf-fed init-project` to scaffold —
   see [`new_project.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/recipes/new_project.md).
8. **Publish `0.1.0`** to the package index.

---

## 9. Phase 2 — the app consumes the library

1. Remove the extracted packages from the app.
2. Add the `mgf-<name>>=0.1,<0.2` dependency (tight one-minor pin,
   rule AP-03).
3. Repoint `cli/` and `gui/` imports to `mgf.<name>`.
4. Create the `presentation/` package — thin at first: the seam
   both front-ends share.
5. Add the new `import-linter` contracts: `cli ↮ gui`;
   `cli`/`gui` → `presentation` → `mgf.<name>`; `presentation`
   imports no GUI toolkit.
6. Update `MGF_ADOPTION.md` and the conformance tracker.

---

## 10. Phase 3 — build the shared presentation layer

A separate, later effort. Consolidate the formatters the CLI and
GUI currently each hand-roll; establish one canonical JSON result
envelope with a single reader/writer (so a file one front-end
writes is by construction readable by the other); confirm one
canonical settings store. See [`cli.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/recipes/cli.md) for the CLI side.

---

## 11. The verification discipline (non-negotiable)

This is what makes the separation safe rather than terrifying:

1. **Every chunk builds on a verified chunk.** Never start chunk
   *N+1* until chunk *N* is gate-green.
2. **The four gates after every chunk:** `ruff check`,
   `mypy --strict`, the test suite + coverage floor, `lint-imports`.
3. **Re-run the import-graph script** after every chunk to confirm
   the cycle/coupling you targeted is gone and no new one appeared.
4. **One commit per verified chunk.** Each commit message records
   the gate results. A misbehaving later chunk reverts to exactly
   one known-good point.
5. **Prove a failing test is pre-existing** before blaming your
   change — re-run it against a stashed (clean) tree. Flaky perf or
   GUI tests will fail regardless of your diff.

---

## 12. Lessons learned (from vm-vmanager-app)

- **Verify with static analysis, not estimates.** The import graph
  is ground truth; a plausible-sounding scope estimate is not. One
  grimp scan corrected "11 coupled files" to "0".
- **"Reads config" ≠ "owns config."** A module that reads
  `image_store` is a trivial parameterize-and-stay. A module that
  `load_config()`/`save_config()`s the app's own file is an app
  self-check that must move app-side. Sort them early.
- **Re-export shims keep diffs small.** Move the definition; leave
  a one-line re-export behind. Change only the import that breaks
  the cycle, not all fifty consumers.
- **`TYPE_CHECKING` imports are still graph edges.** A cycle that
  runs only through an `if TYPE_CHECKING:` import is still a cycle
  `import-linter` will flag — and still worth breaking.
- **"Two modules" can be a vertical slice.** What scoped as "move
  two files" turned out to interconnect a shared data type, five
  checks, and an inventory field. Explore the full blast radius
  before locking scope; re-scope honestly and out loud when the
  ground shifts.
- **Invert app-specific construction — receive, don't build.**
  When a domain function *builds* an app-specific value, don't
  wrestle it into a decoupled form in place — have the function
  *receive* the finished value as a parameter and let the
  app-side caller build it. vm-vmanager-app's host-doctor `collect()`
  built an app-state slice from app config; making `collect()`
  simply *receive* the pre-built slice collapsed a feared
  "make the doctor pluggable" refactor into a one-parameter
  change, and left the inventory type and its checks untouched.
- **Moving a module relocates its test scaffolding too.** A
  `conftest.py` that patches `the_module._helper` by dotted path
  breaks the instant the module moves. And an autouse isolation
  fixture scoped to the old directory stops covering the new
  callers — vm-vmanager-app's vault-probe conftest had to move from one
  test subdirectory up to the whole unit suite once the probe's
  callers spread across the CLI + GUI test trees.
- **Moves disturb import order.** After relocating an import, run
  the formatter's import-sorter (`ruff check --fix`) — and read
  what it did.
- **Decouple in Phase 0, relocate in Phase 1.** Phase 0 breaks the
  *import*; the physical file move is Phase 1's mechanical job.
  Conflating them bloats Phase 0.
- **Beware the "fourth repo".** Shared CLI+GUI code is *not* a new
  `mgf-*` library and *not* a product — it has no federation home.
  It belongs in the app's `presentation/` layer. If you feel the
  pull to create a repo for it, that is the signal you have one
  product with two faces, not two products.

---

## 13. Checklist — for your project

1. [ ] Write an extraction plan in-repo at
   `docs/inprogress/MGF_<NAME>_EXTRACTION.md` — copy vm-vmanager-app's
   structure. Get it approved before cutting.
2. [ ] Decide repo topology (§3) and library identity (§4 R4).
3. [ ] Confirm preconditions (§5).
4. [ ] Build + run the import-graph verification script (§7.1).
5. [ ] Phase 0: break cycles, inject config, evict UI code — one
   verified commit each.
6. [ ] Phase 0 sign-off — definition of done (§7).
7. [ ] Phase 1: create + scaffold + publish the library.
8. [ ] Phase 2: app consumes the library; add layering contracts.
9. [ ] Phase 3: build out the presentation layer.
10. [ ] Record lessons back into this recipe (file via
    [`FEEDBACK.md`](../../FEEDBACK.md)) so the next project
    benefits.

---

## 14. Worked example — vm-vmanager-app → `mgf-vm`

vm-vmanager-app (an end-to-end KVM/QEMU manager, ~139k LOC, ~3.8k tests)
is the reference execution of this playbook.

- **The per-project plan** — vm-vmanager-app's
  `docs/inprogress/MGF_VM_EXTRACTION.md` (in the
  [vm-vmanager-app repo](https://codeberg.org/magogi-admin/vm-vmanager-app),
  branch `mgf-vm-extraction`): the package-by-package destination
  map, the verified cycle list, per-phase definition-of-done. Copy
  its shape for your `MGF_<NAME>_EXTRACTION.md`.
- **Consumer profile** —
  [`../reference-consumers/vm-vmanager-app.md`](../reference-consumers/vm-vmanager-app.md).
- **Phase 0 chunks, each its own gate-verified commit** on the
  `mgf-vm-extraction` branch — illustrating the discipline:
  - `refactor(domain): move VMCredential to domain.py` — cycle C1
    (`vault ↔ common`), via a leaf-relocation + re-export shim.
  - `refactor(providers): move guest_agent into providers/` —
    cycle C2 (`common ↔ providers`), via a whole-module move.
  - `refactor(domain): move IsolationLevel to domain.py` — cycle
    C3 (`creator ↔ security`), via a leaf-relocation.
  - `refactor(environment): parameterize checker.run_all_checks` —
    config-DI of a "config reader" module (§7.3).

Each commit names its four-gate results. The split outcome:
`mgf-vm` (library) + `vmanager` (CLI + GUI app, two-repo topology).
**Epilogue:** `mgf-vm` was subsequently decomposed into granular siblings
(`mgf-vm-core` / `mgf-vm-sdk` / `mgf-vm-libvirt` / …) and the umbrella
retired; every former consumer drained onto those siblings (see
[`umbrella-drain.md`](umbrella-drain.md)).

---

## Anti-patterns

- **Cutting before Phase 0 is done.** Moving packages while cycles
  or config coupling remain produces a library that won't import
  cleanly. Phase 0 is not optional.
- **Trusting a scope estimate over the import graph.** Always
  verify (§7.1).
- **A library typed against the app's config.** Rule R10 / CF-08 —
  inject primitives.
- **`cli/` importing `gui/` (or vice versa).** Rule R5 — both go
  through `presentation/`.
- **A "shared app code" repo.** The fourth-repo trap (§12).
- **One giant commit for the whole extraction.** Rule R7 — verified
  chunks, or you lose every revert point.

---

## See also

- [`../../FEDERATION.md`](../../FEDERATION.md) — the federation
  contract: library vs. consumer, dependency direction.
- [`../design/federation.md`](../design/federation.md) — federation
  design notes.
- `mgf-fed standards SCOPE` — the SP-01..06 scope discipline:
  what belongs in a library.
- `mgf-fed standards DESIGN_PRINCIPLES` — DP-01 layering,
  DP-02 provider seams.
- [`new_project.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/recipes/new_project.md) — scaffolding the new library
  repo once it's carved out.
- [`../internal/lift_checklist.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/internal/lift_checklist.md)
  — the finer-grained "lift one pattern up into the library" move.
- [`cli.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/recipes/cli.md) — building the CLI front-end on
  `mgf.common.cli`.
