Pretty patterns for writing the Elixir you actually want to write.
Patterns is a small Elixir package for turning repeated application structure into explicit, documented APIs. It focuses on code that shows up in real projects: schema query builders, function middleware, scoped context, delegation helpers, and ETS-backed key/value utilities.
Features
Patterns.Queryableadds a consistentquery/1andquery/2API to Ecto schemas.Patterns.Queryable.Filtersprovides reusable filters for fields, associations, ordering, limits, offsets, preloads, selects, and common comparators.Patterns.Middlewarewraps public or private functions with explicit middleware stacks.Patterns.Delegateprovides prefixed, suffixed, and bulk delegates.Patterns.Utilsprovideswith_ctx/2andctx/1.Patterns.Utils.ETSwraps common ETS key/value operations with a map-shaped API.Patterns.Guardsincludes small reusable guards for pattern modules.- Experimental modules provide structured errors, repo-aware keyset pagination, and a small LiveView route/page bridge.
Queryable
Use Patterns.Queryable when schemas should own their query vocabulary.
defmodule Blog.Post do
use Ecto.Schema
use Patterns.Queryable
schema "posts" do
field :title, :string
field :views, :integer
field :published, :boolean
has_many :comments, Blog.Comment
end
end
Callers can pass keyword or map filters:
Blog.Post.query(title: "Hello", published: true)
Blog.Post.query(%{title: "Hello"})
Start from an existing query with query/2:
import Ecto.Query
base_query =
from post in Blog.Post,
where: post.views > 10
Blog.Post.query(base_query, title: {:not, nil})
Built-in filters include query modifiers, equality, lists, ranges, LIKE shorthand, regex shorthand, negation, and association filters:
Blog.Post.query(
title: {:like, "^intro"},
views: {:gte, 100},
published: true,
comments: [body: "Nice"],
order_by: [desc: :views],
limit: 10,
preload: :comments
)
Association filters use schema association names. If the associated schema also implements query/2, nested filters are delegated to it.
defmodule Blog.Comment do
use Ecto.Schema
use Patterns.Queryable
schema "comments" do
field :body, :string
field :likes, :integer
belongs_to :post, Blog.Post
end
@impl Patterns.Queryable
def query(base_query, filters) do
Enum.reduce(filters, base_query, fn
{:popular, true}, query ->
from binding(comment) in query,
where: comment.likes > 5
filter, query ->
apply_filter(query, filter)
end)
end
end
Blog.Post.query(comments: [popular: true])
List values inside one association filter match any value on one associated row:
Blog.Post.query(comments: [body: ["Nice", "Cool"]])
Nested association filters require one matching associated row per filter:
Blog.Post.query(comments: [[body: "Nice"], [body: "Cool"]])
Override base_query/0 when every query should share the same starting point:
defmodule Blog.PublishedPost do
use Ecto.Schema
use Patterns.Queryable
schema "posts" do
field :title, :string
field :published, :boolean
end
@impl Patterns.Queryable
def base_query do
from post in __MODULE__,
where: post.published == true
end
end
Define custom filters by implementing query/2. Unknown filters can be delegated back to apply_filter/2.
defmodule Blog.Post do
use Ecto.Schema
use Patterns.Queryable
schema "posts" do
field :title, :string
field :views, :integer
end
@impl Patterns.Queryable
def query(base_query, filters) do
Enum.reduce(filters, base_query, fn
{:popular, true}, query ->
from binding(post) in query,
where: post.views > 10
filter, query ->
apply_filter(query, filter)
end)
end
end
Middleware
Use Patterns.Middleware when code should run around a function call without moving that logic into the function body.
defmodule Blog.Middlewares.NormalizePostTitle do
use Patterns.Middleware
@behaviour Patterns.Middleware
@impl Patterns.Middleware
def process([attrs], resolution) do
attrs = Map.update!(attrs, :title, &String.trim/1)
yield([attrs], resolution)
end
end
defmodule Blog.Middlewares.AuthorizeEditor do
use Patterns.Middleware
@behaviour Patterns.Middleware
@impl Patterns.Middleware
def process([user, attrs], resolution) do
if user.role == :editor do
yield([user, attrs], resolution)
else
{{:error, :unauthorized}, resolution}
end
end
end
defmodule Blog do
use Patterns.Middleware
@middleware Blog.Middlewares.AuthorizeEditor
@middleware Blog.Middlewares.NormalizePostTitle
def create_post(user, attrs) do
{:ok, attrs}
end
end
Middleware receives the wrapped function arguments as a list and a Patterns.Middleware.Resolution. Calling yield/2 continues to the next middleware or the original function. Returning {result, resolution} without yielding halts the stack.
Middleware can store per-call metadata on the resolution:
defmodule Blog.Middlewares.MarkAudited do
use Patterns.Middleware
@behaviour Patterns.Middleware
@impl Patterns.Middleware
def process(args, resolution) do
{result, resolution} = yield(args, resolution)
{result, put_private(resolution, :audited?, true)}
end
end
Middleware can also replace or wrap the super function that runs after the last middleware yields:
defmodule Blog.Middlewares.MarkSavedPost do
use Patterns.Middleware
@behaviour Patterns.Middleware
@impl Patterns.Middleware
def process(args, resolution) do
resolution =
update_super(resolution, fn super ->
fn args, resolution ->
{:ok, post} = super.(args, resolution)
{:ok, Map.put(post, :saved?, true)}
end
end)
yield(args, resolution)
end
end
Delegate
Use Patterns.Delegate when one module should expose functions from another module with a prettier public name.
defmodule MyString do
use Patterns.Delegate
defdelegate_all String
end
MyString.upcase("hello")
#=> "HELLO"
defdelegate/2 shadows Kernel's delegate macro and accepts :prefix and :suffix.
defmodule Components do
use Patterns.Delegate
alias __MODULE__
defdelegate_all Components.Entry, prefix: true, only: [:hero, :list]
end
prefix: true uses the target module tail, so Components.Entry.hero/1 becomes Components.entry_hero/1.
defdelegate_all/2 also accepts :only and :except to keep the exported surface small.
Utils
with_ctx/2 and ctx/1 provide scoped process-local context. Context is restored after the block exits, including raised, thrown, or exited blocks.
import Patterns.Utils, only: [with_ctx: 2]
Patterns.Utils.ctx(:binding)
#=> nil
with_ctx binding: :comments do
Patterns.Utils.ctx(:binding)
#=> :comments
end
Patterns.Utils.ctx(:binding)
#=> nil
Patterns.Queryable uses this context to make binding/1 target the current named binding during nested query composition.
ETS Helpers
Patterns.Utils.ETS wraps common ETS operations in a small key/value API.
alias Patterns.Utils.ETS
table = ETS.new([count: 1], access: :public)
ETS.fetch(table, :count)
#=> {:ok, 1}
ETS.get(table, :missing, :default)
#=> :default
ETS.update(table, :count, 0, &(&1 + 1))
#=> table
ETS.to_list(table)
#=> [count: 2]
Tables created with new/0, new/1, or new/2 are owned by the calling process. For :set and :ordered_set tables, put/3 replaces the value for a key. For :bag and :duplicate_bag tables, fetch/2 and get/3 return lists of values.
bag = ETS.new([{:tag, :elixir}, {:tag, :ecto}], type: :bag)
ETS.get(bag, :tag)
#=> [:elixir, :ecto]
update/4 is a caller-side read-modify-write operation and only supports :set and :ordered_set tables.
Experimental APIs
Experimental modules are useful, but their internals may still change while the public shape settles.
Patterns.Experimental.Error normalizes errors into one boundary struct with a
public message, stable code, optional transport status, optional public
extensions, and private diagnostic cause.
{:error, error} = Patterns.Experimental.Error.not_found("Post")
error.message
#=> "Post not found"
Use Patterns.Experimental.Error.Normalize when a domain error or dependency
error needs custom normalization.
Patterns.Experimental.Pagination lets repos execute queries carrying cursor
pagination metadata as Relay-style keyset connections.
Post
|> Patterns.Experimental.Pagination.put(%{first: 10})
|> MyApp.Repo.all()
The current pagination backend is not part of the public contract.
Patterns.Experimental.Web makes one module the incoming web request home while
keeping page shells as normal Phoenix LiveView code.
defmodule MyApp.Router do
use Patterns.Experimental.Web
scope "/", MyApp do
pipe_through :browser
router do
route "/", Pages.Home
route "/posts", Pages.Entries, as: Pages.Entries.Posts
route "/*path", Pages.Entry
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(assigns) do
~H"""
{Page.render(assigns)}
"""
end
end
end
end
Module-shaped actions are route identities. as: names the unique route identity
when one page handles multiple routes.
Without a module argument, the generated LiveView is Router.LiveView under the
current Phoenix scope. Pass a module, such as router AdminLive do, when a
router needs more than one generated LiveView.
The generated LiveView delegates fallback handle_event/3 and handle_info/2
calls to the current route page when that page exports the matching callback.
Missing events raise because the rendered client contract is broken; missing
info handlers warn and keep the socket because stale process messages can arrive
after navigation.
Patterns.Experimental.Web.Page pages use LiveView-shaped callbacks like
handle_params/3 and render/1. Use web: MyApp.Router when the page should
inherit the app's HTML helpers and verified routes.
If the current scope root module exports verified_routes/0, its quoted routes
are injected into the generated LiveView. Apps can provide Phoenix verified
routes at their own boundary without making Patterns know about app endpoints,
routers, or static paths.
Installation
Add patterns to your dependencies:
def deps do
[
{:patterns, "~> 0.0.1"}
]
end
Patterns requires Elixir ~> 1.15. ecto is a runtime dependency because Patterns.Queryable builds Ecto queries.
Status
Patterns is early and intentionally small. The stable surface is centered on Patterns.Queryable, Patterns.Middleware, Patterns.Delegate, Patterns.Utils, Patterns.Utils.ETS, and Patterns.Guards.
Development
Fetch dependencies and run tests:
mix deps.get
mix test
Run the project lint checks:
mix lint
The lint alias runs compilation with warnings as errors, unused dependency checks, formatting checks, Credo, and Dialyzer.
Coverage is reported through ExCoveralls:
mix coveralls
License
MIT. See LICENSE.