Project 20: Compile-Time Pricing Policy DSL

Design a restricted Elixir DSL whose pricing rules compile into ordinary functions, reject invalid policy at compile time with file-and-line diagnostics, and return an explanation trace for every price.

Quick Reference

Attribute Value
Difficulty Level 3 - Advanced/Engineer
Suggested Seniority Senior
Time Estimate 16-24 hours
Main Programming Language Elixir
Alternative Programming Languages Erlang
Coolness Level Level 4 - Hardcore Tech Flex
Business Potential Level 3 - Service & Support
Prerequisites Modules, function clauses, structs, protocols, compilation basics
Key Topics Quoted AST, quote/unquote, macro hygiene, module attributes, @before_compile, diagnostics, protocols

1. Learning Objectives

By completing this project, you will:

  1. Treat quoted Elixir syntax as data and inspect only the AST shapes intentionally supported by the DSL.
  2. Use macros sparingly to collect policy declarations at compile time while keeping price evaluation ordinary and testable at runtime.
  3. Accumulate module-local rule definitions, validate overlaps and references, and generate deterministic function clauses in a before-compile hook.
  4. Produce compile errors and warnings tied to caller file and line without evaluating arbitrary user expressions.
  5. Separate integer-money calculation from explanation rendering through stable structs and a protocol.
  6. Test macro expansion, compilation failures, runtime pricing, hygiene, and generated documentation as different contracts.

2. All Theory Needed (Per-Concept Breakdown)

Hygienic Macros and Compile-Time DSL Boundaries

Fundamentals

Elixir macros receive quoted syntax and return quoted syntax that becomes part of the caller’s module. “quote” builds AST, “unquote” injects values or AST, and Macro/Module APIs inspect or accumulate compile-time information. Macros are not ordinary runtime functions and should not be used merely to avoid typing. A useful DSL offers a smaller declarative language, validates rules early, and generates ordinary runtime code whose behavior remains visible. Hygiene prevents many accidental variable collisions, but generated names, aliases, imports, caller environments, and module attributes still require care. The pricing DSL in this project accepts only literal identifiers, integer minor-unit prices, bounded date windows, segment references, and restricted discount forms. It stores normalized declarations as module data, validates them before compilation finishes, and generates runtime functions that return both final price and an explanation trace.

Deep Dive into the concept

Elixir source is represented as AST tuples and literals. A macro call runs while its caller module is compiling. The macro can inspect the caller environment, including module, file, and line, and return code for insertion. It can also register and update module attributes. This power can create elegant APIs, but it can also hide control flow, make errors obscure, lengthen compilation, and couple behavior to compiler details.

Begin with the runtime domain independent of macros. Define policy inputs such as product, customer segment, quantity, and pricing date. Define integer-money PriceResult containing base price, adjustments, final amount, currency, selected rule ids, and explanation steps. Define ordinary functions for date-window membership, tier selection, percentage/basis-point calculations, caps, exclusivity, and ordering. Test these functions before adding DSL syntax. The macro layer should translate declarations into this domain model or generated calls; it should not contain the business algorithm.

Design a deliberately small grammar. Conceptual declarations may include a product with base price/currency, a named segment predicate selected from approved fields, a tier rule, a date window, and a discount referencing product/segment. Do not accept arbitrary function bodies as “conditions,” because evaluating or embedding unrestricted caller AST makes validation, security, and explanation impossible. Match supported AST shapes and reject everything else with a targeted CompileError.

Compile-time data collection commonly uses accumulating module attributes. A “use PricingPolicy” macro can register attributes, import DSL declarations, attach a before-compile callback, and set metadata. Each declaration macro validates literal shape, captures caller file/line, normalizes a RuleDefinition struct or plain compile-time map, and appends it. Do not store runtime-only values such as pids or functions. Keep declaration order explicit because module attribute accumulation order can surprise; sort later by rule id/priority/source location as defined by the language.

The before-compile phase has the complete declaration set. It can validate uniqueness, references, currency consistency, date order, overlapping tiers, mutually exclusive discount groups, missing base prices, and unreachable rules. Errors should name rule ids and both source locations when two declarations conflict. Warnings are suitable for suspicious but valid cases, such as a fully shadowed lower-priority rule, while impossible policy should fail compilation.

Generated code should be boring. One approach embeds validated normalized rule data as escaped literals and generates a small public “price/2” function delegating to a runtime evaluator. Another generates function clauses for product/segment combinations. The data-driven approach often yields clearer diffs and fewer generated clauses; clause generation teaches AST more directly but can inflate code. The learner should implement one and compare the alternative.

