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 “the dependencies between modules have to form a directed acyclic graph”, and the 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:
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)
);

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:
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:
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:
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:
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:
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):
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_idisNOT NULLwithON 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_levelshas a composite primary key ofvariant_idandwarehouse_idand onequantitycolumn. 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, withwarehouses, 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:
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 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, and made a context view 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. Clicking an arrow lists the keys behind it, which is the list you need for the ownership questions above.


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.

An AI agent can do the same reading over the local MCP server. 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. Once the cycles are gone, the remaining arrows are the keys that cross a boundary, and whether to keep foreign keys between modules is the next decision. If the database has no module names at all, start with grouping a legacy database 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.
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.