DEV Community

Cover image for Ash: ways to contact — AshJsonApi, AshGraphQL and AshTypescript
Artyom Molchanov
Artyom Molchanov

Posted on

Ash: ways to contact — AshJsonApi, AshGraphQL and AshTypescript

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 an errors array 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
Enter fullscreen mode Exit fullscreen mode

Then, in your schema module, specify the domains you use:

use AshGraphql, domains: [Your.Domain1, Your.Domain2]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 the resource_short_name and mutation (or action) metadata
  • [:ash, <domain_name>, :gql_query] — execution of a query. To break down measurements, use the resource_short_name and query (or action) metadata
  • [:ash, <domain_name>, :gql_relationship] — resolution of a relationship. To break down measurements, use the resource_short_name and relationship metadata
  • [:ash, <domain_name>, :gql_calculation] — resolution of a calculation. To break down measurements, use the resource_short_name and calculation metadata
  • [:ash, <domain_name>, :gql_relationship_batch] — batch resolution of relationships by the data loader. To break down measurements, use the resource_short_name and relationship metadata
  • [:ash, <domain_name>, :gql_calculation_batch] — batch resolution of calculations by the data loader. To break down measurements, use the resource_short_name and calculation metadata

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

  1. Defining the structure. You describe your domain and resources, specifying the actions (actions) and attributes you need
  2. Enabling the extensions. Add AshTypescript.Resource to the resource and AshTypescript.Rpc to the domain
  3. Configuring the resource. In the typescript block 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
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. Generating the code. By running mix ash_typescript.codegen (or the unified mix ash.codegen), you get the generated files at the path from your configuration. The settings live in config/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"
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

Advantages of using AshTypescript

  1. 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
  2. Automated API creation. No more manually writing methods or endpoints. It's enough to describe the resource and typescript_rpc in the domain and give the methods unique names
  3. Minimized boilerplate. Built-in tools for sorting, filtering, and field selection (select/projections) make the work seamless
  4. 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)