Macro hygiene means variables introduced inside quoted code are scoped to the macro context unless intentionally bound. Avoid “var!” unless the DSL explicitly needs caller-variable access; this project does not. Aliases inside generated code should use fully qualified module names or deliberate alias injection. External values inserted into quoted code must be escaped when they are ordinary data. Never call Code.eval_quoted on policy declarations.

Compile-time versus runtime is the essential boundary. Policy structure and static contradictions can be validated at compile time. Customer input, current date, quantities, and database-derived attributes exist at runtime. Do not freeze “today” during compilation. The price function must receive a pricing date or injected clock output. Similarly, compilation should not query production databases or network services; builds must be deterministic and portable.

Explanations are part of correctness. Every rule application returns a step containing rule id, rule type, before amount, delta, after amount, and human-safe reason. Non-applied candidate rules may be included in debug mode with reason such as wrong segment or outside window. A protocol can render PriceResult for terminal, JSON-like maps, or audit text without coupling evaluation to formatting. The protocol teaches polymorphism while the DSL teaches metaprogramming.

Rule ordering and arithmetic require precise policy. Work in integer minor units and basis points or rational definitions with a documented rounding rule. State whether tiers choose one rate or accumulate; whether multiple discounts stack; whether exclusive groups choose highest priority or best price; whether caps apply before or after discounts. Compilation can reject ambiguous equal-priority overlaps, but runtime explanation must still show selected order.

Testing spans layers. Runtime unit tests verify evaluator arithmetic and traces. Macro expansion tests inspect broad generated shape without asserting every private AST detail. Compile-string fixture modules verify good policies and exact diagnostic fragments for bad ones. Hygiene tests use caller variables/aliases with conflicting names. Recompilation tests show deterministic generated behavior. Tests should not depend on global module names that collide when run concurrently; generate unique fixture module names or compile fixture files under controlled settings.

The invariants are: policy declarations use only supported literal grammar; every reference resolves; rule ids are unique; base prices and adjustments share currency; date windows and tiers are valid; ambiguous exclusivity fails compilation; generated runtime functions contain no compiler-environment dependency; arithmetic uses integer money and declared rounding; every final amount has a trace; builds perform no network/database I/O. Failure modes include arbitrary AST evaluation, hygiene leaks, stale accumulating attributes, reverse declaration order, duplicate generated clauses, code-size explosion, compile-time “today,” inaccessible source locations, float rounding, silent rule ambiguity, and tests coupled to exact AST formatting.

How this fits in the project

Macros provide the declaration surface and collect source-aware definitions; Module and Macro APIs validate/generate; ordinary evaluator modules calculate prices; a protocol renders explainable results.

Definitions & key terms

  • AST: Elixir syntax represented as nested tuples, lists, and literals.
  • Macro hygiene: Scoping rules that reduce accidental collisions between generated and caller variables.
  • Caller environment: Compile-time information such as module, aliases, file, and line.
  • Accumulating attribute: Module attribute configured to collect multiple declarations.
  • Before-compile callback: Hook invoked when module definitions are complete but compilation has not finished.
  • Escaping: Converting ordinary values into safe quoted representation.

Mental model diagram

policy module source
  use PricingPolicy
  product/segment/tier/discount declarations
                |
                v
       declaration macros inspect
       supported AST + source metadata
                |
         accumulating attributes
                |
                v
       @before_compile validator
      /            |             \
   errors        warnings      normalized policy
                                  |
                            generated ordinary
                            price/explain functions
                                  |
                     runtime input -> PriceResult
                                        |
                                  rendering protocol

How it works

  1. “use” registers attributes, imports macros, and attaches callback.
  2. Each declaration pattern-matches the allowed AST and captures location.
  3. Literal data is normalized and accumulated.
  4. Before compile, rules are sorted and cross-validated.
  5. Impossible policy raises source-aware compile diagnostics.
  6. Valid data is escaped into generated runtime functions.
  7. Runtime evaluator applies explicit ordering/arithmetic and builds trace.
  8. Protocol implementations render results without changing calculation.

Minimal concrete example

accepted declaration shape:
  product literal-id, currency literal, base integer-minor-units
  discount literal-id, product reference, segment reference,
           integer basis points, literal date window, priority integer

compile validation:
  duplicate rule id -> error at second declaration, cite first location
  unknown segment -> error
  overlapping equal-priority exclusive discounts -> error

