Dry-struct: how to establish a bidirectional association between to structs?

tl;dr I am searching for a way to establish a bidirectional association between to structs.

Context

I am used to Rails and ActiveRecord. There, it is common to have bidirectional associations between objects, like a Order has_many :order_items and OrderItem belongs_to :order.

I am trying to move away from Rails style coding, and I am experimenting with different ways, like dry-struct. I’d like to create a domain layer of objects that offer rich behaviour (business logic) based on dry-struct. Therefore I view those objects not as pure data carriers (anemic model), but as full-fledged classes (like in OOP) that offer business logic through methods. Sometimes it is handy to be able to navigate an association in both ways.

Problem

A dry-struct object is immutable. After creation, I cannot change it. This makes it impossible to establish the bidirectional association. In the order example from above, I could :a: instantiate all OrderItem objects and then add them to the Order on creation. Then I can navigate from an order to all its order items, but not the other way around. Or I could :b: instantiate the Order first and pass it to all OrderItem objects on their creation. Than I can navigate from all order items to their parent order, but the order does not know its order items.

Question

Am I missing something, or is this what I want just not possible? On a more general level, is dry-struct even a good choice for a rich domain layer as described above?

You say you are trying to move away from Rails style, but it sounds more like you are trying to reinvent it. I suggest stepping back and thinking about why you need circular references in the first place.

In my experience, they are actively harmful. You cannot know how someone is going to access a particular piece of data, and so you become beholden to the massive ActiveRecord API, and your test suite becomes a gravity well of integration tests. I’ve seen this happen over and over again with Rails, model classes attract business logic until they become so gigantic nobody can understand how all the features interact, and you end up with a million unpredictable ways to end up in bad state.

This form of mutable state is the reason why some people swear off ORMs for good and disappear into the woods of hand-crafted SQL.

Maybe this works for you, and if so then I suggest using ActiveModel instead. But if you’ve felt this pain, here’s a few suggestions for thinking differently:

1. Try modeling a use-case, not the platonic, universal representation of an idea

It’s entirely reasonable to have multiple structs for representing the same information in different contexts.

More importantly, you should not be modelling your data objects the same way you model database tables. There are usually much more useful representations of that state in Ruby.

I have not taken the plunge with ROM yet, but that is what it does for you.

2. Think of data as trees, not graphs

This works for me, YMMV depending on what problems you are solving. But think about how you would represent this information to the outside world, in JSON. You can’t have circular references there either, but we make it work.

3. Business logic is a Function that takes data in and produces data

Think about how you work with data in the shell. The data is shipped from one thing to another via a pipeline. You can model business logic this way too.

dry-monads is excellent for this, because all your functions can speak the same “language” and that makes them easy to compose.

When I write a Command object, I have a well-defined execution environment that is injected at runtime, and it behaves like a pure function. I give it inputs, I get outputs. I know every possible failure condition. This works very well.

See: CQRS pattern, A brief example of how I write Commands


If you still really need circular references, then another approach does occur to me. Take a look at how JSON:API specifies Sparse Fieldsets. If you had some kind of global identity-map that your structs could reference, you could potentially lazy-load circular references without sacrificing immutability.

1 Like

I suppose you can achieve this by defining a type in advance as an atomic reference (see Concurrent::AtomicReference and siblings) and memoizing it in some container.

It’s quite enough to inherit AtomicReference, spice it up with Dry::Types::Type and Dry::Types::Decorator modules, preventing recursive decorations.

You’ll be able to use a blank ref as a type and set target lateral. I’ve successfully used this trick to make a recursive struct coercions in combination with Dry::Struct::Sum.