01
a monolith that stays changeable
Business software like ERPs, CRMs and customer portals is full of operations that belong together. Approving a purchase order reserves stock, creates a payable and queues a notification, and the user expects all of it to happen or none of it. In a single Django process with one Postgres database, that's one transaction.atomic() block. Split the same steps across services and you need sagas, retries and reconciliation jobs to get back the guarantee the database gave you for free.
That's why the usual problem with a growing Django codebase isn't the deployment shape but change coupling. Billing imports from accounts, accounts imports from notifications, a signal in one app quietly writes to another, and renaming a field means reading half the repo first. Microservices are often proposed as the cure, but they mostly turn an import problem into a network problem. What helps is boundaries inside the monolith that a machine checks, and a way to test that each module can be upgraded on its own.
This post builds that with a small, runnable example: a billing_app package with a narrow public API, an import contract, a transactional outbox for side effects, an upgrade test that migrates real seeded data from the previous release, and a gateway that lets you move billing into a separate service later without rewriting its callers. Everything was run with Django 6.1, psycopg 3.3, Postgres 18 and uv 0.13.
02
packages with a small public surface
Start by making each high-churn domain a proper Python package with its own pyproject.toml, even if it lives in the same repo. For a single product, a uv workspace is enough: the host project depends on billing-app, and uv installs it from the local path in editable mode, so there's nothing to publish.
Building versioned wheels and publishing them to a private index only pays off when the same app is installed in several projects, for example a billing or auth app reused across products or client projects. Then each project pins a version and upgrades when it's ready, and the upgrade test later in this post becomes the thing that makes that safe. For one product in one repo, the release overhead buys nothing that the workspace and the import contract don't already give you.
Inside the package, one module is the public API and everything else is private. Callers get plain data back, not model instances they could save or traverse into other apps:
Money is stored as integer cents, and the invoice stores account_id as a plain BigIntegerField rather than a ForeignKey to the accounts app. That keeps billing free of imports from accounts, which is what makes it reusable, but it's a real trade-off: the database no longer guarantees the account exists, and deleting an account doesn't cascade or block. If you take that route, add a periodic check that finds invoices pointing at missing accounts. Where the integrity matters more than the independence, a string reference like models.ForeignKey("accounts.Account", on_delete=models.PROTECT) keeps the constraint without a Python import, at the price of a migration dependency on the other app.
Boundaries that live in a wiki erode, so put them in import-linter, which reads contracts from pyproject.toml. This one forbids the package from knowing anything about the host project it's installed into:
Adding from hostproj import settings to the billing API makes lint-imports exit with status 1 and name the offending line:
The same tool has layers and independence contracts for rules between domain packages, such as sales may call billing but billing may never import sales. Contracts like these are what stop the slow drift back to everything importing everything.
03
side effects go through an outbox
The create_invoice function above writes an OutboxEvent row in the same transaction as the invoice. That's the transactional outbox pattern, and it's one of the few patterns worth having from the start in a monolith. Emails, webhooks and messages to other systems are produced from committed rows, so a crash can't leave an invoice without its event or an event for an invoice that was rolled back. Sending the email directly inside the request has both problems, and calling transaction.on_commit fixes only the first one, because the process can still die between the commit and the send.
A relay drains the table. select_for_update(skip_locked=True) lets several workers run it at the same time without blocking each other or picking the same rows:
Run it from a Celery beat task or a small management command loop. Delivery is at-least-once, so whatever consumes the events must tolerate duplicates, for example by keying on invoice_id. Published rows pile up, so delete or archive them on a schedule.
The contract tests for the package test exactly what the host relies on: the API round trip, that an event is published once, and that a failed write leaves no event behind.
04
migrations break on data, so test them on data
Each package owns its migrations, and no other app writes migrations for its models. That part is convention. The part that needs a test is upgrading: a migration that works on an empty test database can fail on a database with real rows, and the normal test suite only ever sees the empty case.
Here's the example. Version 1.1 of billing_app adds a unique invoice number. Declaring the field as CharField(max_length=20, unique=True, default="") and running makemigrations produces this:
All four contract tests pass with it, because pytest-django creates a fresh database and migrates it before any invoice exists. Upgrading a database that already holds invoices from version 1.0 fails, because every existing row gets the same empty string:
The fix is to add the column as nullable, since NULLs don't collide in a unique index, and backfill it in batches:
On a big table, the backfill belongs in its own release rather than the same migration, because a Postgres migration runs in one transaction by default and holds its locks until the end. The point here is the test that catches the mistake. It builds the previous release's wheel from its Git tag and the current one from the working tree, installs the old one into a clean virtualenv, migrates and seeds data through the old public API, then installs the new wheel over it, migrates again, checks the seeded rows and runs the contract tests:
With the naive migration it stops at the UniqueViolation above. With the fixed one it prints seeded data survived the upgrade, all 500 invoices get numbers starting at INV-000001, and the contract tests pass. Seeding through the old version's public API rather than a SQL dump matters: it produces the data the old code would really have written, and it keeps working as the schema changes.
05
the upgrade matrix in ci
The script builds both wheels itself, which is the step that is easy to miss: a job that installs dist/*.whl without building it first is installing nothing, or a stale file left from somewhere else. In GitHub Actions, the job runs once per previous release you still support, against a Postgres service. This wiring is a sketch around the script above:
fetch-depth: 0 is there because the script checks out the old tag with git worktree, and a shallow clone doesn't have it. Which versions go in the matrix is a policy decision. For an app reused across projects, list every version that some project still runs, because those are the upgrade paths someone will actually take. For a single product, the previous release is enough.
06
extracting a service later without rewriting callers
Sometimes a module does need to leave: it has a different scaling profile, a separate team with its own release cadence, or data that must be isolated for compliance. If callers already go through an interface, extraction is a configuration change rather than a rewrite. Define the interface as a Protocol, ship the in-process implementation with the package, and let settings choose:
The remote implementation lives in the host project, because where billing runs is a deployment concern, not a billing one:
The example's contract tests include one that runs the same call through both gateways against a stub HTTP server and compares the results, and that test is the start of a safe extraction sequence. First run the remote service in shadow mode, calling it alongside the local path and logging differences without using its answers. Then switch one tenant or one workflow with a feature flag, keep the local path deployable so rolling back is a settings change, and widen only when the new service isn't producing more incidents than it removes. Note what the remote version loses: create_invoice was atomic with the rest of the caller's transaction, and over HTTP it isn't, so the caller now needs an idempotency key and a plan for the call that succeeded but timed out.
That loss is the reason to extract late and only with a concrete reason. A module that's merely large, or a team that's unhappy with the code layout, is better served by the package boundaries, import contracts and upgrade test above, which keep the single transaction and still let each part change on its own.
