python-monorepo-architecture — SKILL.md
Variant: standard · When to use: architecting the cross-package structure of a multi-package Python uv-workspace monorepo.
Overview
This skill is about the cross-package layer of a Python monorepo: a single repository holding several installable packages (members) managed by a uv workspace — typically one shared library plus one or more application or CLI members that build on it. It covers the decisions that live between packages: where the lib/app boundary goes, which way dependencies are allowed to point, how to keep the boundary honest when the tool can't, what each member exposes to the others, where shared tests live, and how to carve a shared library out of an existing package without breaking it. It does not re-teach intra-package module layout (__all__, layered structure — that is the python-project-structure skill) or general uv usage (uv add/uv run — the uv skill); it owns the uv-workspace wiring because those skills do not cover it, and it composes with both.
When to activate
- ✅ Deciding whether to split a growing codebase into a shared
libs/<core> plus apps/<app> / tools/<cli> members, or keep it one package.
- ✅ Wiring a uv workspace where one member depends on another (the shared library).
- ✅ Diagnosing or preventing a circular import / tangled dependency between packages.
- ✅ Extracting shared logic out of an app or CLI package into a new shared library.
- ✅ Deciding what a member's public API at the package boundary should be, and how consumers import it.
Do NOT activate when:
- The work is inside one package — module layout,
__all__, package __init__ exports → use python-project-structure.
- The work is general uv usage — adding a dependency, running a script, the lockfile → use the
uv skill.
- The repo has one deployable and no shared code to factor out — a single package is correct; splitting it is premature (see Anti-patterns).
- It is a JS/pnpm or Cargo workspace — this skill is Python + uv specific.
Workflow
Step 1: Decide whether to split at all
Split into multiple members only when there is genuinely shared code consumed by two or more deployables, or a deployable that benefits from an independently-testable core. Signals to split: two apps (e.g. a CLI and a web service) both need the same domain logic; one app is a thin shell over reusable logic. Signals not to split:
- One deployable, no reuse — keep one package.
- Members would need conflicting dependency versions or separate virtual environments — a uv workspace shares one environment and one lockfile, so this is the case where you use a path dependency instead of a workspace member (see Step 2), or separate repos.
- Members need different
requires-python — a uv workspace enforces a single requires-python (the intersection of all members), so divergent Python floors don't fit one workspace.
A typical shape, once you do split:
repo/
pyproject.toml # workspace root: [tool.uv.workspace] members
libs/core/ # shared library member (package: core)
apps/api/ # web service member (package: api) -> depends on core
apps/cli/ # CLI member (package: cli) -> depends on core
Step 2: Wire the uv workspace (this skill owns this)
In the root pyproject.toml, declare members as globs:
[tool.uv.workspace]
members = ["libs/*", "apps/*"]
exclude = ["libs/scratch"] # optional
Every directory matched by members (and not excluded) must contain its own pyproject.toml. A member depends on another member by naming it as a normal dependency and marking the source as the workspace:
# apps/api/pyproject.toml
[project]
dependencies = ["core"]
[tool.uv.sources]
core = { workspace = true } # resolve `core` from the workspace, not PyPI; editable
Key mechanics:
- One shared
uv.lock and one virtual environment for the whole workspace. uv lock operates on the entire workspace; uv run / uv sync default to the root and take --package <member> to target one member.
- Workspace dependencies are editable — an edit in
core is immediately visible to api with no reinstall.
[tool.uv.sources] at the root applies to all members; a member's own [tool.uv.sources] overrides it.
- Fallback: when members can't share one environment, use a path dependency (
core = { path = "../../libs/core" }) or separate them — not a workspace.
(For uv add/uv run/lockfile basics see the uv skill; for a member's internal module layout see python-project-structure.)
Step 3: Fix the dependency direction — depend inward, stay acyclic
The package-dependency graph must be a DAG, and it must point inward toward the shared core:
- Apps/CLIs depend on the shared library:
api → core, cli → core.
- Apps never depend on each other: no
api → cli, no cli → api.
- The shared library depends on neither app —
core has no app dependency. Shared code that "needs" something from an app is a sign that thing belongs in core (or in another lib), not that core should import upward.
A cross-app or upward import is the anti-pattern: it creates a cycle (directly or transitively) and couples deployables that should ship independently. If two apps need to share something new, push it down into core (or a second lib), don't import sideways.
Step 4: Keep the boundary honest — uv can't enforce it
A uv workspace shares one environment, and Python has no dependency isolation: nothing at runtime stops a member from importing a package (or module) that a sibling member declared. uv itself states it "can't ensure that packages don't import dependencies declared by another workspace member." So the boundary is kept by convention + code review, not by the tool:
Treat a sideways/upward import (Step 3) as a review-blocking defect.
Declare every dependency a member actually uses in that member's pyproject.toml — never lean on a sibling's transitive install.
Optional automated enforcement — import-linter. It checks a .importlinter contract in CI / a pre-commit hook and fails the build on a forbidden import. The two contract types that express these rules:
- Independence — "these modules don't import each other" → the apps are mutually independent (no
api ↔ cli).
- Layers / Forbidden — a layered order (apps above
core) or "core is forbidden from importing the apps" → enforces depend-inward.
Keep it a thin safety net; the rules above stand with or without it.
Step 5: Define each member's public API at the boundary
A consumer imports a member through that member's public surface — its package __init__ / __all__ — and treats everything else as internal. So:
- Export the shared library's intended API from
libs/core/src/core/__init__.py (with __all__); consumers do from core import X.
- Apps depend on that boundary, not on
core's _internal modules — reaching into another member's private modules re-couples you to its internals and defeats the split.
- (The intra-package
__all__ / underscore-privacy mechanics are the python-project-structure skill's domain — this skill adds only the cross-member rule: consume the boundary, not the internals.)
Use a src layout per member (libs/core/src/core/, apps/api/src/api/) so tests run against the installed package, not a stray top-level import — consistent across members.
Step 6: Place tests and shared fixtures
- Each member owns its
tests/ with its own conftest.py; pytest auto-discovers fixtures per directory.
- For fixtures genuinely shared across members, prefer one of: a small shared test-support package (its own member, or a
tests/fixtures/ module) loaded via pytest_plugins, rather than copy-pasted conftests. Keep a fixture as close as possible to what it represents; only truly repo-wide fixtures belong at the top.
- Don't reach across members'
tests/ directories directly; share through the support package.
Step 7: Extract shared code safely (when splitting an existing package)
Carving a library out of an existing app is a behavior-preserving refactor — safety-net-first:
- Ensure the existing test suite is green first (it is your net).
- Create the new
libs/<core> member (Step 2) and wire the app to depend on it.
- Move the shared modules into
core (move, don't rewrite); repoint the app's imports to from core import ….
- Run the full existing suite — it must stay green with the same test count. Move the moved modules' unit tests into
libs/core/tests/.
- If the move surfaces a cycle (the moved code imported back into the app, or the app needs something the lib now also needs), break it by moving the shared dependency down into
core too, or inverting the dependency (pass the app-specific piece in as a parameter / via an interface) — never satisfy it with an upward import.
See references/worked-example.md for a full three-member walkthrough (root + lib + two apps), the .importlinter contract, and a step-by-step extraction.
Rules
Hard rules (never violate):
- The cross-package dependency graph is a DAG that points inward: apps → shared lib, never app ↔ app, and the shared lib depends on no app.
- A member declares its own dependencies; never rely on a sibling member's transitive install.
- Consumers import another member through its public API (
__init__/__all__), never its _internal modules.
- A member depends on another via
[tool.uv.sources] { <name> = { workspace = true } }; if members can't share one environment / requires-python, it is not a workspace (use a path dep or separate repos).
- Extraction is safety-net-first: existing suite green → move → repoint imports → suite still green; a surfaced cycle is broken by pushing code down, never by an upward import.
Preferences (override-able):
- Use a src layout per member for consistent, install-based test discovery.
- Add an
import-linter contract (Independence + Layers/Forbidden) to enforce the boundary in CI once more than one app exists.
- Share fixtures via a test-support package +
pytest_plugins, not duplicated conftests.
- Keep the member set small; add a member only for a real deployable or real reuse.
Gotchas
- uv won't stop a bad import. The workspace shares one venv, so
from cli import … inside api works at runtime even though it violates the architecture — uv can't catch it. Only review (or import-linter) will. Don't assume "it imports, so it's allowed."
- Single
requires-python for the whole workspace. uv takes the intersection across members; a member that needs a higher Python floor silently raises the floor for everyone (or doesn't fit the workspace). Decide the Python floor at the workspace level.
- Reaching into a sibling's internals.
from core._internal.thing import x compiles and runs, but re-couples you to private structure that can change without notice. Import only the boundary.
- Splitting too early. A two-member workspace for code with no actual reuse adds lockfile + wiring + boundary overhead for nothing. Split when reuse or an independently-testable core is real, not speculatively.
- The extraction that turns green-but-wrong. After moving modules, a still-green suite can hide a new upward import you added to make it pass. Check the dependency direction explicitly after the move, not just the test result.
- Forgetting
[tool.uv.sources]. Listing core in dependencies without the { workspace = true } source makes uv try to resolve it from PyPI — it'll fail or grab a stranger's package. The source line is what makes it a workspace dep.
Anti-patterns
- "I'll just import the other app's helper." A sideways
app → app import to reuse one function. Push the helper down into the shared lib instead; sideways imports cycle and couple deployables.
- "The lib can import from the app just this once." Any upward (lib → app) import inverts the DAG. The thing the lib needs belongs in the lib (or is passed in), never fetched upward.
- "It's all one venv anyway, so boundaries don't matter." The shared environment is a convenience, not a license to cross boundaries — it's exactly why discipline + review are required.
- "Let me split every module into its own package." Over-decomposition: a package per module multiplies pyproject/boundary overhead. A member is a deployable or a genuinely shared library, not every folder.
- "Reach into
_internal to avoid widening the public API." If a consumer needs it, expose it on the boundary deliberately; importing privates is hidden coupling.
Output
The artifact is a decision + structure: a member topology (which packages exist and why), the root + per-member pyproject.toml wiring ([tool.uv.workspace] + [tool.uv.sources]), a stated acyclic depend-inward dependency direction, each member's public-API boundary, a test/fixture layout, and — when refactoring — a safety-net-first extraction sequence. The consumer is the engineer or coding agent building or restructuring the workspace; the structure it produces is what later feature work builds on.
Related
uv — general uv usage (uv add, uv run, the lockfile). This skill owns the workspace wiring the uv skill doesn't cover; reach for uv for everything non-workspace.
python-project-structure — intra-package module architecture, __all__, layered structure. This skill is the cross-package layer above it; use them together (this one for the member boundary, that one for what's inside a member).
import-linter (external tool) — optional CI enforcement of the boundary via .importlinter contracts.
Progressive disclosure
references/worked-example.md — a full three-member workspace walkthrough (root + shared lib + two apps with the pyproject.toml edges), an example .importlinter contract (Independence + Layers), and a step-by-step safe-extraction. Load when building or restructuring a concrete workspace.
references/sources.md — research provenance / citations.
Body budget
description ≤ 1,024 chars. Claude truncates combined description + when_to_use at 1,536 chars in the listing.
- Body ≤ ~500 lines / 5,000 tokens.
- Per reference file: warn >10k tokens, error >25k. Total references: warn >25k tokens, error >50k.
1---2name: python-monorepo-architecture3description: Use when architecting or splitting a Python repository into a multi-package uv-workspace monorepo — a shared internal library plus one or more app or CLI members that depend on it — and you need the cross-package boundaries right: which code becomes a shared lib vs an app member, the acyclic depend-inward dependency direction (apps depend on the core lib, never on each other), the import-isolation discipline uv cannot enforce, each member's public API at the package boundary, cross-member test layout, and safely extracting shared code out of an existing package. Covers uv workspace wiring (members, tool.uv.sources, single lockfile, --package) and composes with the uv and python-project-structure skills. Keywords: monorepo, uv workspace, multi-package, shared library, package boundaries, dependency direction, circular import, import-linter.4---56# `python-monorepo-architecture` — SKILL.md78> **Variant:** standard · **When to use:** architecting the cross-package structure of a multi-package Python uv-workspace monorepo.910## Overview1112This skill is about the **cross-package** layer of a Python monorepo: a single repository holding several installable packages (members) managed by a uv workspace — typically one shared library plus one or more application or CLI members that build on it. It covers the decisions that live *between* packages: where the lib/app boundary goes, which way dependencies are allowed to point, how to keep the boundary honest when the tool can't, what each member exposes to the others, where shared tests live, and how to carve a shared library out of an existing package without breaking it. It does **not** re-teach intra-package module layout (`__all__`, layered structure — that is the `python-project-structure` skill) or general uv usage (`uv add`/`uv run` — the `uv` skill); it *owns* the uv-workspace wiring because those skills do not cover it, and it composes with both.1314## When to activate1516- ✅ Deciding whether to split a growing codebase into a shared `libs/<core>` plus `apps/<app>` / `tools/<cli>` members, or keep it one package.17- ✅ Wiring a uv workspace where one member depends on another (the shared library).18- ✅ Diagnosing or preventing a circular import / tangled dependency between packages.19- ✅ Extracting shared logic out of an app or CLI package into a new shared library.20- ✅ Deciding what a member's public API at the package boundary should be, and how consumers import it.2122**Do NOT activate when:**2324- The work is *inside one package* — module layout, `__all__`, package `__init__` exports → use `python-project-structure`.25- The work is general uv usage — adding a dependency, running a script, the lockfile → use the `uv` skill.26- The repo has one deployable and no shared code to factor out — a single package is correct; splitting it is premature (see Anti-patterns).27- It is a JS/pnpm or Cargo workspace — this skill is Python + uv specific.2829## Workflow3031### Step 1: Decide whether to split at all3233Split into multiple members only when there is **genuinely shared code consumed by two or more deployables**, or a deployable that benefits from an independently-testable core. Signals to split: two apps (e.g. a CLI and a web service) both need the same domain logic; one app is a thin shell over reusable logic. Signals **not** to split:3435- One deployable, no reuse — keep one package.36- Members would need **conflicting dependency versions** or **separate virtual environments** — a uv workspace shares one environment and one lockfile, so this is the case where you use a **path dependency** instead of a workspace member (see Step 2), or separate repos.37- Members need **different `requires-python`** — a uv workspace enforces a single `requires-python` (the intersection of all members), so divergent Python floors don't fit one workspace.3839A typical shape, once you do split:4041```42repo/43 pyproject.toml # workspace root: [tool.uv.workspace] members44 libs/core/ # shared library member (package: core)45 apps/api/ # web service member (package: api) -> depends on core46 apps/cli/ # CLI member (package: cli) -> depends on core47```4849### Step 2: Wire the uv workspace (this skill owns this)5051In the **root** `pyproject.toml`, declare members as globs:5253```toml54[tool.uv.workspace]55members = ["libs/*", "apps/*"]56exclude = ["libs/scratch"] # optional57```5859Every directory matched by `members` (and not `exclude`d) must contain its own `pyproject.toml`. A member depends on another member by naming it as a normal dependency **and** marking the source as the workspace:6061```toml62# apps/api/pyproject.toml63[project]64dependencies = ["core"]6566[tool.uv.sources]67core = { workspace = true } # resolve `core` from the workspace, not PyPI; editable68```6970Key mechanics:7172- One **shared `uv.lock`** and one virtual environment for the whole workspace. `uv lock` operates on the entire workspace; `uv run` / `uv sync` default to the root and take `--package <member>` to target one member.73- Workspace dependencies are **editable** — an edit in `core` is immediately visible to `api` with no reinstall.74- `[tool.uv.sources]` at the **root** applies to all members; a member's own `[tool.uv.sources]` overrides it.75- **Fallback:** when members can't share one environment, use a **path dependency** (`core = { path = "../../libs/core" }`) or separate them — not a workspace.7677(For `uv add`/`uv run`/lockfile basics see the `uv` skill; for a member's *internal* module layout see `python-project-structure`.)7879### Step 3: Fix the dependency direction — depend inward, stay acyclic8081The package-dependency graph must be a **DAG**, and it must point **inward** toward the shared core:8283- Apps/CLIs depend **on** the shared library: `api → core`, `cli → core`.84- Apps **never** depend on each other: no `api → cli`, no `cli → api`.85- The shared library depends on **neither** app — `core` has no app dependency. Shared code that "needs" something from an app is a sign that thing belongs *in* core (or in another lib), not that core should import upward.8687A cross-app or upward import is the anti-pattern: it creates a cycle (directly or transitively) and couples deployables that should ship independently. If two apps need to share something new, push it **down** into `core` (or a second lib), don't import sideways.8889### Step 4: Keep the boundary honest — uv can't enforce it9091A uv workspace shares one environment, and **Python has no dependency isolation**: nothing at runtime stops a member from importing a package (or module) that a *sibling* member declared. uv itself states it "can't ensure that packages don't import dependencies declared by another workspace member." So the boundary is kept by **convention + code review**, not by the tool:9293- Treat a sideways/upward import (Step 3) as a **review-blocking** defect.94- Declare every dependency a member actually uses in **that member's** `pyproject.toml` — never lean on a sibling's transitive install.95- **Optional automated enforcement — `import-linter`.** It checks a `.importlinter` contract in CI / a pre-commit hook and fails the build on a forbidden import. The two contract types that express these rules:96 - **Independence** — "these modules don't import each other" → the apps are mutually independent (no `api ↔ cli`).97 - **Layers** / **Forbidden** — a layered order (apps above `core`) or "`core` is forbidden from importing the apps" → enforces depend-inward.9899 Keep it a thin safety net; the rules above stand with or without it.100101### Step 5: Define each member's public API at the boundary102103A consumer imports a member through that member's **public surface** — its package `__init__` / `__all__` — and treats everything else as internal. So:104105- Export the shared library's intended API from `libs/core/src/core/__init__.py` (with `__all__`); consumers do `from core import X`.106- Apps depend on that boundary, **not** on `core`'s `_internal` modules — reaching into another member's private modules re-couples you to its internals and defeats the split.107- (The intra-package `__all__` / underscore-privacy mechanics are the `python-project-structure` skill's domain — this skill adds only the cross-member rule: *consume the boundary, not the internals*.)108109Use a **src layout per member** (`libs/core/src/core/`, `apps/api/src/api/`) so tests run against the installed package, not a stray top-level import — consistent across members.110111### Step 6: Place tests and shared fixtures112113- Each member owns its `tests/` with its own `conftest.py`; pytest auto-discovers fixtures per directory.114- For fixtures **genuinely shared across members**, prefer one of: a small **shared test-support package** (its own member, or a `tests/fixtures/` module) loaded via `pytest_plugins`, rather than copy-pasted conftests. Keep a fixture as close as possible to what it represents; only truly repo-wide fixtures belong at the top.115- Don't reach across members' `tests/` directories directly; share through the support package.116117### Step 7: Extract shared code safely (when splitting an existing package)118119Carving a library out of an existing app is a **behavior-preserving refactor — safety-net-first**:1201211. Ensure the existing test suite is green first (it is your net).1222. Create the new `libs/<core>` member (Step 2) and wire the app to depend on it.1233. **Move** the shared modules into `core` (move, don't rewrite); repoint the app's imports to `from core import …`.1244. Run the **full existing suite** — it must stay green with the same test count. Move the moved modules' unit tests into `libs/core/tests/`.1255. If the move surfaces a **cycle** (the moved code imported back into the app, or the app needs something the lib now also needs), break it by moving the shared dependency down into `core` too, or inverting the dependency (pass the app-specific piece in as a parameter / via an interface) — never satisfy it with an upward import.126127See `references/worked-example.md` for a full three-member walkthrough (root + lib + two apps), the `.importlinter` contract, and a step-by-step extraction.128129## Rules130131**Hard rules (never violate):**132133- The cross-package dependency graph is a **DAG that points inward**: apps → shared lib, never app ↔ app, and the shared lib depends on no app.134- A member declares **its own** dependencies; never rely on a sibling member's transitive install.135- Consumers import another member through its **public API** (`__init__`/`__all__`), never its `_internal` modules.136- A member depends on another via `[tool.uv.sources] { <name> = { workspace = true } }`; if members can't share one environment / `requires-python`, it is **not** a workspace (use a path dep or separate repos).137- Extraction is **safety-net-first**: existing suite green → move → repoint imports → suite still green; a surfaced cycle is broken by pushing code down, never by an upward import.138139**Preferences (override-able):**140141- Use a **src layout** per member for consistent, install-based test discovery.142- Add an **`import-linter`** contract (Independence + Layers/Forbidden) to enforce the boundary in CI once more than one app exists.143- Share fixtures via a **test-support package** + `pytest_plugins`, not duplicated conftests.144- Keep the member set small; add a member only for a real deployable or real reuse.145146## Gotchas147148- **uv won't stop a bad import.** The workspace shares one venv, so `from cli import …` inside `api` *works at runtime* even though it violates the architecture — uv can't catch it. Only review (or `import-linter`) will. Don't assume "it imports, so it's allowed."149- **Single `requires-python` for the whole workspace.** uv takes the intersection across members; a member that needs a higher Python floor silently raises the floor for everyone (or doesn't fit the workspace). Decide the Python floor at the workspace level.150- **Reaching into a sibling's internals.** `from core._internal.thing import x` compiles and runs, but re-couples you to private structure that can change without notice. Import only the boundary.151- **Splitting too early.** A two-member workspace for code with no actual reuse adds lockfile + wiring + boundary overhead for nothing. Split when reuse or an independently-testable core is real, not speculatively.152- **The extraction that turns green-but-wrong.** After moving modules, a still-green suite can hide a new *upward* import you added to make it pass. Check the dependency direction explicitly after the move, not just the test result.153- **Forgetting `[tool.uv.sources]`.** Listing `core` in `dependencies` without the `{ workspace = true }` source makes uv try to resolve it from PyPI — it'll fail or grab a stranger's package. The source line is what makes it a workspace dep.154155## Anti-patterns156157- **"I'll just import the other app's helper."** A sideways `app → app` import to reuse one function. Push the helper **down** into the shared lib instead; sideways imports cycle and couple deployables.158- **"The lib can import from the app just this once."** Any upward (lib → app) import inverts the DAG. The thing the lib needs belongs in the lib (or is passed in), never fetched upward.159- **"It's all one venv anyway, so boundaries don't matter."** The shared environment is a convenience, not a license to cross boundaries — it's exactly why discipline + review are required.160- **"Let me split every module into its own package."** Over-decomposition: a package per module multiplies pyproject/boundary overhead. A member is a *deployable or a genuinely shared library*, not every folder.161- **"Reach into `_internal` to avoid widening the public API."** If a consumer needs it, expose it on the boundary deliberately; importing privates is hidden coupling.162163## Output164165The artifact is a **decision + structure**: a member topology (which packages exist and why), the root + per-member `pyproject.toml` wiring (`[tool.uv.workspace]` + `[tool.uv.sources]`), a stated acyclic depend-inward dependency direction, each member's public-API boundary, a test/fixture layout, and — when refactoring — a safety-net-first extraction sequence. The consumer is the engineer or coding agent building or restructuring the workspace; the structure it produces is what later feature work builds on.166167## Related168169- `uv` — general uv usage (`uv add`, `uv run`, the lockfile). This skill owns the *workspace* wiring the `uv` skill doesn't cover; reach for `uv` for everything non-workspace.170- `python-project-structure` — *intra-package* module architecture, `__all__`, layered structure. This skill is the *cross-package* layer above it; use them together (this one for the member boundary, that one for what's inside a member).171- `import-linter` (external tool) — optional CI enforcement of the boundary via `.importlinter` contracts.172173## Progressive disclosure174175- `references/worked-example.md` — a full three-member workspace walkthrough (root + shared lib + two apps with the `pyproject.toml` edges), an example `.importlinter` contract (Independence + Layers), and a step-by-step safe-extraction. Load when building or restructuring a concrete workspace.176- `references/sources.md` — research provenance / citations.177178## Body budget179180- `description` ≤ 1,024 chars. Claude truncates combined `description` + `when_to_use` at 1,536 chars in the listing.181- Body ≤ ~500 lines / 5,000 tokens.182- Per reference file: warn >10k tokens, error >25k. Total references: warn >25k tokens, error >50k.