A legacy monolith can need cleaner boundaries without needing new services, a new framework, or a rewrite. I would introduce one boundary around a business action while keeping its database transaction and deployment unchanged, because that gives you a way to test the design before changing its operational risks. For a first attempt, choose an action you can trace from request to database write.
Start with one write path, not a new module layout
Pick a command such as “approve order,” not a broad area such as “orders,” because a single write path has a visible entry point, side effects, and failure cases. Trace one real request through its HTTP handler, validation, database calls, and response. Write down which tables it changes and which other code can call the same operation. If a scheduled job and an HTTP endpoint both approve orders, they are two entry points to the same action; neither should silently retain a separate implementation after you introduce the boundary.
Put an approve_order function between those entry points and the existing persistence code. At first, it can call the same queries in the same order, because moving responsibility is easier to verify when you do not also change behavior. Make the function accept the values it needs—an order ID and the acting user, for example—and return a result that the HTTP handler can translate into a response. Keep HTTP status codes out of the function, because a scheduled job has no meaningful use for an HTTP response.
Read Refactor or wrap first, when QA owns an untested legacy app before moving the write path, but do not let that choice delay a small, testable boundary: extracting one action can proceed while the old storage calls remain in place. The useful question is whether every caller now reaches the same action, because a neat new function does little if the original handler still writes directly to the table.
I would not reorganize the entire repository into domain packages first, because moving dozens of files makes import changes look like progress while leaving runtime behavior unexamined. A first pull request can instead add one function, redirect its callers, and include tests showing that a rejected approval leaves the order unchanged. Record the original call path in the pull-request description so a reviewer can check that no entry point was missed.
For a Python codebase, Ruff 0.6 can check the touched files for unused imports after the move, but linting cannot establish that all callers use the new function. Use git grep to find writes to the relevant table or calls to the old helper, then inspect the results manually because generated SQL and dynamic dispatch can evade a text search. The outcome you want is modest and observable: one named place owns approval, while request handling and persistence remain recognizable.
The database transaction should stay where the business action finishes
An extracted function is not a safe boundary if its validation commits separately from its write, because another request can change the row between those steps. Keep approval and its related writes in one transaction. With SQLAlchemy 2.0, a Session.begin() block can own that transaction; with PostgreSQL 16, a conditional UPDATE … WHERE status = ‘pending’ can reject an approval that lost a race. Check the affected-row count rather than trusting a status value read earlier.
The following Python 3.11 example uses the standard-library sqlite3 module so you can run it as a file without installing the application. It demonstrates the shape of the action and the conditional write, not a substitute for testing PostgreSQL concurrency.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT NOT NULL)")
db.execute("INSERT INTO orders VALUES (7, 'pending')")
def approve_order(order_id):
with db:
changed = db.execute(
"UPDATE orders SET status='approved' WHERE id=? AND status='pending'",
(order_id,),
).rowcount
if changed != 1:
raise ValueError("order not pending")
approve_order(7)
assert db.execute("SELECT status FROM orders WHERE id=7").fetchone()[0] == "approved"
In the application, pass a database session into the action rather than opening a hidden second connection, because a caller may need approval and an audit row to commit or roll back together. If approval sends an email, do not send it halfway through the transaction: a later rollback would leave an email describing a change that never committed. Store an outbox record in the same transaction if reliable delivery is required, then let a worker send it after commit. That adds a table and worker, so use it for a real delivery requirement rather than as decoration on the first extraction.
There are two reasonable choices for the new boundary. A function in the existing package wins when only a few callers need the action, because it changes little wiring; it costs you discipline, since other files can still bypass it. A separate application package with an import rule wins when several callers already duplicate the action, because the rule can prevent new direct persistence calls; it costs package moves and test setup. Choose by the bypasses you found, not by which directory structure looks more architectural.
Read Can this framework untangle your monolith without a rewrite? while evaluating that second option, but make the import rule explicit before adopting a framework: tooling cannot enforce a boundary your team has not defined.
A boundary earns trust through failure tests, not a passing happy path
Write tests against the action’s contract before making callers depend on it. With pytest 8, cover an order that is pending, one that is already approved, and one that does not exist. For the last two, assert both the returned error and the unchanged database state, because a correct-looking response can hide a partial write. Test that an exception after the status update rolls back the update if the action also writes an audit record.
Use Testcontainers for Python 4 to run the concurrency and transaction tests against PostgreSQL if production uses PostgreSQL, because SQLite’s locking behavior does not establish what concurrent PostgreSQL transactions will do. Keep smaller SQLite or mocked tests where they help you get fast feedback, but do not ask them to prove database-specific behavior. Alembic 1.13 should manage any new constraint or outbox table, because an undocumented schema change is difficult to reproduce in CI and on another developer’s machine.
Also test the HTTP translation separately. A rejected approval might become HTTP 409 Conflict under RFC 9110, because the request conflicts with the order’s current state; the action itself should return or raise an application-level conflict. This division lets a job log the same conflict without pretending it handled an HTTP request. If your existing API promises a different response, preserve that contract during the extraction and propose an API change separately, because combining both changes makes a failed client test harder to diagnose.
Add a check that catches bypasses. A targeted GitHub Actions job can run the tests and an import check on every pull request; the check matters because a future handler might otherwise call the repository directly. Start with a rule narrow enough to name the action’s callers and storage module. A rule that bans every cross-package import is likely to produce exceptions immediately, because the rest of the monolith was not designed around your new boundary.
Use a deliberately small review scope: one write path is the initial limit to tune, not a claim that every action needs its own module. If the diff also changes unrelated schema, serialization, and naming, split it, because reviewers need to distinguish the boundary from incidental cleanup. Keep a short note of the old and new call paths beside the tests; that note makes the next similar extraction easier without imposing a repository-wide architecture document.
Release the new route by comparing behavior, then remove the bypass
If every caller can switch to the extracted action in one deploy, a feature flag may add complexity without reducing risk, because both versions still run against the same mutable data. In that case, deploy the action with its tests and watch the existing error and latency metrics. If a heavily used endpoint needs a staged change, use a flag such as Unleash to choose the old or new path at the entry point; do not let the action check the flag internally, because that would make its behavior depend on hidden request context.
Begin with 5% of eligible requests as a rollout setting to adjust to your traffic, and keep the old path available only for the observation period. Compare the two cohorts on the same request type. A Prometheus counter for conflicts and server errors can show whether the new path changes outcomes; a latency histogram can show whether it adds work. Attach a route-variant attribute through OpenTelemetry tracing, but avoid putting order IDs in metric labels because each distinct ID creates another time series.
Choose 200 completed requests per cohort as a provisional minimum before reading a rate comparison, because a handful of requests can make a small difference look dramatic. Set 15 minutes as an adjustable first observation window if that period normally provides enough requests; extend it when traffic is sparse. Treat a 2-percentage-point increase in server-error rate as a local rollback trigger to tune against your baseline, because a threshold without the usual error rate can be either noisy or dangerously permissive. These are release settings, not universal safety guarantees.
Do not run the old and new write paths simultaneously for the same request merely to compare outputs, because both may produce database changes, audit records, or messages. For write operations, compare observed outcomes from separately routed requests and use tests to probe edge cases. If your team needs stronger evidence, shadow a read-only decision calculation rather than the write itself, and verify that the shadow code cannot commit.
Once the new path has held up, delete the old direct-write call and the temporary flag branch in a follow-up change, because a permanent fallback leaves two places to fix the next bug. Your first step tomorrow is simpler: find one approval request, trace every write it makes, and add a failing test for an already-approved order. That test gives your boundary a behavior to preserve before you move any code.



