DEV Community

M Hossein
M Hossein

Posted on

Python Monorepo Magic: Organize, Build, and Ship Multi-Service Apps

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
Enter fullscreen mode Exit fullscreen mode

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/*"]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Reads every pyproject.toml in every workspace directory
  2. Resolves all external dependencies into a single uv.lock file at the root
  3. 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 }
Enter fullscreen mode Exit fullscreen mode

{ 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.lock committed 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. api and worker cannot depend on different versions of shared — they share the same installed environment.
  • IDE setup is trivial. Point your editor's Python interpreter at .venv/bin/python once, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

packages/proto/Taskfile.yml:

version: '3'

tasks:
  generate:
    desc: Generate Python stubs from .proto files
    sources:
      - '**/*.proto'
    generates:
      - 'generated/**/*.py'
    cmds:
      - buf generate
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Running individual apps

# Run just the API
task api:dev

# Run just the worker
task worker:dev

# Build a single package
task shared:build
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

packages/proto/Taskfile.yml:

tasks:
  generate:
    sources: ['**/*.proto']
    generates: ['generated/**/*.py']
    cmds:
      - buf generate   # buf handles all codegen — don't mix with raw protoc
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install dev tools with:

uv sync --group dev
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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:

  1. pre-commit intercepts the commit
  2. ruff checks and auto-fixes staged .py files
  3. ruff-format formats staged .py files
  4. mypy type-checks all of apps/ and packages/ (fast due to incremental cache)
  5. If any hook fails — commit is aborted, fixed files are left staged for review
  6. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Key flags:

  • --package api — installs only api and its transitive deps (including shared and events)
  • --no-dev — excludes pytest, mypy, ruff
  • --frozen — fails if uv.lock is 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
Enter fullscreen mode Exit fullscreen mode

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)