runtime result:
  base=10000
  step rule=vip-10 before=10000 delta=-1000 after=9000
  final=9000 currency=BRL

Common misconceptions

  • Macros are not faster runtime functions; they transform code during compilation.
  • Hygiene does not make arbitrary AST evaluation safe or understandable.
  • Compile-time validation cannot know runtime customer state.
  • A compact DSL is not successful if generated behavior is impossible to inspect.
  • Module attributes used during compilation are not a general runtime database.

Check-your-understanding questions

  1. Why build the evaluator before the macros?
  2. Why restrict condition syntax rather than accept arbitrary expressions?
  3. Which checks belong at compile time versus runtime?

Check-your-understanding answers

  1. It keeps business semantics testable as ordinary functions and prevents macros from owning the algorithm.
  2. Restricted grammar enables validation, deterministic generation, safe compilation, and explanations.
  3. Static ids/references/windows/tier overlaps compile early; customer values, date selection, and quantities evaluate at runtime.

Real-world applications

Authorization policies, routing tables, validation schemas, workflow declarations, command registries, telemetry event definitions, and domain configuration languages use the same compile-time pattern.

Where you will apply it

Use it in “use”, declaration macros, attributes, validation, code generation, runtime pricing, diagnostics, and result rendering. It does not overlap P2’s runtime circuit-breaker state, P9’s hot upgrade mechanics, or P3’s data store.

References

Key insight

The best DSL makes invalid structure impossible early while compiling valid declarations into ordinary, inspectable runtime behavior.

Summary

The project teaches AST literacy, macro hygiene, compile callbacks, source diagnostics, protocols, integer arithmetic, and disciplined separation of compile-time structure from runtime data.

Homework/Exercises

  1. Classify ten policy facts as compile-time or runtime.
  2. Design an allowed AST grammar and three rejected shapes.
  3. Compare embedded normalized data with generated function clauses.

Solutions

  1. Rule ids/references/priorities/windows are static; customer, quantity, price date, and external state are runtime.
  2. Accept literal ids/integers/date tuples; reject anonymous functions, remote calls, and arbitrary operators.
  3. Embedded data centralizes evaluator logic and reduces code size; clauses can optimize dispatch but enlarge/generated surface.

3. Project Specification

3.1 What You Will Build

Build a “PricingPolicy” library plus example policy module supporting products, customer segments, quantity tiers, date windows, percentage/basis-point discounts, exclusive groups, caps, and explanation rendering.

Scope boundary: This is a compile-time language-design project, not a checkout service. Do not add a database, LiveView UI, distributed cache, remote rules, arbitrary user expressions, tax engine, currency conversion, or runtime policy editor.

3.2 Functional Requirements

  1. Provide “use” and restricted declaration macros with source locations.
  2. Validate duplicate ids, unknown references, currencies, windows, tiers, priorities, and exclusive overlaps.
  3. Generate ordinary price and policy-introspection functions.
  4. Calculate in integer minor units with documented rounding/order.
  5. Return PriceResult with complete ordered explanation steps.
  6. Render terminal and map forms through a protocol.
  7. Generate readable compile diagnostics and a policy documentation table.

3.3 Non-Functional Requirements

  • Compilation performs no network/database I/O.
  • Identical policy source produces deterministic generated behavior/docs.
  • Unsupported AST fails explicitly; it is never evaluated.
  • Generated code remains inspectable through expansion/abstracted introspection.
  • Runtime pricing has no dependency on Macro.Env.

3.4 Example Usage / Output

$ mix compile
Compiling 8 files
Generated pricing_policy app
policy=Retail2026 products=3 segments=2 rules=7 warnings=0

$ mix pricing.explain Retail2026 SKU-RED --segment vip --quantity 12 --date 2026-07-15
base BRL 100.00 rule=product:SKU-RED
tier BRL -5.00 rule=bulk-10 quantity>=10
discount BRL -9.50 rule=vip-10 basis_points=1000
final BRL 85.50

3.5 Data Formats / Schemas / Protocols

Compile-time definitions include ProductDefinition, SegmentDefinition, TierDefinition, DiscountDefinition, SourceLocation, and ValidationDiagnostic. Runtime types include PricingInput, AdjustmentStep, and PriceResult. The rendering protocol accepts PriceResult, not compiler AST.

3.6 Edge Cases

  • Duplicate ids in separate lines, unknown product/segment, mixed currencies.
  • Empty/reversed date windows, tier gaps/overlaps, equal thresholds.
  • Exclusive rules with equal priority, stacking order, caps below zero.
  • Rounding fractions, negative/zero quantities, unexpected runtime segment.
  • Caller aliases/variables named like generated internals.
  • Module recompilation and two policy modules in one VM.

