Clean Up Your Seeds with Ash Generators and AshOps

Clean up your seeds with Generators and AshOps

Mike Buhot profile picture

Mike Buhot

31 March 2025 · 8 min read

Creating reliable, consistent development environments is a common challenge for Elixir teams. When you're building applications with the Ash Framework, having predictable test data can make the difference between a frictionless workflow and a constant uphill battle. In this post we'll show you how to keep your seeds.exs script tidy and allow developers to easily generate ad-hoc seed data from the command line.

The seed data dilemma

If you're building with Elixir and Ash Framework, you know the headache: as your application grows, your seeds.exs file can quickly become long and disorganized.

Common approaches often look like this:

# seeds.exs - A long script filled with direct database insertions
user_1 = MyApp.Repo.insert!(%MyApp.User{
  name: "Mike",
  role: "Admin",
})

user_2 = MyApp.Repo.insert!(%MyApp.User{
  name: "Sarah",
  role: "Editor",
})

post_1 = MyApp.Repo.insert!(%MyApp.Post{
  title: "Hello World",
  body: "This is a test post",
  author_id: user_1.id
})

post_2 = MyApp.Repo.insert!(%MyApp.Post{
  title: "Another Post",
  body: "This is another test post",
  author_id: user_2.id
})

# And so on for dozens or hundreds of lines...

This approach falls apart as your application grows. You end up with:

  • A seeds.exs file that spans hundreds or thousands of lines
  • Brittle seeds that break when your schema changes
  • Difficult-to-maintain code with hardcoded IDs and relationships
  • Merge conflicts when multiple developers modify the seed file
  • No easy way for developers to generate additional test data on demand

Enter Ash Generators

Ash Generators allow you to create modular, reusable data generation code in separate files. The key advantage is that generators exercise the same actions and validations your production code uses. This means your seed data goes through the same business logic as real user data, ensuring consistency across your application.

Here's what a real Ash Generator module looks like:

defmodule Realworld.Accounts.UserGenerator do
  alias Ash.Generator
  alias Realworld.Accounts.User

  def user(opts \\ []) do
    Generator.changeset_generator(
      User,
      :register_with_password,
      defaults: [
        username: StreamData.repeatedly(fn -> Faker.Internet.user_name() end),
        email: StreamData.repeatedly(fn -> Faker.Internet.email() end),
        password: "Passw0rd",
        password_confirmation: "Passw0rd"
      ],
      overrides: opts
    )
  end
end

This approach gives you several advantages:

  • Your generator mirrors your domain logic by using the same changesets your application uses
  • You can leverage libraries like Faker and StreamData for realistic test data
  • Generators are composable and can be used in different contexts

Here's another example showing how to generate more complex entities:

defmodule Realworld.Articles.ArticleGenerator do
  alias Ash.Generator
  alias Realworld.Articles.Article

  def article(opts \\ []) do
    Generator.changeset_generator(
      Article,
      :publish,
      defaults: [
        title: StreamData.repeatedly(fn -> Faker.Lorem.sentence(3..6) end),
        description: StreamData.repeatedly(fn -> Faker.Lorem.paragraph(1..2) end),
        body_raw: StreamData.repeatedly(fn ->
          Faker.Lorem.paragraphs(3..5) |> Enum.join("\n\n")
        end),
        tags: StreamData.repeatedly(fn ->
          Faker.Lorem.words(3)
          |> Enum.uniq()
          |> Enum.map(&%{name: &1})
        end)
      ],
      overrides: opts,
      actor: opts[:actor]
    )
  end
end

With generators defined for each resource, you can create realistic, related data throughout your application.

Putting Generators to Work in Your Seeds.exs

Now that we have our generator modules defined, we need to integrate them into our seeds.exs file. Instead of a long file filled with direct database insertions, we can leverage our generators to create data through the proper domain actions.

The syntax for calling the generators from your seeds script looks like:

# seeds.exs
alias Ash.Generator
alias Realworld.Accounts.UserGenerator
alias Realworld.Articles.ArticleGenerator

# Generate 10 users
users = Generator.generate_many(UserGenerator.user(), 10)

# Generate 3 articles for each user
Enum.each(users, fn user ->
  Generator.generate_many(ArticleGenerator.article(actor: user), 3)
end)

