<div align="center">
# ๐ก๏ธ GuardedStruct
**Build Elixir structs with validation, sanitization, nested sub-structs, conditional fields, pattern-keyed maps, and a first-class Ash extension โ declared once, parsed at compile time, validated on every build.** โจ
[](https://hex.pm/packages/guarded_struct)
[](https://hex.pm/packages/guarded_struct)
[](https://github.com/mishka-group/guarded_struct/blob/master/LICENSE)
[](https://github.com/sponsors/mishka-group)
[](https://www.buymeacoffee.com/mishkagroup)
</div>
---
> [!NOTE]
> **Status โ `0.1.0-beta`.** v0.1.0 rewrites the macro core on [Spark](https://hex.pm/packages/spark). Every existing 0.0.x API keeps working unchanged. Track every change in [`CHANGELOG.md`](./CHANGELOG.md).
---
## ๐ Table of contents
- [Why GuardedStruct?](#-why-guardedstruct)
- [Highlights](#-highlights)
- [Installation](#-installation)
- [Quick start](#-quick-start)
- [A struct](#-a-struct)
- [Nested + conditional](#-nested--conditional)
- [Custom validators / sanitizers](#-custom-validators--sanitizers)
- [Ash integration](#-ash-integration)
- [Atomic mode (Ash)](#๏ธ-atomic-mode-ash)
- [Introspection](#-introspection)
- [Architecture](#-architecture)
- [Compatibility](#-compatibility)
- [Documentation](#-documentation)
- [LLM agent skills & usage rules](#-llm-agent-skills--usage-rules)
- [Status & roadmap](#-status--roadmap)
- [Contributing](#-contributing)
- [Funding & sponsorship](#-funding--sponsorship)
- [License](#-license)
---
## ๐ญ Why GuardedStruct?
Defining a "good" struct in Elixir means doing the same boilerplate every time: `defstruct`, `@enforce_keys`, a `@type t()`, a constructor, per-field validation, sanitization, default values, nested structs, error messages, i18n. Each surface ends up subtly different across projects.
**GuardedStruct collapses that into a DSL.** One `guardedstruct do ... end` block declares fields, validation rules, sanitization, nested sub-structs, conditional dispatch, custom callbacks. The library generates `defstruct`, `@type t()`, a `builder/1,2` constructor, introspection functions, and a configurable error pipeline โ all parsed once at compile time so the runtime hot path is small.
```elixir
defmodule User do
use GuardedStruct
guardedstruct do
field :name, :string, enforce: true,
derives: "sanitize(trim, capitalize) validate(string, max_len=80)"
field :email, :string, enforce: true,
derives: "sanitize(trim, downcase) validate(email_r)"
field :age, :integer,
derives: "validate(integer, min_len=0, max_len=120)"
field :role, :string, default: "user",
derives: "validate(enum=String[admin::user::guest])"
end
end
User.builder(%{
name: " alice ",
email: "ALICE@EXAMPLE.COM",
age: 30
})
# => {:ok, %User{name: "Alice", email: "alice@example.com", age: 30, role: "user"}}
User.builder(%{name: "x", email: "bad", age: -5})
# => {:error, [
# %{field: :email, action: :email_r, message: "..."},
# %{field: :age, action: :min_len, message: "..."}
# ]}
```
That's the full surface. No `defstruct`, no `@enforce_keys`, no validator boilerplate, no constructor. ๐
---
## โจ Highlights
### ๐๏ธ Core DSL
- ๐งฑ **`field`** โ typed, optionally enforced, with default, sanitize+validate derive, auto-fill MFA, per-field validator, cross-field `on:`/`from:`/`domain:`.
- ๐ฒ **`sub_field`** โ recursive nested struct, any depth, generates real submodules with their own `builder/1`.
- ๐ญ **`conditional_field`** โ sum-type-like dispatch: same field name resolves to different shapes based on the input (string OR struct OR list). Nestable to arbitrary depth.
- ๐ป **`virtual_field`** โ validated through the full pipeline but excluded from `defstruct` (classic `password_confirm` use case).
- ๐ **`dynamic_field`** โ free-form map with passthrough; atom-attack-safe (string keys stay strings, no `String.to_atom` of attacker input).
- ๐ฃ **Pattern-keyed maps** โ `field` whose name is a regex declares a map shape with no fixed keys; uniform per-value validation.
- ๐งฌ **Erlang Records** โ `validate(record=tag)` accepts tagged tuples.
### ๐งช Derive mini-language
```elixir
field :slug, :string,
derives: "sanitize(trim, downcase) validate(string, not_empty, max_len=80) sanitize(slugify)"
# OR
@derives "sanitize(trim, downcase) validate(string, not_empty, max_len=80) sanitize(slugify)"
field :slug, :string
```
- ๐งผ **Sanitize ops** โ `trim`, `upcase`, `downcase`, `capitalize`, `strip_tags`, `basic_html`, `html5`, `tag`, plus user-defined custom ops.
- โ
**Validate ops** โ `string`, `integer`, `float`, `boolean`, `atom`, `list`, `map`, `tuple`, `record`, `not_empty`, `not_empty_string`, `max_len`, `min_len`, `max`, `min`, `equal`, `uuid`, `email`, `email_r`, `url`, `url_r`, `ipv4`, `ipv6`, `regex`, `enum`, `datetime`, `date`, `time`, `geo`, `location`, plus user-defined.
- ๐ฏ **All ops parsed at compile time** โ runtime reads pre-built op-maps from `__fields__/0`; zero `Code.eval_string` on the hot path.
- ๐งฐ **`@derives` decorator** โ alternative to inline `derives:` for keeping fields short.
### ๐ช Custom validators / sanitizers (`Derive.Extension`)
```elixir
defmodule MyApp.Derives do
use GuardedStruct.Derive.Extension
derives do
validator :slug, fn input ->
is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
end
sanitizer :slugify, fn input ->
input |> String.downcase() |> String.replace(~r/[^a-z0-9]+/u, "-")
end
end
end
```
Register globally (`config :guarded_struct, derive_extensions: [MyApp.Derives]`) or per-module (`use GuardedStruct, derive_extensions: [MyApp.Derives]`). Per-module lists support a `:config` sentinel for in-position merge with the global registry. Compile-time shadow warnings if a custom op-name collides with a built-in.
### ๐ Ash integration
```elixir
defmodule MyApp.User do
use Ash.Resource, extensions: [GuardedStruct.AshResource]
guardedstruct do
auto_wire true
field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
end
attributes do
uuid_primary_key :id
attribute :email, :string, allow_nil?: false, public?: true
end
actions do
defaults [:read, :destroy]
create :create, accept: [:email]
end
end
```
- ๐ **`GuardedStruct.AshResource.Change`** โ bridges `__guarded_change__/1` into the Ash changeset pipeline.
- โก **`auto_wire true`** โ Spark transformer injects the change for you; no `changes do ... end` block needed.
- ๐ฆ **`batch_change/3`** โ `Ash.bulk_create/3` and `Ash.bulk_update/3` (with `strategy: :stream`) work end-to-end.
- ๐ **Auto-map cascade** โ every `sub_field` returns a plain map at every depth (matches Ash's `:map` attribute type).
- โ๏ธ **Atomic-safe by default** โ `Change.atomic/3` runs the pipeline on plain literals and returns `{:atomic, sanitized_map}`; update actions stay atomic without `require_atomic? false`.
### ๐ฎ Standalone validation API
```elixir
GuardedStruct.Validate.run("validate(email_r)", "alice@x.io")
# => {:ok, "alice@x.io"}
GuardedStruct.Validate.field(User, :email, "bad")
# => {:error, [%{field: :email, action: :email_r, ...}]}
GuardedStruct.Validate.partial(User, %{name: "Alice"})
# => {:ok, %{name: "Alice"}} # missing fields skipped, no enforce check
```
### ๐ก Telemetry
Every top-level `builder/1` emits `[:guarded_struct, :builder, :start | :stop | :exception]`. Attach a handler for logging, metrics, tracing โ no manual instrumentation needed.
### ๐ช Introspection (`GuardedStruct.Info`)
```elixir
GuardedStruct.Info.describe(User)
# => %{module: User, keys: [...], enforce_keys: [...],
# fields: [%{name: :email, kind: :field, ...}, ...],
# options: %{enforce: true, json: false, ...}}
GuardedStruct.Info.field_kind(User, :email) #=> :field
GuardedStruct.Info.enforce?(User, :email) #=> true
GuardedStruct.Info.sub_module(User, :address) #=> User.Address
GuardedStruct.Info.conditional_children(User, :billing)
```
### ๐ก๏ธ Errors as Splode exceptions (opt-in)
```elixir
case User.builder(input) do
{:ok, _} = ok -> ok
{:error, errs} -> {:error, GuardedStruct.Errors.from_tuple(errs)}
end
```
Gives `Splode.traverse_errors/2`, `to_class/1`, JSON-serializable errors.
### ๐ค JSON encoding (opt-in)
```elixir
guardedstruct json: true do
field :id, :string
end
```
Auto-derives `Jason.Encoder` when `:jason` is in deps, falling back to the built-in `JSON.Encoder` on Elixir 1.18+. No-op if neither is present.
### ๐ Cross-cutting
- ๐ **i18n** โ every error message resolves through `GuardedStruct.Messages`; override callbacks to translate.
- ๐ก๏ธ **Atom-attack safe** โ `dynamic_field` and pattern-keyed maps never `String.to_atom` user input.
- ๐งช **Property-based tested** โ 740+ tests including 6 property tests, real Ash integration suite with ETS data layer.
---
## ๐ Installation
Add to your `mix.exs`:
```elixir
def deps do
[
{:guarded_struct, "~> 0.1.0"}
]
end
```
Fetch and compile:
```sh
mix deps.get
mix compile
```
Upgrading from `0.0.x`? Existing code keeps working unchanged โ see [`CHANGELOG.md`](./CHANGELOG.md) for every change in v0.1.0.
### Optional deps
Pull in only what you need:
```elixir
{:jason, "~> 1.4"} # for `json: true` (Elixir < 1.18, otherwise built-in JSON works)
{:splode, "~> 0.3"} # for Errors wrapper
{:ash, "~> 3.0"} # for the Ash extension
{:html_sanitize_ex, "~> 1.5"} # for `sanitize(strip_tags, basic_html, html5)`
{:email_checker, "~> 0.2"} # for `validate(email)` (DNS lookup; non-atomic)
{:ex_url, "~> 2.0"} # for `validate(url)` (DNS / port check; non-atomic)
```
---
## ๐ฏ Quick start
### ๐ A struct
```elixir
defmodule Order do
use GuardedStruct
guardedstruct enforce: true do
field :id, :string, auto: {Ecto.UUID, :generate}
field :total, :integer, derives: "validate(integer, min_len=0)"
field :currency, :string, default: "USD",
derives: "validate(enum=String[USD::EUR::GBP::JPY])"
field :placed_at, :string, derives: "validate(datetime)"
end
end
Order.builder(%{total: 9_900, placed_at: "2026-05-14T10:00:00Z"})
# => {:ok, %Order{id: "a-uuid", total: 9900, currency: "USD", placed_at: "..."}}
```
### ๐ณ Nested + conditional
```elixir
defmodule Account do
use GuardedStruct
guardedstruct do
field :name, :string, enforce: true
sub_field :owner, struct(), enforce: true do
field :email, :string, enforce: true, derives: "validate(email_r)"
field :role, :string, default: "owner"
end
# Same field name resolves to either a string preset OR a detailed map
conditional_field :plan, any() do
field :plan, :string, hint: "preset",
derives: "validate(enum=String[free::pro::enterprise])"
sub_field :plan, struct() do
field :tier, :string, enforce: true
field :seats, :integer, derives: "validate(integer, min_len=1)"
end
end
end
end
Account.builder(%{name: "Acme", owner: %{email: "z@a.io"}, plan: "pro"})
# => {:ok, %Account{plan: "pro", ...}}
Account.builder(%{name: "Acme", owner: %{email: "z@a.io"},
plan: %{tier: "custom", seats: 50}})
# => {:ok, %Account{plan: %Account.Plan1{tier: "custom", seats: 50}, ...}}
```
### ๐ช Custom validators / sanitizers
```elixir
defmodule MyApp.Derives do
use GuardedStruct.Derive.Extension
derives do
validator :slug, fn input ->
is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
end
sanitizer :slugify, fn input when is_binary(input) ->
input
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
end
validator :positive_int, fn n -> is_integer(n) and n > 0 end
end
end
# Register globally:
# config :guarded_struct, derive_extensions: [MyApp.Derives]
defmodule Post do
use GuardedStruct
guardedstruct do
field :slug, :string, derives: "sanitize(slugify) validate(slug)"
field :views, :integer, derives: "validate(positive_int)"
end
end
```
### ๐ Ash integration
```elixir
defmodule MyApp.User do
use Ash.Resource, extensions: [GuardedStruct.AshResource]
guardedstruct do
auto_wire true
field :email, :string,
derives: "sanitize(trim, downcase) validate(email_r, max_len=320)"
field :nickname, :string,
derives: "sanitize(trim) validate(string, max_len=20)"
end
attributes do
uuid_primary_key :id
attribute :email, :string, allow_nil?: false, public?: true
attribute :nickname, :string, public?: true
end
actions do
defaults [:read, :destroy]
create :create, accept: [:email, :nickname]
update :update do
accept [:email, :nickname]
end
end
end
MyApp.User
|> Ash.Changeset.for_create(:create, %{email: " Alice@X.IO "})
|> Ash.create()
# => {:ok, %MyApp.User{email: "alice@x.io", ...}}
```
---
## โ๏ธ Atomic mode (Ash)
`GuardedStruct.AshResource.Change` is atomic-safe by default. There's no flag to flip and no `require_atomic? false` to add โ update and destroy actions run as single-statement SQL with sanitized values.
```elixir
guardedstruct do
auto_wire true
field :email, :string, derives: "sanitize(trim, downcase) validate(email_r, max_len=320)"
field :age, :integer, derives: "validate(integer, min_len=0, max_len=150)"
field :role, :string, derives: "validate(enum=String[admin::user::guest])"
field :tenant_id, :string, derives: "validate(uuid)"
end
# Update goes through atomic/3 โ pipeline runs in Elixir on the plain
# literal input, sanitized value is substituted into the UPDATE SQL.
record
|> Ash.Changeset.for_update(:update, %{email: " New@X.IO "})
|> Ash.update()
# => {:ok, %{email: "new@x.io", ...}}
```
**How it works.** `Change.atomic/3` reads `changeset.attributes` and `changeset.atomics`, detects whether any atomic value is an `Ash.Expr`, and:
- if every value is a plain literal โ runs the full `__guarded_change__/1` pipeline (sanitize โ validate โ derive โ `auto:` โ main_validator) and returns `{:atomic, sanitized_map}` for Ash to substitute into the SQL,
- if any value is an `Ash.Expr` (e.g. from `Ash.Changeset.atomic_update(record, :counter, expr(counter + 1))`) โ returns `{:not_atomic, reason}` and Ash falls back to the imperative path. This is rare in practice; 99% of changesets pass plain values.
---
## ๐ช Introspection
```elixir
# Full dump in one call
GuardedStruct.Info.describe(MyApp.User)
# %{
# module: MyApp.User,
# path: [], key: :root, shape: :struct,
# keys: [:email, :nickname], enforce_keys: [:email],
# conditional_keys: [],
# options: %{enforce: true, json: false, ...},
# fields: [
# %{name: :email, kind: :field, enforce?: true,
# type: "String.t()", derive: "...", auto: nil, ...},
# ...
# ]
# }
# Field-level helpers
GuardedStruct.Info.field_kind(MyApp.User, :email) #=> :field
GuardedStruct.Info.enforce?(MyApp.User, :email) #=> true
GuardedStruct.Info.virtual?(MyApp.User, :password_confirm) #=> true
GuardedStruct.Info.field_derives(MyApp.User, :email)
#=> "sanitize(trim, downcase) validate(email_r)"
# Collections by kind
GuardedStruct.Info.sub_fields(MyApp.User) #=> [:address]
GuardedStruct.Info.virtual_fields(MyApp.User) #=> [:password_confirm]
GuardedStruct.Info.conditional_fields(MyApp.User) #=> [:plan]
# Navigation
GuardedStruct.Info.sub_module(MyApp.User, :address)
#=> MyApp.User.Address
GuardedStruct.Info.conditional_children(MyApp.User, :plan)
#=> [%{kind: :field, ...}, %{kind: :sub_field, ...}]
```
---
## ๐๏ธ Architecture
```mermaid
flowchart TD
User["<b>guardedstruct do ... end</b><br/>user-facing DSL block"]
Spark["<b>Spark.Dsl.Extension</b><br/>parses entities + section opts"]
User --> Spark
Spark --> Transformers["<b>Transformers</b><br/>ParseDerive ยท ParseCoreKeys<br/>GenerateBuilder ยท GenerateSubFieldModules<br/>GenerateAshValidator ยท AutoWireAshChange"]
Spark --> Verifiers["<b>Verifiers</b><br/>VerifyValidatorMFA ยท VerifyAutoMFA<br/>VerifyNoStructCycles"]
Spark --> AsyncCompile["<b>Async submodule compile</b><br/>Spark.Dsl.Transformer.async_compile<br/>for sub_field branches"]
Transformers --> Fields["<b>__fields__/0</b> ยท <b>__information__/0</b><br/>introspection metadata<br/>(read by GuardedStruct.Info)"]
Verifiers --> Fields
AsyncCompile --> Fields
Fields --> Runtime["<b>Runtime pipeline</b><br/>sanitize โ validate โ derive โ main_validator"]
Runtime --> Standalone["<b>builder/1,2</b><br/>{:ok, %Struct{}}<br/>or {:error, [%{field, action, message}]}"]
Runtime --> AshBridge["<b>__guarded_change__/1</b><br/>+ GuardedStruct.AshResource.Change<br/>(bridges to Ash changeset pipeline)"]
```
- ๐ง **DSL layer** โ Spark sections + entities define `field`, `sub_field`, `conditional_field`, `virtual_field`, `dynamic_field`. Every op-string parsed at compile time.
- ๐ง **Transformers** โ codegen for `defstruct`/`builder`/`keys`/`__information__`/`__fields__`, async sub_field submodule generation, derive parsing, core-key parsing, Ash-variant codegen, auto-wire injection.
- ๐ **Verifiers** โ validator MFAs exist, auto MFAs exist, no struct cycles.
- ๐ **Runtime** โ receives a map, walks pre-parsed op-lists per field, hands back `{:ok, %Struct{}}` or `{:error, [%{field, action, message}]}`. The Ash bridge routes the same pipeline through `__guarded_change__/1` into changeset attributes.
---
## ๐ Compatibility
| Dependency | Required version | Required? |
|---|---|---|
| Elixir | `~> 1.17` | โ
|
| Spark | `~> 2.7` | โ
|
| Splode | `~> 0.3` | โ
(errors module) |
| Telemetry | `~> 1.0` | โ
|
| html_sanitize_ex | `~> 1.5` | โช optional (`sanitize(strip_tags/basic_html/html5)`) |
| Jason | `~> 1.4` | โช optional (`json: true` on Elixir < 1.18) |
| email_checker | `~> 0.2` | โช optional (`validate(email)` with DNS) |
| ex_url | `~> 2.0` | โช optional (`validate(url)` with DNS) |
| Ash | `~> 3.0` | โช optional (for the `Ash.Resource` extension) |
---
## ๐ Documentation
- ๐ **API docs** โ [hexdocs.pm/guarded_struct](https://hexdocs.pm/guarded_struct)
- ๐ **LiveBook walkthrough** โ [`guidance/guarded-struct.livemd`](./guidance/guarded-struct.livemd) โ runnable end-to-end examples
- ๐ **Changelog** โ [`CHANGELOG.md`](./CHANGELOG.md)
- ๐ **Security policy** โ [`SECURITY.md`](./SECURITY.md) โ supported versions + how to report a vulnerability
- ๐งฑ **DSL reference** โ auto-generated cheat sheets in `documentation/dsls/` (published to hexdocs)
- ๐ฐ **Blog post** โ [Consolidating Input and Output Validation and Sanitization in Elixir with GuardedStruct library](https://mishka.tools/blog/guardedstruct-advanced-elixir-struct-data-validation-and-sanitization)
---
## ๐ค LLM agent skills & usage rules
<details>
<summary>Ship agent context for Claude Code, Cursor, Copilot, and any <a href="https://www.skills.sh/">skills.sh</a>-compatible runner. Two formats โ click to expand.</summary>
| Layout | For | Source of truth |
|---|---|---|
| `usage-rules.md` + `usage-rules/*.md` | [`ash-project/usage_rules`](https://github.com/ash-project/usage_rules) consumers | This repo's root |
| `.claude/skills/*/SKILL.md` | skills.sh / Claude Code / Cursor / Copilot | This repo's `.claude/skills/` |
### Option A โ pull into your project via `usage_rules`
Add the dev dep and a `:usage_rules` block to your `mix.exs`:
```elixir
# mix.exs
def project do
[
...,
usage_rules: [
file: "AGENTS.md", # or "CLAUDE.md"
usage_rules: [:guarded_struct], # inline our usage-rules
skills: [
location: ".claude/skills",
package_skills: [:guarded_struct] # pull our SKILL.md files in
]
]
]
end
defp deps do
[{:usage_rules, "~> 1.1", only: [:dev]}]
end
```
Install and sync โ these are the only commands you need:
```sh
mix deps.get # pull :usage_rules
mix usage_rules.sync # generate AGENTS.md + .claude/skills/
mix usage_rules.sync --check # verify in CI nothing has drifted
mix usage_rules.search_docs "atomic" # search package docs for a term
```
`mix usage_rules.sync` reads `:usage_rules` from `mix.exs`, gathers
`usage-rules.md` (and any `usage-rules/*.md`) from every listed dep, writes
the consolidated `AGENTS.md`, and drops one `SKILL.md` per package into
`.claude/skills/`. Re-run after any `mix deps.update`.
Sub-rules are addressable by name in the `usage_rules:` list:
```elixir
usage_rules: [
"guarded_struct:dsl", # just the DSL doc
"guarded_struct:ash", # just the Ash integration
"guarded_struct:derive", # just the derive op reference
"guarded_struct:errors" # just the error-shape contract
]
```
Full list lives in this repo's `usage-rules/` directory.
### Option B โ copy the skills directly
If you don't use `usage_rules`, copy any of these directories into your project's
`.claude/skills/` (or wherever your agent runner looks):
| Skill | When it triggers |
|---|---|
| `guarded-struct` | Any use of the library โ umbrella skill |
| `guarded-struct-dsl` | `field` / `sub_field` / `conditional_field` / `virtual_field` / `dynamic_field` declarations |
| `guarded-struct-derive` | `derives:` strings, `SanitizerDerive.sanitize/2` |
| `guarded-struct-conditional` | `conditional_field` runtime dispatch + error aggregation |
| `guarded-struct-ash` | `extensions: [GuardedStruct.AshResource]`, atomic mode, `Change` wiring |
| `guarded-struct-extensions` | `use GuardedStruct.Derive.Extension`, custom validators / sanitizers |
| `guarded-struct-api` | `builder/1`, `Validate`, `Diff`, `Info`, telemetry |
Each skill is a single `SKILL.md` with YAML frontmatter (`name`, `description`)
followed by markdown. The descriptions are written with concrete trigger
signals (module names, function calls, error atoms) so agents auto-load the
right skill without manual invocation.
### Option C โ read inline
Start with [`usage-rules.md`](./usage-rules.md). It's < 100 lines, links every
sub-topic, and pins down the universal contracts (error shape, generated
module surface, compile-time guarantees) every consumer must know.
</details>
---
## ๐ฃ๏ธ Status & roadmap
| Area | Status |
|---|---|
| `0.1.0` rewrite on Spark | ๐ข Shipped |
| Backward compatibility with `0.0.x` | ๐ข Drop-in โ every 0.0.x API preserved |
| Nested `conditional_field` (closes #7, #8, #25) | ๐ข Shipped |
| Pattern-keyed maps (closes #11) | ๐ข Shipped |
| `virtual_field` / `dynamic_field` (closes #5) | ๐ข Shipped |
| Standalone `Validate` API (closes #2) | ๐ข Shipped |
| Erlang Records (closes #6) | ๐ข Shipped |
| Custom validators via Spark DSL | ๐ข Shipped |
| Ash extension + auto-wire + atomic mode | ๐ข Shipped |
| Test coverage | ๐ข 743+ tests, real Ash integration suite |
| `1.0.0` release | ๐ข Shipped |
Breaking changes will be flagged in the [CHANGELOG](./CHANGELOG.md).
---
## ๐ค Contributing
Issues, PRs, and design discussions are welcome. ๐ฌ
```sh
git clone https://github.com/mishka-group/guarded_struct.git
cd guarded_struct
mix deps.get
mix test
```
Before opening a PR:
- โ
`mix test` โ full suite green (`mix test --max-failures 1` for fail-fast)
- โ
`mix lint` โ `spark.formatter` + `format` both pass
- โ
`mix cheat` โ regenerate DSL cheat sheets if you touched entities
For larger feature work, please open an issue first so we can align on the design.
---
## ๐ Funding & sponsorship
GuardedStruct is open-source software developed by [Mishka Group](https://github.com/mishka-group). If your team or company benefits from this work, please consider supporting continued development:
<div align="center">
[](https://github.com/sponsors/mishka-group)
[](https://www.buymeacoffee.com/mishkagroup)
**โ Donate / sponsor:**
[github.com/sponsors/mishka-group](https://github.com/sponsors/mishka-group) ยท [buymeacoffee.com/mishkagroup](https://www.buymeacoffee.com/mishkagroup)
</div>
Sponsorship directly funds maintenance, new features, and documentation. Thank you. ๐
---
## ๐ License
Apache License 2.0 โ see [`LICENSE`](LICENSE).
Copyright ยฉ [Mishka Group](https://mishka.tools) and contributors.