Just finished the Ash book and excited to dive in, but still trying to wrap my head around incorporating Ash into my mental model of an Elixir/Phoenix app.
I had some more abstract questions:
How is function composability intended to work with actions? I’m used to functions calling functions calling functions (from Context modules). For example, Orders.place_order might create a customer, look up a payment method, call out to Stripe, do audit logging, clear a cart, send some emails, create shipments/orders/transactions, etc. These functions would live in Orders or maybe other contexts or modules like MyApp.Cart, etc. If I define an action that has a lot of different business logic to perform, where semantically should those functions live? In the resource? The domain? The specific Change module for that resource? Or elsewhere in the app?
Related to #1. With a Phoenix app, complex operations that involve multiple schemas often live in the relevant Context as a function that does multiple things. With Ash, it seems like the domain (despite being similar to a context) is not intended to have functions defined in it manually (via def) that do this kind of work. Reviewing Multi-Step Actions — ash v3.5.12, it seems I’d instead need to pick a single resource (of the schemas involved in the transaction) to semantically “own” the action and then use a series of Changes to do the work in the other relevant resources before/after the data model creation, is this understanding correct?
In a fully Ash-ified app, how do you decide what should be modeled as an Ash resource and what is just a regular Elixir module? Obviously resources backed by the data model would be Ash resources, but what about API clients? Form validations not backed by persistence? Other parts of the app?
I never built too much with Rails (was more of a Roda/Sequel guy), but I remember the common Rails wisdom eventually becoming “no fat models” and “avoid ActiveRecord callbacks like the plague” (single-responsibility principle, mixing data layer and business logic layer, unexpected bugs/gotchas, side effect spaghetti, etc). Unless I’m misreading, Ash seems to encourage both by design - is this due to a disagreement with the dogma or is the architecture fundamentally different?
A lot of my feedback and opinions on this front can be hard to explain because they are nuanced. Often times what I see in applications is that, out of a desire to have very strict answers to all of these questions, we introduce a bunch of useless and arbitrary separations and boundaries in our applications that lead to endless complexity and difficult change management down the road.
I think 1 & 2 are basically the same question.
There are various ways to approach this. You can:
choose a resource that it makes sense to “live on”. You mentioned this. This is great for simple things, when the model of the additional things that happen are a “side effect”. i.e creating a user subsequently creates something else.
make a resource just for housing the workflow itself. This is a perfectly valid resource:
defmodule MyApp.Orders.Placement do
use Ash.Resource
actions do
action :place_order, :struct do
#returns an order
constraints instance_of: MyApp.Orders.Order
argument :item, MyApp.Types.Sku
argument :quantity, :integer, allow_nil?: false, constraints: [min: 1]
run fn input, _ -> .... end
end
end
end
This is where people often get into adding dedicated service layers because they want a perfect place for this stuff to live, but IMO thats throwing the baby out with the bathwater, adopting a bunch of architectural complexity just because some portion of actions don’t feel like they belong in any one existing file. Resources are declarative action-containers first-and-foremost, and they don’t have a requirement to model state in addition to behavior.
ignore me and define a context module if you want Its okay if sensibilities differ.
Composability is done via Ash’s tools for composable logic, which include changes (Ash.Resource.Change), preparations (Ash.Resource.Preparation), validations (Ash.Resource.Validation), calculations etc.
For me, almost everything that is called from “outside” of my domain will go through a domain & resource. Once “inside” (i.e within a resource action, maybe a generic action) then it’s purely tactics. If I just have a one-off thing that calls some HTTP API I’m just going to toss a call to Req in and move on with my life. The important abstraction there is the typed interface to my domain logic.
Many Ash apps will have thousands of files and not a context in sight, and in fact the emergence of contexts in an Ash app is often a smell, indicating that you’re thinking around what a resource and a domain is is too tightly coupled to the idea of modeling “things”.
Yeah, personally, I like my models fat. Either it should be in the model, or it shouldn’t exist (i.e should be derived). BUT there is nuance here that actually makes my version of “fat models” entirely different from what you might have seen in rails land.
A “model” in rails is first and foremost 2 things:
a state-oriented abstraction
typically associated with some mutable object.
Fat models in those examples are bad because every place you are looking is in fact conflating state and behavior by its very nature. In Ash, we actually do have separate places for behavior and state. Or at least, we have progressively complex declarative “verbs” via actions, the action types (generic vs crud). Each layer (like ash_graphql and ash_json_api etc.) are defined in their own section of the resource. The concerns are simultaneously separated and localized. This isn’t an easy thing to explain
The big thing here is that Ash runs in Elixir where there is no mutable state. What this means is that by nature Ash actions can effectively only be “instructions as data for performing some action”. We just don’t have the spooky-action-at-a-distance problem that mutable languages have, and so Ash thrives, and side-steps the foot guns that plague higher level abstractions in other languages.
So, for example:
create :create do
accept [:first_name, :last_name]
end
on its own is nothing but a description of an action. It isn’t actually even “callable” like a method on an active record object.
You can see what the instructions are via Ash.Resource.Info.action(Resource, :create). You could even define your own resource creator that you call, with a resource and an action and some inputs, using Ash only as a modeling language.
Ultimately for me it’s all about letting simple things be simple, but retaining the right to separate when necessary. 85% of the stuff in your app is going to be simple mappings to data layers. Let’s not complicate our entire stack just to handle the 15% where that isn’t the case. Instead, we can just use a generic action. Or make a domain & resource just for that thing, etc.
Not my best explanation ever, but I’ve run out of steam
This really helped with making things make sense, thanks so much @zachdaniel!
100% agree. My goal here is mostly just to understand how declarative design “intends” to approach this so I don’t actively fight the framework with my ingrained imperative habits
My default inclination is to fill that “run” function with all the steps to place an order (calling out to other actions). Would the more Ash-y approach be a series of hooks inside changes instead? e.g.
create :place do
argument :cart_id, :uuid, allow_nil?: false
change MyApp.Order.Changes.BuildCustomer
change MyApp.Order.Changes.AuthorizePayment
change MyApp.Order.Changes.MoveCartItems
change MyApp.Order.Changes.EmitEvents
change MyApp.Order.Changes.EnqueueEmails
end
By “context” do you mean a module that does broad domain logic (like Phoenix contexts, or a service layer in general)? Is the idea that we can model all of our business logic declaratively, so it all naturally fits into a resource in one way or another, so contexts don’t need to emerge in an Ash app?
Does “outside” mean with respect to that specific domain or to the application in general? i.e. something that has no API exposure of any kind and is purely used internally by your own app (maybe called from actions that are external-facing), is that usually still a resource?
Do you typically still write non-Ash Elixir modules in a production app, just not ones that handle business logic? e.g. a module that’s just a bag of pure functions, or one that converts photos from png to jpeg, for example.
In general, yes I’d use hooks to compose logic around manipulating records in my app. Then, if complexity or some other need warrants it, I’d upgrade to using a generic action with or without a reactor, depending on the context. The new multi step actions guide provides some guidance on choosing between those.
By “context” do you mean a module that does broad domain logic (like Phoenix contexts, or a service layer in general)? Is the idea that we can model all of our business logic declaratively, so it all naturally fits into a resource in one way or another, so contexts don’t need to emerge in an Ash app?
I mean no explicitly defined service layers. Some folks might disagree with me on this, I’ve seen someone write that Ash is great as long as you define a strict service layer not that long ago. Folks can make up their own mind, but I disagree
Does “outside” mean with respect to that specific domain or to the application in general? i.e. something that has no API exposure of any kind and is purely used internally by your own app (maybe called from actions that are external-facing), is that usually still a resource?
This means “outside” the domain, either cross-domain or from something like a Liveview, custom controller, plug, background job etc.
Do you typically still write non-Ash Elixir modules in a production app, just not ones that handle business logic? e.g. a module that’s just a bag of pure functions, or one that converts photos from png to jpeg, for example.
Yes, very often. We still have a need for “regular” code like this often Its just often utilities and/or stuff backing our actions.