Patterns

Hex VersionHex DocsCI StatusCoverage Status

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

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.