A DSL for defining schemas that describe how commands modify context.
To use the DSL, create a schema module:
defmodule MyApp.SeedFactorySchema do
use SeedFactory.Schema
endCommands
Commands are the central building block of a schema — they define how entities are created, updated, and deleted. Entities and traits don't exist on their own; they are always a result of executing a command.
Use the command macro to define a command. Inside the command block,
you can define input parameters, a resolution, and produce, update, and delete directives.
command :create_user do
# Parameters, resolution, produce, update, and delete directives
endParameters
Parameters define the inputs for the command's resolver function and how default values should be generated.
Parameters can be defined using the param macro and can have an arbitrary level of nesting.
Options
:value- a static default value. Applied by default asvalue: nil.:generate- a zero-arity function that generates data. For the commands of a plan it runs once while the plan is built, before any command executes, and the execution reuses the value. Keep it to producing a value (random data, a counter): no writes to a database or a file.:entity- refers to an entity within the context. If the entity is not in the context, SeedFactory will automatically execute a command that produces it.:with_traits- a list of trait names or{name, value}pairs for parameterized traits. Requires:entityoption. SeedFactory creates the entity or applies the missing traits before the command runs. If it cannot satisfy these requirements, the request fails before execution.Note
:with_traitsis only used for automatic dependency resolution. If you explicitly pass the entity as a parameter viaexec/3, the traits are not validated.:map- a function that maps the entity to another value. Requires:entityoption.
command :create_employee do
param :address do
param :city, value: "Lemberg"
param :street, generate: &random_street/0
end
param :paid_by, entity: :user, with_traits: [:active]
param :office_id, entity: :office, map: & &1.id
param :github_username
# the line above is equivalent to
# param :github_username, value: nil
# resolve, produce, etc.
endResolution
The resolve macro defines the logic executed when the command is invoked.
The resolver is a function that takes args and must return either:
{:ok, map}— where keys are atoms used by:fromoption inproduceandupdatedirectives{:error, reason}— aborts execution by raisingSeedFactory.ExecError
command :create_user do
param :name, generate: &Faker.Person.name/0
param :email, generate: &Faker.Internet.email/0
resolve(fn args ->
user = MyApp.insert_user!(args)
{:ok, %{user: user}}
end)
produce :user
endProducing Entities
The produce directive specifies that the command will put a new entity to the context.
Options
:from- an atom that specifies the key of the map returned by the resolver. Defaults to the entity name.
command :register_user do
param :name, generate: &Faker.Person.name/0
param :company, entity: :company
resolve(fn args ->
{user, profile} = MyApp.register_user!(args)
{:ok, %{user: user, profile: profile}}
end)
produce :user
produce :user_profile, from: :profile
endUpdating Entities
The update directive modifies an existing entity within the context.
Options
:from- an atom that specifies the key of the map returned by the resolver. Defaults to the entity name.
command :update_user do
param :user, entity: :user
param :profile, entity: :user_profile
resolve(fn args ->
{user, profile} = MyApp.update_user!(args.user, args.profile)
{:ok, %{user: user, profile: profile}}
end)
update :user
update :user_profile, from: :profile
endDeleting Entities
The delete directive removes an entity from the context.
command :delete_user do
param :user, entity: :user
resolve(fn args ->
MyApp.delete_user!(args.user)
{:ok, %{}}
end)
delete :user
endTraits
The trait directive declares a trait for an entity.
The first argument is the trait name, the second is the entity name.
A trait must contain an exec directive with the name of the command.
Executing that command marks the entity with the trait.
Options
:from- the trait or list of traits this transition replaces. To plan a transition, SeedFactory requires at least one source trait to be present when its command runs. For example,from :pendingrequires:pending;from [:pending, :active]allows either. Applying the new trait removes all listed source traits that are present. Omitfromto add a trait without replacing another.
trait :pending, :user do
exec :create_user
end
trait :active, :user do
from :pending
exec :activate_user
end
# :suspended can replace either :pending or :active
trait :suspended, :user do
from [:pending, :active]
exec :suspend_user
end
# execute :create_user command
produce(ctx, user: [:pending])
# execute :create_user -> :activate_user
produce(ctx, user: [:active])
# execute :create_user -> :suspend_user
produce(ctx, user: [:suspended])
# execute :create_user -> :activate_user -> :suspend_user
ctx |> produce(user: [:active]) |> produce(user: [:suspended])Same trait from multiple commands
The same trait can be defined multiple times with different commands. This is useful when the same state can be reached through different paths in your business logic:
# User can become :active through a state transition...
trait :active, :user do
from :pending
exec :activate_user
end
# ...or directly via a command that creates an already-active user
trait :active, :user do
exec :create_active_user
end
# Unique marker for the direct path
trait :pending_skipped, :user do
exec :create_active_user
endWhen several commands can provide a trait, SeedFactory chooses one that fits the plan. To require a specific path, request a trait unique to that command:
# Either path can provide :active
produce(ctx, user: [:active])
# Force the direct path by requesting :pending_skipped trait
produce(ctx, user: [:active, :pending_skipped])Exec step
The exec directive inside a trait specifies which command must be executed
to mark the entity with the trait.
Options
:args_pattern- a map with args. If the command args match the pattern, the entity is marked with the trait. The pattern is also used to generate args when the entity is requested with this trait.:args_match- a function that accepts command args and returns a boolean. Must be used with:generate_args.:generate_args- a function that generates a map with args satisfying:args_match. Must be used with:args_match.
:args_pattern is a simpler alternative to the :args_match + :generate_args combination.
Parameterized traits
A parameterized trait accepts a value in produce or with_traits.
Put _ in :args_pattern where the command should receive that value:
command :create_user do
param :age, value: 21
resolve(fn args -> MyApp.Users.create_user(args) end)
produce :user
end
trait :age, :user do
exec :create_user, args_pattern: %{age: _}
end
produce(ctx, user: [age: 18])Here, produce passes age: 18 to :create_user. After the command runs,
SeedFactory records {:age, 18} in the user's traits. A direct exec/3 call
records the trait in the same way, using the value from the command arguments.
A dependency can require the same value through with_traits:
param :user, entity: :user, with_traits: [age: 18]Other fields in the pattern must match too. For example,
%{age: _, role: :admin} records the trait only when the command receives
role: :admin.
The rules:
- Each parameterized trait uses exactly one
_placeholder as a map value, for example%{age: _}or%{settings: %{locale: _}}. - A parameterized trait requires one value, which can be any Elixir term. Use separate traits for separate arguments.
- Values are compared with
===, so18and18.0are different values. fromcan only name ordinary traits. A parameterized trait can usefromto replace an ordinary trait; its own previous value is replaced automatically.:asis reserved for rebinding and cannot name a parameterized trait.
Existing entities and value changes
Requesting a value already recorded in the entity's traits does not run another command. To change the value, add an update command and another declaration of the same trait to the schema above:
command :set_age do
param :user, entity: :user
param :age
resolve(fn args -> MyApp.Users.set_age(args.user, args.age) end)
update :user
end
trait :age, :user do
exec :set_age, args_pattern: %{age: _}
end
ctx = produce(ctx, user: [age: 18]) # runs :create_user
ctx = produce(ctx, user: [age: 18]) # runs nothing
ctx = produce(ctx, user: [age: 21]) # runs :set_age on the same userAfter the update, {:age, 21} replaces {:age, 18} in the user's traits.
Without an update command for the trait, requesting a different value fails
before execution.
Ordinary trait matching and argument generation
Use args_pattern when a trait requires fixed argument values:
trait :admin, :user do
exec :create_user, args_pattern: %{role: :admin}
end
trait :normal, :user do
exec :create_user, args_pattern: %{role: :normal}
endThe equivalent declarations using functions are:
trait :admin, :user do
exec :create_user do
generate_args(fn -> %{role: :admin} end)
args_match(&match?(%{role: :admin}, &1))
end
end
trait :normal, :user do
exec :create_user do
generate_args(fn -> %{role: :normal} end)
args_match(&match?(%{role: :normal}, &1))
end
endUse args_match and generate_args when the condition cannot be expressed
with fixed values. args_match checks whether the arguments satisfy the trait;
generate_args supplies arguments that satisfy it when the trait is requested.
For example, an expiration check depends on today's date:
trait :not_expired, :project do
exec :publish_project do
args_match(fn args -> Date.compare(Date.utc_today(), args.expiry_date) in [:lt, :eq] end)
generate_args(fn ->
today = Date.utc_today()
%{start_date: today, expiry_date: Date.add(today, 21)}
end)
end
end
trait :expired, :project do
exec :publish_project do
args_match(fn args -> Date.compare(Date.utc_today(), args.expiry_date) == :gt end)
generate_args(fn ->
today = Date.utc_today()
%{start_date: Date.add(today, -22), expiry_date: Date.add(today, -1)}
end)
end
endgenerate_args runs once during planning, before any command executes.
The command then uses those generated arguments. Keep this function free of
database and file writes. SeedFactory also evaluates args_match during
planning when the required arguments are known; if it needs an entity that
has not been created yet, the check waits until that entity is available.
Splitting large schemas with fragments
SeedFactory uses Spark for its DSL. Spark provides a mechanism
called Spark.Dsl.Fragment that allows splitting a large DSL module into multiple files.
When a schema grows large, you can extract groups of related commands and traits into fragments. Each fragment can contain commands and traits, and they are merged into the main schema at compile time.
defmodule MyApp.SeedFactorySchema.UserCommands do
use Spark.Dsl.Fragment,
of: SeedFactory.Schema
command :create_user do
param :name, generate: &Faker.Person.name/0
param :role, value: :normal
resolve(fn args -> MyApp.Users.create_user(args) end)
produce :user
end
command :activate_user do
param :user, entity: :user, with_traits: [:pending]
resolve(fn args ->
{:ok, %{user: MyApp.Users.activate_user!(args.user)}}
end)
update :user
end
trait :pending, :user do
exec :create_user
end
trait :active, :user do
from :pending
exec :activate_user
end
end
defmodule MyApp.SeedFactorySchema do
use SeedFactory.Schema,
fragments: [MyApp.SeedFactorySchema.UserCommands]
# other commands go here
endFragments can reference entities from other fragments or from the main schema — dependency resolution works across all of them.
Include schemas
It is possible to include multiple schemas into a new schema in order to reuse everything that is declared in specified modules.
defmodule MyAppWeb.SeedFactorySchema do
use SeedFactory.Schema
include_schema MyApp.SeedFactorySchema
# Web-specific stuff goes here. You may need such a separation of modules
# if you have an umbrella project and web is a separate app
command :build_conn do
resolve(fn _ ->
conn =
Phoenix.ConnTest.build_conn()
|> Plug.Conn.put_private(:phoenix_endpoint, MyAppWeb.Endpoint)
{:ok, %{conn: conn}}
end)
produce :conn
end
command :create_user_session do
param :user, entity: :user, with_traits: [:active]
param :conn, entity: :conn, with_traits: [:unauthenticated]
resolve(fn args ->
{:ok, %{conn: MyAppWeb.Session.init_user_session(args.conn, args.user)}}
end)
update :conn
end
trait :unauthenticated, :conn do
exec :build_conn
end
trait :user_session, :conn do
from :unauthenticated
exec :create_user_session
end
end