# Circular Dependencies Between Modules in a Database: A Real Dependency, or One Table in the Wrong Module?

> A cycle between modules is counted from two things: the foreign keys, and the list that says which table belongs to which module. On a 34-table PostgreSQL store split into seven modules, ten cycles fell to three when `addresses` moved to the accounts module and to zero when `stock_levels` moved to shipping, with no constraint changed. A cycle is a real dependency when every table already sits with the module that writes it; then one foreign key has to go, or the two modules are one.

Source: https://schemity.com/blog/circular-dependency-between-modules-misplaced-table/

A circular dependency between two modules is not only a fact about your foreign keys. It is a fact about your foreign keys and about the list that says which table belongs to which module, and changing that list can remove the cycle without touching the database. So before you break a dependency, check whether one table is filed in the wrong module.

I measured it on a 34-table PostgreSQL 18.3 store with 43 foreign keys, split into seven modules. That split gave ten cycles between modules. Moving `addresses` from shipping to accounts left three. Moving `stock_levels` from catalog to shipping left none. Not one constraint changed. This post shows the query that counts the cycles, how to tell which tables are worth moving, and what a cycle looks like when no move can remove it.

## Why does a cycle between modules depend on where the boundaries are?

Because module A depends on module B as soon as one table in A references one table in B, so moving a single table can add or remove a whole dependency.

Tools that check module structure treat a cycle as an error. Spring Modulith's verification [requires that](https://docs.spring.io/spring-modulith/reference/verification.html) "the dependencies between modules have to form a directed acyclic graph", and the [acyclic dependencies principle](https://en.wikipedia.org/wiki/Acyclic_dependencies_principle) says the same about packages. Those checks read the code. The database has the same graph, drawn by foreign keys, and those checks never read it.

Here is the part of the store that matters. A user has addresses, a user has a default address, and a warehouse has an address:

```sql
CREATE TABLE users (
    id                  BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email               TEXT NOT NULL UNIQUE,
    default_address_id  BIGINT
);

CREATE TABLE addresses (
    id       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id  BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    line1    TEXT NOT NULL
);

ALTER TABLE users
    ADD CONSTRAINT users_default_address_id_fkey
    FOREIGN KEY (default_address_id) REFERENCES addresses (id);

CREATE TABLE warehouses (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        TEXT NOT NULL,
    address_id  BIGINT NOT NULL REFERENCES addresses (id)
);
```

