Resolver

api

Resolver

Definition

A resolver is a function responsible for returning the data for a specific field in an API response. In GraphQL, every field in the schema has a corresponding resolver function that knows how to fetch or compute the field's value. Resolvers form the execution layer between the schema definition (what data is available) and the data sources (where the data lives). They are the fundamental unit of work in any API system -- the point where an abstract request becomes a concrete data retrieval operation.

The resolver pattern extends beyond GraphQL. In any API system, something must translate a client's request into actual data retrieval operations. REST controllers, GenServer handlers, and the Prismatic API's dispatch controller all serve as resolvers in this broader sense -- they receive a request specification and return the corresponding data. The key distinction is that GraphQL resolvers are fine-grained (one per field), while REST resolvers are coarse-grained (one per endpoint).

In the Prismatic Platform, the resolver concept manifests in three distinct layers: the auto-introspecting API dispatch system (which discovers resolvers at boot time), the OSINT tool execution layer (where each tool's search/2 or run/2 function acts as a resolver), and the LiveView data layer (where mount and handle_params callbacks resolve page-level data).

Core Concepts

Resolver Anatomy

Every resolver, regardless of framework, shares four fundamental components:

ComponentPurposeGraphQL (Absinthe)REST (Phoenix)Prismatic Auto-API
InputRequest parametersField arguments + contextConn params{app, action, params} tuple
AuthorizationAccess controlResolution context (current_user)Plug pipelineETS-cached endpoint ACL
Data FetchingRetrieve/computeResolver function bodyController actionapply(module, function, args)
OutputFormatted response{:ok, value} / {:error, msg}json(conn, data)Serialized JSON envelope

Resolution Strategies

Resolvers employ different strategies depending on the data source and access pattern:

StrategyWhen to UseLatency ProfileComplexity
DirectSingle record lookupO(1) - index hitLow
BatchedRelated records (N+1 prevention)O(1) amortizedMedium
DeferredExpensive computationAsync, non-blockingMedium
CachedFrequently accessed, rarely changingSub-ms (ETS/Cachex)Low
StreamedLarge result setsProgressive deliveryHigh
ComputedDerived/aggregated dataDepends on sourceVariable

GraphQL vs REST Resolution

AspectGraphQL ResolverREST Controller
GranularityPer-fieldPer-endpoint
N+1 RiskHigh (nested fields)Low (single response)
Over-fetchingNone (client specifies fields)Common (fixed response shape)
Under-fetchingNoneCommon (multiple requests needed)
BatchingDataloader / middlewareManual (preload)
CachingPer-field, complexPer-endpoint, simple
Error handlingPer-field errors in responseHTTP status codes

Technical Deep Dive

Absinthe Resolver Patterns

In Absinthe (Elixir's GraphQL library), resolvers are defined as functions that receive three arguments: the parent object, the field arguments, and the resolution context (which includes the current user, request metadata, and data loaders). Resolvers return {:ok, value} or {:error, message} tuples following Elixir conventions.

# Basic Absinthe resolver pattern
object :user do
  field :name, :string
  field :email, :string

  field :investigations, list_of(:investigation) do
    resolve fn user, _args, %{context: %{loader: loader}} ->
      loader
      |> Dataloader.load(:db, :investigations, user)
      |> on_load(fn loader ->
        {:ok, Dataloader.get(loader, :db, :investigations, user)}
      end)
    end
  end
end

The N+1 Problem Deep Dive

The N+1 problem is the primary performance challenge with resolvers. When a list query resolves N items, and each item has a field that requires a separate database query, the total becomes 1 + N queries.

Concrete example in Prismatic context:

Query: { ddCases { entities { name riskScore } } }

Without batching:
  1. SELECT * FROM dd_cases LIMIT 50           -- 1 query
  2. SELECT * FROM entities WHERE case_id = 1   -- +1
  3. SELECT * FROM entities WHERE case_id = 2   -- +1
  ... (48 more)
  Total: 51 queries, ~500ms

With Dataloader batching:
  1. SELECT * FROM dd_cases LIMIT 50           -- 1 query
  2. SELECT * FROM entities WHERE case_id IN (1,2,...,50)  -- 1 query
  Total: 2 queries, ~15ms

Dataloader (a batching library) solves this by collecting field resolution requests during the first pass, then executing them in batches during the second pass. It operates on a two-phase cycle:

  1. Collection phase: Each resolver registers what it needs (source, key, parent)
  2. Execution phase: Dataloader groups requests by source, executes batch queries, and distributes results

Resolver Middleware

Absinthe supports middleware -- functions that wrap resolver execution to provide cross-cutting concerns:

MiddlewarePurposeExample
AuthenticationVerify identityCheck JWT token validity
AuthorizationVerify permissionsRole-based field access
LoggingAudit trailTrack resolver execution time
Rate LimitingPrevent abusePer-user query complexity budget
Error HandlingNormalize errorsConvert exceptions to GraphQL errors
CachingPerformanceCache resolved values in ETS
TelemetryObservabilityEmit :telemetry events per resolver

Authorization in Resolvers

Authorization can be applied at three levels, each with different trade-offs:

  1. Schema-level: Middleware that checks permissions before any resolver runs. Coarse but fast.
  2. Resolver-level: Authorization logic inside individual resolvers. Fine-grained but repetitive.
  3. Data-level: The data source itself enforces access (e.g., Ecto query scopes). Most secure but hardest to debug.

Auto-Introspecting API Dispatch

Prismatic API's auto-introspecting system takes a fundamentally different approach to resolution. Instead of explicit resolver definitions, it discovers public functions on Prismatic facade modules at boot time and generates resolver-like dispatch logic automatically. The DispatchController acts as a universal resolver that maps {app, action} tuples to module.function(args) calls.

The discovery process:

  1. At application startup, scan all modules matching Prismatic* namespace
  2. Extract public functions with @doc and @spec annotations
  3. Build an ETS-backed endpoint registry mapping {app_name, action_name} to {module, function, param_specs}
  4. On each request, look up the registry, validate params against specs, and apply/3

Error Handling Patterns

Resolver error handling must balance informativeness with security:

Error TypeInternal RepresentationClient ResponseLog Level
Validation error{:error, changeset}Field-level errors:debug
Not found{:error, :not_found}"Resource not found":info
Unauthorized{:error, :unauthorized}"Not authorized":warning
Rate limited{:error, :rate_limited}"Too many requests":warning
Internal error{:error, exception}"Internal server error":error
Timeout{:error, :timeout}"Request timed out":error

Advanced Topics

OSINT Tool Execution as Resolvers

Each OSINT tool in Prismatic Platform implements a resolver-like contract. The search/2 and run/2 functions accept structured input and return structured output, making them composable resolution units:

# OSINT tools follow the resolver contract:
# Input: {tool_slug, params} -> Output: {:ok, results} | {:error, reason}

# This maps directly to the dispatch pattern:
# /api/v1/osint/execute_tool {slug: "czech-ares", input: {query: "Navigara"}}
#   -> PrismaticOsintSources.CzechAres.search(%{query: "Navigara"})
#   -> {:ok, %{entities: [...], metadata: %{source: "ARES", timestamp: ...}}}

Batch Resolution Strategies

Beyond Dataloader, several batch resolution patterns exist:

PatternMechanismBest For
DataloaderTwo-phase collect-then-executeEcto associations
Absinthe.Middleware.BatchManual batch functionNon-Ecto sources
Async middlewareTask.async per fieldIndependent expensive computations
ETS pre-loadingWarm cache before resolutionRead-heavy, stable data
Stream resolutionLazy enumerationLarge result sets

Query Complexity Analysis

Unbounded queries through resolvers can cause denial-of-service. Complexity analysis assigns a cost to each field and rejects queries exceeding a budget:

# Field complexity scoring
field :entities, list_of(:entity) do
  complexity fn _args, child_complexity ->
    # Each entity adds child_complexity to total
    50 * child_complexity
  end
end

# Query-level budget
max_complexity: 5000  # Reject queries exceeding this score

Resolver Performance Profiling

Key metrics for resolver health:

MetricTargetAlert ThresholdMeasurement
Resolution time (P50)< 10ms> 50ms:telemetry span
Resolution time (P95)< 50ms> 200ms:telemetry span
Batch efficiency> 80% hit rate< 50%Dataloader stats
Error rate< 0.1%> 1%Error count / total
N+1 detection0 occurrencesAnyQuery count per request

Usage in Prismatic Platform

The API gateway's dispatch mechanism functions as an auto-generated resolver layer. When a request arrives at /api/v1/:app/:action, the controller looks up the registered module and function in the ETS-cached endpoint registry, validates parameters against the discovered typespec, and calls the function. The function return value is serialized to JSON and returned to the client.

For OSINT tool execution via the API, each tool's search/2 or run/2 function acts as a resolver that translates tool-specific parameters into external API calls and returns structured results. The 157 self-registering OSINT adapters are all exposed through this uniform resolution interface.

The DD (Due Diligence) pipeline uses resolver-like patterns for entity enrichment. When investigating an entity, multiple OSINT tools are invoked in parallel, each resolving a different facet of the entity's profile (business registry, court records, financial data). The results are merged into a unified entity view -- essentially a parallel fan-out resolution pattern.

LiveView mount callbacks serve as page-level resolvers, fetching all data needed for initial render. The handle_params/3 callback acts as a re-resolver when URL parameters change, enabling URL-driven state that can be bookmarked and shared.

Code Examples

defmodule PrismaticApi.Resolver do
  @moduledoc """
  Generic resolver that dispatches API requests to discovered modules.
  Auto-discovered from Prismatic* facade modules at boot time.

  The resolver implements a three-phase pipeline:
  1. Lookup: Find the target module/function in the ETS registry
  2. Validate: Check parameters against discovered typespecs
  3. Execute: Apply the function and wrap the result

  ## Architecture

  This module is the core of Prismatic Platform's auto-introspecting
  API. Instead of manually defining routes and controllers for each
  endpoint, the system discovers available functions at startup and
  generates dispatch logic automatically.
  """

  alias PrismaticApi.EndpointRegistry

  require Logger

  @doc """
  Resolve an API request by dispatching to the registered module.

  ## Parameters

    - `app` - The application namespace (e.g., "osint", "dd")
    - `action` - The action to invoke (e.g., "search_tools", "list_cases")
    - `params` - Request parameters as a string-keyed map

  ## Examples

      iex> PrismaticApi.Resolver.resolve("osint", "list_tools", %{})
      {:ok, [%{slug: "czech-ares", name: "ARES Business Registry"}, ...]}

      iex> PrismaticApi.Resolver.resolve("dd", "get_case", %{"id" => "123"})
      {:ok, %{id: "123", status: :active, entities: [...]}}

  """
  @spec resolve(String.t(), String.t(), map()) :: {:ok, term()} | {:error, term()}
  def resolve(app, action, params) do
    start_time = System.monotonic_time(:microsecond)

    result =
      with {:ok, endpoint} <- EndpointRegistry.lookup(app, action),
           {:ok, args} <- validate_params(endpoint, params),
           {:ok, result} <- safe_apply(endpoint.module, endpoint.function, args) do
        {:ok, result}
      end

    duration = System.monotonic_time(:microsecond) - start_time

    :telemetry.execute(
      [:prismatic, :api, :resolve],
      %{duration: duration},
      %{app: app, action: action, status: elem(result, 0)}
    )

    result
  end

  @doc """
  Resolve multiple API requests in parallel with bounded concurrency.

  Useful for dashboard pages that need data from multiple sources.
  Uses Task.async_stream with a concurrency limit to prevent overload.
  """
  @spec resolve_batch([{String.t(), String.t(), map()}], keyword()) ::
          [{:ok, term()} | {:error, term()}]
  def resolve_batch(requests, opts \\ []) do
    max_concurrency = Keyword.get(opts, :max_concurrency, 10)
    timeout = Keyword.get(opts, :timeout, 15_000)

    requests
    |> Task.async_stream(
      fn {app, action, params} -> resolve(app, action, params) end,
      max_concurrency: max_concurrency,
      timeout: timeout,
      on_timeout: :kill_task
    )
    |> Enum.map(fn
      {:ok, result} -> result
      {:exit, :timeout} -> {:error, :timeout}
    end)
  end

  defp validate_params(%{param_specs: specs}, params) do
    validated =
      Enum.reduce_while(specs, {:ok, []}, fn {name, type}, {:ok, acc} ->
        case Map.fetch(params, to_string(name)) do
          {:ok, value} -> {:cont, {:ok, acc ++ [cast(value, type)]}}
          :error -> {:halt, {:error, {:missing_param, name}}}
        end
      end)

    validated
  end

  defp safe_apply(module, function, args) do
    try do
      result = apply(module, function, args)
      {:ok, result}
    rescue
      e in [ArgumentError, FunctionClauseError] ->
        Logger.warning("Resolver dispatch failed",
          module: module,
          function: function,
          error: Exception.message(e)
        )
        {:error, {:invalid_arguments, Exception.message(e)}}

      e in [RuntimeError] ->
        Logger.error("Resolver execution error",
          module: module,
          function: function,
          error: Exception.message(e)
        )
        {:error, {:execution_error, Exception.message(e)}}
    end
  end

  defp cast(value, :string), do: to_string(value)
  defp cast(value, :integer), do: String.to_integer(value)
  defp cast(value, :boolean) when value in ["true", "1"], do: true
  defp cast(value, :boolean), do: value == true
  defp cast(value, _), do: value
end
defmodule PrismaticApi.ResolverMiddleware.Authorization do
  @moduledoc """
  Authorization middleware for API resolvers.

  Checks whether the current user has permission to invoke
  the requested endpoint before dispatching to the resolver.
  Uses the platform's role-based access control system.
  """

  @behaviour Absinthe.Middleware

  require Logger

  @doc """
  Execute authorization check before resolver runs.

  Returns `{:error, :unauthorized}` if the user lacks
  the required permission for the resolved field.
  """
  @spec call(Absinthe.Resolution.t(), keyword()) :: Absinthe.Resolution.t()
  def call(%{context: %{current_user: user}} = resolution, required_role: role) do
    if has_role?(user, role) do
      resolution
    else
      Logger.warning("Unauthorized resolver access",
        user_id: user.id,
        required_role: role,
        field: resolution.definition.name
      )

      Absinthe.Resolution.put_result(resolution, {:error, "Not authorized"})
    end
  end

  def call(resolution, _config) do
    Absinthe.Resolution.put_result(resolution, {:error, "Authentication required"})
  end

  defp has_role?(%{roles: roles}, required), do: required in roles
  defp has_role?(_, _), do: false
end

Common Pitfalls

PitfallProblemSolution
N+1 queries in nested fieldsEach parent triggers a separate query for childrenUse Dataloader or Absinthe.Middleware.Batch for all association fields
Fat resolvers with business logicResolvers become untestable, violate single responsibilityKeep resolvers thin; delegate to context modules
Missing error normalizationRaw exceptions leak to clients, exposing internalsCatch specific exceptions, return sanitized error tuples
Unbounded list resolutionRepo.all without limit returns millions of rowsAlways apply limit and pagination in list resolvers
Blocking in async resolversSynchronous I/O in async resolution blocks the schedulerUse Task.async or Dataloader for I/O-bound resolution
Cache invalidation gapsStale cached resolver results after mutationsPublish cache invalidation events via PubSub on writes
Missing telemetryCannot identify slow resolvers in productionEmit :telemetry events with field name and duration
Over-permissive authorizationAll authenticated users can access all fieldsApply field-level authorization middleware
String.to_atom in param castingUser input creates atoms, exhausting atom tableUse String.to_existing_atom/1 or maintain a fixed allowlist
No query complexity budgetDeeply nested queries cause server overloadSet max_complexity in Absinthe schema configuration

Best Practices

  1. Keep resolvers thin -- resolvers should delegate to business logic modules, not contain domain logic themselves. A resolver is a translation layer, not a business layer.
  2. Use batching for related data -- Dataloader or manual batching prevents N+1 query patterns in nested field resolution. Every association field should use batched resolution.
  3. Return typed results -- {:ok, value} and {:error, reason} tuples provide consistent error handling across all resolver types.
  4. Validate inputs early -- parameter validation in the resolver prevents invalid data from reaching business logic. Use Ecto embedded schemas for complex validation.
  5. Log resolution metrics -- track resolver execution time to identify slow fields and optimization opportunities. Use :telemetry for structured observability.
  6. Apply authorization at the right level -- use middleware for role-based checks, resolver-level for data-dependent checks, and query scopes for row-level security.
  7. Set query complexity budgets -- prevent abuse by assigning complexity scores to fields and rejecting queries that exceed the budget.
  8. Handle timeouts explicitly -- external data sources (OSINT tools, third-party APIs) can be slow; set timeouts and return partial results or errors gracefully.
  9. Cache stable data in ETS -- for data that changes infrequently (tool registry, schema metadata), resolve from ETS rather than hitting the database on every request.
  10. Test resolvers in isolation -- mock data sources and verify resolver logic independently from the GraphQL schema and HTTP layer.
  • Query -- the request specification that resolvers process
  • Schema -- the type definitions that resolvers implement
  • Scope -- authorization context available to resolvers
  • API Gateway -- the entry point that routes to resolvers
  • GenServer -- OTP abstraction used for stateful resolver caching
  • ETS -- in-memory storage for endpoint registry and resolver caching
  • PubSub -- event distribution for cache invalidation after mutations
  • Latency -- key metric for resolver performance monitoring
  • Backpressure -- flow control preventing resolver overload
  • Process -- BEAM processes that execute resolver functions
  • Server -- HTTP server hosting the resolver infrastructure
  • Token -- JWT tokens providing resolver authorization context
  • Complexity

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL