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)
);

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

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_domainto_domainforeign_keysfrom_tables
accountsshipping1users
catalogshipping1stock_levels
ordersshipping1orders
shippingaccounts1addresses
shippingcatalog1shipment_items
shippingorders1shipments

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 tableMovesExample
Fewer cycles38addresses to accounts: 3
Same count77sessions to billing: 10
More cycles89warehouses 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:

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 cycleReal cycle
What clears itMoving one table to the module that writes itOnly moving tables away from the module that writes them, or merging two modules
Schema change neededNoneOne foreign key dropped or turned around
On the storeaddresses and stock_levels: ten cycles to zeroorders.latest_shipment_id
Who decidesWhoever owns the module listWhoever 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.

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

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

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

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.