This approach offers several advantages over the traditional seeds.exs approach:

  1. The code is concise and focused on the relationships between entities
  2. Each entity is created through its proper action, ensuring all validations and hooks run
  3. The generator modules handle the details of what data to generate
  4. Adding more seed data is as simple as changing the counts

Beyond Seeds.exs: Ad-hoc Data Generation with AshOps Mix Tasks

While our seeds.exs script is now much cleaner and more maintainable, we're still limited to running it once during database setup. What if developers need to generate additional test data during development?

This is where AshOps really shines. AshOps allows you to expose any Ash action as Mix tasks, which we'll use to create data on demand from the command line.

The first step is to create a resource called Realworld.Accounts.Generator. This resource is a little unusual - it doesn't have any attributes and is never stored in a data layer. Instead, it serves as a module for us to add a generate_user action:

defmodule Realworld.Accounts.Generator do
  use Ash.Resource,
    otp_app: :realworld,
    domain: Realworld.Accounts

  actions do
    action :generate_user, {:array, :struct} do
      argument :count, :integer, allow_nil?: false

      run fn input, ctx ->
        Realworld.Accounts.UserGenerator.user()
        |> Ash.Generator.generate_many(input.arguments.count)
        |> then(&{:ok, &1})
      end
    end
  end
end

This resource defines a generate_user action that takes a count argument and uses our UserGenerator to create the specified number of users.

The next step is to add this new resource to our Domain, and use the AshOps extension to map the :generate_useraction to a mix task:

defmodule Realworld.Accounts do
  require AshAuthentication.Strategies

  use Ash.Domain,
    otp_app: :realworld,
    extensions: [AshOps] # Add the Extension

  mix_tasks do
    action Realworld.Accounts.Generator, :generate_user, :generate_user, arguments: [:count]
  end

  resources do
    resource Realworld.Accounts.Token
    resource Realworld.Accounts.User do
      define :get_user_by_id, action: :read, get_by: :id
      define :get_user_by_username, action: :get_by_username, args: [:username]
    end
    resource Realworld.Accounts.Generator  # Add the resource to the Domain
  end
end

By adding AshOps to our domain's extensions and configuring the mix_tasks block, we've now exposed our generate_user action as a mix task. The arguments: [:count] part specifies which arguments from the action should be exposed as command-line arguments.

Let's try it out!

mix realworld.accounts.generate_user 3

Oops! We hit an error:

protocol Ymlr.Encoder not implemented for type Realworld.Accounts.User (a struct), Ymlr.Encoder protocol must always be explicitly implemented.
If you own the struct, you can derive the implementation specifying which fields should be encoded to YAML:
    @derive {Ymlr.Encoder, only: [....]}
    defstruct ...
It is also possible to encode all fields, although this should be used carefully to avoid accidentally leaking private information when new fields are added:
    @derive Ymlr.Encoder
    defstruct ...
Finally, if you don't own the struct you want to encode to YAML, you may use Protocol.derive/3 placed outside of any module:
    Protocol.derive(Ymlr.Encoder, NameOfTheStruct, only: [...])
    Protocol.derive(Ymlr.Encoder, NameOfTheStruct)

Got value:

    #Realworld.Accounts.User<
      __meta__: #Ecto.Schema.Metadata<:loaded, "user">,
      confirmed_at: nil,
      id: "1f17ac31-3c9f-4826-bc17-ba70fbd07a2b",
      email: #Ash.CiString<"enid1957@kutch.name">,
      username: #Ash.CiString<"deonte1916">,
      bio: nil,
      image: "/images/avatar.png",
      created_at: ~U[2025-03-27 08:10:31.551997Z],
      updated_at: ~U[2025-03-27 08:10:31.551997Z],
      aggregates: %{},
      calculations: %{},
      ...
    >

This happens because AshOps uses YAML for its default output format when using generic actions, but it isn’t able to encode our User struct. We’ll solve this by adding the Ymlr.Encoder protocol for our structs.

# lib/realworld/encoders.ex
require Protocol

# Encode case-insensitive strings as plain strings
defimpl Ymlr.Encoder, for: Ash.CiString do
  def encode(data, indent_level, opts) do
    data |> to_string() |> Ymlr.Encoder.encode(indent_level, opts)
  end
end

