Dealing with Bidirectional Dependencies and 'Packwerk Validate' Error for Acyclic Dependency Graph in Engines

This message was imported from the Ruby/Rails Modularity Slack server. Find more info in the import thread.

Message originally sent by slack user U72S8JZ2EP8

Has anyone here dealt with bidirectional dependencies between two engines, and this error in packwerk validate:

Expected the package dependency graph to be acyclic, but it contains the following circular dependencies
I’m pretty convinced that it’s reasonable for two engines to depend on each other. A hypothetical example I can think of is having a finance domain and an order domain, where the order creates an invoice, and once the invoice is paid the order is updated accordingly.

What am I missing? I could only find one reference to the word “acyclic” in https://github.com/Shopify/packwerk/blob/main/USAGE.md so I’d love to know how others have dealt with this issue previously. Cheers!

The typical recommendation is probably to have order depend on finance:
• order explicitly calls finance to create an invoice
• order registers an event handler for “invoice was paid” to update the corresponding order

Another strategy is to find what both things depend on and extract that out. For example, instead of a very broad finance domain that forces bidirectional dependencies, the order domain creates an invoice (invoice domain doesn’t rely on orders or fulfillment, only the ledger), fulfillment domain accepts requests to pay an invoice, which then marks the invoice as paid (which ledgers the accounts receivable). From there, fulfillments can directly talk to orders to progress the order stage machine OR orders could just periodically check on progress with the invoicing domain and update itself.

Events are kind of doing the same thing – extracting something both things depend on… specifically the specification for the event. But IMO it’s a helpful design exercise to think about how you can achieve the same thing without events too.

Message originally sent by slack user U70TIGAX94P

As for the reasoning, please check the link to the package principles in USAGE.md

Message originally sent by slack user U70TIGAX94P

In my experience it is often to look into the two strategies that Franz and Alex proposed above. These tell you were to build abstractions and this help improve the design.

Message originally sent by slack user U72S8JZ2EP8

I dunno, I see where you’re coming from but I’m still not convinced that it’s Packwerk’s job to enforce an acyclic dependency graph.

Taking the event handling idea a bit further, why not have event handlers for all cross-package interactions?

Anyway, I’ll keep digging into possible changes to the boundary between the packs, since I’m sure that’s the root cause of my confusion.

I think avoiding bidirectional dependencies is a very, very valuable design goal.

Message originally sent by slack user U70TIGAX94P

To be honest I don’t quite understand where you’re coming from. Packwerk is a tool intended to help you establish boundaries within an existing monolith.

Boundaries are about dependencies first and foremost.

Robert Martin defined an architectural boundary as a line that’s only crossed by dependencies in one direction.

It follows that there is no boundary if there is a cyclic dependency.

If you care about boundaries, you have to care about dependency cycles.

If you don’t care about boundaries, you shouldn’t use packwerk. It‘s not the right tool.

What are you trying to achieve?

Message originally sent by slack user U70TIGAX94P

Also - regarding events for all package interactions - you can try by sketching out what that would look like for parts of your code. It‘s a very loose form of coupling and IMO not ideal in most circumstances.

If you couple too loosely, things become hard to change together, runtime behavior gets hard to predict, debugging becomes difficult etc.

Boundaries take longer to establish and to move.

Packwerk is built on the assumption that we want to make it easy for people to understand the code, and that it should be easy to move boundaries.

Boundaries need to be iterated on to improve the architecture and adjust it to changing business realities

Message originally sent by slack user U712YWCKK8T

I was a bit confused because we have acyclic dependencies without issues, but TIL that packwerk check does not check for acyclic dependencies despite packwerk check saying it runs all checks.

So I guess an option is to just run packwerk check and not packwerk validate :man-shrugging:

Message originally sent by slack user U72S8JZ2EP8

Yeah I had that same moment of confusion <@U712YWCKK8T> :man-facepalming:

As an update, my team is going down the event-emitting path. The finance/order example was a hypothetical, but the same “trigger a process synchronously in one direction, and have the status change be broadcast in the opposite direction” approach makes sense in the actual domain I’m modelling. Specifically, we’re synchronously requesting an EV charger begin charging our user’s vehicle, and need to transition the mobile app from a ‘starting’ state to either a ‘charging’ or a ‘failed, please try again’ state.

To be clear, I really appreciate the discourse here. I think the crux of where I’m coming from is that I’m thinking of a pack as semantic group of logic with an explicit public interface, carved from a monolith, rather than as a “package” or “dependency” in the ground-up sense. Because I’m trying to decompose a monolith into engines it’s kinda painful to be told that I can’t have two packs depend on each other when they were already calling each other before I wrapped them in two discrete engines.

I do understand and appreciate, however, that Packwerk is just trying to twist my arm a little and get me to reflect on where the boundary is. Honestly, it’s probably right, but I’m just trying to leave the codebase in a better state than I found it, where that might not be the quote-unquote perfect place to draw a line between the packs/engines.