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 · 6 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
- 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

AshOps only shows public fields, leaving out 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 also product JSON output 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.