# Encode NotLoaded calculations/relationships as nil
defimpl Ymlr.Encoder, for: Ash.NotLoaded do
  def encode(_data, indent_level, opts) do
    nil |> Ymlr.Encoder.encode(indent_level, opts)
  end
end

# Exclude meta-data fields and dynamic calculations/aggregations
exclude_fields = [
  :__meta__,
  :__lateral_join_source__,
  :__metadata__,
  :__order__,
  :calculations,
  :aggregates,
  :created_at,
  :updated_at
]

# Also exclude hashed_password on User
Protocol.derive(Ymlr.Encoder, Realworld.Accounts.User,
  except: [:hashed_password | exclude_fields]
)

Protocol.derive(Ymlr.Encoder, Realworld.Articles.Article, except: exclude_fields)
Protocol.derive(Ymlr.Encoder, Realworld.Articles.Tag, except: exclude_fields)

This file implements the Ymlr.Encoder protocol for:

  • Ash.CiString - so case-insensitive strings can be output as YAML strings
  • Ash.NotLoaded - unloaded calculations or relationships are treated as nil
  • Our domain entities like UserArticle, and Tag

We also exclude certain fields to keep the output clean and avoid exposing sensitive data like hashed passwords.

With these encoders in place, let's try our mix task again:

mix realworld.accounts.generate_user 3

This time we get a clean output:

- id: d88b01b1-d7ed-4645-9b6c-6f9673a1f517
  image: /images/avatar.png
  username:
    adelle.wilkinson
  bio:
  confirmed_at:
  email:
    hadley_mclaughlin@jakubowski.net
- id: 9cf57818-1d18-4732-835d-0801a78180a8
  image: /images/avatar.png
  username:
    chasity2057
  bio:
  confirmed_at:
  email:
    kyla2046@grady.biz
- id: d2eb9b45-7667-46be-a33d-ba17ab3bb58e
  image: /images/avatar.png
  username:
    tre2020
  bio:
  confirmed_at:
  email:
    dante_hodkiewicz@bayer.net

We've excluded internal fields and sensitive data like hashed_password, while still showing all the relevant user information. The YAML format makes it easy to read the generated data directly in the terminal.

We can use a similar process for JSON output by implementing the Jason.Encoder protocol for our structs and using the —format=json option. For example we could process the JSON output with jq to extract just the id and username of each generated user.

mix realworld.accounts.generate_user 3 --format=json | jq '.[] | {id: .id, username: .username}'
{
  "id": "e74c995d-c074-48e1-ac0d-464ed0baf120",
  "username": "abigayle2026"
}
{
  "id": "dfef59f8-481c-4b8c-8f3c-22882a4f3766",
  "username": "deontae_wyman"
}
{
  "id": "c710482c-755c-4364-8599-9925bffadad9",
  "username": "charley2020"
}

Generating Related Resources

Now that we have our user generator working, let's set up a similar generator for articles. Since articles must be associated with a user, we'll need to make sure our generator can receive the current actor.

First, we'll define a Generator resource for the Articles domain:

defmodule Realworld.Articles.Generator do
  use Ash.Resource,
    otp_app: :realworld,
    domain: Realworld.Articles

  actions do
    action :generate_article, {:array, :struct} do
      argument :count, :integer, allow_nil?: false

      run fn input, ctx ->
        Realworld.Articles.ArticleGenerator.article(Ash.Context.to_opts(ctx))
        |> Ash.Generator.generate_many(input.arguments.count)
        |> then(&{:ok, &1})
      end
    end
  end
end

Note that since an Article must be associated with a User to be published, we use Ash.Context.to_opts/1 to forward the current actor to the generator. This ensures that any article we create is properly associated with a real user.

Next, we need to add this generator to our Articles domain and expose the action as a mix task, just like we did with the user generator:

defmodule Realworld.Articles do
  use Ash.Domain,
    otp_app: :realworld,
    extensions: [AshOps]

  mix_tasks do
    action Realworld.Articles.Generator, :generate_article, :generate_article, arguments: [:count]
  end

  resources do
    resource Realworld.Articles.Article
    resource Realworld.Articles.Tag
    resource Realworld.Articles.Generator
  end
end

Now let's try generating some articles for a user we created earlier:

mix realworld.articles.generate_article 3 --actor=Realworld.Accounts.User:1f17ac31-3c9f-4826-bc17-ba70fbd07a2b

