I think a reasonable definition of "no lock-in" is required. To me its the ability to “start making new choices”, not necessarily “you can push a button and remove your ash stuff”. But we'll get to ejecting at the end.
Ash is stateless. You call functions and it follows the instructions defined in the action. It supports "dropping the bottom" out of any given operation, by overriding whatever action Ash was going to take, And it will happily work along side ecto resources (or w/e) that modify the underlying data it works with.
Keeping in mind that Ash does significantly more than just simple data manipulation/crud, lets look at a simple example.
defmodule MyApp.Support.Ticket do
use Ash.Resource,
domain: MyApp.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :submit do
accept [:subject, :priority]
end
end
attributes do
uuid_primary_key :id
attribute :subject, :string, allow_nil?: false
attribute :priority, :atom do
constraints one_of: [:low, :medium, :high]
end
end
end
defmodule MyApp.Support do
use Ash.Domain
resources do
resource MyApp.Support.Ticket do
define :submit_ticket, action: :submit, args: [:subject, :priority]
define :list_tickets, action: :read
end
end
end
# submit a ticket
MyApp.Support.submit_ticket!("subject", :high)
# list a ticket
MyApp.Support.list_tickets!()
Lets say I want to wholesale replace the action
defmodule MyApp.Support do
use Ash.Domain
resources do
resource Ticket do
# define :submit_ticket, :submit, args: [:subject, :priority]
define :list_tickets, :read
end
end
def submit_ticket(subject, priority) do
end
end
Now, this is only mostly true. One of the big benefits is that these are actually rich interfaces. i.e the define :submit_ticket, ...
but lets me say things like this:
Support.submit_ticket(
"subject",
:high,
actor: current_user,
upsert?: true,
upsert_identity: :unique_something, upsert_fields: [:priority]
)
or
Support.list_tickets(query: Ash.Query.filter(Ticket, priority == :high))
So, while you can replace the action, you are going to have to figure out some way to replace whatever subset of action functionality you were using. In practice, I don't really see this as "lock in", because you can see explicitly what you need. i.e I might add functions like this following:
def upsert_ticket(....)
def list_tickets(priority, ...)
Ultimately Ash doesn't do anything to make rewriting this code worse, and I think in general it is quite easy to see what "special stuff" was going on in the case you decide to rip Ash out.
With all that said, there is another important angle here, which is that:
So you decide you don't like how Ash does your actions, or how it does X/Y/Z other thing. You can do things like this:
create :submit do
accept [:subject, :priority]
manual ManualImplementation
end
defmodule ManualImplementation do
use Ash.Resource.ManualCreate
def create(changeset, _opts, _context) do
# fuck you Ash I'll do it myself
MyApp.Repo.insert(struct!(changeset.data, changeset.attributes))
end
end
Or say you realize you need some less restrictive contract, like submit_ticket(subject, priority) -> priority
, you can use a generic action:
# just defining a type as an example. In real life it would be in a different file
defmodule Priority do
use Ash.Type.Enum, values: [:low, :medium, :high]
end
action :submit, Priority do
argument :subject, :string, allow_nil?: false
argument :priority, Priority, allow_nil?: false
run fn input, _context ->
# use Ash.create!
# or Repo.insert
# or whatever you want
Repo.insert(%__MODULE__{priority: priority, subject: subject})
priority
end
end
The point is that if you want to opt-out, you usually don't need to opt-out of the whole thing. And I mean, if you really want to, here is a little script to get folks started:
for domain <- Application.get_env(:my_app, :ash_domains) do
funs =
domain
|> Ash.Domain.Info.resource_references()
|> Enum.map_join("\n\n", fn %{definitions: definitions} ->
Enum.map_join(definitions || [], fn definition ->
args =
Enum.map_join(definition.args || [], ", ", fn
{:optional, arg} ->
"#{arg} \\\\ nil"
arg ->
arg
end)
"""
def #{definition.name}(#{args}) do
# your logic here
end
"""
end)
end)
"""
defmodule #{inspect(domain)} do
@moduledoc "This module was generated by Ash Ejector v0.0.1!"
#{funs}
end
"""
|> Code.format_string!()
|> IO.iodata_to_binary()
|> IO.puts()
end
For the above example, this would generate:
defmodule MyApp.Support do
@moduledoc "This module was generated by Ash Ejector v0.0.1!"
def submit_ticket(subject, priority) do
# your logic here
end
def list_tickets() do
# your logic here
end
end
This could be extended to the point that, honestly, fully ejecting out of Ash would be a reasonable proposal. But ultimately we've found that most people don't want out 🤷🏻♂️