Building a stable product capable of withstanding high loads across different servers is a complex and resource-intensive task. A programmer can't focus on business logic alone: first you need to set up the database, design the server-side API, and much more.
The complexity grows manyfold when you work solo. You end up implementing the interface yourself on your framework of choice, ensuring the layers stay compatible, packaging the project into Docker images, and deploying it to Kubernetes. Add to that configuring request routing through Nginx and designing the system according to clean-architecture principles — and you get an extremely labor-intensive process.
That's why many languages are actively adopting tools to automate routine work (boilerplate), letting you concentrate on the unique code. In Go, for example, there's the oapi-codegen library: you describe the contract in Swagger format, and the server code is generated from it automatically. This format simplifies frontend–backend integration and makes changing logic less painful — just update the description in the Swagger file and regenerate the service.
Approaches like this — systematizing repetitive tasks — are appearing everywhere. The main goal is to make integration predictable and eliminate the situation where a developer has to rewrite and test the same thing dozens of times just to achieve perfect compatibility. And in this area there's a clear leader — Ash!
ㅤ
ㅤ
🙏 Acknowledgments
I want to express my deep gratitude to Zach Daniel and everyone who responded to my previous article about Ash (Ash Framework: Introduction) and shared it. Your support gave me a huge boost of energy and motivation and confirmed that the Ash community is amazing!
ㅤ
📖 Foreword
In this material I deliberately won't provide a complete practical guide to the topics covered, since I'm counting on your curiosity. As much as I'd like to cover everything, I can't describe the entire practical side of these frameworks in a single article and teach you everything at once. Instead, I'll share information with you and set a direction for reflection — which is the main purpose of this publication. For a deeper dive into how these libraries work, I recommend starting to build your own projects on top of them — that's the approach that once helped me understand their internals best. Remember that your main mentor will be practice, so good luck mastering it!
Important: the goal of this article is by no means to talk you into using Ash everywhere and always!
ㅤ
ㅤ
🧐 What the Ash ecosystem offers
If you're familiar with Elixir and Phoenix, you know the framework already contains built-in mechanisms to simplify development. One of them is LiveView. It lets you drop the classic split into an API backend and a separate JavaScript frontend: the interface is written right inside the application. This speeds up development and is convenient for backend programmers working without a frontend team. For simple and medium projects the LiveView path is a great fit, but when building large-scale systems the code risks turning into "spaghetti," where it's hard to track dependencies between components and to find handlers like def handle_params/3.
But what if you need a solid, standalone frontend, yet you're tired of writing a boilerplate REST interface and wiring up the connection from scratch every time? The answer is to master what Ash has to offer. That's what we'll talk about.
ㅤ
ㅤ
😮 REST with AshJsonApi: a new approach
Standard practice involves pinning down the contract between frontend and backend not only verbally but also in writing — usually via Swagger. It describes the data structures and API methods, after which the documentation is handed to the interface team. Any changes require care so as not to break compatibility. Even the basic setup of this approach costs considerable effort: you need to integrate a generation library, create configurations per the OpenAPI (Swagger) specification, and manually describe the endpoints. If you're rewriting an existing project, the process can easily take more than a workday.
AshJsonApi offers an alternative. The main advantage is that you no longer need to write a specification in YAML/JSON upfront and generate code from it. To get started, all you need is a ready domain of resources and the AshJsonApi extension enabled.
You can create a new project with support for this tool right on the main page of the Ash portal via the builder. If you already have an Ash project, you can add the functionality with a single command:
mix igniter.install ash_json_api
During installation, the system asks clarifying questions (Y/N). You can agree with all the suggestions or study each item and decide for yourself. After installation, a few simple steps remain.
Configuring a resource to work with JSON:API
To make resources available through the API, enable the AshJsonApi.Resource extension. This will let you automatically generate endpoints based on your domain definitions. Add AshJsonApi.Resource to the extensions list and set the resource name in the type parameter:
defmodule CourseHub.Courses.Lesson do
use Ash.Resource, extensions: [AshJsonApi.Resource]
# Enable endpoint generation
json_api do
type "lesson"
end
actions do
defaults [:read, :create, :destroy, :update]
action :list_lessons_allowed, :map do
run fn _input, _ctx ->
allowed_courses = %{"go" => "Golang", "elixir" => "Elixir", "rust" => "Rust"}
{:ok, allowed_courses}
end
end
end
end
The type parameter defines the resource name in the JSON:API namespace. It's used primarily for documentation (e.g., in Swagger or Redoc) and lets you group methods by tags.
Next, describe the routes in the domain, specifying the paths and linking them to specific resource actions:
defmodule CourseHub.Courses do
use Ash.Domain, extensions: [AshJsonApi.Domain]
json_api do
routes do
base_route "/lessons", CourseHub.Courses.Lesson do
index :read, description: "Get the list of lessons"
get :read, primary?: true, description: "Get a single lesson"
post :create, description: "Create a lesson"
patch :update, description: "Update a lesson"
delete :destroy, description: "Delete a lesson"
# Path is relative to base_route; final endpoint: /lessons/allowed
route :get, "/allowed", :list_lessons_allowed,
description: "Get the list of allowed lessons"
end
end
end
end
Once you've done this, you get six fully functional endpoints. All the logic is now tied to your actions inside the domain model. Separate Swagger files or manual route definitions aren't needed: a change to the action's logic in the code is automatically reflected in the behavior of the corresponding API endpoint.
Key strengths of AshJsonApi
A unified approach and predictability
The main advantage is the strict consistency of your application. When switching to a different contract type (e.g., GraphQL), you won't have to rewrite your business logic. Because AshJsonApi works on top of your actions, you're insured against the situation where changes in the code aren't reflected in one of the interfaces: all the logic is encapsulated in the action, which you "hand off" to different contracts. This noticeably speeds up development — by my own estimate, a finished product comes together roughly five times faster. To automate the process, you can use the command:
mix ash.patch.extend CourseHub.Courses.Lesson json_api
By specifying the resource's address, you let AshJsonApi automatically generate all the plumbing needed to work with the API.
Working with data: filtering, sorting, and relationships
As a backend developer, I used to spend a lot of time on the routine implementation of filters and sorting, hand-writing SQL with LIMIT, OFFSET, and WHERE. AshJsonApi removes this burden: filtering and ordering happen automatically. The frontend just needs to specify the required fields and conditions right in the request. This dramatically simplifies collaboration between teams: the frontend forms its own data queries without distracting the backend. And all computation happens on the server, so the client bears no extra load — it merely describes the desired shape of the data. The full list of parameters is in the official documentation. An example request:
GET /courses/1?include=lessons&included_page[lessons][limit]=10&included_page[lessons][offset]=20
The request returns information about the course and loads the related lessons with the given pagination. The response comes in application/vnd.api+json format — a standardized specification (more at jsonapi.org) that structures JSON for predictable handling.
An example response:
{
"data": {
"id": "1",
"type": "course",
"attributes": {
"title": "My Course!"
},
"relationships": {
"lessons": {
"data": [
{"id": "1", "type": "lessons"},
{"id": "2", "type": "lessons"}
],
"links": {
"self": "/courses/1/relationships/lessons",
"related": "/courses/1/lessons",
"first": "/courses/1?include=lessons&included_page[lessons][limit]=10",
"next": "/courses/1?include=lessons&included_page[lessons][limit]=10&included_page[lessons][offset]=10",
"prev": null,
"last": "/courses/1?include=lessons&included_page[lessons][limit]=10&included_page[lessons][offset]=40"
},
"meta": {
"limit": 10,
"offset": 0,
"count": 50
}
}
}
},
"included": [
{
"id": "1",
"type": "lessons",
"attributes": {
"description": "Lessons about Elixir 1!"
}
},
{
"id": "2",
"type": "lessons",
"attributes": {
"description": "Lessons about Elixir 2!"
}
}
]
}
Wrap-up and personal experience
To sum up, I'll share my experience. On my current project, creating the REST endpoints took just 20–30 minutes. Most of the time went into customizing how the documentation is rendered in Redoc — that turned out to be fairly labor-intensive (unlike generating the API itself). If you decide to use Redoc, be prepared to manually rewrite its blocks for a nice presentation of responses and examples.
Otherwise the tool performed excellently: superb flexibility, customizable error handling, fine-grained control over endpoint access. If your goal is to build a convenient and powerful REST API, AshJsonApi is an excellent choice.
ㅤ
ㅤ
🙂↕️ AshGraphQL
This library may come with some difficulties: not that many people have worked with GraphQL at all. So first I'll give an introduction — to refresh your memory or learn what it is — and then we'll look at the library itself.
What GraphQL is
GraphQL is a query language for APIs and a runtime for executing them. The client itself describes what data it needs, and the server returns exactly that shape. The contract is a strictly typed schema.
Fundamental principles
Interaction happens through a single endpoint (usually POST /graphql). The logic is built around a graph of types. A query has a hierarchical structure, and the server's response mirrors it exactly. This solves the problems of over-fetching and under-fetching.
Types of operations
- Query — read operations (idempotent).
- Mutation — data-modifying operations (executed sequentially).
- Subscription — subscribing to a stream of real-time events (via WebSocket).
Schema and resolvers
The central element is the schema, described in SDL. It serves as the source of truth and enables introspection. Each field is mapped to a resolver — a function that returns a value. The server traverses the query tree and calls the resolvers for each field.
Difficulties
- The N+1 problem. Resolvers work independently, and without optimization (batching) this leads to a multitude of database queries
- Caching. Standard HTTP mechanisms (CDN, ETag) don't work, since everything goes through POST. A normalized cache on the client is required
- Security. A client can send a deeply nested query that overloads the database. Depth limiting and complexity analysis are needed
-
Errors. The server almost always returns status
200, and the errors themselves are placed in anerrorsarray alongside partial data. This breaks monitoring based on HTTP status codes - Infrastructure. File uploads, rate limiting, and observability require a separate infrastructure layer
Where it's effective
The technology pays off when there are many diverse clients with complex data requirements. For a single client with simple resources, it's easier to go with REST. For internal service-to-service communication with rigid contracts and minimal latency, gRPC is a better fit.
What AshGraphQL is
Despite GraphQL still being less popular than other approaches, its integration with Ash is done very well. The technology is easy to bring into Ash-based projects.
To get started, add the library to your project:
mix igniter.install ash_graphql
Then, in your schema module, specify the domains you use:
use AshGraphql, domains: [Your.Domain1, Your.Domain2]
Configuring resources and domains
To make a resource available through GraphQL, enable the AshGraphql.Resource extension and describe the base configuration block:
defmodule CourseHub.Courses.Lesson do
use Ash.Resource,
extensions: [
AshGraphql.Resource
]
graphql do
type :lesson
end
# ... rest of the resource logic
end
In the domain module, define the operations (handles) via the AshGraphql.Domain extension:
defmodule CourseHub.Courses do
use Ash.Domain,
extensions: [
AshGraphql.Domain
]
graphql do
queries do
get CourseHub.Courses.Lesson, :get_lesson, :read
read_one CourseHub.Courses.Lesson, :most_popular_lesson, :most_popular
list CourseHub.Courses.Lesson, :list_lessons, :read
end
mutations do
create CourseHub.Courses.Lesson, :create_lesson, :create
update CourseHub.Courses.Lesson, :update_lesson, :update
destroy CourseHub.Courses.Lesson, :destroy_lesson, :destroy
end
end
end
These handles can be described not only in the domain module, but also right inside the resource.
In addition, you can create your own actions for custom responses. Example:
graphql do
type :lesson
queries do
action :random_lesson, :random_lesson
end
end
actions do
action :random_lesson, {:array, :struct} do
constraints items: [instance_of: __MODULE__]
run fn _input, _context ->
# return a list of structs, as declared in the action type
Ash.read(__MODULE__)
end
end
end
Generating the SDL schema
As with AshJsonApi (where Swagger is generated automatically after edits), the GraphQL schema is updated in a similar way. Specify the path for saving it in use AshGraphql via generate_sdl_file:. The file will be generated automatically on every run of mix ash.codegen (if auto_generate_sdl_file?: true is enabled):
use AshGraphql,
domains: [Domain1, Domain2],
generate_sdl_file: "priv/schema.graphql",
auto_generate_sdl_file?: true
Creating Subscriptions
You can set up receiving data in real time through the subscriptions mechanism. This uses Absinthe configuration:
# in your Absinthe schema file
subscription do
field :field, :type_name do
config(fn
_args, %{context: %{current_user: %{id: user_id}}} ->
{:ok, topic: user_id, context_id: "user/#{user_id}"}
_args, _context ->
{:error, :unauthorized}
end)
resolve(fn args, _, resolution ->
AshGraphql.Subscription.query_for_subscription(
YourResource,
YourDomain,
resolution
)
|> Ash.Query.filter(id == ^args.id)
|> Ash.read(actor: resolution.context.current_user)
end)
end
end
Monitoring
For a deeper dive I recommend the official Ash monitoring guide. Here we'll briefly describe the tracing mechanisms and telemetry events that become available with this extension.
You can define your own tracer at the domain level — its settings take precedence over the global config :ash, :tracer parameter:
graphql do
tracer MyApp.Tracer
end
Traces
The operations of GraphQL resolvers and of the underlying batch data loaders are wrapped in spans with appropriate names. For convenience, they're tagged with the source: :graphql metadata, which lets you filter or annotate this data in your monitoring system.
Telemetry
The AshGraphql module publishes telemetry events, each suffixed with :start and :stop. Start events (:start) carry a system_time measurement, while stop events (:stop) carry system_time and the execution duration. All time values are given in the native time unit.
The list of emitted events:
-
[:ash, <domain_name>, :gql_mutation]— execution of a mutation. To break down measurements, use theresource_short_nameandmutation(oraction) metadata -
[:ash, <domain_name>, :gql_query]— execution of a query. To break down measurements, use theresource_short_nameandquery(oraction) metadata -
[:ash, <domain_name>, :gql_relationship]— resolution of a relationship. To break down measurements, use theresource_short_nameandrelationshipmetadata -
[:ash, <domain_name>, :gql_calculation]— resolution of a calculation. To break down measurements, use theresource_short_nameandcalculationmetadata -
[:ash, <domain_name>, :gql_relationship_batch]— batch resolution of relationships by the data loader. To break down measurements, use theresource_short_nameandrelationshipmetadata -
[:ash, <domain_name>, :gql_calculation_batch]— batch resolution of calculations by the data loader. To break down measurements, use theresource_short_nameandcalculationmetadata
Verdict
Honestly, I haven't reached for this kind of interaction often, but from the little experience I do have, I'll say one thing — it really is convenient.
ㅤ
ㅤ
😳 AshTypescript — the hidden gem
We've already gotten acquainted with AshJsonApi and AshGraphQL, and it was a useful experience. These two approaches are a kind of industry standard for web development, and they hold no special surprises for seasoned specialists. But Ash has a "trump card" — AshTypescript.
Imagine you want to interact with the frontend with gRPC-level type safety, while keeping an endpoint structure like AshJsonApi and the data-handling flexibility of AshGraphQL. That's exactly what AshTypescript solves.
How it works
- Defining the structure. You describe your domain and resources, specifying the actions (actions) and attributes you need
-
Enabling the extensions. Add
AshTypescript.Resourceto the resource andAshTypescript.Rpcto the domain -
Configuring the resource. In the
typescriptblock inside the resource, set its name for generation
defmodule CourseHub.Courses.Lesson do
use Ash.Resource,
domain: CourseHub.Courses,
extensions: [AshTypescript.Resource] # Enable the extension
# Set the type name for the generated RPC file
typescript do
type_name "Lesson"
end
# Standard CRUD actions
actions do
defaults [:read, :create, :update, :destroy]
end
# Define the resource attributes
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :description, :string, default: "Some text!"
end
# Define the relationship with Course
relationships do
belongs_to :course, CourseHub.Courses.Course
end
end
- Binding RPC methods. In the domain module you define which methods will be available to the frontend and link them to the resource's actions.
defmodule CourseHub.Courses do
use Ash.Domain, extensions: [AshTypescript.Rpc]
# Public RPC methods for the frontend
typescript_rpc do
resource CourseHub.Courses.Lesson do
rpc_action :list_lessons, :read
rpc_action :create_lesson, :create
rpc_action :get_lesson, :get
rpc_action :delete_lesson, :destroy
rpc_action :update_lesson, :update
end
end
end
-
Generating the code. By running
mix ash_typescript.codegen(or the unifiedmix ash.codegen), you get the generated files at the path from your configuration. The settings live inconfig/config.exs:
config :ash_typescript,
ash_domains: [
Backend.CourseHub
],
output_file: "assets/js/ash_rpc.ts", # Path for saving the generated code
run_endpoint: "/rpc/run", # Endpoint for sending requests
validate_endpoint: "/rpc/validate", # Endpoint for validating data against the schema
input_field_formatter: :camel_case, # Field-name format on input
output_field_formatter: :camel_case, # Field-name format on output
require_tenant_parameters: false, # Multitenancy
generate_zod_schemas: true, # Generate Zod schemas
generate_phx_channel_rpc_actions: false,
generate_validation_functions: true,
zod_import_path: "zod",
zod_schema_suffix: "ZodSchema",
phoenix_import_path: "phoenix"
Zod is a library for describing and validating the structure of data. It lets you create schemas and automatically validate incoming data against them.
Key advantages of Zod:
- TypeScript-oriented — automatically infers types
- An intuitive and easy-to-read API
- Support for complex validation rules (unions, intersections, refinements)
- High performance and a minimal footprint
Starting with newer versions, AshTypescript generates not a single monolithic file but several: besides ash_rpc.ts, you get shared types (ash_types.ts) and Zod schemas (ash_zod.ts), imported from a common place.
In the end, all that's left is to hand the generated files off to the frontend. Client developers simply call methods, e.g. listLessons(), without describing the API by hand. This makes integration incredibly simple and convenient.
Sorting, filtering, and field projection mechanisms
In standard backend development, you have to hand-construct SQL queries for sorting, filtering, and selection in order to avoid transferring excessive data. AshTypescript implements the same capabilities as AshJsonApi — flexibly and type-safely:
// CREATE
const createLessonResponse = await createLesson({
fields: ["id", "title"],
input: { title: "Elixir lesson about Ash", priority: "very high!" },
headers
});
if (!createLessonResponse.success) {
console.error("Create failed:", createLessonResponse.errors);
return;
}
const lessonId = createLessonResponse.data.id;
// READ (single)
const getLesson = await getLesson({
fields: ["id", "title", "description", { course: ["name"] }],
input: { id: lessonId },
headers
});
// READ (list)
const lessons = await listLessons({
fields: ["id", "title"],
headers
});
// UPDATE
const updateLessonResult = await updateLessson({
fields: ["id", "title"],
identity: lessonId,
input: { title: "Yoo chill im just a vessel!" },
headers
});
// DELETE
const deleteLessonResponse = await deleteLesson({
identity: lessonId,
headers
});
Advantages of using AshTypescript
- Guaranteed contract integrity. No need to worry about interface drift, as can happen with "hand-rolled" gRPC. The single source of truth is the .ts-file generator
-
Automated API creation. No more manually writing methods or endpoints. It's enough to describe the resource and
typescript_rpcin the domain and give the methods unique names - Minimized boilerplate. Built-in tools for sorting, filtering, and field selection (select/projections) make the work seamless
- Easy integration with other teams. You can hand the generated files (ash_rpc.ts, ash_types.ts, ash_zod.ts) to colleagues — they can start working immediately without spending time studying the documentation
Impressions
When I learned about this way of organizing server–client interaction, I felt a renewed enthusiasm for programming. Hand-writing endpoints is monotonous, and describing schemas for GraphQL takes a lot of time. Declaratively describing actions on the backend and simply calling methods on the frontend, on the other hand, is fast and convenient. This approach removes up to 80% of the excess code and speeds up development manyfold.
ㅤ
ㅤ
🧮 Comparing the three approaches
All three approaches share one foundation — your actions. Only the "storefront" through which the logic faces the outside world changes. Hence the main takeaway: choosing an approach doesn't lock you into it forever — if needed, you can add a second contract on top of the same logic.
| Criterion | AshJsonApi (REST / JSON:API) | AshGraphQL | AshTypescript |
|---|---|---|---|
| Contract type | REST per the JSON:API standard | GraphQL schema (SDL) | Generated .ts files (RPC) |
| Source of truth | your actions + OpenAPI autogen | your actions + SDL autogen | your actions + TypeScript autogen |
| How the client specifies a request | query parameters (filter, sort, include) |
a GraphQL query with a field tree | typed calls (fields, filter, sort) |
| Selection flexibility | high | maximal | high |
| Industry standard | yes, ubiquitous | yes, but niche | no, specific to the Ash + TS stack |
| Type safety on the frontend | via codegen from OpenAPI | via codegen from the schema | native, out of the box |
| Real-time | not out of the box | yes (subscriptions) | yes (Phoenix channels) |
| HTTP caching | works (GET) | difficult (POST) | depends on the implementation |
| Entry barrier on the frontend | low | medium/high | low (for TypeScript teams) |
| Ideal for | public and external APIs | many diverse clients with complex queries | full-stack Elixir + TypeScript |
How to choose in practice:
- AshJsonApi — when you need familiar REST, a public or external API, caching at the HTTP level, and maximum compatibility with any client. The most "neutral" choice
- AshGraphQL — when there are many clients with different, often nested data requirements, and under-/over-fetching (over/under-fetching) is a real hindrance. Be prepared to pay for it with batching against N+1, query-complexity limiting, and a client-side cache
- AshTypescript — when both the backend and the frontend are yours, and the frontend is in TypeScript. It gives you end-to-end type safety without a hand-written contract: rename a field in Elixir and you immediately get a compile error in TS
If you're unsure and the client is single and relatively simple — start with AshJsonApi. If you later need GraphQL or type-safe RPC, you'll simply add another contract on top of the same actions without rewriting the logic.
ㅤ
ㅤ
😴 Conclusion
The world of Ash gives you not just a convenient way of working with the database and freedom from boilerplate — it lets you build applications quickly, with quality, and with enjoyment. Fully mastering these methods will take a little more time, but it will pay off more than once and will genuinely help you in your projects. Good luck!
ㅤ
ㅤ
🥴 From the author
Thank you so much for your interest in this article! I hope it helped you understand what AshJsonApi, AshGraphQL, and AshTypescript are and why they're used!
If you enjoyed this article and want more material like it, join me on my Bsky!
Top comments (0)