This command will generate three articles and associate them with the user identified by the given UUID. The --actorflag is automatically provided by AshOps, allowing us to specify which user should be considered the actor for the action.

It worked! Here's an example of one of the generated articles:

- id: c4f8fee6-fcd5-49a3-8790-d7dd75be6edb
  user:
    id: 1f17ac31-3c9f-4826-bc17-ba70fbd07a2b
    image: /images/avatar.png
    username:
      deonte1916
    bio:
    confirmed_at:
    email:
      enid1957@kutch.name
  description: Odit aspernatur mollitia maiores atque rerum ex.
  title: Cupiditate eum recusandae!
  body: |-
    <p>
    Quasi quos neque laborum voluptatem omnis totam assumenda iste! Quasi magni eaque consequuntur sequi modi voluptatem ut? Pariatur rem ut quaerat autem inventore optio cum accusamus. Esse autem officiis omnis quas fuga velit sit ducimus?</p>
    <p>
    Eos sit numquam eveniet quas dolores consectetur qui! Ad provident iure eos expedita sed. Qui inventore repellat explicabo voluptates possimus sunt doloribus sit sapiente. Distinctio odit minus ut modi magni ipsa!</p>
    <p>
    Impedit occaecati saepe nihil ut et minima qui. Consectetur eveniet vel facilis provident quae qui est! Ut voluptatem facilis vitae est! Cumque ex ducimus nemo. Blanditiis modi consequuntur maxime nemo ex eos aut dolores?</p>
    <p>
    Ullam nam magni et nesciunt ut hic modi voluptatibus commodi? Facere quisquam aperiam excepturi aperiam blanditiis! Expedita iste quos et deleniti est eveniet praesentium deleniti repudiandae?</p>
  comments:
    nil
  tags:
    - id: 2878ec13-d641-4a49-8491-a8b67070a1b8
      name: quibusdam
    - id: 4bc6d439-a410-4332-bc1e-6fd69790e595
      name: ea
    - id: a0d03155-5ac5-486b-86d1-dc921b68d756
      name: optio

Notice how the article is properly associated with the user we specified, and includes rich content generated by our ArticleGenerator including a title, description, body content, and tags.

With the seeds in place, we can exercise a paginated UI for displaying articles with many pages of content:

Conclusion

While Ash Generators are typically used in unit testing scenarios, we've demonstrated how they can be effectively repurposed for seeding local development databases. This approach offers several advantages:

  1. Modular and maintainable - Each generator lives in its own module, making the code easier to maintain and update
  2. Respects domain logic - By using changesets and actions, we ensure our seed data follows the same business rules as production data
  3. Flexible and extendable - Generators can be configured with different options and composed together for complex relationships
  4. Developer-friendly - AshOps makes it possible to generate additional seed data as needed, without modifying the seeds.exs file

By combining Ash Generators with AshOps mix tasks, we've created a powerful system that simplifies both initial database seeding and ad-hoc data generation during development.

This pattern works particularly well for applications with complex domain rules and relationships, where traditional seed scripts would become unwieldy and difficult to maintain. The next time you find yourself wrestling with a growing seeds.exs file in your Ash Framework project, consider giving this approach a try.

Resources and Further Reading

Ash Framework: https://ash-hq.org

Ash Generators: https://hexdocs.pm/ash/Ash.Generator.html

AshOps: https://hexdocs.pm/ash_ops/dsl-ashops.html

Ash Weekly Newsletter announcing AshOps: https://ashweekly.substack.com/i/158634041/ashops-a-mind-blowing-surprise

Ash Framework Book, Chapter 7: All about testing: https://pragprog.com/titles/ldash/ash-framework/

Realworld Demo application: https://github.com/team-alembic/realworld-phoenix-inertia-react

Thanks

Thanks to Rebecca Le for pioneering the Generators for Seeds approach at Alembic, Zach Daniel for feedback and writing the chapter on Testing with generators in the Ash Book, and James Harton for feedback and creating AshOps.

Mike Buhot profile picture

WRITTEN BY

Mike Buhot

Hi, I’m Mike! I'm a functional programming enthusiast. When I'm not building awesome software I'm probably enjoying the outdoors with my kids or trying a new low-and-slow barbeque recipe.

Want to read more?

The latest news, articles, and resources, sent to your inbox occasionally.