![Part of the store's ERD in Schemity: an accounts box holding users, password_resets, sessions, user_roles and roles, and a shipping box holding carriers, shipments, warehouses and addresses. Two relationship lines run between users in accounts and addresses in shipping, one for addresses.user_id and one for the nullable users.default_address_id, and a third joins warehouses to addresses inside shipping](https://schemity.com/images/blog/circular-dependency-between-modules-misplaced-table/erd-addresses-in-shipping.webp)

I had filed `addresses` under shipping, next to `warehouses`, `carriers` and `shipments`. That one choice makes accounts depend on shipping (`users.default_address_id`) and shipping depend on accounts (`addresses.user_id`). File `addresses` under accounts and both of those keys stay inside one module. The keys are the same; the cycle is gone.

## How do you list the cycles between modules from the database?

Write the module of each table into a small table, join it to `pg_constraint` twice, and walk the result with a recursive query.

The mapping is the part no catalog can give you, so it is a table you fill in:

```sql
CREATE TABLE table_domains (
    table_name  TEXT PRIMARY KEY,
    domain      TEXT NOT NULL
);

INSERT INTO table_domains (table_name, domain) VALUES
    ('users', 'accounts'), ('roles', 'accounts'), ('user_roles', 'accounts'),
    ('sessions', 'accounts'), ('password_resets', 'accounts'),
    ('addresses', 'shipping'), ('carriers', 'shipping'), ('warehouses', 'shipping'),
    ('shipments', 'shipping'), ('shipment_items', 'shipping'),
    ('brands', 'catalog'), ('categories', 'catalog'), ('products', 'catalog'),
    ('product_variants', 'catalog'), ('product_images', 'catalog'),
    ('stock_levels', 'catalog'), ('reviews', 'catalog'),
    ('campaigns', 'marketing'), ('coupons', 'marketing'),
    ('newsletter_subscriptions', 'marketing'), ('wishlists', 'marketing'),
    ('carts', 'orders'), ('cart_items', 'orders'), ('orders', 'orders'),
    ('order_items', 'orders'), ('order_status_history', 'orders'),
    ('payment_methods', 'billing'), ('invoices', 'billing'),
    ('payments', 'billing'), ('refunds', 'billing'),
    ('tickets', 'support'), ('ticket_messages', 'support'),
    ('ticket_attachments', 'support'), ('return_requests', 'support');
```

The first query lists every dependency between two modules, with the tables that cause it:

```sql
SELECT c.domain AS from_domain, p.domain AS to_domain,
       count(*) AS foreign_keys,
       string_agg(k.conrelid::regclass::text, ', '
                  ORDER BY k.conrelid::regclass::text) AS from_tables
FROM pg_constraint k
JOIN table_domains c ON c.table_name = k.conrelid::regclass::text
JOIN table_domains p ON p.table_name = k.confrelid::regclass::text
WHERE k.contype = 'f' AND c.domain <> p.domain
GROUP BY c.domain, p.domain
ORDER BY 1, 2;
```

On the store it returns 14 rows. Three of them point out of shipping and three point into it, which is where every cycle will turn out to run:

| from_domain | to_domain | foreign_keys | from_tables |
|---|---|---|---|
| accounts | shipping | 1 | users |
| catalog | shipping | 1 | stock_levels |
| orders | shipping | 1 | orders |
| shipping | accounts | 1 | addresses |
| shipping | catalog | 1 | shipment_items |
| shipping | orders | 1 | shipments |

The second query walks those dependencies and prints each cycle once, starting from its alphabetically first module:

```sql
WITH RECURSIVE edges AS (
    SELECT DISTINCT c.domain AS src, p.domain AS dst
    FROM pg_constraint k
    JOIN table_domains c ON c.table_name = k.conrelid::regclass::text
    JOIN table_domains p ON p.table_name = k.confrelid::regclass::text
    WHERE k.contype = 'f' AND c.domain <> p.domain
),
walk AS (
    SELECT src AS start, dst AS at, ARRAY[src] AS path
    FROM edges
    UNION ALL
    SELECT w.start, e.dst, w.path || e.src
    FROM walk w
    JOIN edges e ON e.src = w.at
    WHERE w.at <> w.start
      AND w.at > w.start
      AND NOT w.at = ANY (w.path)
)
SELECT array_to_string(path, ' > ') AS cycle
FROM walk
WHERE at = start
ORDER BY cardinality(path), cycle;
```

It returns seven:

```text
accounts > shipping
catalog > shipping
orders > shipping
accounts > shipping > catalog
accounts > shipping > orders
catalog > shipping > orders
accounts > shipping > orders > catalog
```

The query reads declared foreign keys only. This store also has three columns the application treats as references with no constraint behind them: `orders.coupon_id`, `tickets.order_id` and `return_requests.shipment_id`. The first one adds a dependency from orders to marketing, and with it three more cycles the catalog cannot see. Counting those three columns, there are ten.

Now move the table. One `UPDATE` each, and the cycle query again:

```sql
UPDATE table_domains SET domain = 'accounts' WHERE table_name = 'addresses';
UPDATE table_domains SET domain = 'shipping' WHERE table_name = 'stock_levels';
```

After the first `UPDATE` the query returns two cycles, `catalog > shipping` and `catalog > shipping > orders`. After the second it returns no rows.

Two limits before you trust it on your own database. The join is an inner join, so a table missing from `table_domains` drops out of the count without an error, and `conrelid::regclass::text` writes a table outside the `search_path` as `shop.addresses` and a mixed-case or reserved name in double quotes, so the mapping has to spell names the same way. This lists every table the mapping misses (it will name `table_domains` itself, which is fine):

```sql
SELECT c.oid::regclass AS unmapped_table
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND c.oid::regclass::text NOT IN (SELECT table_name FROM table_domains);
```

## Which tables are worth trying in another module?

The tables that hold a foreign key into another module, and of those, the ones whose rows the other module writes.

Counting is a poor guide, and I checked that by brute force. With 34 tables and seven modules there are 204 ways to move one table to another module. A script tried all of them against the ten cycles:

| Result of moving one table | Moves | Example |
|---|---|---|
| Fewer cycles | 38 | `addresses` to accounts: 3 |
| Same count | 77 | `sessions` to billing: 10 |
| More cycles | 89 | `warehouses` to support: 25 |

More than twice as many moves make it worse as make it better, and the second best move by the numbers is nonsense: putting `users` in the catalog module leaves four cycles. A low count proves nothing about whether the table belongs there.

So ask who owns the rows, and use the count only to confirm. Two questions settle most tables:

- **Which module writes it?** An address row is created when a user fills in a form, and `addresses.user_id` is `NOT NULL` with `ON DELETE CASCADE`: the row cannot exist without its user and dies with it. That is accounts. Shipping reads addresses; it does not own them.
- **If more than one module writes it, whose fact is it?** `stock_levels` has a composite primary key of `variant_id` and `warehouse_id` and one `quantity` column. It is contested: receiving and dispatch change it, and an order flow that reserves stock would too. What tips it is the key. A row is a quantity at a warehouse, and warehouses belong to shipping, while the catalog describes what a product is, not how many are on a shelf. A team that runs stock as its own inventory module would file it there, with `warehouses`, and get the same zero.

A third option is to give the contested table a module of its own. That does not help when two tables reference each other. I put `addresses` alone in a new locations module and got four cycles, one of them `accounts > locations`, because `users` and `addresses` point at each other. Two tables that reference each other form a cycle wherever you draw the line between them; they can only share a module, or lose one of the two keys.

## Is a circular dependency between modules real, or is one table in the wrong module?

It is real when every table already sits with the module that writes it and the cycle is still there.

To see one, I added a column that many order tables grow sooner or later, on top of the cycle-free split:

```sql
ALTER TABLE orders
    ADD COLUMN latest_shipment_id BIGINT REFERENCES shipments (id);
```

The cycle query returns one row, `orders > shipping`: `shipments.order_id` points one way and the new column points back. Both tables are where their writers are: the order flow writes `orders`, dispatch writes `shipments`.

The move test agrees. I ran the 204 single moves again and exactly one clears the cycle: moving `orders` into the shipping module, which leaves an orders module without its `orders` table. Moving two tables together, `shipments` and `shipment_items` into orders, clears it as well, and that is not a table changing module. It is shipping losing its shipments, which is merging the two modules under another name.

| | Boundary cycle | Real cycle |
|---|---|---|
| What clears it | Moving one table to the module that writes it | Only moving tables away from the module that writes them, or merging two modules |
| Schema change needed | None | One foreign key dropped or turned around |
| On the store | `addresses` and `stock_levels`: ten cycles to zero | `orders.latest_shipment_id` |
| Who decides | Whoever owns the module list | Whoever owns the two modules' code |

For a real cycle one direction has to go. Here the back reference is the weak one: the latest shipment of an order is the row in `shipments` with the highest `id` for that `order_id`, so a query can derive it and the column can be dropped. Dropping only the constraint and keeping the column does not count: the reference is still there, the catalog query just stops seeing it, as it already fails to see `orders.coupon_id`. When neither direction can go, merging the two modules is the honest answer: they were one module all along.

## How does Schemity show which cycles come from the boundaries?

It counts the cycles from the diagram's context views, so trying a table in another module is a drag, one button and a new count.

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. To understand your schema at the module level it has two pieces. A context view is a saved, focused view of the schema that shows only a subset of entities and the relationships between them. The [Context Map](https://schemity.com/doc/context-map/) draws each context view as one node and derives the arrows between them from the foreign keys in the diagram.

I connected the store, grouped its tables into seven [legends](https://schemity.com/doc/legends-and-annotations/), and made a [context view](https://schemity.com/doc/context-views/) per legend with Sync from legends. The map showed 17 dependencies and its Cycles list showed ten cycles, not seven, because the diagram also counts the three undeclared columns as [inferred relations](https://schemity.com/doc/virtual-relations/). Clicking an arrow lists the keys behind it, which is the list you need for the ownership questions above.

![Schemity's Context Map of the store in the dark theme: seven boxes named catalog, marketing, billing, orders, accounts, shipping and support with their entity counts, joined by arrows with count badges. Three pairs of curved arrows meet at shipping, from catalog, from orders and from accounts, and three arrows are dashed: orders to marketing, support to orders and support to shipping](https://schemity.com/images/blog/circular-dependency-between-modules-misplaced-table/context-map-ten-cycles.webp)

![The Dependency cycles drawer open beside the Context Map, listing ten loops: accounts, shipping; catalog, shipping; orders, shipping; then seven longer ones through three to five context views, every one passing through shipping. The footer reads Contexts: 7, Dependencies: 17](https://schemity.com/images/blog/circular-dependency-between-modules-misplaced-table/cycles-list-ten.webp)

Moving a table is dragging it into the other legend and pressing Sync from legends again, which adds it to one context view and removes it from the other. After `addresses` moved, the map showed 15 dependencies and three cycles. After `stock_levels` moved, 14 dependencies and none.

![The same Context Map after the two moves: accounts and catalog now hold 6 entities each and shipping 5, every arrow is straight, and shipping only points outward, to catalog with a count of 2, to orders and to accounts. The three dashed arrows are unchanged](https://schemity.com/images/blog/circular-dependency-between-modules-misplaced-table/context-map-no-cycles.webp)

An AI agent can do the same reading over [the local MCP server](https://schemity.com/doc/ai-assisted-design/). `get_context_views` returns the dependencies, the keys behind each and the cycles. The built-in prompt Organize a diagram by domain groups the tables from your codebase, puts a table several domains use with the domain that writes it, and ends by asking the agent to say, for each cycle, whether one table looks like it belongs in another domain or the dependency is real.

Schemity does not make that call itself. It lists every cycle the same way and does not mark one as a boundary cycle and another as real. The count is computed; the ownership decision is yours.

## What to do with a cycle between modules

Check the module list before you change the schema.

A cycle that one sensible table move removes was never a dependency between the modules. List the foreign keys behind both directions. If one of them sits in a table the other module writes, move the table and count again. If every table is where it belongs, drop or turn around one key, and say in the review which one and why.

The same picture, drawn from a live database, is in [how a DDD context map is already in your foreign keys](https://schemity.com/blog/your-ddd-context-map-is-already-in-your-foreign-keys/). Once the cycles are gone, the remaining arrows are the keys that cross a boundary, and [whether to keep foreign keys between modules](https://schemity.com/blog/foreign-keys-between-modules-modular-monolith/) is the next decision. If the database has no module names at all, start with [grouping a legacy database by domain](https://schemity.com/blog/reverse-engineer-legacy-database-group-by-domain/). And when two tables reference each other with `NOT NULL` columns, the problem is older than modules: [the first row cannot be inserted](https://schemity.com/blog/circular-foreign-keys-first-row-cannot-be-inserted/).

## Frequently asked questions

### How do I fix a circular dependency between modules in a modular monolith?

First check whether it is a boundary problem. List the foreign keys behind each direction of the cycle and ask which module writes the table that holds them; if that table sits in the wrong module, moving it removes the cycle with no schema change. If every table is where it belongs, one direction has to go: drop that foreign key and derive the value with a query, or point it the other way.

### Can a circular dependency between modules disappear without changing any foreign key?

Yes. A module depends on another as soon as one of its tables references one of the other's, so the cycle count changes whenever a table changes module. On the 34-table store in this post, moving two tables took the count from ten to zero while all 43 foreign keys stayed as they were.

### Does a query on `pg_constraint` find every dependency between modules?

Only the declared ones. A column such as `orders.coupon_id` that the application treats as a reference, with no `FOREIGN KEY` behind it, is invisible to the catalog. On the store in this post the catalog query found seven cycles and the diagram, which also counts three inferred relations, found ten.
