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:
- The code is concise and focused on the relationships between entities
- Each entity is created through its proper action, ensuring all validations and hooks run
- The generator modules handle the details of what data to generate
- 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_user
action 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 stringsAsh.NotLoaded
- unloaded calculations or relationships are treated asnil
- Our domain entities like
User
,Article
, andTag
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 --actor
flag 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:
- Modular and maintainable - Each generator lives in its own module, making the code easier to maintain and update
- Respects domain logic - By using changesets and actions, we ensure our seed data follows the same business rules as production data
- Flexible and extendable - Generators can be configured with different options and composed together for complex relationships
- 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.