# Foreign Keys Between Modules in a Modular Monolith: Keep, Restrict, or Drop?

> 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 `RESTRICT` so 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.

Source: https://schemity.com/blog/foreign-keys-between-modules-modular-monolith/

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](https://milanjovanovic.tech/blog/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:

```sql
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](https://www.postgresql.org/docs/current/sql-altertable.html) 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"`:

```sql
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](https://schemity.com/blog/postgres-on-delete-set-null-vs-cascade/). 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:

```sql
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:

```sql
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](https://schemity.com/blog/should-you-use-foreign-key-constraints/) 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](https://schemity.com/blog/reverse-engineer-legacy-database-group-by-domain/), 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.

![The seven example tables on Schemity's main diagram, grouped into customers, ordering, billing and shipping legends, with declared foreign keys drawn solid and the undeclared order_id references from billing_refund and shipping_shipment drawn dashed](https://schemity.com/images/blog/foreign-keys-between-modules-modular-monolith/main-diagram-modules.webp)

A context view is a saved view of a subset of the schema's entities, and the [Context Map](https://schemity.com/doc/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](https://schemity.com/doc/virtual-relations/), 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.

![The Context Map for the four modules: billing to ordering is a solid arrow with a dashed ring around its count of 2, shipping to ordering is a dashed arrow, ordering to customers and customers to billing are solid, and the Cycles button reads 1](https://schemity.com/images/blog/foreign-keys-between-modules-modular-monolith/context-map-declared-virtual.webp)

![The loop picked from the Cycles list: billing, ordering and customers selected with the three arrows between them at full strength, while the dashed shipping to ordering arrow is dimmed](https://schemity.com/images/blog/foreign-keys-between-modules-modular-monolith/cycles-picked.webp)

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](https://schemity.com/doc/ai-assisted-design/), 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](https://schemity.com/blog/your-ddd-context-map-is-already-in-your-foreign-keys/) is the same picture read as an architecture rather than a list.

![The dependency drawer for the billing to ordering arrow, reading 1 declared, 1 virtual: billing_invoice.order_id to ordering_order with constraint billing_invoice_order_id_fkey, and billing_refund.order_id to ordering_order tagged Virtual](https://schemity.com/images/blog/foreign-keys-between-modules-modular-monolith/dependency-drawer-declared-virtual.webp)

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](https://schemity.com/blog/circular-foreign-keys-first-row-cannot-be-inserted/).

## Frequently asked questions

### Should a modular monolith have foreign keys between modules?

Keep them while the modules share one database and are deployed together, because they stop orphan rows for the price of an index and drop cleanly on the day a module is extracted. Use `ON DELETE RESTRICT` rather than `CASCADE` across a module boundary. Drop the constraint when the module moves to its own database, where PostgreSQL cannot enforce it anyway.

### How do I find every foreign key that crosses a module boundary in PostgreSQL?

Query `pg_constraint` for rows with `contype = 'f'` and compare the module of the child table with the module of the parent table: the table name prefix when modules share a schema, or `relnamespace` when each module has its own schema. The `confdeltype` column shows the delete action, where `c` means `CASCADE` and `a` means `NO ACTION`.

### What replaces a dropped cross-module foreign key?

The column stays, and three things take over from the constraint: an orphan check such as a `LEFT JOIN` looking for missing parents, a rule in the owning module about deleting or archiving rows other modules point at, and documentation that the dependency still exists. In Schemity the documentation is a virtual relation, which keeps the dependency on the diagram and on the Context Map, drawn dashed.
