A constructor dependency cycle exists when creating bean A requires bean B while creating B requires A. Neither constructor can finish first, so the container rejects the graph.
Spring circular dependencies: break the constructor graph
Find the actual ownership mistake
A receipt service sometimes calls an audit service, while the audit service calls back into the receipt service to fetch display fields. That is a design cycle before it is a Spring error. Passing the required fields to the audit operation removes the backward lookup and makes the data ownership visible.
package in.aitrove.receipts;
import org.springframework.stereotype.Service;
record AuditRecord(long receiptId, String action) {}
interface AuditSink {
void append(AuditRecord record);
}
@Service
class ReceiptWorkflow {
private final ReceiptStore receiptStore;
private final AuditSink auditSink;
ReceiptWorkflow(ReceiptStore receiptStore, AuditSink auditSink) {
this.receiptStore = receiptStore;
this.auditSink = auditSink;
}
void approve(long receiptId) {
receiptStore.approve(receiptId);
auditSink.append(new AuditRecord(receiptId, "APPROVED"));
}
}
interface ReceiptStore {
void approve(long receiptId);
}The two writes above are not automatically atomic. If audit is a required database record, use a transaction that covers both rows and test rollback. If audit goes to an external service, a local transaction cannot commit that remote call with the receipt; use a durable delivery design where the requirement demands it.
Do not conceal the cycle
Changing one side to field injection, @Lazy, or a provider may delay resolution, but it does not answer why two services need to own each other. Deferred lookup has legitimate uses when creation really must be late; it should not be the default repair for an accidental cycle.
For n beans and m dependency edges, a graph walk to identify a cycle is O(n + m). Runtime invocation cost is a separate question: replacing an in-process call with a remote audit call changes latency and failure modes far more than replacing constructor injection with a provider.
Common Mistakes
- Assuming a successful context startup proves two writes are atomic.
- Using a lazy proxy to postpone a design error until the first request.
- Making the audit adapter query the workflow for data it could receive as an argument.
Read next
ApplicationContext ownership, transaction proxy calls, and the transactional outbox.
