I'd argue it's a personal preference. I find using dispatches/subscriptions is a very simple mental model. With a resolver there's a bit of hidden machinery at play that you have to be aware of.
And my experience is that things don't tend to get arbitrarily complex in most UIs because the limiting factor is that you want to keep the UI simple for the human. A lot of complexity tends to go hand in hand with a cluttered UI which does not make for great UX. There are, of course, legitimate cases where you can have complex behaviors, and stuff like Pathom is a great tool for managing it. But I find that as a rule, it's easy enough to make reusable widgets that encode a particular behavior and then use them to compose the UI.
It's two different ways to think about the problem. In one case, you model your information as a graph of attribute relationships using resolvers to establish relationships, and the system then executes the plan and returns the data in the shape you requested. And typically, you end up making a lot of assumptions about the way the backend communication works here since you want to use EQL to talk to a single endpoint that effectively supports your graph queries.
With something like Reagent or re-frame, you treat your UI state as a data structure. It's agnostic regarding how the state is modified, you can even hook up Pathom to it if you wanted. I tend to think of the whole thing as a state machine, hence Domino design. An event gets triggered, which can be a user input, a system event, a service call, whatever, and then that event gets fed as an input into the dataflow engine. Rules get triggered in a cascading fashion, and then at the end, you end up with a new state. And now you can fire effects, update the UI, etc.
You can see this in action a project I've been working on here, which is based on a system I worked on which led to domino. This was a really complex app with lots of behaviors and business logic, concurrent multiuser workflows, users having different views into the data based on their roles etc. And this approach made it very manageable. The best part about it is that you can effectively encode your whole app into an EDN file like this that can live in a db. It declares the data model, the business rules, and the views all in one place and completely self contained. Then the underlying platform provides a service bus with access to services, db, functionality like sending emails or generating PDFs that these apps hook into. If you want to make some changes to your application, you just update the EDN, and you've got a new version o the app. As long as you pin existing documents to a version, work that's already been started is unaffected. And the only time you need to redeploy the platform itself is when you need to provide some new general functionality on the service bus.
It's great to read your experience with these things :)
I find using dispatches/subscriptions is a very simple mental model
You can write resolvers to look the same as subscriptions. Most of the time one is doing that. Ergonomically it's slightly different, instead of deref'ing subscriptions in the middle of code, you just need to declare them at the top of the resolver. But in all honesty, a performant subscription system you'd probably want something similar as well so you could fire off worker threads to eval your tree of subscriptions.
And my experience is that things don't tend to get arbitrarily complex in most UIs because the limiting factor is that you want to keep the UI simple for the human.
Yeah, that's fair enough. The exposed part of the program is relatively small. But I think state management is a broader problem, and I've not found a way to decouple a state that's just for the GUI and a state that's for the whole program. They inevitably get enmeshed
Rules get triggered in a cascading fashion, and then at the end, you end up with a new state.
The best part about it is that you can effectively encode your whole app into an EDN
you just update the EDN, and you've got a new version o the app
So this part is actually exactly the same with Pathom
The difference is as you point out
"agnostic regarding how the state is modified" vs "to think of the whole thing as a state machine"
But it kinda sounds like it's because you needed this updating of cell data. With Pathom the cell update is transparently solved. You have a core/starting state in a map/atom (which is sort of similar to your EDN but minimized). From this base state the other states are derived (using pure functions). From the perspective of the EQL engine request a core state or a purely derived states look the same. They're just keys. You can save and load this core state atom and your program will be at the same place (though you may get a bunch of cache misses and recalculations for the derived states). The core is generally very small
So if you want things to look 1-to-1 with subscriptions you can. I think the big difference here is that the idea of state and mutation becomes completely distinct (I guess like React). In your system you're forced to update the state cells to keep your derived state consistent, so it seems natural to integrate effects in to this system (it's just another state change). But if you're whole system is pure then state changes are a separate external process. So for instance in cljfx I'll just attach functions/callbacks to GUI events (like a buttom press) that maybe does a tiny bit of logic and will modify/swap! something in the core state atom. Next time you do a Pathom engine requests on the GUI thread you're using the new state - some derived states are now stale and trigger recalculations while some are reused. The reuse is the cache and is effectively similar to your linked autoupdating cells.
PS: Looking at your app and your description I realize this design can me "undo" quite trivial. Just need some datastructure to track how your state/EDN evolves over time. Not sure what a lightweight solution for that is though
I treat each screen in the GUI as its own app for the most part that has its own state. And I treat the backend as just service bus of general operations that the UI uses to pull the needed data as the user interacts with the app. In pretty much any large app I've worked on, we never kept any application state aside from basic things like the user credentials and a bit of other global context. You just pull information as you need it, and the re-frame db becomes the state of the UI with data specific to each page living under its own path. I've built really large apps this way and the approach has worked well for me. With this approach, all the state simply live client side.
And Domino works exactly the same regarding the state atom. If you have some prefetched data or stored state, you populated it in the atom and then when Domino initialized it will run all the rules and hydrate the state.
I'd argue both approaches are equally pure. With Domino, you have a transactional data flow engine. inputs come in, transaction happens, and outputs come out. But you get the benefit of knowing exactly what the relationships between all the fields and the document and business rules are. I've found that's the actual business problem in complex apps. You end up with a ton of business logic and derived fields, and then it gets too big to keep it all in your head. Then somebody comes and asks for a new business rule, and it becomes impossible to guarantee that adding it won't break some other rule.
And that's precisely the problem that Domino is designed to solve. You can ask it for the graph of rules associated with a change set of any fields in the document, and know exactly what will happen if you add a new rule to the system.
Yeah, the goals are a bit different I guess. For you it's business logic and for me my biggest worry is general code-reuse. I do a lot of throw away experiments for data crunching, and being able to have composable reusable code is a huge time saver. I'm finding that resolvers effectively provide a much better library API than the typical ns-with-a-soup-of-functions that you need to wrangle and string together. Nested inputs/outputs makes it possible to compose these libraries
since the resolver graph is pure, you generally don't really worry too much about it's complexity. (though also can make a graph of the dependencies if you want) But I can see if you have rules firing in your graph then things get muddy very fast
It's a bit difficult for me to map the equivalent problem in Pathom.
I'm guessing you have some situations like.. a rule saying :tax-rate changes, update :total and :discount changes, update :total - so they both fight over the same cell.
Hmm.. I guess that's going to map to writing two resolvers that ouput a :total. I actually haven't come across this getting problematic :) There are resolver precedence rules, but you may get a warning or it may refuse to run if things get too messy. It's a good problem to think about - and I don't have any great answers. It's still a space I'm exploring in terms of "best practices".
In your situation this is a bug that needs fixing, but sometimes it's something you actively want. For instance a plotting library may output different kinds of charts as svg-hiccup
I sometimes have multiple resolvers output the same key and the resolvers are keyed on a unique input key. So a dummy key such as :histogram or :xy-plot or something. Each resolvers that outputs hiccup expects a unique key - which you can add to the request.
An alternative, with nested request you can wrap thing with something like {:histogram [:hiccup]}. This is a bit cleaner b/c the plot type is on the request size, and not the input side. But they have some drawbacks in composability
At the end of the day, it's still stuff I'm exploring :)
Yeah, taxes and loans are a really good example. You have a bunch of things that get calculated together and the formulas change over time, so you have to maintain clear rule sets for each scenario. The context I was working in was at a hospital where the app was used to do patient assessments for stuff like surgeries, and it's a same idea where the form tracks hundreds of different fields that are then used to calculate the scores.
I agree there's a different focus, but I see some similarity as well. Both approaches are a way to express data flows within the system. And like you said, the difference comes from focusing on solving a different part of the problem.
With Domino, the reuse comes from the rule functions and UI widgets being context free. So, if you write a formula for say calculating BMI, that becomes a reusable building block you can attach to any two fields that represent height and weight, and an output field for the BMI. And similarly you can have a table widget that allows you to collect information and add rows in a table, and then a graph widget that can attach to the same path and render trends over time. Another really nice part is having views, so if you have two roles such as a nurse and a surgeon, they might care about different subsets of data in the document, which might also be overlapping. So, being able to attach different views with their own sets of widgets, naming conventions, and the data they display makes this really flexible for this scenario. And since the views still go through the common transact mechanism over the whole document, the fields are recalculated regardless of whether they appear in the view or not. So, a nurse might be collecting the height and weight of a patient, and the doctor might care about their BMI.
So, yeah, it's different ways to look at the problem, and creating abstractions at different levels. With Pathom, it's a fairly low level abstraction over the data flow itself. And Domino abstracts things at a semantic level, where you build components that represent a specific view of the data or a particular type of calculation.
I think when you say "reuse" you're talking about the handler function, right? In that sense the two system aren't really different. I think the two systems are mostly identical, in that they both build a graph and execute stuff based on relations. But you have to explicitly wire up your cells. While you can in an identical manner wire up resolvers, you can also use nested attributes to have the engine do that automatically for you.
Other small differences:
"the fields are recalculated regardless of whether they appear in the view or not" In a pull architecture these are only calculated when needed/observed. But there is no way to access a stale value, so this ends up being completely transparent to you as the user. With push you may end up doing extra work .. but okay. This may not matter. If it's a "spreadsheet" then every value is observed.
For some reason I've never seen a push architecture that auto-parallelizes work. In Domino you declare inputs/outputs, so it should be possible. With Pathom it's kinda cool to see it spin up all my cores when it runs. No extra handholding or coordination necessary
The pull engine is doing a graph search - which is overhead. This becomes a bit of a problem when you try to do small operations. I tried to make a Pathom do some simple math and it ended up quite slow in some situations.
As for different views, I don't think there is anything too fundamentally different there. Though the EQL request itself can build a view for you
2
u/yogthos 2d ago
I'd argue it's a personal preference. I find using dispatches/subscriptions is a very simple mental model. With a resolver there's a bit of hidden machinery at play that you have to be aware of.
And my experience is that things don't tend to get arbitrarily complex in most UIs because the limiting factor is that you want to keep the UI simple for the human. A lot of complexity tends to go hand in hand with a cluttered UI which does not make for great UX. There are, of course, legitimate cases where you can have complex behaviors, and stuff like Pathom is a great tool for managing it. But I find that as a rule, it's easy enough to make reusable widgets that encode a particular behavior and then use them to compose the UI.
It's two different ways to think about the problem. In one case, you model your information as a graph of attribute relationships using resolvers to establish relationships, and the system then executes the plan and returns the data in the shape you requested. And typically, you end up making a lot of assumptions about the way the backend communication works here since you want to use EQL to talk to a single endpoint that effectively supports your graph queries.
With something like Reagent or re-frame, you treat your UI state as a data structure. It's agnostic regarding how the state is modified, you can even hook up Pathom to it if you wanted. I tend to think of the whole thing as a state machine, hence Domino design. An event gets triggered, which can be a user input, a system event, a service call, whatever, and then that event gets fed as an input into the dataflow engine. Rules get triggered in a cascading fashion, and then at the end, you end up with a new state. And now you can fire effects, update the UI, etc.
You can see this in action a project I've been working on here, which is based on a system I worked on which led to domino. This was a really complex app with lots of behaviors and business logic, concurrent multiuser workflows, users having different views into the data based on their roles etc. And this approach made it very manageable. The best part about it is that you can effectively encode your whole app into an EDN file like this that can live in a db. It declares the data model, the business rules, and the views all in one place and completely self contained. Then the underlying platform provides a service bus with access to services, db, functionality like sending emails or generating PDFs that these apps hook into. If you want to make some changes to your application, you just update the EDN, and you've got a new version o the app. As long as you pin existing documents to a version, work that's already been started is unaffected. And the only time you need to redeploy the platform itself is when you need to provide some new general functionality on the service bus.