3.7 Real World Outcome

3.7.1 How to Run

Compile a valid fixture policy, run an explanation command, then compile invalid fixture modules and verify file/line/rule diagnostics. Inspect the generated policy table and rerun tests after changing declaration order.

3.7.2 Golden Path Demo

Three products and seven rules compile. VIP bulk pricing yields the documented trace. Invalid policies fail for unknown segment, duplicate rule, and ambiguous exclusive overlap before runtime.

3.7.3 Exact Terminal Transcript

$ mix test test/pricing_policy_test.exs
24 tests, 0 failures

$ mix test test/compile_diagnostics_test.exs
diagnostic duplicate rule id cites both source locations
diagnostic unknown segment cites discount declaration
diagnostic ambiguous exclusive rules cites group and priority
3 tests, 0 failures

$ mix pricing.explain Retail2026 SKU-RED --segment vip --quantity 12 --date 2026-07-15
final_minor_units=8550 currency=BRL steps=3 status=ok

4. Solution Architecture

4.1 High-Level Design

DSL macros -> normalized definitions -> module attributes
                                         |
                                  before_compile
                                  validation graph
                                   /          \
                             diagnostics     escaped policy
                                                 |
                                         runtime evaluator
                                                 |
                                            PriceResult
                                                 |
                                        explanation protocol

4.2 Key Components

  • “use PricingPolicy” bootstrap macro
  • Restricted product/segment/tier/discount macros
  • Source-aware definition normalizer
  • Cross-definition validator
  • Before-compile generator and introspection docs
  • Ordinary runtime evaluator/math modules
  • PriceResult rendering protocol

4.3 Data Structures

Use compile-time definition maps/structs containing only escapable values, source locations, ordered diagnostics, normalized policy indexes, runtime PricingInput, AdjustmentStep lists, and PriceResult.

4.4 Algorithm Overview

Collect validated declaration shapes, normalize/sort, build reference indexes, run all static checks, raise/warn with caller locations, escape valid policy into generated functions, evaluate ordered rules at runtime, and append a trace for every amount transition.

5. Implementation Guide

5.1 Development Environment Setup

Use supported Elixir/OTP and ExUnit. Create ordinary runtime domain modules first. Keep compile-fixture modules under test support and avoid dynamic atoms from arbitrary input.

5.2 Project Structure

lib/pricing_policy/
  dsl
  definitions
  validator
  compiler
  evaluator
  result
  formatter_protocol
test/fixtures/policies/

5.3 The Core Question You’re Answering

When is compile-time metaprogramming justified, and how do I keep a DSL safe, diagnosable, deterministic, and transparent at runtime?

5.4 Concepts You Must Understand First

Understand quoted AST, macro expansion, hygiene, unquote/escape, caller environment, module attribute registration/accumulation, before-compile callbacks, protocols, integer money, and compile-error testing.

5.5 Questions to Guide Your Design

  1. What smaller language does the DSL define?
  2. Which AST forms are accepted and rejected?
  3. Which rules are statically contradictory?
  4. What ordinary runtime API does compilation produce?
  5. How is arithmetic order explained and tested?
  6. What source metadata makes errors actionable?

5.6 Thinking Exercise

Take a three-line policy and manually sketch its AST at the macro boundary, normalized definition, generated runtime data, and final trace. Then add two exclusive equal-priority discounts and design the exact diagnostic.

5.7 The Interview Questions They’ll Ask

  1. What does an Elixir macro receive and return?
  2. What is macro hygiene, and what problems remain?
  3. When should a normal function be preferred over a macro?
  4. How do accumulating module attributes and before-compile hooks interact?
  5. Why must arbitrary caller AST not be evaluated?
  6. How do you test generated code without coupling to private AST shape?

5.8 Hints in Layers

Hint 1: Implement price arithmetic and traces with plain structs/functions.

Hint 2: Make the first macro accept only literal product declarations.

Hint 3: Accumulate normalized definitions with file/line, then validate all references before generation.

Hint 4: Generate one small function delegating to the evaluator before considering per-rule clauses.

5.9 Books That Will Help

Topic Book Chapter
Elixir metaprogramming “Metaprogramming Elixir” by Chris McCord Ch. 1, The Language of Macros; Ch. 3, Advanced Compile-Time Code Generation
Macros and protocols “Programming Elixir >= 1.6” by Dave Thomas Ch. 20, Macros and Code Evaluation; Ch. 21, Linking Modules: Behaviours and Use
Pricing domain modeling “Domain Modeling Made Functional” by Scott Wlaschin Ch. 6, Domain Integrity and Consistency; Ch. 9, A Complete Example

