What is a Monorepo?
A monorepo is a single Git repository that contains multiple distinct packages or applications. The alternative is a polyrepo: one repo per app or package.
This repo is nest-monorepo. It contains:
nest-monorepo/
├── apps/
│ ├── api/ ← FastAPI backend
│ ├── worker/ ← Celery async worker
│ └── cli/ ← Internal tooling CLI
├── packages/
│ ├── shared/ ← Pydantic schemas + SQLAlchemy models
│ ├── events/ ← Event type definitions
│ └── proto/ ← Protobuf definitions + generated stubs
The core idea: code that is needed by multiple apps lives in packages/, and all apps can import it directly — no PyPI publishing required.
Tool 1: uv Workspaces — The Foundation
uv is the package manager. Workspaces are its monorepo feature.
How it's declared
Root pyproject.toml:
[tool.uv.workspace]
members = ["apps/*", "packages/*"]
That's it. Three lines define the entire workspace. uv reads every pyproject.toml under those globs and treats each directory as a workspace member.
What this enables
When you run uv sync at the repo root, uv:
- Reads every
pyproject.tomlin every workspace directory - Resolves all external dependencies into a single
uv.lockfile at the root - Installs internal packages as editable installs so they can import each other directly
The { workspace = true } source
Look at apps/api/pyproject.toml:
[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["shared", "events", "fastapi>=0.115"]
[tool.uv.sources]
shared = { workspace = true }
events = { workspace = true }
{ workspace = true } means: don't fetch this from PyPI — link it from this workspace instead. When api imports shared, Python resolves it to packages/shared/src/ on disk via an editable install. No publishing, no version pinning against PyPI, no symlink juggling.
This is the mechanism that makes internal sharing work.
Why uv over pip/poetry?
uv uses a global content-addressable cache at ~/.cache/uv. Every version of every package is stored once globally across all projects on your machine. Workspaces get hard links to the cache, not copies. This means:
- 10–100x faster installs than pip
- A single
uv.lockcommitted at the repo root — one lockfile, all packages - Strict resolution — packages can't accidentally import things they didn't declare
Note: uv.lock is committed to version control. Unlike go.work.sum, it is not gitignored. It is the canonical record of every resolved dependency across the entire workspace.
One virtualenv to rule them all
uv sync creates a single .venv at the repo root. All workspace members are installed into it as editable installs. There is no per-package virtualenv.
This has two important consequences:
-
No version skew.
apiandworkercannot depend on different versions ofshared— they share the same installed environment. -
IDE setup is trivial. Point your editor's Python interpreter at
.venv/bin/pythononce, at the repo root. Every package is immediately importable — no per-project interpreter switching.
uv sync # creates .venv, installs everything
source .venv/bin/activate # or let your IDE detect it
python -c "import shared" # works from anywhere in the repo
Add .venv/ to .gitignore.
Tool 2: Task (Taskfile.yml) — Task Orchestration and Caching
uv workspaces handle dependencies. Task handles tasks (build, test, lint, etc.).
Installing Task
Task is a language-agnostic binary — it has nothing to do with Go and requires no Go installation.
# macOS / Linux via Homebrew (recommended)
brew install go-task
# Linux: direct install script (no Go needed)
sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
# Or grab a binary directly from https://github.com/go-task/task/releases
Verify with task --version.
The problem Task solves
If you run uv run pytest across 6 packages, you have to figure out the order yourself. shared must be importable before events, which must be importable before api and worker. proto must run buf generate before anything imports the generated stubs. Do it wrong and you get import errors or stale generated code.
Also, if nothing in packages/shared changed, you shouldn't rebuild it.
Task solves both: explicit task ordering + file-based caching via sources/generates.
Taskfile.yml — the pipeline
version: '3'
includes:
shared:
taskfile: ./packages/shared/Taskfile.yml
dir: ./packages/shared # without dir:, commands run from repo root
events:
taskfile: ./packages/events/Taskfile.yml
dir: ./packages/events
proto:
taskfile: ./packages/proto/Taskfile.yml
dir: ./packages/proto
api:
taskfile: ./apps/api/Taskfile.yml
dir: ./apps/api
worker:
taskfile: ./apps/worker/Taskfile.yml
dir: ./apps/worker
cli:
taskfile: ./apps/cli/Taskfile.yml
dir: ./apps/cli
tasks:
build:all:
desc: Build all packages in dependency order
cmds:
- task: shared:build
- task: events:build
- task: proto:generate
- task: api:build
- task: worker:build
- task: cli:build
test:all:
desc: Run all tests
deps: [shared:build, events:build, proto:generate]
cmds:
- task: api:test
- task: worker:test
- task: cli:test
lint:all:
cmds:
- task: shared:lint
- task: events:lint
- task: proto:lint
- task: api:lint
- task: worker:lint
- task: cli:lint
packages/proto/Taskfile.yml:
version: '3'
tasks:
generate:
desc: Generate Python stubs from .proto files
sources:
- '**/*.proto'
generates:
- 'generated/**/*.py'
cmds:
- buf generate
Key concepts:
-
sources— glob patterns of input files. Task fingerprints these. -
generates— files produced by the task. Task checks if they still exist and match the fingerprint. - If inputs haven't changed and outputs still exist, Task skips the task entirely (cache hit). Results are stored in
.task/. -
deps:runs dependencies in parallel before the task executes. -
cmds:runs commands sequentially, in order.
How the build graph flows
packages/shared (uv sync)
↓
packages/events (uv sync) packages/proto (buf generate)
↓ ↓
apps/api (generates stubs)
apps/worker ↓
apps/cli apps/api, apps/worker (import proto stubs)
Running individual apps
# Run just the API
task api:dev
# Run just the worker
task worker:dev
# Build a single package
task shared:build
Affected-only in CI
Task has no --affected flag. The naive approach — looping over changed package names — is wrong: if shared changes, api and worker (which depend on it) must also be tested. You need reverse-dependency propagation.
#!/usr/bin/env bash
# ci/affected.sh — tests changed packages AND their dependents
CHANGED=$(git diff --name-only origin/main...HEAD)
# Reverse dependency map: if X changes, also test these
declare -A RDEPS=(
[shared]="api worker cli"
[events]="api worker"
[proto]="api worker"
)
PACKAGES=()
mark() {
local pkg=$1
[[ " ${PACKAGES[*]} " =~ " ${pkg} " ]] && return # skip if already queued
PACKAGES+=("$pkg")
for dep in ${RDEPS[$pkg]:-}; do mark "$dep"; done # propagate to dependents
}
echo "$CHANGED" | grep -q "^packages/shared/" && mark "shared"
echo "$CHANGED" | grep -q "^packages/events/" && mark "events"
echo "$CHANGED" | grep -q "^packages/proto/" && mark "proto"
echo "$CHANGED" | grep -q "^apps/api/" && mark "api"
echo "$CHANGED" | grep -q "^apps/worker/" && mark "worker"
echo "$CHANGED" | grep -q "^apps/cli/" && mark "cli"
for pkg in "${PACKAGES[@]}"; do task "${pkg}:test"; done
A change to packages/shared marks shared → then propagates to api, worker, cli. A change to apps/api only marks api.
The honest alternative: skip this complexity and run task test:all every time. Task's sources/generates caching skips packages whose inputs haven't changed. You get the same outcome with far less script maintenance.
The key insight: Task knows nothing about Python — it just tracks file fingerprints and runs shell commands in the order you declare. That's exactly the level of abstraction you want.
Tool 3: Shared Packages — The Internal Packages Pattern
Source-level packages (no build step)
Most packages in this repo point directly at Python source:
packages/shared/pyproject.toml:
[project]
name = "shared"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sqlalchemy>=2.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
packages/shared/src/shared/__init__.py exports the Pydantic models and SQLAlchemy base classes directly. No compilation, no dist/ folder. When apps/api imports shared, it reaches straight into packages/shared/src/ via the editable install.
This is the internal packages pattern — source is the distribution artifact.
Packages that do need a build step
packages/proto generates Python stubs from .proto files:
packages/proto/pyproject.toml:
[project]
name = "proto"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["grpcio>=1.60", "protobuf>=4.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
packages/proto/Taskfile.yml:
tasks:
generate:
sources: ['**/*.proto']
generates: ['generated/**/*.py']
cmds:
- buf generate # buf handles all codegen — don't mix with raw protoc
Task's sources/generates fingerprinting ensures proto:generate runs before anything that imports the stubs — and skips if the .proto files haven't changed.
Consuming internal packages in apps
apps/api/pyproject.toml:
[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"shared",
"events",
"proto",
"fastapi>=0.115",
"uvicorn>=0.30",
]
[tool.uv.sources]
shared = { workspace = true }
events = { workspace = true }
proto = { workspace = true }
apps/worker/pyproject.toml declares the same [tool.uv.sources] block. Because uv resolves everything into a single uv.lock, both api and worker are guaranteed to use the exact same version of shared — no version skew between apps in the same repo.
Why this matters: you can refactor a Pydantic model in packages/shared and immediately see type errors in both apps/api and apps/worker without publishing anything. The workspace is the registry.
Tool 4: Root pyproject.toml — Shared Config
Rather than duplicating [tool.mypy] and [tool.pytest.ini_options] across 6 packages, this repo centralizes all tool configuration in the root pyproject.toml.
Root pyproject.toml:
[project]
name = "nest-monorepo"
version = "0.0.0"
requires-python = ">=3.12"
[tool.uv.workspace]
members = ["apps/*", "packages/*"]
# Dev tools live here — not in subpackage pyproject.toml files
[dependency-groups]
dev = [
"pytest>=8",
"pytest-asyncio>=0.25", # asyncio_mode = "auto" requires this
"pytest-cov>=5",
"mypy>=1.15",
"ruff>=0.9",
"pydantic[mypy]>=2.0", # provides the pydantic.mypy plugin
"types-requests",
]
[tool.mypy]
python_version = "3.12"
strict = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
plugins = ["pydantic.mypy"]
# Paths where mypy finds internal package source
mypy_path = "packages/shared/src:packages/events/src:packages/proto/src"
[tool.pytest.ini_options]
testpaths = ["apps", "packages"] # no top-level tests/ dir — each package has its own
asyncio_mode = "auto"
addopts = "-v --tb=short"
[tool.coverage.run]
branch = true
source = ["apps", "packages"] # not "src" — there's no top-level src/
[tool.coverage.report]
fail_under = 80
show_missing = true
Install dev tools with:
uv sync --group dev
How mypy finds this config
mypy does not walk up the directory tree. Unlike Ruff, mypy reads config from the directory where it's invoked — not from the file being type-checked. If you run uv run mypy src/ from inside apps/api/, mypy finds no [tool.mypy] there, silently falls back to non-strict defaults, and your strict = true is ignored.
The correct pattern: always run mypy from the repo root, passing the directories to check:
uv run mypy apps/ packages/
This is different from Ruff, which discovers config per-file by walking upward. With mypy, the invocation location is what matters — not the file location.
There is no [tool.mypy] in subpackages. Do not add one. mypy does not merge configs — a subpackage [tool.mypy] completely replaces the root config rather than extending it.
Why this matters: change strict = true in one place and it applies to all 6 packages — but only when mypy is invoked from the root. Enforce this in your Taskfile and CI.
Tool 5: Ruff — Unified Linting and Formatting
Ruff replaces Flake8 + Black + isort with a single fast tool written in Rust. A single [tool.ruff] section in the root pyproject.toml applies to the entire repo.
Root pyproject.toml (continued):
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["apps", "packages"] # not "src" — tells Ruff where source roots are
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
]
ignore = ["E501"] # line length handled by formatter
[tool.ruff.lint.per-file-ignores]
"**/tests/**" = ["S101"] # allow assert in tests
[tool.ruff.lint.isort]
known-first-party = ["shared", "events", "proto"] # without this, internal packages
# sort as third-party — wrong grouping
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
Config discovery: how subpackages inherit
Ruff discovers config by walking up the directory tree from the file being linted until it finds a pyproject.toml, ruff.toml, or .ruff.toml. In nest-monorepo, every file in apps/ and packages/ walks up and hits the root pyproject.toml. One config, all packages.
Per-package overrides
If apps/cli has looser requirements (it's internal tooling), add an extend in apps/cli/pyproject.toml:
[tool.ruff]
extend = "../../pyproject.toml"
[tool.ruff.lint]
ignore = ["E501", "T201"] # allow print() in CLI
extend inherits all parent settings and overrides only what you specify. This is the right pattern — not a full [tool.ruff] block that silently drops all parent rules.
Each package runs:
uv run ruff check src/
uv run ruff format src/
But the rules are defined once. This is the same pattern as the mypy config: one source of truth, many consumers.
The key insight: Ruff running at 10–100x the speed of Flake8 is not just a nice-to-have in a monorepo. When pre-commit runs on every commit across 6 packages, linting speed is the difference between a 2-second hook and a 30-second one.
Tool 6: pre-commit + Git Hooks — Enforcing Quality at Commit Time
Install pre-commit as a uv tool (not a project dependency):
uv tool install pre-commit
pre-commit install
.pre-commit-config.yaml at the repo root:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
# mypy CANNOT use the standard pre-commit mirror hook here.
# pre-commit/mirrors-mypy runs mypy in an isolated virtualenv that has no
# access to your uv workspace's editable installs — `from shared import ...`
# produces false ImportError failures. Run mypy via the local repo instead:
- repo: local
hooks:
- id: mypy
name: mypy
language: system # uses the active .venv, not an isolated env
entry: uv run mypy
args: [apps/, packages/, --config-file, pyproject.toml]
types: [python]
pass_filenames: false # mypy needs whole-package context, not individual files
The key insight: pre-commit automatically passes only staged files to Ruff — no custom script needed. ruff check --fix runs only against the files you're about to commit. mypy, however, needs the full package context to resolve imports correctly, so pass_filenames: false is set and it receives the full apps/ packages/ instead.
The flow on git commit:
- pre-commit intercepts the commit
-
ruffchecks and auto-fixes staged.pyfiles -
ruff-formatformats staged.pyfiles -
mypytype-checks all ofapps/andpackages/(fast due to incremental cache) - If any hook fails — commit is aborted, fixed files are left staged for review
- If all hooks pass — commit proceeds
Installing consistently across the team
Add to root Taskfile.yml:
tasks:
setup:
desc: Bootstrap the repo for local development
cmds:
- uv sync
- uv tool install pre-commit
- pre-commit install
Every developer runs task setup once after cloning. No tribal knowledge required.
Why this matters: a linting problem caught at commit time costs 3 seconds to fix. The same problem caught in CI costs 5 minutes of context switching. The same problem caught in code review costs someone else's time too.
CI: GitHub Actions
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true # caches ~/.cache/uv between runs
- run: uv sync --frozen --group dev
# --frozen: fail if uv.lock is out of date (never auto-update in CI)
# --group dev: includes pytest, mypy, ruff
- run: uv run ruff check apps/ packages/
- run: uv run ruff format --check apps/ packages/
- run: uv run mypy apps/ packages/
# always run from repo root — mypy does not walk up per-file
- run: uv run pytest apps/ packages/ -x --cov
env:
PYTHONDONTWRITEBYTECODE: "1"
- run: uv cache prune --ci
# trims downloaded wheels, keeps source-built cache for next run
The --frozen flag is the CI safety net. If a developer forgot to commit an updated uv.lock after adding a dependency, the CI job fails immediately rather than silently installing a different package version than the rest of the team has.
Deploying One App from a Monorepo
The hardest Python monorepo question isn't the build — it's Docker. You need an image for apps/api that includes its internal deps (packages/shared, packages/events) without shipping the entire repo or the dev toolchain.
uv makes this clean with --package:
# apps/api/Dockerfile
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# Copy the workspace manifests first (cache layer for dep installation)
COPY pyproject.toml uv.lock ./
COPY packages/shared/pyproject.toml packages/shared/pyproject.toml
COPY packages/events/pyproject.toml packages/events/pyproject.toml
COPY apps/api/pyproject.toml apps/api/pyproject.toml
# Install only api's deps — no dev tools, no other apps
RUN uv sync \
--package api \
--no-dev \
--frozen \
--no-editable # copies source into site-packages instead of editable links
# Now copy the actual source (separate layer so code changes don't bust dep cache)
COPY packages/shared/src packages/shared/src
COPY packages/events/src packages/events/src
COPY apps/api/src apps/api/src
FROM python:3.12-slim
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/apps/api/src /app/src
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
Key flags:
-
--package api— installs onlyapiand its transitive deps (includingsharedandevents) -
--no-dev— excludes pytest, mypy, ruff -
--frozen— fails ifuv.lockis stale -
--no-editable— copies package source into.venv/lib/so the final image doesn't need the source tree
Why this matters: without --no-editable, the editable installs point back at the source paths in the build context. The final image would need all of packages/shared/src/ mapped at the same absolute path. --no-editable severs that dependency — the .venv is self-contained.
The Full Picture: How These Tools Interact
Developer commits
↓
pre-commit hook
↓ ruff-check (staged files only, --fix)
↓ ruff-format (staged files only)
↓ (fails → abort commit; passes → continue)
↓
uv workspace resolves internal deps via { workspace = true } editable installs
↓
task build:all
↓ reads Taskfile.yml includes
↓ builds packages in order: shared → events → api/worker/cli
↓ caches outputs in .task/
↓
CI: git diff → affected packages → test only those + dependents
↓ diffs against main branch
↓ runs only impacted packages
↓ Ruff config inherited from root pyproject.toml
↓ mypy config inherited from root pyproject.toml
Summary: Why This Stack
| Problem | Tool | Mechanism |
|---|---|---|
| Share code without publishing to PyPI | uv workspaces |
{ workspace = true } + editable installs |
| Run tasks in dependency order | Task | explicit cmds: ordering + deps: for parallel |
| Don't rebuild unchanged packages | Task cache |
sources/generates fingerprinting in .task/
|
| Run tasks on only changed code in CI | git diff script | git diff --name-only origin/main...HEAD |
| Consistent mypy/pytest config | root pyproject.toml
|
single [tool.mypy]; always invoke from repo root |
| One linter/formatter config | Ruff root [tool.ruff]
|
config discovery walks up per-file |
| Per-package lint overrides | Ruff extend
|
inherits parent rules, adds/removes specific ones |
| Enforce quality before commits | pre-commit hooks | staged-file Ruff; mypy via language: system
|
| Deploy one app without full repo | uv sync --package api --no-editable |
installs app + internal deps, no dev tools |
The monorepo isn't one tool — it's these tools composing together. uv handles what exists and how it's linked, Task handles when things run and in what order, and the tooling layer in pyproject.toml handles how everything is configured.
Top comments (0)