Disclosure: I build Schemity, a desktop ERD tool - this post is from our blog and uses it for the examples.
TL;DR: Keep a foreign key between two modules while they share one database and ship together: it costs an index and a row lock, and the coupling it creates is the coupling your code already has. Change its delete action to
RESTRICTso no module deletes another module's rows, and drop it only when a module moves to its own database or needs to rebuild its tables alone, keeping the column documented and checked for orphans.
A foreign key from one module's table to another's is worth keeping while both modules live in one database and ship as one application. It costs an index and a row lock per write, it refuses orphan rows, and on the day a module leaves, dropping it is one ALTER TABLE. What you should not keep is a cross-module ON DELETE CASCADE, and what you cannot afford is not knowing where those keys are.
The common advice runs the other way. Milan Jovanović's modular monolith data isolation guide states that "Joins are only allowed between tables of the same module" and that "Removing the foreign keys when extracting tables is not a problem." Both are true. Neither is an argument for removing the keys today, years before any extraction, and giving up the one integrity check that no module can forget to call. The question worth answering is narrower: which cross-module keys to keep, which delete action they should have, and when one should go.
Every statement below was run on PostgreSQL 18.3 in a throwaway container. The example is a Django-style monolith in one schema, where the table name prefix is the module: customers_customer, ordering_order, billing_invoice.
Foreign keys between modules in a modular monolith: what do they cost?
Here is the schema most monoliths end up with:
CREATE TABLE customers_customer (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE
);
CREATE TABLE ordering_order (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers_customer (id) ON DELETE CASCADE,
total numeric(12, 2) NOT NULL
);
CREATE TABLE billing_invoice (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES ordering_order (id),
amount numeric(12, 2) NOT NULL
);
Two keys cross a boundary, and each one ties the modules together in five concrete ways.
A delete in one module reaches into another. Deleting a customer whose orders were never invoiced silently deletes those orders, which belong to the ordering module. Deleting a customer whose order was invoiced fails, and the error names a table the customers module has never heard of: ERROR: update or delete on table "ordering_order" violates foreign key constraint "billing_invoice_order_id_fkey" on table "billing_invoice". The customers module's delete was stopped by billing, two modules away.
A module cannot reset or rebuild its own tables. TRUNCATE customers_customer fails with cannot truncate a table referenced in a foreign key constraint, and DROP TABLE customers_customer fails with cannot drop table customers_customer because other objects depend on it. A migration that rebuilds one of them needs the other module's cooperation. Tests have an easier way out: the error's own hint is to truncate both tables in one statement, or a test can roll back its transaction instead.
Every write reaches into the other module. Inserting an order takes a FOR KEY SHARE lock on the customer row it points at, which pgrowlocks shows while the transaction is open, and deleting a customer looks up its orders by customer_id. PostgreSQL does not index a foreign key column for you, so without CREATE INDEX ON ordering_order (customer_id) that lookup scans the whole orders table.
Schema changes lock the other module's table. The ALTER TABLE documentation says ADD FOREIGN KEY "also acquires a SHARE ROW EXCLUSIVE lock on the referenced table". Dropping one is heavier: in the same container, ALTER TABLE ... DROP CONSTRAINT on a cross-module key held ACCESS EXCLUSIVE on both the child table and the referenced table until it committed. On a busy table, run it with SET lock_timeout so it gives up instead of queueing every other query behind it.
It cannot survive a move to another database. PostgreSQL has no cross-database foreign keys: REFERENCES orders_db.public.ordering_order (id) is refused with cross-database references are not implemented. The key has to be dropped on the day the module moves out, whatever you decide now.
None of these is a reason to drop a key in a monolith that is staying a monolith. They are the reasons to choose its delete action deliberately and to know exactly which keys you have.
Which cross-module foreign keys to keep, restrict, or drop
| Situation | Decision | Why |
|---|---|---|
| Modules share one database and deploy together | Keep the key, index its column | Integrity costs an index and a row lock, and dropping it later is one statement |
The key is ON DELETE CASCADE across a boundary |
Keep it, change the action to RESTRICT
|
No module should delete another module's rows as a side effect |
| The module is moving to its own database | Drop it before the move | PostgreSQL cannot enforce it across databases |
| The referenced module must rebuild its tables on its own in production | Drop it | Otherwise every rebuild needs the other module |
| The referenced rows are archived or deleted on their own schedule | Drop it, or copy the values you need | The reference is history, not a live link |
| Two modules reference each other | Remove one direction first | A loop means extracting either module means changing the other too |
The RESTRICT row is the one most schemas need today. A module that wants to delete a customer should be told that orders still point at it, and decide what that means for its own data: deactivate the customer, or ask the ordering module to deal with its orders first. The change is one statement, and afterwards the delete fails with violates RESTRICT setting of foreign key constraint "ordering_order_customer_id_fkey":
ALTER TABLE ordering_order DROP CONSTRAINT ordering_order_customer_id_fkey,
ADD CONSTRAINT ordering_order_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES customers_customer (id) ON DELETE RESTRICT;
Picking the action for a key inside a module is a different question, answered in ON DELETE SET NULL vs CASCADE vs RESTRICT. Across a module boundary the answer is almost always RESTRICT or NO ACTION, because the child row belongs to someone else. The two refuse the same deletes; NO ACTION, the default and what billing_invoice already has, differs only in that its check can be deferred to the end of the transaction.
A key also tends to point at a module's identity table rather than at its internals. ordering_order.customer_id referencing customers_customer is borrowing one concept, the customer, which is the shape a boundary can live with. A key from billing into a customers module's customers_address table is reaching into an implementation detail, and it is the first one to question.
How to list every foreign key that crosses a module boundary
The catalogue already knows. When modules share a schema and the table prefix is the module, compare the prefix of the child table with the prefix of the parent:
SELECT c.conrelid::regclass AS child_table,
c.confrelid::regclass AS parent_table,
c.conname,
c.confdeltype AS on_delete
FROM pg_constraint c
JOIN pg_class ch ON ch.oid = c.conrelid
JOIN pg_class pa ON pa.oid = c.confrelid
WHERE c.contype = 'f'
AND split_part(ch.relname, '_', 1) <> split_part(pa.relname, '_', 1)
ORDER BY 1, 2;
On the example it returns the two keys, with on_delete reading c for ordering_order_customer_id_fkey and a for billing_invoice_order_id_fkey. The pg_constraint documentation defines the codes: a is no action, r restrict, c cascade, n set null, d set default, so every c in that column is a cascade across a boundary. The prefix test assumes module names contain no underscore; a Django app called user_profiles needs a list of module prefixes instead. When each module has its own schema, replace the last condition with ch.relnamespace <> pa.relnamespace.
The query has two blind spots. It only sees declared keys, so a customer_id column that was never given a constraint does not appear, though it is just as much a dependency; and it lists edges, not loops. Add last_order_id bigint REFERENCES ordering_order (id) to customers_customer and the result gains a third row, but nothing in it says that customers and ordering now depend on each other. Finding a loop through three modules from a flat list of rows is a graph walk you do by hand.
When you drop one, what replaces it?
The column stays. What leaves is the guarantee, and three things should take its place.
An orphan check. Without the key, nothing stops billing_invoice from holding an order_id of 999 that no order has. A scheduled query finds them:
SELECT i.id, i.order_id
FROM billing_invoice i
LEFT JOIN ordering_order o ON o.id = i.order_id
WHERE o.id IS NULL;
A rule in the owning module. If ordering may now hard-delete orders, billing has to learn about it, usually through an event, and decide whether an invoice for a deleted order is an error or a record to keep.
Documentation that the dependency still exists. A dropped key leaves a column that looks like any other bigint. The next engineer, and the next AI agent, reads the schema and sees no relationship at all, which is how skipping foreign keys turns into a schema nobody can read.
Going back is possible once the orphan check returns no rows, since any orphan makes validation fail. ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID commits without scanning the table, though it still takes SHARE ROW EXCLUSIVE on both tables for that moment, and a later VALIDATE CONSTRAINT checks the existing rows; the ALTER TABLE documentation notes that validation takes only a SHARE UPDATE EXCLUSIVE lock on the table, plus a ROW SHARE lock on the referenced one.
Seeing module dependencies as a map instead of a query result
Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. For this decision its job is to help you understand your schema at the scale of modules: connect it to the database, group the tables into one context view per module, and open the Context Map. Schemity reads one PostgreSQL schema per connection, so this works directly on the single-schema, prefix-per-module layout above; a schema-per-module database is one diagram per schema, and the map cannot join them.
A context view is a saved view of a subset of the schema's entities, and the Context Map is the view above it, where each context view becomes one box and each arrow is counted from the relations that cross between two of them. The pg_constraint query becomes a picture, with four things it could not show:
- Declared and undeclared dependencies, side by side. An arrow is solid when every relation behind it is a declared foreign key, dashed when every one is a virtual relation, and carries a ringed count badge when the two mix. When an arrow has any virtual relations, the dependency drawer behind it says how many are declared and how many are virtual, and tags each virtual row.
- Loops. A Cycles button lists the dependency loops between context views, shortest first and up to 50, including loops through three or more modules where no single arrow looks wrong. Picking one selects its modules and lights only the loop's arrows.
- Direction for one module at a time. Selecting a module highlights what it depends on, dims what depends on it, and hides every other arrow; Shift+click (Cmd+click on macOS) adds more modules.
- The boundary from inside a module. In a context view, the boundary indicator on an entity lists what crosses it: the keys it holds pointing out, and the entities outside that reference it. Picking a row lands on that entity in the main diagram.
That last view is where the drop decision gets made. When a key comes out of the database, draw it back as a virtual relation on the column that already carries it, and the dependency stays on the diagram and on the map, dashed: the table above told you to keep documenting it, and this is the documentation. An AI agent sees the same split over the database MCP server, where get_context_views returns each dependency with its relations, which of them are virtual, and the cycles.
The arrows count a dependency whether or not the database enforces it, because architecturally it is the same dependency. A dashed arrow is not a smaller coupling than a solid one; it is the same coupling with no check behind it. Each arrow's relations, the keys you would have to drop to separate two modules, are one click away, and the DDD context map you get from your foreign keys is the same picture read as an architecture rather than a list.
The decision itself is still yours. Keep the key while the modules share a database, give it RESTRICT instead of CASCADE, and drop it when a module leaves or needs its tables to itself, with the dependency still drawn where everyone can see it. A loop between two tables inside one module is a different problem, covered in circular foreign keys and the first row.




Top comments (0)