5.10 Implementation Phases

Phase 1: Foundation (5-7 hours)

Define runtime inputs/results/math/traces, protocol, example tests, and minimal product/segment definitions.

Phase 2: Core Functionality (6-9 hours)

Implement DSL grammar, source capture, attributes, callback validation, generated API, tiers/discounts/exclusivity.

Phase 3: Polish & Edge Cases (5-8 hours)

Add diagnostics, compile fixtures, hygiene tests, generated docs, introspection, deterministic order, and expansion review.

5.11 Key Implementation Decisions

Document grammar, literal restrictions, generated-data versus generated-clause strategy, module attribute order, ids/atoms, arithmetic/rounding, stacking/exclusivity, static versus runtime checks, warnings, and public introspection.

6. Testing Strategy

6.1 Test Categories

  • Runtime evaluator/math/trace unit tests
  • DSL declaration normalization tests
  • Valid fixture compilation and public API tests
  • Invalid fixture diagnostic tests
  • Hygiene/alias collision tests
  • Macro expansion broad-shape tests
  • Deterministic policy docs/introspection tests

6.2 Critical Test Cases

  1. Unsupported arbitrary expression fails without evaluation.
  2. Duplicate id diagnostic cites both definitions.
  3. Unknown references and mixed currencies fail compilation.
  4. Equal-priority exclusive overlap fails.
  5. Runtime date/quantity selects correct ordered rules and trace.
  6. Caller variable/alias names do not collide with generated internals.
  7. Reordered independent declarations produce defined deterministic behavior.
  8. Compilation does not capture current date.

6.3 Test Data

Keep minimal one-rule fixtures, full Retail2026 fixture, one invalid fixture per diagnostic, arithmetic boundaries, overlapping date/tier cases, rounding cases, and hygiene-conflict module names.

6.4 Definition of Done

  • Runtime pricing works independently of macros.
  • DSL accepts only a documented restricted grammar.
  • All definitions preserve source file and line.
  • Cross-rule contradictions fail during compilation.
  • Generated API is small, deterministic, and inspectable.
  • Integer arithmetic and rounding order are documented.
  • Every price has an ordered explanation trace.
  • Compile diagnostics and hygiene have dedicated tests.

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

Macro owns business logic: Runtime behavior is hard to test. Move evaluation to ordinary modules.

Mysterious compile error: Source metadata was discarded. Carry caller location with every definition.

Rules reverse order: Accumulating attributes were assumed ordered. Normalize and sort deliberately.

Build is environment-dependent: Compile phase read clock/network/database. Inject runtime values and keep compilation pure.

Caller collision: Generated names/aliases leaked. Use hygienic variables and qualified modules.

7.2 Debugging Strategies

Inspect one declaration’s quoted form, normalized definition, and generated expansion. Provide a compiler debug mode that prints normalized policy data, never arbitrary evaluated AST. Reproduce diagnostics with minimal fixture modules.

7.3 Performance Traps

Generating a clause for every combination can inflate compile time and BEAM size. Repeated macro expansion, huge escaped data, and runtime protocol consolidation choices also matter. Measure compilation and artifact size before optimizing.

8. Extensions & Challenges

8.1 Beginner Extensions

Add policy documentation output, rule listing, and a trace renderer for Markdown.

8.2 Intermediate Extensions

Add deprecation warnings, compile-time unreachable-rule analysis, multiple currencies without conversion, and generated typespec/docs.

8.3 Advanced Extensions

Build a verified external-data compiler that parses a safe policy format into the same normalized definitions, generate decision tables, and benchmark data-driven versus clause-generated evaluation.

9. Real-World Connections

9.1 Industry Applications

Elixir libraries use macros for schemas, routes, validations, commands, event declarations, and test DSLs. Pricing demonstrates the same techniques while forcing explicit arithmetic and explanation contracts.

Study Ecto.Schema and Phoenix router APIs for ergonomic compile-time declarations, but focus on their public contracts and diagnostics rather than copying internals.

9.3 Interview Relevance

The project shows senior judgment: not merely writing macros, but constraining them, preserving compiler diagnostics, separating phases, testing generated behavior, and explaining when metaprogramming is the wrong tool.

10. Resources

10.1 Essential Reading

10.2 Official Documentation and Talks