Skip to content

Instantly share code, notes, and snippets.

@zachdaniel
Last active April 30, 2024 23:59
Show Gist options
  • Save zachdaniel/41f6f924f5f9cb8cf73c67af2201f773 to your computer and use it in GitHub Desktop.
Save zachdaniel/41f6f924f5f9cb8cf73c67af2201f773 to your computer and use it in GitHub Desktop.

What does "no lock-in" mean in the context of Ash

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!()

Actions are just a contract

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:

Actions can be only a contract

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:

Ash Ejector v0.0.1

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 🤷🏻‍♂️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment