Definition & Overview
A storage adapter is a module that implements the PrismaticStorage.Core behaviour, providing a standardized interface for data operations (get, put, delete, list, query) against a specific backend technology. Each adapter translates the platform's abstract storage operations into the native API of its backend -- ETS calls for the in-memory adapter, Ecto queries for PostgreSQL, HTTP requests for Meilisearch, Cypher queries for KuzuDB. Client code depends only on the abstract interface, never on backend-specific details.
The storage adapter pattern enables the Prismatic Platform to support seven distinct storage technologies through a single API. Application code that stores an entity record does not need to know whether the data lands in PostgreSQL, ETS, or Redis. The adapter handles all translation, connection management, error normalization, and serialization. This decoupling means that storage backends can be swapped without modifying any business logic -- a change from ETS to Redis requires only a configuration update.
Storage adapters are a core architectural element of the platform. Every subsystem -- OSINT tool results, DD entity records, Academy progress tracking, Perimeter vulnerability findings -- stores data through the adapter interface. The adapters are individually tested through a shared contract test suite that verifies behavioral compliance, and are collectively monitored through instrumented telemetry wrappers that provide uniform observability.
Technical Deep Dive
The Core Behaviour
Every storage adapter must implement this behaviour:
defmodule PrismaticStorage.Core do
@moduledoc """
Core storage behaviour that all adapters must implement.
Compile-time enforcement via @behaviour ensures complete implementation.
"""
@type key :: binary() | atom()
@type value :: term()
@type opts :: keyword()
@callback get(key(), opts()) :: {:ok, value()} | {:ok, nil} | {:error, term()}
@callback put(key(), value(), opts()) :: {:ok, value()} | {:error, term()}
@callback delete(key(), opts()) :: :ok | {:error, term()}
@callback list(opts()) :: {:ok, [value()]} | {:error, term()}
@callback exists?(key(), opts()) :: {:ok, boolean()} | {:error, term()}
@callback count(opts()) :: {:ok, non_neg_integer()} | {:error, term()}
@callback query(map(), opts()) :: {:ok, [value()]} | {:error, term()}
@callback bulk_put([{key(), value()}], opts()) :: {:ok, non_neg_integer()} | {:error, term()}
@optional_callbacks [query: 2, bulk_put: 2]
endAdapter Implementations
Each adapter translates the abstract interface to backend-specific operations:
defmodule PrismaticStorage.ETS do
@moduledoc """
ETS storage adapter. Sub-microsecond operations.
Optimized for hot-path reads with read_concurrency.
"""
@behaviour PrismaticStorage.Core
@impl true
def get(key, opts) do
table = Keyword.fetch!(opts, :table)
case :ets.lookup(table, key) do
[{^key, value}] -> {:ok, value}
[] -> {:ok, nil}
end
end
@impl true
def put(key, value, opts) do
table = Keyword.fetch!(opts, :table)
:ets.insert(table, {key, value})
{:ok, value}
end
@impl true
def delete(key, opts) do
table = Keyword.fetch!(opts, :table)
:ets.delete(table, key)
:ok
end
@impl true
def list(opts) do
table = Keyword.fetch!(opts, :table)
values = :ets.tab2list(table) |> Enum.map(&elem(&1, 1))
{:ok, values}
end
@impl true
def exists?(key, opts) do
table = Keyword.fetch!(opts, :table)
{:ok, :ets.member(table, key)}
end
@impl true
def count(opts) do
table = Keyword.fetch!(opts, :table)
{:ok, :ets.info(table, :size)}
end
enddefmodule PrismaticStorage.Meilisearch do
@moduledoc """
Meilisearch storage adapter. Full-text search optimized.
Translates storage operations to Meilisearch HTTP API calls.
"""
@behaviour PrismaticStorage.Core
@impl true
def get(key, opts) do
index = Keyword.fetch!(opts, :index)
url = "#{base_url()}/indexes/#{}/documents/#{}"
case PrismaticHttp.SecureClient.get(url) do
{:ok, %{status: 200, body: body}} -> {:ok, Jason.decode!(body)}
{:ok, %{status: 404}} -> {:ok, nil}
{:error, reason} -> {:error, reason}
end
end
@impl true
def put(key, value, opts) do
index = Keyword.fetch!(opts, :index)
document = Map.put(value, "id", key)
url = "#{base_url()}/indexes/#{}/documents"
case PrismaticHttp.SecureClient.post(url, Jason.encode!([document])) do
{:ok, %{status: status}} when status in [200, 202] -> {:ok, value}
{:error, reason} -> {:error, reason}
end
end
@impl true
def query(filters, opts) do
index = Keyword.fetch!(opts, :index)
search_params = build_search_params(filters)
url = "#{base_url()}/indexes/#{}/search"
case PrismaticHttp.SecureClient.post(url, Jason.encode!(search_params)) do
{:ok, %{status: 200, body: body}} ->
%{"hits" => hits} = Jason.decode!(body)
{:ok, hits}
{:error, reason} ->
{:error, reason}
end
end
defp base_url, do: Application.get_env(:prismatic_storage_meilisearch, :url)
defp build_search_params(%{search: query} = filters) do
%{"q" => query}
|> maybe_add_filter(filters)
|> maybe_add_facets(filters)
end
defp maybe_add_filter(params, %{filter: filter}), do: Map.put(params, "filter", filter)
defp maybe_add_filter(params, _), do: params
defp maybe_add_facets(params, %{facets: facets}), do: Map.put(params, "facets", facets)
defp maybe_add_facets(params, _), do: params
endArchitecture & Implementation
The platform organizes storage adapters as separate umbrella applications: prismatic_storage_ets, prismatic_storage_ecto, prismatic_storage_meilisearch, prismatic_storage_kuzudb, prismatic_storage_duckdb, prismatic_storage_redis, prismatic_storage_file. This isolation ensures that each adapter's dependencies (Tesla for HTTP-based backends, Ecto for PostgreSQL, etc.) do not pollute other adapters or application code.
Runtime adapter selection is configuration-driven. Development environments default to ETS for zero-dependency operation. Production environments use PostgreSQL (via Ecto) for durable storage with ETS as a caching layer. Specialized workloads select the optimal backend: Meilisearch for full-text search, KuzuDB for graph traversal, DuckDB for analytical queries.
The contract test suite (PrismaticStorage.AdapterContractTest) is a reusable test macro that validates any adapter against the full behaviour specification. Each adapter's test suite includes the contract tests plus backend-specific tests for features beyond the common interface.
Usage in Prismatic Platform
Storage adapters are consumed through the unified facade:
# Runtime adapter selection
adapter = Application.get_env(:prismatic_storage, :default_adapter, PrismaticStorage.ETS)
# Uniform operations across any backend
{:ok, _} = adapter.put("entity-123", %{name: "Test Corp"}, table: :entities)
{:ok, entity} = adapter.get("entity-123", table: :entities)
{:ok, all} = adapter.list(table: :entities)Cross-References
- Adapter Pattern - Design pattern implemented by storage adapters
- Behaviour - Contract mechanism enforcing adapter compliance
- ETS - In-memory storage adapter for high-speed caching
- Ecto - Database wrapper used by the PostgreSQL adapter
Created by Tomas Korcak (korczis) | GitHub