https://zarar.dev Zarar's blog 2026-04-27T17:57:11.352454+00:00 zarar hidden python-feedgen ... https://zarar.dev/commit-based-progress-report/ Commit-based Progress Report 2026-03-29T20:20:14.528870+00:00 zarar hidden <p>I had to give someone an update on what's new with the app over the last couple months, what value was added and what, if any, technical improvements were made. Took an agent 2 minutes using a common-sense prompt to produce the below. I asked it to scan <code>git log</code> and based on that produce an update. The value of commit messages was always important, but now it's even more so since they are the ultimate source of truth (along with the code) on what actually was done.</p> <hr /> <h1 id=jump-platform-changes-summary-feb-1-mar-29-2026>Jump Platform — Changes Summary (Feb 1 – Mar 29, 2026)</h1><h2 id=product-features-improvements>Product Features & Improvements</h2><h3 id=payments-checkout>Payments & Checkout</h3><ul> <li><strong>Apple Pay & Google Pay</strong> — Customers can now tap to pay with Apple Pay or Google Pay on their phones, making checkout as fast as buying a coffee.</li> <li><strong>Smarter Email Typo Detection</strong> — If a customer accidentally types "gmial.com" or similar at checkout, a helpful popup suggests the correction before they complete the purchase — fewer missed confirmation emails.</li> <li><strong>Payment Progress Indicator</strong> — During PayPal payments, customers now see a clear step-by-step progress bar so they're never left wondering if their purchase went through.</li> <li><strong>Clearer Payment Errors</strong> — When something goes wrong with a payment, customers see a specific, helpful message instead of a generic error — making it easier to retry or fix the issue.</li> <li><strong>Optimized Payment Processing</strong> — Purchases are now automatically routed to the best payment processor based on currency, meaning lower fees for organizers and faster processing for customers.</li> <li><strong>Redesigned Quantity Selector</strong> — The ticket quantity picker now uses clean, branded +/- buttons that are easier to tap on mobile devices.</li> </ul> <h3 id=tickets-orders>Tickets & Orders</h3><ul> <li><strong>Text Message Order Details</strong> — After purchasing, customers can text themselves a link to their order details — handy when they need quick access on their phone at the door.</li> <li><strong>Shareable Order Link</strong> — Every order now has its own link that customers can open without logging in, making it easy to forward order info to a friend or check details from any device.</li> <li><strong>Invoice Generation</strong> — Customers and organizers can now generate and download a proper invoice for any order — useful for expense reports, accounting, or tax purposes.</li> <li><strong>Automatic Ticket Grouping</strong> — Returning customers who buy tickets across multiple orders will see all their tickets grouped together automatically — no more hunting through separate emails.</li> <li><strong>Group Tickets with Friends</strong> — Customers can share an invite link so friends can join their ticket group, making it simple to coordinate who's sitting where at an event.</li> <li><strong>Badge Printing</strong> — Organizers running conferences or networking events can now print name badges for attendees directly from the platform.</li> <li><strong>Apple Wallet Passes</strong> — Tickets can be saved to Apple Wallet for one-tap access at the event door — no need to dig through emails.</li> <li><strong>Smarter Ticket Swap Notifications</strong> — When a ticket is swapped to a different showtime, an updated confirmation email is sent automatically so customers always have the right details.</li> </ul> <h3 id=events-products>Events & Products</h3><ul> <li><strong>Product Options & Variants</strong> — Organizers selling merchandise, food, or other items can now offer options like size and color, each with its own price — customers see a clean picker and the price updates automatically.</li> <li><strong>Digital Products</strong> — Organizers can sell digital downloads (e.g., recordings, guides) with automatic delivery emails sent right after purchase.</li> <li><strong>Related Event Suggestions</strong> — Event pages can now highlight related shows or products, helping customers discover more of what they'd enjoy.</li> <li><strong>Add-Ons Now Available to All</strong> — Every organizer can now offer add-ons (parking, merch, VIP upgrades) alongside their tickets — previously this was limited to select accounts.</li> <li><strong>Flexible Pricing Display</strong> — Organizers can set special "override" prices that are always visible to customers, useful for early promotions or group deals.</li> </ul> <h3 id=passes-subscriptions>Passes & Subscriptions</h3><ul> <li><strong>Redesigned Passes Experience</strong> — The passes system got a major refresh with clearer management pages, making it easier for organizers to set up and for customers to understand what they're getting.</li> <li><strong>Automated Monthly Payouts</strong> — Organizers offering pass-based products now receive their revenue automatically each month — no manual payout requests needed.</li> </ul> <h3 id=promotions-discounts>Promotions & Discounts</h3><ul> <li><strong>Buy-One-Get-One Deals</strong> — Organizers can now create BOGO promotions targeted to specific events, customer groups, or date ranges — a powerful tool for filling seats.</li> <li><strong>Smart Promotion Targeting</strong> — Promotions can be configured to appear only for the right audience based on rules like past purchases, event type, or time window.</li> <li><strong>One-Click Promo Links</strong> — Organizers can generate a URL that automatically applies a discount when customers click it — perfect for social media posts, emails, or influencer partnerships.</li> <li><strong>QR Codes for Promo Codes</strong> — Each promo code now comes with a downloadable QR code, ideal for printing on flyers, posters, or table cards at events.</li> </ul> <h3 id=marketing-campaigns>Marketing & Campaigns</h3><ul> <li><strong>Email Ticket Buyers Directly</strong> — Organizers can send a beautifully designed email to everyone who bought tickets for a specific event — great for pre-show reminders, post-show thank-yous, or upselling future events.</li> <li><strong>Showtime-Specific Waitlist Alerts</strong> — When a sold-out showtime reopens, only the people who signed up for that exact date and time get notified — no more irrelevant "tickets available!" emails.</li> <li><strong>Saved Audience Filters</strong> — Organizers can save their favorite email audience filters and reuse them for future campaigns, cutting setup time for repeat sends.</li> <li><strong>Campaign Performance Tracking</strong> — Email campaigns now show how many orders they generated, so organizers can see exactly which campaigns are driving ticket sales.</li> <li><strong>Email Health Tool</strong> — A built-in tool that finds and fixes problematic email addresses in the customer database, improving email deliverability and reducing bounces.</li> </ul> <h3 id=checkout-forms>Checkout & Forms</h3><ul> <li><strong>Custom Checkout Questions</strong> — Organizers can add their own questions to checkout (meal preferences, t-shirt sizes, accessibility needs) with a new section header option for organizing longer forms.</li> <li><strong>Smart Fulfillment Fields</strong> — Checkout automatically asks for the right info based on what's being purchased — shipping address for merchandise, no address needed for digital products.</li> </ul> <h3 id=discovery-search-visibility>Discovery & Search Visibility</h3><ul> <li><strong>Better Google Search Results</strong> — Event pages now show up as rich results in Google with the event date, price range, and venue displayed right in the search listing — helping customers find events faster and driving more clicks.</li> <li><strong>Fresher Search Listings</strong> — Search engines now see accurate "last updated" dates for events, meaning upcoming shows get crawled and indexed more frequently.</li> <li><strong>Jump Blvd Landing Page</strong> — A dedicated landing page for Jump Blvd with a custom design and search engine optimization, giving the platform its own public-facing presence.</li> <li><strong>Multi-Tenant Onboarding</strong> — New organizations joining the platform can now set themselves up through a guided onboarding flow without needing manual help from the Jump team.</li> </ul> <h3 id=ai-analytics>AI & Analytics</h3><ul> <li><strong>Soma AI (Rebrand)</strong> — The built-in AI assistant was rebranded to Soma AI with a refreshed look and feel.</li> <li><strong>Step-by-Step AI Guides</strong> — Soma AI can now walk organizers through platform features with interactive, step-by-step tutorials — like having a personal onboarding coach.</li> <li><strong>Historical Sales Analytics</strong> — A new analytics view lets organizers explore their ticket sales history across events and currencies, helping them spot trends and plan future pricing.</li> <li><strong>AI Conversation Monitoring</strong> — An internal tool that helps the Jump team review how organizers interact with Soma AI, enabling continuous improvements to the AI experience.</li> </ul> <h3 id=other-improvements>Other Improvements</h3><ul> <li><strong>Capacity-Aware Comp Tickets</strong> — Complimentary ticket invites now check venue capacity before being issued, so organizers never accidentally oversell a show.</li> <li><strong>Accurate Refund Tracking</strong> — Refundable amounts update in real-time as partial refunds are issued, giving organizers a clear picture of what's left to refund on any order.</li> <li><strong>Refreshed Order Details Page</strong> — The admin order details view was redesigned with a cleaner layout and quick-action dropdown, making order management faster.</li> <li><strong>Updated About Page</strong> — A refreshed company about page with improved content and design.</li> <li><strong>Correct Tax on Absorbed Fees</strong> — Fixed an issue where tax was miscalculated when organizers chose to absorb service fees, ensuring customers are charged the right amount.</li> <li><strong>Multi-Currency Sales Reports</strong> — Fixed sales reports to correctly show all currencies when an event sells in more than one currency.</li> </ul> <hr /> <h2 id=technical-architectural-changes>Technical & Architectural Changes</h2><h3 id=medusa-to-elixir-migration>Medusa-to-Elixir Migration</h3><ul> <li><strong>Cart Operations</strong> — Replaced Medusa HTTP-based cart operations (add/delete line items, recalculate shipping) with native Elixir modules (<code>CartOperations</code>, <code>CartContext</code>, <code>CheckoutService</code>, <code>VariantPriceCalculator</code>), eliminating cross-service HTTP overhead.</li> <li><strong>Discount Application</strong> — Moved discount/promo code application logic from Medusa to Elixir with native Ecto operations.</li> <li><strong>Order Totals Calculation</strong> — Individual order totals (tax, shipping, discounts, gift cards, service fees) are now computed entirely in Elixir, replacing the Medusa dependency.</li> <li><strong>Service Fee Calculation</strong> — Service fee is now derived from <code>unit_price</code> at calculation time rather than a stale cached value, fixing accuracy issues with PWYL and absorbed pricing.</li> <li><strong>Seat Allocations Endpoint</strong> — Migrated the seat allocations API to Elixir to resolve Node.js performance bottlenecks under load.</li> <li><strong>Campaign Stats Sync</strong> — Moved SendGrid campaign stats sync to Elixir with retry logic for transient API failures.</li> <li><strong>Reserved Seating Cleanup</strong> — Migrated the 15-minute reserved seat expiry job from Medusa to an Oban worker.</li> <li><strong>Order Cache Elimination</strong> — Replaced <code>metadata.cache</code> reads with direct Ecto column access on the orders table, removing stale-data risk.</li> <li><strong>Get Order Consolidation</strong> — Replaced <code>Ecommerce.Orders.get_order</code> HTTP calls with a direct <code>Context.Orders.get_order</code> DB query, reducing latency.</li> <li><strong>Cart API Expansion</strong> — The cart API now returns full cart and line item totals computed in Elixir, enabling the frontend to rely on Elixir-calculated values.</li> </ul> <h3 id=paypal-integration-overhaul>PayPal Integration Overhaul</h3><ul> <li><strong>Dead-View PayPal Controller</strong> — Replaced the PayPal LiveView with a traditional Phoenix controller to avoid iframe destruction issues caused by LiveView re-renders.</li> <li><strong>Authorization Status Check</strong> — Cart completion now verifies PayPal authorization status before proceeding, preventing silent payment failures.</li> <li><strong>Apple Pay / Google Pay via PayPal</strong> — Integrated PayPal Advanced Checkout SDK with domain verification, cents-to-dollars conversion, and billing contact handling per PayPal docs.</li> </ul> <h3 id=fulfillment-email-system>Fulfillment & Email System</h3><ul> <li><strong>Product-Level Fulfillment Types</strong> — Added <code>product.fulfillment_type_id</code> with a derivation hierarchy (variant > product > NULL) and a <code>Services.Fulfillment</code> module for resolution logic.</li> <li><strong>Email Orchestration</strong> — Four separate fulfillment email pipelines (add-on codes, digital downloads, admin notifications, merchandise fulfillment) with Swoosh/MJML templates.</li> <li><strong>Async Email Dispatch</strong> — Emails are now sent asynchronously to avoid blocking request processing.</li> </ul> <h3 id=pricing-engine>Pricing Engine</h3><ul> <li><strong>Modifier Pricing</strong> — Extended the pricing system to support combination-based pricing with early bird, group, and override types, calculated through <code>VariantPriceCalculator</code>.</li> <li><strong>Dynamic Refund Computation</strong> — <code>refundable_amount</code> is now computed at query time (<code>total - SUM(issued_refunds)</code>) rather than stored as a mutable column.</li> </ul> <h3 id=database-performance>Database & Performance</h3><ul> <li><strong>Ecto Connection Pooling</strong> — Switched to Ecto-managed connection pooling with tuned pool size for better resource utilization.</li> <li><strong>Race-Safe Token Generation</strong> — Public order tokens use a <code>WHERE public_token IS NULL</code> guard to prevent race conditions during concurrent order processing.</li> <li><strong>Variant Deletion Guard</strong> — Variant combinations referenced by existing orders are now protected from deletion, preventing data integrity issues.</li> </ul> <h3 id=frontend-architecture>Frontend Architecture</h3><ul> <li><strong>Centralized Store Layout</strong> — Removed per-LiveView padding in favor of a single layout-level spacing system (<code>store2</code> layout).</li> <li><strong>Shared UI Components</strong> — Extracted <code>line_item_display</code>, <code>OrderDetailsComponents</code>, <code>ccreator_link</code>, and <code>OrderHelpers</code> to reduce template duplication across cart, payment, and order pages.</li> </ul> <h3 id=observability>Observability</h3><ul> <li><strong>PostHog Event Tracking</strong> — SMS sends from cart completion are tracked as PostHog events for funnel analysis.</li> <li><strong>Email Send Logging</strong> — All outbound emails now log delivery metadata for debugging and deliverability monitoring.</li> <li><strong>LLM Chats Viewer</strong> — Admin LiveView with paginated session list, infinite scroll via IntersectionObserver, and per-message metadata (model, latency, tokens, ratings).</li> </ul> 2026-03-29T20:15:00+00:00 https://zarar.dev/spec-driven-development-from-vibe-coding-to-structured-development/ Spec-Driven Development: From Vibe Coding to Structured Development 2026-02-25T00:48:06.655671+00:00 zarar hidden <p>Note: I currently work with a Payments Engineering team and wrote this as we are introducing spec-driven development into our development workflow.</p> <h2 id=introduction>Introduction</h2><p>If you've used an AI coding tool in the last year, you've probably had the experience: you describe what you want, the AI generates something that looks right, you run it, and... it doesn't quite work. You refine your prompt. The AI fixes one thing and breaks another. Three iterations later you're debugging code you didn't write and don't fully understand.</p> <p>This is the failure mode of what Andrej Karpathy called "vibe coding" and it's become the default way most developers interact with AI. Spec driven development (SDD) is the emerging counter movement. Instead of throwing prompts at an LLM and hoping for the best, you write a structured specification first, then let the AI implement against it.</p> <p>The idea isn't new. We've been writing requirements documents since forever, but the tooling is new. Tools like GitHub's Spec Kit, Amazon's Kiro, and Fission AI's OpenSpec are attempting to formalize this workflow into something repeatable. Whether that formalization helps or hinders depends entirely on what you're building, how you're building it, and the tradeoffs you're willing to make.</p> <p>Our team uses OpenSpec, so most of the practical examples in this post come from that experience. But the principles apply regardless of which tool you pick.</p> <hr /> <h2 id=the-problem-why-just-prompting-breaks-down>The Problem: Why "Just Prompting" Breaks Down</h2><p>The pitch for AI assisted coding is attractive: describe what you want in English and get working code back. And for simple tasks, a helper function, a config change, renaming a module, it works remarkably well. The challenges starts when changes aren't trivial but require edits to multiple files or packages/modules.</p> <p>The core issue is context loss. When you're five prompts deep into a feature, the AI has no persistent memory of the architectural decisions you made in prompt one. It doesn't know you chose a specific idempotency strategy for a reason. It doesn't remember that you explicitly avoided storing raw card data outside the tokenization boundary. Every new prompt starts from a partial view of the world, and the AI fills in the gaps with whatever patterns it's seen most in training data.</p> <p>In payments systems, this produces particularly dangerous failures. Reconciliation logic scattered across three different modules because each prompt generated its own approach. A refund handler that doesn't account for partial captures. Currency conversion applied twice because the AI didn't know about the upstream normalization step. And perhaps most critically in our domain, security flaws: API keys committed to source, missing input validation on transaction amounts, authorization checks that live on the client instead of the server. Studies have found that roughly 45% of AI generated code contains security vulnerabilities. In a payments context, that's more than just a bug but a compliance issue.</p> <p>The other failure is architectural drift. Without a shared plan, each prompt/response cycle makes locally reasonable decisions that are globally incoherent. The AI can't refactor itself out of architectural problems it doesn't understand. You ask it to add retry logic to a payment gateway call and it builds a standalone retry mechanism, unaware that you already have a circuit breaker pattern in your infrastructure layer. Once the codebase reaches a certain size, the context window can only see fragments of it. You end up with a system that processes transactions but that nobody, including the AI, fully understands anymore.</p> <p>This isn't the AI being dumb. It's the natural consequence of building without a map.</p> <hr /> <h2 id=what-spec-driven-development-actually-is>What Spec Driven Development Actually Is</h2><p>At its simplest, spec driven development means: write down what you're building <em>before</em> you write the code, and make that written artifact the thing your AI agent works from.</p> <p>That might sound like waterfall but It's not, or at least, it doesn't have to be. The key differences are timescale and scope. Traditional waterfall specs were project level documents written over weeks and often carved in stone. SDD specs are feature level documents written in minutes and meant to evolve. You're not planning an entire system upfront; you're planning the next meaningful chunk of work in enough detail that an AI can implement it without guessing.</p> <p>A typical SDD workflow looks like this:</p> <ol> <li><strong>Define requirements.</strong> What should this feature do? Who is it for? What are the acceptance criteria? What are the edge cases?</li> <li><strong>Create a technical design.</strong> How should it be implemented? What's the data model? What APIs are involved? What patterns should be followed?</li> <li><strong>Break it into tasks.</strong> What are the discrete, testable units of work? In what order should they be done?</li> <li><strong>Implement.</strong> The AI executes against the task list, one piece at a time, with the full spec as context.</li> </ol> <p>You're not writing all of this yourself. You describe the intent in natural language, and the AI generates the spec artifacts: the proposal, the requirements, the design, the task breakdown. Your job is to review, refine, and correct. You steer and the AI does the heavy lifting. This is what makes the process fast enough to be practical. Writing a 200 line spec by hand for every feature would be painful. Having the AI draft it in 30 seconds and then spending 5 minutes reviewing and adjusting it is a different proposition entirely.</p> <p>The spec becomes a persistent artifact, a "super prompt" that doesn't disappear when your chat session ends. It lives in version control alongside your code. When the AI drifts, you point it back to the spec. When requirements change, you update the spec and regenerate.</p> <p>The fundamental shift is that the specification becomes the source of truth, and code becomes the derived artifact. Traditional documentation describes code that already exists. SDD inverts that relationship. You define the behaviour, constraints, and architecture in the spec, and the AI produces code that conforms to it. The spec isn't something you write after the fact to explain what was built but the input that determines what gets built. Code is the output.</p> <hr /> <h2 id=the-tooling-landscape>The Tooling Landscape</h2><p>Three tools have emerged as the most prominent in this space. Each takes a different philosophical approach.</p> <h3 id=github-spec-kit>GitHub Spec Kit</h3><p>Spec Kit is an open source CLI from GitHub that scaffolds a spec driven workflow into your existing project. It's agent agnostic, working with GitHub Copilot, Claude Code, Gemini CLI, and others. The workflow follows rigid phases driven by slash commands: <code>/speckit.constitution</code> to establish project principles, <code>/speckit.specify</code> to create feature specs, <code>/speckit.plan</code> for a technical plan, <code>/speckit.tasks</code> for work items, and <code>/speckit.implement</code> to execute.</p> <p><strong>Strengths:</strong> Thorough documentation output, the "constitution" concept for project wide principles, works with many agents.</p> <p><strong>Weaknesses:</strong> Heavyweight. Sometimes it get generate a lot of artifacts for simple changes. Rigid phase gates mean you can't easily jump back and forth between planning and implementing.</p> <h3 id=amazon-kiro>Amazon Kiro</h3><p>Kiro is a full IDE (a VS Code fork) with spec driven development baked into the editing experience. The workflow follows a similar shape (requirements → design → tasks → implement) but is tightly integrated with the editor. It generates user stories with acceptance criteria, creates technical design documents, and produces task lists. It also introduces "Hooks," user defined prompts triggered by file changes.</p> <p><strong>Strengths:</strong> Most polished integrated experience. The Hooks system is excellent and something you'd have to configure manually if you decide to do it on your own. No context switching between planning and editing because of the IDE integration.</p> <p><strong>Weaknesses:</strong> You're locked into their IDE and limited to Claude models. Can be overkill for small changes. One developer reported a simple bug fix generating 4 user stories with 16 acceptance criteria. The overhead can be significant.</p> <h3 id=openspec-fission-ai>OpenSpec (Fission AI)</h3><p>OpenSpec is the most lightweight of the three. It's a TypeScript CLI with a fluid, iterative workflow and no rigid phase gates. Where Spec Kit enforces a strict sequence and Kiro wraps everything in an IDE, OpenSpec gets out of your way and lets you move between planning artifacts freely.</p> <p>Its distinguishing philosophy is "brownfield first." While the other tools are optimized for building new things from scratch, OpenSpec is designed to work with existing codebases. Each change produces a "spec delta," a document that captures what's being added, modified, or removed relative to the existing system. Over time, these deltas merge into a living specification that reflects the current state of the system.</p> <p>OpenSpec also handles change history better. Every completed change is archived with its full artifact set: the original proposal, the spec deltas, the design, and the task list. This means you can go back and see not just what changed in the system, but <em>why</em> it changed, what alternatives were considered in the design, and what the original acceptance criteria were. Spec Kit and Kiro generate artifacts during planning but don't have the same structured archive and merge cycle. In OpenSpec, the <code>openspec/changes/archive/</code> directory becomes a chronological record of every significant change to the system, and the <code>openspec/specs/</code> directory is always the merged, current truth. For regulated environments where auditability matters, this distinction is significant.</p> <p><strong>Strengths:</strong> Works with 20+ AI tools including Claude Code, Cursor, Copilot, Windsurf, and many others. The brownfield focus is valuable in our context as most real work is on existing codebases. Fluid workflow lets you update any artifact at any time and you are not forced into a linear way of working. The archive/merge cycle produces both a living spec and an auditable change history.</p> <p><strong>Weaknesses:</strong> Less hand holding in the spec writing process is the trade-off it makes while allowing you to navigate back-and-forth between spec and implementation. The tool is newer and the ecosystem is still growing.</p> <hr /> <h2 id=installing-openspec>Installing OpenSpec</h2><p>OpenSpec requires Node.js 20.19.0 or higher.</p> <p>Install OpenSpec globally:</p> <div class="highlight"><pre><span></span>npm<span class="w"> </span>install<span class="w"> </span>-g<span class="w"> </span>@fission-ai/openspec@latest </pre></div> <p>Then navigate to your project directory and initialize:</p> <div class="highlight"><pre><span></span><span class="nb">cd</span><span class="w"> </span>your-project openspec<span class="w"> </span>init </pre></div> <p>The init process will ask which AI tool you're using and configure the appropriate slash commands or agent instructions for your environment.</p> <p>OpenSpec also works with pnpm, yarn, bun, and nix. See the <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2FFission-AI%2FOpenSpec%2Fblob%2Fmain%2Fdocs%2Finstallation.md">official installation docs</a> for alternative paths.</p> <h3 id=keeping-openspec-updated>Keeping OpenSpec Updated</h3><p>Upgrade the package:</p> <div class="highlight"><pre><span></span>npm<span class="w"> </span>install<span class="w"> </span>-g<span class="w"> </span>@fission-ai/openspec@latest </pre></div> <p>Then refresh agent instructions in each project:</p> <div class="highlight"><pre><span></span>openspec<span class="w"> </span>update </pre></div> <hr /> <h2 id=openspecs-workflow-in-depth>OpenSpec's Workflow in Depth</h2><p>Understanding the full lifecycle of an OpenSpec change is worth the time, because the artifacts it generates serve different roles on the team in different ways.</p> <h3 id=the-core-commands>The Core Commands</h3><p>OpenSpec's workflow is built around the <code>opsx</code> slash commands. Here's the complete set, the ones you interact with the most are bolded:</p> <table> <thead> <tr> <th>Command</th> <th>Purpose</th> </tr> </thead> <tbody> <tr> <td><code>/opsx:onboard</code></td> <td>Guided tutorial through the complete workflow using real code</td> </tr> <tr> <td><strong><code>/opsx:explore</code></strong></td> <td>Think through ideas, investigate problems, clarify requirements before committing to a change</td> </tr> <tr> <td><strong><code>/opsx:new</code></strong></td> <td>Create a new change folder with metadata</td> </tr> <tr> <td><code>/opsx:continue</code></td> <td>Progress a change to its next phase (proposal → design → tasks)</td> </tr> <tr> <td><strong><code>/opsx:ff</code></strong></td> <td>"Fast forward": generate all planning artifacts at once</td> </tr> <tr> <td><strong><code>/opsx:apply</code></strong></td> <td>Implement tasks, writing code and checking off items</td> </tr> <tr> <td><code>/opsx:verify</code></td> <td>Validate that implementation matches the artifacts (completeness, correctness, coherence)</td> </tr> <tr> <td><code>/opsx:sync</code></td> <td>Merge delta specs into main specs without archiving (useful for long running changes)</td> </tr> <tr> <td><strong><code>/opsx:archive</code></strong></td> <td>Archive a completed change, merging delta specs into main specs</td> </tr> <tr> <td><code>/opsx:bulk-archive</code></td> <td>Archive multiple completed changes at once, handling spec conflicts</td> </tr> </tbody> </table> <p>The typical flow is <code>new → ff → apply → archive</code>, but the power of OpenSpec is that you can break out of that sequence at any point. Need to revisit the design after you've started implementing? Just edit <code>design.md</code>. Want to add acceptance criteria while coding? Update the spec delta. There are no phase gates forcing you to "finish" one stage before moving to another.</p> <h3 id=starting-a-change-explore-vs-new>Starting a Change: Explore vs. New</h3><p>One of the first decisions in any OpenSpec workflow is how you enter it. There are two entry points, and choosing the right one makes a real difference in the quality of what comes out the other side.</p> <p><strong><code>/opsx:new</code> is for when you know what you're building.</strong> You have a clear feature in mind, you understand the requirements well enough to describe them, and you're ready to start generating planning artifacts. Maybe you've already discussed this in a planning meeting. Maybe you've built something similar before. Maybe the ticket is well defined and you just need to formalize it. In these cases, <code>/opsx:new add-feature-name</code> followed by <code>/opsx:ff</code> gets you from zero to a full set of planning documents in minutes.</p> <div class="highlight"><pre><span></span>You: /opsx:new add-payment-retry-with-exponential-backoff AI: Created openspec/changes/add-payment-retry-with-exponential-backoff/ Ready to create: proposal You: /opsx:ff AI: Creating all planning artifacts... ✓ proposal.md ✓ specs/ ✓ design.md ✓ tasks.md Ready for implementation! </pre></div> <p>This works well when the problem space is familiar and constraints are understood. You've implemented retry logic before, you know the gateway's behavior, you just need to formalize it.</p> <p><strong><code>/opsx:explore</code> is for when you're still figuring things out.</strong> Maybe you have a vague idea but the requirements aren't clear. Maybe you're investigating a production anomaly and you don't know where the root cause is yet. Maybe a stakeholder said "we need to support instant payments" and you need to figure out what that actually means for your settlement architecture before you can spec anything.</p> <p>Explore opens a broader, open ended conversation with the AI <em>before</em> any change folder is created and before any artifacts are generated. There's no structure imposed, no proposal template to fill out, no pressure to define acceptance criteria. You're just thinking out loud with an assistant that has context on your codebase.</p> <div class="highlight"><pre><span></span>You: /opsx:explore AI: What would you like to explore? You: We&#39;re seeing intermittent failures on our direct debit collections via the pain.008 pathway. Some mandates are being rejected with AM04 (insufficient funds) but the retry logic doesn&#39;t seem to be kicking in correctly. I&#39;m not sure if it&#39;s a status mapping issue, a timing problem with our batch processing window, or something in our mandate lifecycle management. I want to figure out where to focus before committing to a specific fix. AI: Let me help investigate. Based on your codebase, I can see a few potential areas... [Analyzes code paths, suggests profiling approaches, identifies likely bottlenecks, asks clarifying questions] </pre></div> <p>The explore conversation might last five minutes or an hour. It might branch into several tangents before converging on a clear problem statement. That's intentional. You're doing the messy, nonlinear thinking that produces good requirements, and you're doing it before the spec exists rather than discovering gaps during implementation.</p> <p>This is also where existing requirements documents from business analysts become valuable. In payments organizations, BAs often produce detailed feature requirements independently of any SDD tool: field mapping spreadsheets, business rule matrices, sample ISO 20022 payloads (pain.001, pain.008, pacs.008), regulatory constraint documents, and workflow diagrams. These artifacts are the raw material that explore turns into actionable specs.</p> <p>Rather than asking the AI to invent requirements from scratch, you feed it the BA's work:</p> <div class="highlight"><pre><span></span>You: /opsx:explore AI: What would you like to explore? You: Our BA has written up requirements for adding SEPA Instant Credit Transfers. I&#39;m attaching their document which includes the field mappings from our internal format to pacs.008, the business rules for amount limits and BIC validation, and sample XML payloads. I need to understand how this fits into our existing payment orchestration layer and what the technical implications are before we spec the implementation. AI: I&#39;ve reviewed the BA requirements document. Let me walk through the key integration points... [Maps BA requirements against existing codebase, identifies gaps, flags technical decisions that need to be made] </pre></div> <p>The explore phase becomes a bridge between the BA's domain knowledge and the engineering reality of the codebase. The BA doesn't need to know about your GenServer architecture or your Ecto schema conventions. The developer doesn't need to memorize the ISO 20022 payload structure. Explore lets both perspectives converge into a proposal that reflects both business intent and technical feasibility.</p> <p>When you've reached clarity, you transition naturally into the structured workflow:</p> <div class="highlight"><pre><span></span>You: OK, the main complexity is in the real-time settlement confirmation flow. The BA&#39;s field mappings look solid but we need to add timeout handling for the 10 second SCT Inst window. Let&#39;s spec that. You: /opsx:new add-sepa-instant-credit-transfers AI: Created openspec/changes/add-sepa-instant-credit-transfers/ Ready to create: proposal </pre></div> <p>Now the proposal and specs will be grounded in both the BA's requirements and the technical understanding you built during exploration, rather than being generated from a one line prompt.</p> <p><strong>When to use which:</strong></p> <p>Use <code>/opsx:new</code> when you can describe the feature or fix in a sentence and you're confident in the scope. Use <code>/opsx:explore</code> when any of the following are true: you're unsure what the root cause of a problem is, the requirements are ambiguous or underspecified, you need to evaluate multiple approaches before committing to one, or you want to pressure test an idea before investing in formal planning. In practice, we find ourselves using explore more often than we initially expected. The few minutes spent thinking before speccing consistently produce better specs, which in turn produce better code.</p> <h3 id=the-artifact-lifecycle>The Artifact Lifecycle</h3><p>When you run <code>/opsx:new add-idempotent-refunds</code>, OpenSpec creates a change directory:</p> <div class="highlight"><pre><span></span>openspec/changes/add-idempotent-refunds/ ├── .openspec.yaml # Metadata: change name, status, timestamps └── (ready for artifacts) </pre></div> <p>Running <code>/opsx:ff</code> (or stepping through with <code>/opsx:continue</code>) generates the planning artifacts:</p> <div class="highlight"><pre><span></span>openspec/changes/add-idempotent-refunds/ ├── .openspec.yaml ├── proposal.md # Why we&#39;re doing this, what&#39;s changing, scope ├── specs/ # Requirements and scenarios (the spec delta) │ └── refunds/ │ └── spec.md # Functional requirements with ADDED/MODIFIED/REMOVED markers ├── design.md # Technical approach, data model, component structure └── tasks.md # Ordered implementation checklist </pre></div> <p>Each of these artifacts has a specific purpose and a specific audience. Let's look at what goes into them.</p> <p><strong>proposal.md</strong> is the "why" document. It describes the motivation for the change, the scope of what's included and excluded, and any constraints or dependencies. This is the document you'd share in a planning meeting or attach to a ticket. It answers the question: "Why are we doing this, and what does 'done' look like at a high level?" For a refunds feature, this might capture that the driver is duplicate refund incidents costing the business money, that the scope includes full and partial refunds but excludes chargebacks, and that the constraint is backwards compatibility with the existing refund API contract.</p> <p><strong>specs/</strong> contains the spec delta, the functional requirements for this specific change. Requirements are marked as <code>ADDED</code>, <code>MODIFIED</code>, or <code>REMOVED</code> relative to the current system. Each requirement uses structured language ("The system SHALL...") with clear acceptance criteria and scenarios. This is where edge cases live. This is where you define what happens when a refund is submitted with the same idempotency key as a previous request, what the system does when the gateway returns a timeout mid refund, or how partial refunds interact with the original transaction's settlement status.</p> <p><strong>design.md</strong> is the technical blueprint. It covers the data model, API contracts, component architecture, sequence flows, and any technology choices specific to this feature. For the refunds example, it's where you'd document the idempotency key storage strategy, the state machine transitions for refund lifecycle, and the gateway adapter interface for multi acquirer support.</p> <p><strong>tasks.md</strong> breaks the work into discrete, ordered implementation steps. Each task is small enough to verify independently, ideally something that can be implemented in under 30 minutes. Tasks have clear completion criteria so both the developer and the AI know when they're done.</p> <h3 id=what-happens-at-archive>What Happens at Archive</h3><p>When all tasks are complete and verified, <code>/opsx:archive</code> does something important: it merges the spec deltas from the change back into the main <code>openspec/specs/</code> directory. The change folder moves to <code>openspec/changes/archive/</code>, preserving the history. The main specs now reflect the updated state of the system.</p> <p>This is the mechanism that turns specs into a living document. After a dozen features have been built and archived, <code>openspec/specs/</code> contains a comprehensive, up to date description of what the system does. Not what it was designed to do originally, but what it actually does right now.</p> <hr /> <h2 id=who-benefits-sdd-across-roles>Who Benefits: SDD Across Roles</h2><p>One of the underappreciated aspects of spec driven development is that the artifacts aren't just for the developer writing the code. They create value across every role that touches the project.</p> <h3 id=for-developers>For Developers</h3><p>The immediate benefit is implementation quality. Instead of translating a vague Jira ticket into code via a series of increasingly frustrated prompts, you're working from a spec that already captures requirements, edge cases, and technical decisions. The AI produces better code because it has better context. You spend less time debugging and reworking because misunderstandings surface during spec review, not during code review.</p> <p>The longer term benefit is onboarding and maintenance. When you come back to a feature six months later, or when a new developer joins the team, the spec explains not just what the code does but <em>why</em> it was built that way. The proposal captures the business motivation. The design doc captures the technical rationale. The spec captures the behavioral contract.</p> <h3 id=for-business-analysts-and-product-managers>For Business Analysts and Product Managers</h3><p>The proposal and spec artifacts are written in structured natural language, not code. A BA or PM can read <code>proposal.md</code> and immediately understand the scope, motivation, and acceptance criteria for a change without needing to parse a pull request.</p> <p>More importantly, they can contribute to these documents. If the spec says "The system SHALL retry failed direct debit collections up to 3 times" and the BA knows the scheme rules mandate a maximum of 2 retries with specific interval requirements, they can flag that in the spec before any code is written. The spec becomes a shared contract between product and engineering, reviewable by both sides.</p> <p>BAs in payments organizations often produce detailed requirements documents that exist outside of any development tool: field mapping spreadsheets between internal formats and ISO 20022 messages, business rule matrices for transaction routing, sample payloads for pain.001 or pacs.008 messages, regulatory constraint documents, and scheme specific validation rules. These documents don't need to be rewritten into OpenSpec format. Instead, they serve as input to the <code>/opsx:explore</code> conversation and as reference material that the proposal and specs can point to. The spec might say "Field mappings follow the BA's pain.008 mapping document (see docs/ba-requirements/sepa-dd-field-mappings.xlsx)" rather than duplicating that content. OpenSpec captures the engineering requirements; the BA's documents capture the domain requirements. The two reference each other.</p> <p>For teams practicing any kind of requirements analysis, the spec delta format (ADDED/MODIFIED/REMOVED) maps naturally to how BAs think about change impact. You can see at a glance exactly what existing behavior is changing and what's new.</p> <h3 id=for-qa-engineers>For QA Engineers</h3><p>The specs are essentially test plans waiting to happen. Each requirement with its acceptance criteria maps directly to test cases. "WHEN a refund is submitted with an idempotency key matching a previously completed refund, THEN the system SHALL return the original refund response without processing a duplicate" is a test case in all but name.</p> <p>QA can review specs before implementation begins, catching gaps in test coverage at the cheapest possible point in the development cycle. In payments, where edge cases around timeouts, partial failures, and concurrent operations are where bugs hide, having QA eyes on the spec early is especially valuable. They can also use specs to verify completeness: does the implementation actually cover every scenario in the spec? OpenSpec's <code>/opsx:verify</code> command automates part of this check, but human QA review of the spec itself is where the real value lies.</p> <h3 id=for-tech-leads-and-principal-engineers>For Tech Leads and Principal Engineers</h3><p>The design document is where architectural oversight happens. A principal can review <code>design.md</code> to ensure the proposed approach fits the system's overall architecture, without needing to wait for a code review to discover that someone introduced a new database table that duplicates an existing one, or bypassed the payment gateway abstraction layer by calling the acquirer API directly.</p> <p>The proposal document is equally valuable at this level. It provides enough context to make prioritization decisions, estimate impact on downstream systems like settlement and reconciliation, and flag dependencies before work begins.</p> <p>For organizations running architecture review boards or design review processes, OpenSpec artifacts slot directly into those workflows. The artifacts are markdown in version control, which means they can be reviewed through the same pull request process as code.</p> <h3 id=for-the-whole-team>For the Whole Team</h3><p>The <code>openspec/specs/</code> directory, the living spec that accumulates as changes are archived, becomes something like institutional memory for the project. It captures not just the current state of the system but the evolution of requirements over time. New team members can browse the specs to understand the system. Archived changes provide an audit trail of what changed, when, and why.</p> <p>This is especially valuable for distributed teams where not everyone is in every meeting. The spec is always available, always current, and always in the repo.</p> <hr /> <h2 id=bridging-ba-requirements-and-engineering-specs>Bridging BA Requirements and Engineering Specs</h2><p>In most payments organizations, business analysts produce detailed requirements documents long before any developer opens an IDE. These documents are the product of weeks of domain analysis: field mapping spreadsheets that map internal data structures to ISO 20022 message formats like pain.001 or pacs.008, business rule matrices that define transaction routing logic, validation rules derived from scheme specifications, sample XML payloads, regulatory constraint summaries, and workflow diagrams for processes like mandate lifecycle management or chargeback dispute flows.</p> <p>These artifacts are enormously valuable. They represent concentrated domain knowledge that no AI model has in its training data. But they exist outside of any development tool, usually in Confluence pages, shared drives, or Excel files. The question is: where do they plug into the SDD workflow?</p> <p>The answer is <code>/opsx:explore</code>.</p> <p>Rather than asking the AI to invent payment domain requirements from scratch (which it will get wrong in subtle, dangerous ways), you feed the BA's existing documents into the explore conversation. The AI reads the BA's field mappings, business rules, and sample payloads, then maps them against your actual codebase. The explore conversation becomes a structured dialogue about feasibility: which business rules can be implemented directly, which ones require architectural changes, where the BA's requirements conflict with existing behavior, and what edge cases the BA may not have considered from a technical perspective.</p> <div class="highlight"><pre><span></span>You: /opsx:explore AI: What would you like to explore? You: The BA team has finalized requirements for adding SEPA Direct Debit collections. I&#39;m attaching their document which includes the pain.008 field mappings from our internal mandate format, the business rules for collection timing windows, the R-transaction handling matrix (AM04, MS02, MD01 etc.), and sample XML for both first and recurring collections. I need to understand what this means for our payment orchestration layer before we plan the implementation. AI: I&#39;ve reviewed the BA&#39;s requirements. Let me walk through the integration points with your existing codebase... Your mandate schema already covers most of the creditor reference fields, but the BA&#39;s mapping requires three additional fields for the CreditorSchemeIdentification that aren&#39;t in your current model... The R-transaction handling matrix defines 12 reason codes but your existing rejection handler only supports 4 generic rejection states. This will need a more granular status model... </pre></div> <p>This conversation might reveal that 70% of the BA's requirements map cleanly to existing patterns and 30% require new design decisions. Those design decisions then flow into the proposal and spec with full context, rather than being invented by the AI from a one sentence prompt.</p> <p>The key principle: <strong>the BA's documents are input to the spec, not replaced by it.</strong> The OpenSpec proposal can reference them directly ("Field mappings follow the BA's pain.008 mapping document, see docs/ba-requirements/sepa-dd-field-mappings.xlsx"). The spec captures the engineering interpretation of business requirements, while the BA's artifacts remain the authoritative source for domain rules. The two complement each other.</p> <p>For teams with a strong BA function, this workflow turns explore into the most valuable step in the entire process. It's where domain expertise meets technical reality, and where misunderstandings between product and engineering get caught before they become expensive.</p> <hr /> <h2 id=beyond-epics-and-user-stories>Beyond Epics and User Stories</h2><p>For years, the standard way to decompose work in software organizations has been the Agile hierarchy: Epics break into Features, Features break into User Stories, User Stories break into Tasks. Each layer adds structure, and each layer adds overhead. Grooming sessions to refine stories. Estimation ceremonies to assign points. Sprint planning to negotiate what fits. Story splitting when something is "too big." Acceptance criteria written in Given/When/Then format.</p> <p>This process was designed for a world where humans wrote every line of code, and work needed to be decomposed into pieces small enough for one developer to complete in a sprint. The granularity served a coordination function: if three developers are working on the same feature in parallel, you need clearly bounded units of work to avoid stepping on each other.</p> <p>With AI agents handling the bulk of code generation, developers now work in significantly larger chunks. A feature that would have been split into 8 user stories with 24 tasks can be described as a single spec and implemented in one session. The AI doesn't need two week sprints to context switch between stories. It doesn't need story points to estimate effort. It doesn't care whether a unit of work is a 3 or a 5. It needs a clear description of what to build and enough context to build it correctly.</p> <p>The overhead of the old hierarchy was always significant. Ceremonies consume 15-30% of a team's time. The BA writes detailed requirements and translates them into epics and stories. The tech lead estimates them. The developer re-interprets them during implementation. Each translation step is an opportunity for information loss.</p> <p>SDD collapses this. Instead of an Epic with 5 Features containing 20 User Stories containing 60 Tasks, you have a proposal that captures the business intent, a spec that defines the requirements, a design that describes the technical approach, and a task list that the AI executes against. The entire chain from "why are we doing this" to "what code gets written" lives in one change folder, reviewable as a single unit. There's no translation loss and the spec is the shared artifact that all of those roles read, review, and contribute to.</p> <p>This doesn't mean you abandon planning. It means the unit of planning shifts from "what can one developer finish in two days" to "what is the next coherent change to the system." That change might be small (fix a settlement rounding error) or large (add multi currency support). The spec scales to fit either. And because the AI generates the task breakdown from the spec, you don't need to spend planning meetings debating granularity. The tasks exist to guide the AI's implementation, not to fill a sprint board.</p> <p>SDD offers a natural landing spot for a switch to AI-first development. You keep the parts that matter (clear requirements, technical review, incremental delivery) and shed the parts that were only ever coordination scaffolding.</p> <hr /> <h2 id=a-practical-workflow-adding-multi-currency-settlement-to-a-payment-engine>A Practical Workflow: Adding Multi Currency Settlement to a Payment Engine</h2><p>Let's walk through a concrete example using OpenSpec to add multi currency settlement support to a payment processing platform.</p> <p><strong>Step 1: Explore (optional but useful)</strong></p> <p>If the requirements aren't fully clear, or if you have BA created documents that need to be digested before planning, start with exploration:</p> <div class="highlight"><pre><span></span>You: /opsx:explore AI: What would you like to explore? You: We need to support settlement in multiple currencies. Right now everything settles in the merchant&#39;s home currency, but we&#39;re onboarding merchants in the EU who need EUR settlement even when the cardholder pays in GBP. I&#39;ve attached the BA&#39;s requirements document which includes the field mappings, currency pair rules, and the regulatory constraints for cross border settlement. I need to understand how this fits into our existing reconciliation pipeline and whether our current batch architecture can handle currency grouping. AI: I&#39;ve reviewed the BA requirements document. Let me map these against your existing settlement pipeline... [Analyzes BA requirements against codebase, identifies integration points, flags technical decisions needed] </pre></div> <p>This step is for thinking, not committing. No change folder is created yet.</p> <p><strong>Step 2: Create the change</strong></p> <div class="highlight"><pre><span></span>You: /opsx:new add-multi-currency-settlement AI: Created openspec/changes/add-multi-currency-settlement/ Ready to create: proposal </pre></div> <p><strong>Step 3: Generate planning artifacts</strong></p> <p>If you have a clear picture of what to build, fast forward through all planning stages:</p> <div class="highlight"><pre><span></span>You: /opsx:ff AI: Creating all planning artifacts... ✓ proposal.md ✓ specs/ ✓ design.md ✓ tasks.md Ready for implementation! </pre></div> <p>Alternatively, use <code>/opsx:continue</code> to step through one phase at a time, reviewing and refining each artifact before moving to the next.</p> <p><strong>Step 4: Review and refine the artifacts</strong></p> <p>This is the most important step. Read the generated proposal, specs, and design. Push back on anything that doesn't match your intent:</p> <div class="highlight"><pre><span></span>You: The design proposes fetching FX rates at settlement time. We need to lock the rate at authorization and store it on the transaction record. Also the spec is missing the requirement that settlement files must include both the original transaction currency and the settlement currency. AI: I&#39;ll update design.md to lock FX rates at authorization and add the dual currency settlement file requirement to the spec. [Updates artifacts] </pre></div> <p><strong>Step 5: Implement</strong></p> <div class="highlight"><pre><span></span>You: /opsx:apply AI: Working through tasks... ✓ 1.1 Add fx_rate and settlement_currency to transactions schema ✓ 1.2 Create FX rate locking module with gateway adapter ✓ 1.3 Update authorization flow to capture and persist rate ✓ 2.1 Modify settlement batch builder for multi currency grouping ✓ 2.2 Update reconciliation to match on settlement currency ✓ 2.3 Add settlement file format with dual currency columns All tasks complete! </pre></div> <p><strong>Step 6: Verify</strong></p> <div class="highlight"><pre><span></span>You: /opsx:verify AI: Checking implementation against artifacts... ✓ All requirements covered ✓ Design patterns followed ✓ All tasks marked complete ⚠ Spec mentions &quot;handle rate expiration gracefully&quot; but current implementation raises an unhandled error when the cached rate is older than 30 minutes. Consider a re-fetch with merchant notification. </pre></div> <p><strong>Step 7: Archive</strong></p> <div class="highlight"><pre><span></span>You: /opsx:archive AI: ✓ Merged specs into openspec/specs/ ✓ Archived to openspec/changes/archive/2026-02-24-add-multi-currency-settlement/ Specs updated. Ready for the next feature. </pre></div> <h3 id=handling-interrupts-and-stopping-mid-implementation>Handling Interrupts and Stopping Mid Implementation</h3><p>Real work isn't linear. OpenSpec handles context switches gracefully because your plan lives in the artifacts, not in the AI's memory.</p> <p>One important detail: during <code>/opsx:apply</code>, you can stop the AI at any point while monitoring its output. If you see it heading in the wrong direction on task 1.3, you stop it, correct course, and resume. The task list in <code>tasks.md</code> tracks what's been completed, so nothing about your plan is lost. This is fundamentally different from vibe coding, where stopping mid stream means losing context and hoping you can reconstruct where you were.</p> <p>The same principle applies to bigger interrupts. You can pause an entire feature to handle something urgent:</p> <div class="highlight"><pre><span></span>You: I&#39;m working on multi-currency-settlement but we have an urgent issue: the nightly batch is double-settling some transactions. You: /opsx:new fix-duplicate-settlement-batch AI: Created openspec/changes/fix-duplicate-settlement-batch/ You: /opsx:ff [Plans the fix] You: /opsx:apply [Implements the fix] You: /opsx:archive ✓ Archived fix-duplicate-settlement-batch You: Let&#39;s get back to multi currency settlement. You: /opsx:apply add-multi-currency-settlement AI: Resuming add-multi-currency-settlement... Picking up at task 2.2: Update reconciliation to match on settlement currency... </pre></div> <p>The multi currency feature picks up exactly where it left off. The artifacts held the plan while you were away, whether that was five minutes or five days.</p> <hr /> <h2 id=when-not-to-use-spec-driven-development>When NOT to Use Spec Driven Development</h2><p>SDD is not appropriate in all cases. Here are some cases where the overhead isn't worth it:</p> <p><strong>Quick bug fixes.</strong> If you know exactly what's wrong and the fix is a one line change to a gateway timeout value, writing a spec is like filing a building permit to hang a picture frame. Just fix it.</p> <p><strong>Exploratory prototyping.</strong> When you're trying to figure out <em>what</em> to build, not how to build it, specs slow you down. Vibe coding is genuinely great for rapid exploration. If you're prototyping a new merchant dashboard layout to see what feels right, just build it iteratively.</p> <p><strong>Highly visual or interactive work.</strong> SDD tools are text based. If your feature is primarily about UI layout, animation, or interaction design, you'll spend more time describing the visual result in markdown than you'd spend just building it with visual feedback (though pairing SDD with TideWave can work wonders for UI work).</p> <p><strong>Trivial features.</strong> Updating an error message string, renaming a config key, bumping a dependency version. These don't need a spec. Use your judgment about the complexity threshold.</p> <p><strong>Rapidly changing requirements.</strong> If you're in a phase where the payment scheme keeps revising the spec and requirements shift weekly, maintaining your own specs becomes overhead that fights against your pace. Get to stability first, then spec the features that need to stick.</p> <p>The general rule: <strong>if you can hold the entire change in your head and verify it by looking at it, you probably don't need a spec.</strong> If the change involves multiple files, multiple concerns, or behavior you can't verify visually, a spec starts paying for itself.</p> <hr /> <h2 id=what-to-watch-out-for>What to Watch Out For</h2><p>Having used these tools and studied the experiences of others, here are the traps:</p> <p><strong>Spec bloat.</strong> The AI loves to generate exhaustive specifications. A feature that would take you 30 minutes to implement can produce 800+ lines of markdown. You have to be disciplined about trimming specs to what's actually useful. If you're not reading the spec carefully, it's worse than not having one because you'll have false confidence that edge cases are covered when they're not.</p> <p><strong>The waterfall trap.</strong> SDD can slide into big design up front if you're not careful and start bundling many features into one spec. If changing the spec feels expensive or bureaucratic, you've over formalized. OpenSpec's fluid workflow helps here since there are no phase gates, but you still need the discipline to keep specs lightweight enough to throw away and rewrite if you find yourself going down the wrong path.</p> <p><strong>Spec drift.</strong> The spec says one thing; the code does another. This happens when you make implementation fixes outside the spec workflow. Either update the spec when you deviate, or accept that the spec is aspirational rather than authoritative. OpenSpec's <code>/opsx:sync</code> command can help keep specs aligned during long running changes.</p> <p><strong>The AI ignores its own spec.</strong> This is a real and documented problem. Context windows are larger, but that doesn't mean the AI attends to everything in them equally. People have reported that AI agents generate code that contradicts the spec they just wrote, creating duplicate classes, ignoring constraints, or implementing patterns the spec explicitly avoided. The <code>/opsx:verify</code> step exists specifically to catch this.</p> <p><strong>Review fatigue.</strong> SDD adds a new category of artifact to review. You're now reviewing specs AND code. If your team doesn't value spec review as highly as code review, specs become rubber stamped documents that provide an illusion of rigour.</p> <p><strong>Over application to small changes.</strong> The tooling doesn't scale down well. Applying the full SDD workflow to a minor feature creates overhead that dwarfs the implementation time. You need a personal threshold for when to spec and when to just build.</p> <hr /> <h2 id=the-waterfall-question>The Waterfall Question</h2><p>Every discussion of SDD eventually arrives at the same question: isn't this just waterfall with better marketing?</p> <p>The comparison is fair to raise and unfair to leave unexamined. Traditional waterfall failed because of long feedback loops: months of design, months of implementation, and discovery at the end that the design didn't match reality. The feedback cycle was measured in quarters.</p> <p>SDD, practiced well, has feedback cycles measured in minutes to hours. You write a spec for a single feature, not an entire system. You review the generated design before implementation starts. You implement in small, verifiable tasks. And critically, changing the spec and regenerating is cheap. The whole point is that code is a derived artifact you can throw away and recreate.</p> <p>SDD can slide into waterfall like rigidity if you treat specs as immutable, if the spec writing phase becomes its own bottleneck, or if you use SDD as a substitute for iterative discovery. As Gojko Adzic observed, the movement builds on solid intent-first ideas but could reintroduce rigidity if practitioners aren't thoughtful about it.</p> <p>The Thoughtworks perspective captures the nuance well: the problems of vibe coding come from being too fast, spontaneous, and haphazard, while the problems of waterfall come from being too slow, rigid, and disconnected from reality. SDD, when practiced well, occupies the middle ground. It provides a mechanism for shorter and more effective feedback loops than either extreme.</p> <p>The honest answer is that SDD sits on a spectrum. At one end, you have "spec as lightweight sketch," a quick outline that gives the AI direction without constraining it. At the other end, you have "spec as source of truth," a comprehensive document that the code must conform to. OpenSpec's fluid approach leans toward the lighter end of that spectrum, which is why it appeals to teams who want discipline without ceremony.</p> <hr /> <h2 id=pros-and-cons>Pros and Cons</h2><h3 id=what-sdd-gives-you>What SDD Gives You</h3><p><strong>Reduced rework.</strong> Catching misunderstandings at the spec level is dramatically cheaper than catching them in code. When a BA's field mapping is wrong, you want to discover that while reviewing a proposal, not while debugging a failed settlement file at 2 AM.</p> <p><strong>Persistent context.</strong> Specs survive session boundaries, tool switches, and team changes. Six months from now, when someone asks why the FX rate locking works the way it does, the spec and its proposal explain both the what and the why.</p> <p><strong>Reviewable intent across roles.</strong> You can review a spec without reading any code. Product managers, BAs, QA, and principals can participate in spec review and catch requirement gaps before implementation begins. In a payments context, this means compliance can review the spec for regulatory alignment without needing to read Elixir.</p> <h3 id=what-sdd-costs-you>What SDD Costs You</h3><p><strong>Time upfront.</strong> Writing and reviewing specs takes time that vibe coding doesn't require. For simple tasks, this overhead is pure cost with minimal benefit.</p> <p><strong>False precision.</strong> Detailed specs can create an illusion of completeness. Just because the spec covers edge cases on paper doesn't mean the AI will implement them correctly. You still need to test.</p> <p><strong>Tool immaturity.</strong> These tools are all early stage. Expect rough edges, breaking changes, and workflow gaps. The ecosystem is moving fast, which means today's best practices may be obsolete in six months.</p> <hr /> <h2 id=where-this-is-heading>Where This Is Heading</h2><p>Spec driven development is less than a year old as a named practice, and the tooling is evolving fast. The fundamental insight, that AI agents produce better code when given structured intent rather than ad hoc prompts, seems durable even if the specific tools don't survive.</p> <p>What's interesting is the convergence. BDD (Behavior Driven Development), TDD (Test Driven Development), and now SDD all share the same DNA: define the desired behavior before writing the implementation. SDD is that idea adapted for a world where the implementer is an AI agent rather than a human developer.</p> <p>The open question is whether specs will remain the domain of dedicated tools, or whether this discipline gets absorbed into the AI coding tools themselves. We're already seeing Cursor, Claude Code, and Copilot add planning and multi step reasoning capabilities that accomplish some of what SDD tools do, without the explicit spec writing step.</p> <p>For now, the practical takeaway is simple: if you're doing anything more complex than a quick prototype with AI coding tools, some form of structured planning, whether you call it SDD or just "thinking before prompting," will produce better results than vibing your way through it. The tools can help enforce that discipline, but the discipline itself is what matters.</p> <p>The spec isn't the point. The thinking is.</p> 2026-02-25T00:46:00+00:00 https://zarar.dev/podcast-new-age-development-workflows/ Podcast: New Age Development Workflows 2026-01-15T13:43:56.472609+00:00 zarar hidden <p>With Cheezy and Csaba. Topics include:</p> <ul> <li>Importance of ancillary tools built on top of coding agents</li> <li>Review of Stride requirements system so you don't waste your tokens</li> <li>Does Programming Language matter? Survey says...</li> <li>What exactly are Product Managers doing?</li> <li>Code Review and AI Trust - this won't just won't die</li> <li>How important are Software Engineering principles?</li> </ul> <iframe width="560" height="315" src="proxy.php?url=https%3A%2F%2Fwww.youtube.com%2Fembed%2FY5TLQpYXEFs%3Fsi%3DPS7OHZRnygBi8Gsw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> 2026-01-15T13:43:56.472257+00:00 https://zarar.dev/its-a-great-time-to-be-a-software-engineer/ It's a Great Time to be a Software Engineer 2026-01-07T14:49:12.572487+00:00 zarar hidden <p>Here are some thoughts on AI development based on my experience of the last two years. As with any list, these are in no particular order.</p> <ol> <li><p>Get excited. AI is only coming for your job if you treat it as an optional part of your job. It's here to help you become a better and more efficient software engineer. Embrace it wholeheartedly just like you embraced IDEs in favour of text editors. Using AI doesn't make you a lesser programmer and not using it doesn't make you special in any way. In fact, not using it or resisting it makes you look out-of-touch. This is what you have been waiting for to love your job again, and it just might remind you that you got into this business because it feels great to create things, not necessarily code things.</p> </li> <li><p>Most code (upwards of 80%) should be AI generated at this point. If it's not, there is something inherently flawed about your workflow. Just put your pride aside, and acknowledge that AI is a better programmer than you. Your coding skills are now worth little, but your software engineering skills are worth a lot more. Invest in the latter, don't cling on to the former. AI code is still "your" code so you can take the same pride in it as you did before. You just learned how to type faster. A lot faster!</p> </li> <li><p>SRP, DRY, SOLID and clean design/code should be the focus of the programmer. Guiding AIs to get these right requires understanding the business context in which the software is being used, which AI doesn't know. How a feature is expected to change in the future, and what trade-offs need to be made is something you need to be an expert at. Do I create a new module? Is this method named appropriately? Is it taking too many parameters? Am I violating Demeter's Law? Is this file getting too big? Should I separate these two concerns? What would make this more reusable? These are the decisions you should be spending time on. This requires understanding the product more than you needed to in the past. You're not only a Software Engineer, you're a Product Engineer, and that requires a deep understanding of something you may have ignored in the past.</p> </li> <li><p>Context management (or engineering) is where efficiencies are to be gained. If you find yourself repeating things to a forgetful AI, then that's a problem to be solved. Simple solutions include <a href="proxy.php?url=https%3A%2F%2Fcode.claude.com%2Fdocs%2Fen%2Fskills">Claude Skills</a> and more sophisticated ones include using <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2Fsteveyegge%2Fbeads">Beads</a>. Your workflow should be constantly "saving" things to memory to make you more efficient. Sometimes I find myself frustrated by having to remind Claude that it needs to "do X first when it's doing Y" - those rules should be codified. Don't treat AGENTS.md or any other instruction file as a static document or it'll waste your time. How to manage your own context (and your team's) is something to dedicate time to. If you work in a large company, this is an especially interesting challenge as you have to balance alignment and autonomy, hard rule and guidelines, etc.</p> </li> <li><p>Everyone should read a book where you <a href="proxy.php?url=https%3A%2F%2Fwww.goodreads.com%2Fbook%2Fshow%2F209234015-build-a-large-language-model">build an LLM from scratch</a>. It's going to be painful and, like me, you're probably going to have to re-read chapters just to get it through your head (I did, many times), but when it does, you'll be better off for it. Though chances are you'll never develop your own LLM and probably use a frontier model most of the time, it helps knowing how things are working underneath the hood. You'll need to tweak model parameters at some point in your career, and having this foundational knowledge will be the difference between winging it and knowing what you're doing.</p> </li> <li><p>Code review is the new bottleneck. The good news is that we already have tools popping up that make this easier (e.g., <a href="proxy.php?url=https%3A%2F%2Fwww.coderabbit.ai%2F">Code Rabbit</a>). For reviewing code locally, multi-agent workflows work great. Having a separate agent contextualized to reviewing code for correctness, security etc. with rules and guidelines are easy to implement, e.g. <code>claude-code review --aspect &quot;correctness&quot; src/ &gt; /tmp/review_correctness.md</code>. If you're not using multi-agent workflows, this is an easy place to start. Here's a couple other candidates: 1) an agent dedicated to providing good commit messages based on <code>git diff</code>, 2) test refactoring agent which gets invoked to clean up tests; shoving test clean up rules into the "development" context may be too much, so having a separate focused agent will work better.</p> </li> <li><p>There is no excuse not to have clean code. Refactoring is cheap, writing tests is cheaper. If you have code that's not clean, generate higher-level tests for it, and then ask the agent to refactor. The tests will serve as your guiding light on whether something went wrong. This is especially valuable in brownfield codebases where changes are the riskiest. Having dedicated workflows to "clean up code" is another example of easy to implement multi-agent workflows.</p> </li> <li><p>Documentation is free. Whether it be inline code documentation, architectural diagrams or Correction of Error analysis, what used to take days now takes minutes. There is simply no excuse not to have comprehensive and up-to-date documentation, both from a product and engineering point of view. Not only should your code describe what it does where clarity is needed, it should also indicate the business rules behind it (whether it be inline or linked to external docs). A programmer reading the code should have a single point of entry to understand both the design decisions and the context in which the customer is using it.</p> </li> <li><p>Cost optimization is now part of software engineering. Not every task needs Claude Opus, and knowing when to delegate to cheaper AIs is a skill. Even better, a free one like Qwen Code should be installed locally for simple tasks and basic CRUD operations (which is about 90% of all development). Complex refactoring with business context is worth the Opus pricing. You should have mental models about which model to reach for given the problem at hand. Track your AI costs per feature just like you'd track compute costs on AWS so you can optimize your workflow and not just the code. Running expensive models on trivial tasks is wasteful and unprofessional.</p> </li> <li><p>High-Level System Design is where you are needed. AI will crush implementation details but architectural decisions require human judgment that understands business constraints, team capabilities, and long-term maintenance burden. You need to get better at system design, understanding trade-offs between different architectural patterns, and making decisions that account for factors AI can't know - like the fact your team hates microservices or that you're planning to acquire a company next quarter. This is where your value multiplies.</p> </li> </ol> 2026-01-07T02:00:00+00:00 https://zarar.dev/how-to-setup-a-new-wordpress-site-on-digital-ocean-droplet/ How to Setup a New WordPress Site on Digital Ocean Droplet 2026-01-04T18:14:43.939380+00:00 zarar hidden <p>I had setup a Wordpress site a while back on Digital Ocean using their <a href="proxy.php?url=https%3A%2F%2Fwww.digitalocean.com%2Fcommunity%2Ftutorials%2Fhow-to-use-the-wordpress-one-click-install">One-Click Installer</a>. That installer sets it up for exactly one site with no direction on how to add additional sites. Months later I had to setup another one, and after some fiddling around, figured out all the steps. So here they are (more or my reference than anything).</p> <p>This guide assumes you setup the site with the One-Click WordPress droplet and want to add additional WordPress sites to it.</p> <h2 id=prerequisites>Prerequisites</h2><ul> <li>Digital Ocean droplet with WordPress One-Click already installed</li> <li>SSH access to your server</li> <li>A domain name pointed to your droplet's IP address</li> </ul> <h2 id=1-create-the-virtual-host-configuration>1. Create the Virtual Host Configuration</h2><p>Create a new Apache config file for your site:</p> <div class="highlight"><pre><span></span>sudo<span class="w"> </span>nano<span class="w"> </span>/etc/apache2/sites-available/yoursite.conf </pre></div> <p>Add the following configuration (replace <code>yourdomain.com</code> and <code>yoursite</code> with your actual values):</p> <div class="highlight"><pre><span></span><span class="nt">&lt;VirtualHost</span><span class="w"> </span><span class="s">*:80</span><span class="nt">&gt;</span> <span class="w"> </span><span class="nb">ServerName</span><span class="w"> </span>yourdomain.com <span class="w"> </span><span class="nb">ServerAlias</span><span class="w"> </span>www.yourdomain.com <span class="w"> </span><span class="nb">DocumentRoot</span><span class="w"> </span><span class="sx">/var/www/yoursite</span> <span class="w"> </span><span class="nt">&lt;Directory</span><span class="w"> </span><span class="s">/var/www/yoursite</span><span class="nt">&gt;</span> <span class="w"> </span><span class="nb">AllowOverride</span><span class="w"> </span><span class="k">All</span> <span class="w"> </span><span class="nb">Require</span><span class="w"> </span><span class="k">all</span><span class="w"> </span>granted <span class="w"> </span><span class="nt">&lt;/Directory&gt;</span> <span class="w"> </span><span class="nb">ErrorLog</span><span class="w"> </span>${APACHE_LOG_DIR}/yoursite_error.log <span class="w"> </span><span class="nb">CustomLog</span><span class="w"> </span>${APACHE_LOG_DIR}/yoursite_access.log<span class="w"> </span>combined <span class="nt">&lt;/VirtualHost&gt;</span> </pre></div> <h2 id=2-create-the-document-root-directory>2. Create the Document Root Directory</h2><div class="highlight"><pre><span></span>sudo<span class="w"> </span>mkdir<span class="w"> </span>/var/www/yoursite sudo<span class="w"> </span>chown<span class="w"> </span>-R<span class="w"> </span>www-data:www-data<span class="w"> </span>/var/www/yoursite </pre></div> <h2 id=3-enable-the-site>3. Enable the Site</h2><div class="highlight"><pre><span></span>sudo<span class="w"> </span>a2ensite<span class="w"> </span>yoursite.conf sudo<span class="w"> </span>apache2ctl<span class="w"> </span>configtest sudo<span class="w"> </span>systemctl<span class="w"> </span>reload<span class="w"> </span>apache2 </pre></div> <h2 id=4-setup-https-with-certbot>4. Setup HTTPS with Certbot</h2><p>Certbot is preinstalled on the droplet. Run:</p> <div class="highlight"><pre><span></span>sudo<span class="w"> </span>certbot<span class="w"> </span>--apache<span class="w"> </span>-d<span class="w"> </span>yourdomain.com<span class="w"> </span>-d<span class="w"> </span>www.yourdomain.com </pre></div> <p>Follow the prompts to enter your email and agree to terms. Certbot will automatically configure SSL and set up auto-renewal.</p> <p>Verify auto-renewal works:</p> <div class="highlight"><pre><span></span>sudo<span class="w"> </span>certbot<span class="w"> </span>renew<span class="w"> </span>--dry-run </pre></div> <h2 id=5-create-mysql-database-and-user>5. Create MySQL Database and User</h2><p>Get the MySQL root password:</p> <div class="highlight"><pre><span></span>cat<span class="w"> </span>/root/.digitalocean_password </pre></div> <p>Log into MySQL:</p> <div class="highlight"><pre><span></span>mysql<span class="w"> </span>-u<span class="w"> </span>root<span class="w"> </span>-p </pre></div> <p>Create the database and user:</p> <div class="highlight"><pre><span></span><span class="k">CREATE</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">yoursite</span><span class="p">;</span> <span class="k">CREATE</span><span class="w"> </span><span class="k">USER</span><span class="w"> </span><span class="s1">&#39;yoursite_user&#39;</span><span class="o">@</span><span class="s1">&#39;localhost&#39;</span><span class="w"> </span><span class="n">IDENTIFIED</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="s1">&#39;your_strong_password_here&#39;</span><span class="p">;</span> <span class="k">GRANT</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">PRIVILEGES</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">yoursite</span><span class="p">.</span><span class="o">*</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="s1">&#39;yoursite_user&#39;</span><span class="o">@</span><span class="s1">&#39;localhost&#39;</span><span class="p">;</span> <span class="n">FLUSH</span><span class="w"> </span><span class="k">PRIVILEGES</span><span class="p">;</span> <span class="n">EXIT</span><span class="p">;</span> </pre></div> <h2 id=6-download-and-install-wordpress>6. Download and Install WordPress</h2><div class="highlight"><pre><span></span><span class="nb">cd</span><span class="w"> </span>/var/www/yoursite sudo<span class="w"> </span>wget<span class="w"> </span>https://wordpress.org/latest.tar.gz sudo<span class="w"> </span>tar<span class="w"> </span>-xzf<span class="w"> </span>latest.tar.gz<span class="w"> </span>--strip-components<span class="o">=</span><span class="m">1</span> sudo<span class="w"> </span>rm<span class="w"> </span>latest.tar.gz sudo<span class="w"> </span>chown<span class="w"> </span>-R<span class="w"> </span>www-data:www-data<span class="w"> </span>. </pre></div> <p>Visit your domain in a browser to complete the WordPress installation. You'll need:</p> <ul> <li>Database Name: <code>yoursite</code></li> <li>Username: <code>yoursite_user</code></li> <li>Password: (the password you set)</li> <li>Database Host: <code>localhost</code></li> <li>Table Prefix: <code>wp_</code> (or customize for security)</li> </ul> <h2 id=importing-an-existing-database-optional>Importing an Existing Database (Optional)</h2><p>If you have a mysqldump file to import:</p> <div class="highlight"><pre><span></span>mysql<span class="w"> </span>-u<span class="w"> </span>yoursite_user<span class="w"> </span>-p<span class="w"> </span>yoursite<span class="w"> </span>&lt;<span class="w"> </span>/path/to/your-database.sql </pre></div> <p>For gzipped files:</p> <div class="highlight"><pre><span></span>gunzip<span class="w"> </span>&lt;<span class="w"> </span>your-database.sql.gz<span class="w"> </span><span class="p">|</span><span class="w"> </span>mysql<span class="w"> </span>-u<span class="w"> </span>yoursite_user<span class="w"> </span>-p<span class="w"> </span>yoursite </pre></div> <h2 id=troubleshooting>Troubleshooting</h2><h3 id=viewing-apache-error-logs>Viewing Apache Error Logs</h3><div class="highlight"><pre><span></span>tail<span class="w"> </span>-f<span class="w"> </span>/var/log/apache2/yoursite_error.log </pre></div> <h3 id=enabling-wordpress-debug-mode>Enabling WordPress Debug Mode</h3><p>Edit <code>wp-config.php</code>:</p> <div class="highlight"><pre><span></span>nano<span class="w"> </span>/var/www/yoursite/wp-config.php </pre></div> <p>Add or modify these lines:</p> <div class="highlight"><pre><span></span><span class="x">define(&#39;WP_DEBUG&#39;, true);</span> <span class="x">define(&#39;WP_DEBUG_LOG&#39;, true);</span> <span class="x">define(&#39;WP_DEBUG_DISPLAY&#39;, false);</span> </pre></div> <p>Then view the debug log:</p> <div class="highlight"><pre><span></span>tail<span class="w"> </span>-f<span class="w"> </span>/var/www/yoursite/wp-content/debug.log </pre></div> <h3 id=php-not-working>PHP Not Working</h3><p>PHP 8.0 is enabled globally on the droplet, so it should work automatically. Test with:</p> <div class="highlight"><pre><span></span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;&lt;?php phpinfo(); ?&gt;&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>sudo<span class="w"> </span>tee<span class="w"> </span>/var/www/yoursite/test.php sudo<span class="w"> </span>chown<span class="w"> </span>www-data:www-data<span class="w"> </span>/var/www/yoursite/test.php </pre></div> <p>Visit <code>https://yourdomain.com/test.php</code> — then delete the file after testing:</p> <div class="highlight"><pre><span></span>sudo<span class="w"> </span>rm<span class="w"> </span>/var/www/yoursite/test.php </pre></div> <h2 id=notes>Notes</h2><ul> <li>The default web root for the original site is <code>/var/www/html</code></li> <li>MySQL credentials are stored in <code>/root/.digitalocean_password</code></li> <li>XML-RPC is blocked by default; enable with <code>a2disconf block-xmlrpc</code> if needed</li> <li>Apache config files are in <code>/etc/apache2/sites-available/</code></li> <li>Enabled sites are symlinked in <code>/etc/apache2/sites-enabled/</code></li> </ul> 2026-01-04T18:14:43.939164+00:00 https://zarar.dev/struct-updates-now-require-pattern-matching-in-elixir-119/ Struct Updates Now Require Pattern Matching in Elixir 1.19 2026-01-02T17:36:03.310436+00:00 zarar hidden <p>After upgrading to Elixir 1.19, you might see warnings like this:</p> <div class="highlight"><pre><span></span>warning: a struct for Amplify.Models.Product is expected on struct update: %Amplify.Models.Product{product | variants: variants} but got type: dynamic() </pre></div> <p>The fix is to pattern match on the struct when you define the variable:</p> <div class="highlight"><pre><span></span><span class="c1"># Before (now warns)</span> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="n">product</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Products</span><span class="o">.</span><span class="n">get_product</span><span class="p">(</span><span class="n">id</span><span class="p">)</span> <span class="n">product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">%</span><span class="nc">Product</span><span class="p">{</span><span class="n">product</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="ss">variants</span><span class="p">:</span><span class="w"> </span><span class="n">filtered</span><span class="p">}</span> <span class="c1"># After (two options)</span> <span class="c1"># Option 1: Pattern match + struct update</span> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="p">%</span><span class="nc">Product</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">product</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Products</span><span class="o">.</span><span class="n">get_product</span><span class="p">(</span><span class="n">id</span><span class="p">)</span> <span class="n">product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">%</span><span class="nc">Product</span><span class="p">{</span><span class="n">product</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="ss">variants</span><span class="p">:</span><span class="w"> </span><span class="n">filtered</span><span class="p">}</span> <span class="c1"># Option 2: Pattern match + map update (recommended)</span> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="p">%</span><span class="nc">Product</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">product</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Products</span><span class="o">.</span><span class="n">get_product</span><span class="p">(</span><span class="n">id</span><span class="p">)</span> <span class="n">product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">%{</span><span class="n">product</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="ss">variants</span><span class="p">:</span><span class="w"> </span><span class="n">filtered</span><span class="p">}</span> </pre></div> <p>Both work. Elixir's hint suggests Option 2 since the pattern match already guarantees the type.</p> <p>Why the change? The struct update syntax <code>%Product{product | key: value}</code> implies a runtime assertion that if <code>product</code> isn't actually a <code>Product</code>, it crashes. The compiler trusted you knew what you were doing but there wasn't any runtime enforcement despite the code appearing to look like a type was enforced.</p> <p>This also doesn't work well with type inference, which is Elixir's typing approach (as opposed to explicit annotations). When <code>product</code> comes from a function returning <code>{:ok, any()}</code>, the compiler sees it as <code>dynamic()</code>. It can't verify the struct update is safe without runtime execution.</p> <p>This change was needed since Elixir 1.19 continues the rollout of set-theoretic types, i.e., a <a href="proxy.php?url=https%3A%2F%2Fhexdocs.pm%2Felixir%2Fgradual-set-theoretic-types.html">gradual typing system</a> that infers types from patterns and guards rather than explicit annotations. The goal is catching bugs at compile time without requiring you to annotate everything.</p> <p>For this to work, the compiler needs evidence. A pattern match like <code>%Product{} = product</code> provides that evidence. Without it, the type system has to treat the variable as <code>dynamic()</code>, which defeats the purpose.</p> <p>The struct update syntax was convenient shorthand, but it created a hole in type inference. You could write code that the compiler couldn't verify, getting neither the safety of static typing nor a clear signal that you'd opted out. The new approach is more verbose, but the pattern match documents your assumption explicitly. Six months from now, when someone refactors <code>get_product/1</code> to return a different struct, the compiler will catch it instead of production.</p> <p>If you're updating many struct fields and want the old convenience, the map update syntax <code>%{product | key: value}</code> works identically at runtime but <em>only after you've pattern matched</em> when defining the variable.</p> 2026-01-02T17:36:03.310149+00:00 https://zarar.dev/regex-serialization-has-changes-in-elixir-119-otp-28/ Why Regex Serialization Changed in Elixir 1.19/OTP 28 2026-01-02T06:23:03.497042+00:00 zarar hidden <p>When upgrading to Elixir 1.19 with Erlang/OTP 28, the deployment failed with a cryptic error:</p> <div class="highlight"><pre><span></span>** (Mix) Could not write configuration file because it has invalid terms Application: :cors_plug Key: :origin Invalid value: ~r/.*\.jumpcomedy\.com$/ Reason: you must use the /E modifier to store regexes </pre></div> <p>The fix was simple - just add one characters:</p> <div class="highlight"><pre><span></span>config :cors_plug, <span class="gd">- origin: ~r/.*\.jumpcomedy\.com$/</span> <span class="gi">+ origin: ~r/.*\.jumpcomedy\.com$/E</span> </pre></div> <p>But why did this break? And what does that /E actually do?</p> <p>When you deploy an Elixir application, the build process creates a <em>release</em>, which is a packaged version of your app that includes all your code and configuration pre-compiled and ready to run (like a JAR in Java).</p> <p>For this to work, Elixir needs to serialize your configuration into a format that can be stored on disk and loaded later. Erlang/OTP 28 has introduced runtime optimizations but to benefit from them, regexes need to be stored differently on disk than how they're represented in code.</p> <p>The /E modifier tells Elixir: "Convert this regex to a format that can be stored in releases."</p> <p>Without /E:</p> <ul> <li>Your regex works fine in development (running with <code>mix phx.server</code>)</li> <li>Your regex works fine when running tests</li> <li>Your regex fails when building a production release</li> </ul> <p>With /E:</p> <ul> <li>Your regex works everywhere, including in releases</li> </ul> <p>/E stands for "external term format" and ensures the regex can be packaged for deployment. In our case, we were using a regex in our CORS configuration:</p> <div class="highlight"><pre><span></span><span class="n">config</span><span class="w"> </span><span class="ss">:cors_plug</span><span class="p">,</span> <span class="w"> </span><span class="ss">origin</span><span class="p">:</span><span class="w"> </span><span class="sx">~r/.*\.jumpcomedy\.com$/E</span> </pre></div> <p>This lives in <code>config/runtime.exs</code>, which gets evaluated when the release starts up. For that to work, the regex needs to be in a format that can be saved to the release bundle, hence the need for /E.</p> <p>You need the /E modifier when:</p> <ol> <li>Using regexes in application configuration (like we did with CORS)</li> <li>Storing regexes in struct defaults (though Elixir 1.19 now prevents this entirely - see the <a href="proxy.php?url=https%3A%2F%2Fhexdocs.pm%2Felixir%2Fchangelog.html%23v1-19-0-2025-10-16">release notes</a>)</li> <li>Any place where the regex will be serialized into a release</li> </ol> <p>You don't need /E when:</p> <ul> <li>Using regexes in your runtime code (pattern matching, validations, etc.)</li> <li>Using regexes in tests</li> <li>Using regexes that are created dynamically at runtime</li> </ul> <p>Elixir 1.19's stricter handling of regexes reflects Erlang/OTP 28's more sophisticated internal representation. While it requires updating configuration files, it's a small price to pay for the performance improvements.</p> 2026-01-02T03:13:00+00:00 https://zarar.dev/elixir-cqrs-commanded/ Trade-offs in Aggregate Design when implementing CQRS in Elixir 2025-12-29T16:52:02.059205+00:00 zarar hidden <h2 id=introduction>Introduction</h2><p>Event sourcing with CQRS is a powerful feature, but it presents difficult design decisions which can challenge dogmatic Domain Driven Design theory. Ultimately as with all software engineering trade-offs, the business need dictates whether the complexity is worth it.</p> <p>It's not an easy decision to introduce the CQRS pattern when simpler ones appear to be adequate, at least on the surface. In this blog post we'll cover how we used it to solve the seemingly simple problem of waitlist notifications. We'll also cover how it addressed the need for efficient analytics and history tracking of sales.</p> <p>This post walks through our implementation of CQRS (Command Query Responsibility Segregation) with the <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2Fcommanded%2Fcommanded">Commanded</a> library to build a complete inventory audit trail. We'll cover:</p> <ul> <li><strong>The business problem</strong>: Why simple inventory tracking wasn't enough</li> <li><strong>Evaluating alternatives</strong>: From database triggers to CQRS, and why we chose what we did</li> <li><strong>Aggregate design</strong>: The critical decision of small vs large aggregates</li> <li><strong>Implementation</strong>: Commands, events, aggregates, projectors, handlers, and the service layer</li> <li><strong>Event handlers</strong>: How we used the event stream to solve notifying customers when sold-out items become available</li> <li><strong>Production challenges</strong>: EventStore on managed PostgreSQL</li> <li><strong>Test synchronization without sleep calls</strong>: Writing deterministic tests without brittle sleep calls</li> <li><strong>Key lessons</strong>: What we'd do the same, and what we learned</li> </ul> <p>Let's dive in.</p> <hr /> <h2 id=the-business-problem>The Business Problem</h2><h3 id=notifying-customers-when-inventory-becomes-available>Notifying Customers When Inventory Becomes Available</h3><p>Inventory in the system can increase for an item for many reasons: refunds, swaps, or an administrator increasing capacity for an event. The question we are trying to answer is whether a sold out item just became available due to any of these reasons, and if so, can we notify people who signed up to get notified when it became available?</p> <p>Popular events sell out fairly quickly leaving many disappointed, and instead of having people call the organizer asking to allocate more tickets or people coming refreshing the page to see if something opened up, we decided to implement a "post sell out waitlist" where people can sign up to receive notifications if inventory became available.</p> <h3 id=improving-auditability>Improving Auditability</h3><p>We also wanted to improve the auditability and granularity of how we track inventory changes over time. The existing system tracked <code>inventory_quantity</code> on each item, but had no history of how it got there, at least not one that is easy and efficient to read. We could do several joins and some in-memory calculations to replay how sales went, but the user experience would be slow and the data wouldn't be conducive to analytics. We wanted efficient reads of sales history for both customer analytics and internal system auditability and debugging, so we know where things went wayward when problems inevitably happen. Customers also wanted to know:</p> <ul> <li>How did this show sell out earlier than expected?</li> <li>Who adjusted the capacity, and when?</li> <li>What was the inventory level at any point in time and what happened to get it there?</li> </ul> <p>We didn't just need to know <em>what</em> changed, but <em>why</em> it changed and <em>who</em> made it happen.</p> <hr /> <h2 id=evaluating-the-options>Evaluating the Options</h2><h3 id=option-1-database-triggers>Option 1: Database Triggers</h3><p>PostgreSQL triggers could automatically log changes to the variants table:</p> <div class="highlight"><pre><span></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TRIGGER</span><span class="w"> </span><span class="n">log_inventory_change</span> <span class="k">AFTER</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span><span class="k">OF</span><span class="w"> </span><span class="n">inventory_quantity</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">product_variants</span> <span class="k">FOR</span><span class="w"> </span><span class="k">EACH</span><span class="w"> </span><span class="k">ROW</span><span class="w"> </span><span class="k">EXECUTE</span><span class="w"> </span><span class="k">FUNCTION</span><span class="w"> </span><span class="n">log_variant_change</span><span class="p">();</span> </pre></div> <p>This is easy to implement, transparent and requires no application code changes as the database does the work.</p> <p>The problem with this approach is that business context is completely lost. The trigger sees "quantity changed from 100 to 98" but can't distinguish a sale from a return from an admin adjustment. Maintenance becomes a separate concern from application logic. The days or PL/SQL where business logic sits inside the database are long gone, and application logic holds business rules. This method doesn't have easy access to the larger context of why data is being manipulated.</p> <h3 id=option-2-ecto-callbacks>Option 2: Ecto Callbacks</h3><p>We could use Ecto's lifecycle callbacks to log changes:</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">ProductVariant</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kn">use</span><span class="w"> </span><span class="nc">Ecto.Schema</span> <span class="w"> </span><span class="n">after_update</span><span class="w"> </span><span class="ss">:log_inventory_change</span> <span class="w"> </span><span class="kd">defp</span><span class="w"> </span><span class="n">log_inventory_change</span><span class="p">(</span><span class="n">changeset</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="c1"># Log the change...</span> <span class="w"> </span><span class="k">end</span> <span class="k">end</span> </pre></div> <p>This keeps the code in Elixir and there is some entity specific business context available via the changeset. The logic lives "near" the entity so it's hard to miss and the language seems logical. The issue with this pattern is that it's brittle as it still has to calculate in code why the change happened, which means the changeset will need to be bloated and carry more information than it actually is changing.</p> <p>For example, just because we're updating the quantity from one value to another, the changeset would have to carry much more information than that to serve the auditability needs. It's also easy to bypass with direct <code>Repo.update_all</code> calls.</p> <p>It also tightly couples the business transaction with logging needs.</p> <h3 id=option-3-manual-logging-in-context-functions>Option 3: Manual Logging in Context Functions</h3><p>We could add explicit log inserts alongside every inventory-changing operation:</p> <div class="highlight"><pre><span></span><span class="kd">def</span><span class="w"> </span><span class="n">process_refund</span><span class="p">(</span><span class="n">params</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="c1"># Update inventory</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="n">variant</span><span class="p">,</span><span class="w"> </span><span class="ss">inventory_quantity</span><span class="p">:</span><span class="w"> </span><span class="n">params</span><span class="o">.</span><span class="n">quantity</span><span class="p">)</span> <span class="w"> </span><span class="c1"># Log the change</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">insert!</span><span class="p">(%</span><span class="nc">InventoryLog</span><span class="p">{</span> <span class="w"> </span><span class="ss">variant_id</span><span class="p">:</span><span class="w"> </span><span class="n">params</span><span class="o">.</span><span class="n">variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">reason</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;refund&quot;</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_change</span><span class="p">:</span><span class="w"> </span><span class="o">-</span><span class="n">params</span><span class="o">.</span><span class="n">quantity</span><span class="p">,</span> <span class="w"> </span><span class="ss">order_id</span><span class="p">:</span><span class="w"> </span><span class="n">params</span><span class="o">.</span><span class="n">order_id</span> <span class="w"> </span><span class="p">})</span> <span class="k">end</span> </pre></div> <p>We could do this for sales, swaps etc, and have the full business context available while having explicit code that writes to the log table.</p> <p>This is easy to forget in some code paths and you have logging code scattered across the codebase. Consistency guaranteed decrease as if the log fails, the transaction will fail which may not be what we always want. There is lots of code duplication as the log entity needs to be constructed in multiple places.</p> <h3 id=option-4-cqrs-with-large-aggregates>Option 4: CQRS with Large Aggregates</h3><p>CQRS with Commanded can work if we use aggregates at the Order level. An <code>OrderInventory</code> aggregate would track all inventory changes for an entire order. We get transactional consistency across all line items in an order.</p> <p>However, aggregate boundary design is hard, and when multiple operations touch the same order we run into consistency challenges. The larger aggregate state needs to load/rebuild frequently and cross-order operations like admin adjustments don't fit the model well as it's not happening within the context of an order. We could design multiple aggregates like OrderInventory and AdminInventory but now there is overlap in concepts and language, which violates some core principles of Domain Driven Design.</p> <p>Invariants are also hard to construct as the relationship between orders, item inventory and an admin's workflow spans many entities, making the invariant brittle.</p> <h3 id=option-5-cqrs-with-small-aggregates-chosen-one>Option 5: CQRS with Small Aggregates (Chosen One)</h3><p>CQRS with Commanded but with smaller aggregates specific to an item/variant's inventory is what we landed on. Specifically, a <code>VariantInventory</code> aggregate per product variant which tracks that items inventory and doesn't explicitly tie the aggregate to larger entities like Order. A big reason we chose this was the guidance provided by Vaughn Vernon in his three-part series (<a href="proxy.php?url=http%3A%2F%2Fdddcommunity.org%2Fwp-content%2Fuploads%2Ffiles%2Fpdf_articles%2FVernon_2011_1.pdf">1</a>, <a href="proxy.php?url=http%3A%2F%2Fdddcommunity.org%2Fwp-content%2Fuploads%2Ffiles%2Fpdf_articles%2FVernon_2011_2.pdf">2</a>, <a href="proxy.php?url=http%3A%2F%2Fdddcommunity.org%2Fwp-content%2Fuploads%2Ffiles%2Fpdf_articles%2FVernon_2011_3.pdf">3</a>) discussing aggregate modelling.</p> <p>There's also minimal contention as different variants get processed concurrently due to the simpler aggregate state. It's easy to reason about as each aggregate answers one question: "What happened to this variant's inventory?"</p> <p>The audit requirements demanded explicit business intent capture. We needed "this inventory decreased because of a sale on order #123," not just "inventory_quantity changed from 100 to 98."</p> <p>CQRS with Commanded gave us:</p> <ol> <li>Explicit commands that capture intent (RecordSale, RecordReturn, RecordAdminAdjustment)</li> <li>Immutable events stored in an append-only log (EventStore)</li> <li>Separation of write model (aggregates) from read model (projections)</li> <li>Inventory changes are naturally variant-scoped</li> <li>High concurrency during ticket sales demands minimal contention</li> <li>Each aggregate tracks one thing, making it easy to understand and debug</li> </ol> <p>Eric Evans' DDD "Blue Book" often implies larger aggregates that enforce complex invariants. But when the domain naturally partitions (inventory per variant), smaller aggregates reduce complexity and improve performance.</p> <p>The cons may be that cross-variant operations require multiple commands and we can't enforce cross-variant business rules in a single transaction. This is not currently a business requirement for us, so we went with the smaller, more purposeful aggregates rather than a more traditional one.</p> <h2 id=architecture-overview>Architecture Overview</h2><h3 id=the-cqrs-pattern-in-our-context>The CQRS Pattern in Our Context</h3><p>Here's the flow from a sale to the audit log:</p> <div class="highlight"><pre><span></span>Service Layer (Inventory.record_order_sales) ↓ Command (RecordSale) ↓ Router (InventoryRouter) ↓ Aggregate (VariantInventory.execute) ↓ Event (InventoryChanged) ↓ Projector (InventoryProjector) ↓ Handler (InventoryHandler) ↓ Read Model (inventory_events table) </pre></div> <p>The sequence diagram illustrates this further: <img src="proxy.php?url=https%3A%2F%2Fbear-images.sfo2.cdn.digitaloceanspaces.com%2Fzarar%2Fcqrs-sequence-diagram.svg" alt="cqrs-sequence-diagram" /></p> <div style="margin: 4px 0; text-align: center"> <a target="_blank" href="proxy.php?url=https%3A%2F%2Fbear-images.sfo2.cdn.digitaloceanspaces.com%2Fzarar%2Fcqrs-sequence-diagram.svg">Open in New Window</a> </div> <p>Each layer has a specific responsibility:</p> <ul> <li><strong>Commands</strong> represent intent ("record a sale of 2 tickets")</li> <li><strong>Aggregates</strong> enforce business rules and produce events</li> <li><strong>Events</strong> represent facts that happened ("inventory changed")</li> <li><strong>Projectors</strong> build read models optimized for queries</li> <li><strong>Handlers</strong> implement side effects (e.g., sending out emails)</li> </ul> <h3 id=key-components>Key Components</h3><table> <thead> <tr> <th>Component</th> <th>Module</th> <th>Purpose</th> </tr> </thead> <tbody> <tr> <td>Application</td> <td><code>Amplify.CommandedApplication</code></td> <td>Commanded application, supervises everything</td> </tr> <tr> <td>Router</td> <td><code>Amplify.CQRS.Routers.InventoryRouter</code></td> <td>Routes commands to aggregates by variant_id</td> </tr> <tr> <td>Aggregate</td> <td><code>Amplify.CQRS.Aggregates.VariantInventory</code></td> <td>Business logic, produces events</td> </tr> <tr> <td>Event</td> <td><code>Amplify.CQRS.Events.InventoryChanged</code></td> <td>Immutable fact record</td> </tr> <tr> <td>Projector</td> <td><code>Amplify.CQRS.Projectors.InventoryProjector</code></td> <td>Writes to inventory_events table</td> </tr> <tr> <td>Handler</td> <td><code>Amplify.CQRS.Handlers.InventoryHandler</code></td> <td>Checks if any business actions with side effects need to be taken</td> </tr> <tr> <td>Service</td> <td><code>Amplify.Services.Inventory</code></td> <td>Clean API for callers</td> </tr> </tbody> </table> <hr /> <h2 id=implementation-deep-dive>Implementation Deep Dive</h2><h3 id=command-design>Command Design</h3><p>We have six command types, each capturing specific business intent:</p> <div class="highlight"><pre><span></span><span class="c1"># Record a sale from an order</span> <span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.CQRS.Commands.Inventory.RecordSale</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kd">defstruct</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="ss">:variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:order_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:quantity_sold</span><span class="p">,</span> <span class="w"> </span><span class="ss">:actor_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:actor_type</span><span class="p">,</span> <span class="w"> </span><span class="ss">metadata</span><span class="p">:</span><span class="w"> </span><span class="p">%{}</span> <span class="w"> </span><span class="p">]</span> <span class="k">end</span> <span class="c1"># Record an admin capacity adjustment</span> <span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.CQRS.Commands.Inventory.RecordAdminAdjustment</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kd">defstruct</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="ss">:variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:quantity_remaining</span><span class="p">,</span><span class="w"> </span><span class="c1"># Absolute value, not delta</span> <span class="w"> </span><span class="ss">:actor_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:actor_type</span><span class="p">,</span> <span class="w"> </span><span class="ss">metadata</span><span class="p">:</span><span class="w"> </span><span class="p">%{}</span> <span class="w"> </span><span class="p">]</span> <span class="k">end</span> <span class="c1"># Other commands: RecordReturn, RecordSwapIn, RecordSwapOut, RecordVariantCreated</span> </pre></div> <p>Notice the difference: <code>RecordSale</code> has <code>quantity_sold</code> (a delta), while <code>RecordAdminAdjustment</code> has <code>quantity_remaining</code> (an absolute value). This matches how humans think about these operations. A sale may reduce inventory by 2, but when an admin makes a change they change the overall capacity of an event from 50 to 60 and enter the number 60 into the UI instead of 10 (60-50). This is a tenet of Domain Driven Design where our language matches the business context of an operation.</p> <h3 id=the-single-event-approach>The Single Event Approach</h3><p>We use one event type for all inventory changes:</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.CQRS.Events.InventoryChanged</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="na">@derive</span><span class="w"> </span><span class="nc">Jason.Encoder</span> <span class="w"> </span><span class="kd">defstruct</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="ss">:variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:order_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:return_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:reason</span><span class="p">,</span><span class="w"> </span><span class="c1"># :sale, :return, :admin_adjustment, :swap_in, :swap_out</span> <span class="w"> </span><span class="ss">:actor_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">:actor_type</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_remaining</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_sold</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_adjustment</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span> <span class="w"> </span><span class="ss">was_sold_out</span><span class="p">:</span><span class="w"> </span><span class="no">false</span><span class="p">,</span> <span class="w"> </span><span class="ss">is_sold_out</span><span class="p">:</span><span class="w"> </span><span class="no">false</span><span class="p">,</span> <span class="w"> </span><span class="ss">metadata</span><span class="p">:</span><span class="w"> </span><span class="p">%{}</span> <span class="w"> </span><span class="p">]</span> <span class="k">end</span> </pre></div> <p>Why one event type instead of <code>InventorySold</code>, <code>InventoryReturned</code>, etc.? Simplicity. The <code>reason</code> field captures the business intent, and the projector handles all events uniformly. We can always split into multiple event types later if needed, but we opted to go for a simpler approach to start.</p> <h3 id=the-aggregate>The Aggregate</h3><p>The aggregate is where business logic lives. It's identified by <code>variant_id</code>:</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.CQRS.Routers.InventoryRouter</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kn">use</span><span class="w"> </span><span class="nc">Commanded.Commands.Router</span> <span class="w"> </span><span class="kn">alias</span><span class="w"> </span><span class="nc">Amplify.CQRS.Aggregates.VariantInventory</span> <span class="w"> </span><span class="kn">alias</span><span class="w"> </span><span class="nc">Amplify.CQRS.Commands.Inventory</span><span class="o">.</span><span class="p">{</span><span class="nc">RecordSale</span><span class="p">,</span><span class="w"> </span><span class="nc">RecordAdminAdjustment</span><span class="p">,</span><span class="w"> </span><span class="n">...</span><span class="p">}</span> <span class="w"> </span><span class="c1"># Each variant_id gets its own aggregate instance</span> <span class="w"> </span><span class="n">identify</span><span class="p">(</span><span class="nc">VariantInventory</span><span class="p">,</span><span class="w"> </span><span class="ss">by</span><span class="p">:</span><span class="w"> </span><span class="ss">:variant_id</span><span class="p">,</span><span class="w"> </span><span class="ss">prefix</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;variant-inventory-&quot;</span><span class="p">)</span> <span class="w"> </span><span class="n">dispatch</span><span class="p">([</span><span class="nc">RecordSale</span><span class="p">,</span><span class="w"> </span><span class="nc">RecordAdminAdjustment</span><span class="p">,</span><span class="w"> </span><span class="n">...</span><span class="p">],</span> <span class="w"> </span><span class="ss">to</span><span class="p">:</span><span class="w"> </span><span class="nc">VariantInventory</span><span class="p">,</span> <span class="w"> </span><span class="ss">identity</span><span class="p">:</span><span class="w"> </span><span class="ss">:variant_id</span><span class="p">)</span> <span class="k">end</span> </pre></div> <p>The aggregate's <code>execute/2</code> function takes a command and returns an event:</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.CQRS.Aggregates.VariantInventory</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kd">defstruct</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="ss">:variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_remaining</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_sold</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span> <span class="w"> </span><span class="ss">is_sold_out</span><span class="p">:</span><span class="w"> </span><span class="no">false</span> <span class="w"> </span><span class="p">]</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">execute</span><span class="p">(</span><span class="err">%</span><span class="bp">__MODULE__</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">state</span><span class="p">,</span><span class="w"> </span><span class="p">%</span><span class="nc">RecordSale</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">cmd</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">new_sold</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">state</span><span class="o">.</span><span class="n">quantity_sold</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">cmd</span><span class="o">.</span><span class="n">quantity_sold</span> <span class="w"> </span><span class="n">new_remaining</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">state</span><span class="o">.</span><span class="n">quantity_remaining</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">cmd</span><span class="o">.</span><span class="n">quantity_sold</span> <span class="w"> </span><span class="n">new_sold_out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">new_remaining</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="mi">0</span> <span class="w"> </span><span class="p">%</span><span class="nc">InventoryChanged</span><span class="p">{</span> <span class="w"> </span><span class="ss">variant_id</span><span class="p">:</span><span class="w"> </span><span class="n">cmd</span><span class="o">.</span><span class="n">variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">order_id</span><span class="p">:</span><span class="w"> </span><span class="n">cmd</span><span class="o">.</span><span class="n">order_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">reason</span><span class="p">:</span><span class="w"> </span><span class="ss">:sale</span><span class="p">,</span> <span class="w"> </span><span class="ss">actor_id</span><span class="p">:</span><span class="w"> </span><span class="n">cmd</span><span class="o">.</span><span class="n">actor_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">actor_type</span><span class="p">:</span><span class="w"> </span><span class="n">cmd</span><span class="o">.</span><span class="n">actor_type</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_remaining</span><span class="p">:</span><span class="w"> </span><span class="n">new_remaining</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_sold</span><span class="p">:</span><span class="w"> </span><span class="n">new_sold</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_adjustment</span><span class="p">:</span><span class="w"> </span><span class="o">-</span><span class="n">cmd</span><span class="o">.</span><span class="n">quantity_sold</span><span class="p">,</span> <span class="w"> </span><span class="ss">was_sold_out</span><span class="p">:</span><span class="w"> </span><span class="n">state</span><span class="o">.</span><span class="n">is_sold_out</span><span class="p">,</span> <span class="w"> </span><span class="ss">is_sold_out</span><span class="p">:</span><span class="w"> </span><span class="n">new_sold_out</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">execute</span><span class="p">(</span><span class="err">%</span><span class="bp">__MODULE__</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">state</span><span class="p">,</span><span class="w"> </span><span class="p">%</span><span class="nc">RecordAdminAdjustment</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">cmd</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="c1"># Admin adjustments set absolute quantity, not delta</span> <span class="w"> </span><span class="n">adjustment</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">cmd</span><span class="o">.</span><span class="n">quantity_remaining</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">state</span><span class="o">.</span><span class="n">quantity_remaining</span> <span class="w"> </span><span class="p">%</span><span class="nc">InventoryChanged</span><span class="p">{</span> <span class="w"> </span><span class="n">...</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="c1"># apply/2 updates state from events (for rebuilding from event stream)</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">apply</span><span class="p">(</span><span class="err">%</span><span class="bp">__MODULE__</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">state</span><span class="p">,</span><span class="w"> </span><span class="p">%</span><span class="nc">InventoryChanged</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">event</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="err">%</span><span class="bp">__MODULE__</span><span class="p">{</span><span class="n">state</span><span class="w"> </span><span class="o">|</span> <span class="w"> </span><span class="ss">variant_id</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_remaining</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">quantity_remaining</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_sold</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">quantity_sold</span><span class="p">,</span> <span class="w"> </span><span class="ss">is_sold_out</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">is_sold_out</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="k">end</span> <span class="k">end</span> </pre></div> <h3 id=the-projector-and-read-model>The Projector and Read Model</h3><p>The projector subscribes to events and writes to the database. Importantly, it enriches the event with <code>product_id</code> and <code>account_id</code> that we derive from the variant:</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.CQRS.Projectors.InventoryProjector</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kn">use</span><span class="w"> </span><span class="nc">Commanded.Projections.Ecto</span><span class="p">,</span> <span class="w"> </span><span class="ss">application</span><span class="p">:</span><span class="w"> </span><span class="nc">Amplify.CommandedApplication</span><span class="p">,</span> <span class="w"> </span><span class="ss">repo</span><span class="p">:</span><span class="w"> </span><span class="nc">Amplify.Repo</span><span class="p">,</span> <span class="w"> </span><span class="ss">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;InventoryProjector&quot;</span><span class="p">,</span> <span class="w"> </span><span class="ss">consistency</span><span class="p">:</span><span class="w"> </span><span class="ss">:strong</span> <span class="w"> </span><span class="n">project</span><span class="p">(%</span><span class="nc">InventoryChanged</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">event</span><span class="p">,</span><span class="w"> </span><span class="n">_metadata</span><span class="p">,</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="n">multi</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="c1"># Derive product_id and account_id from the variant</span> <span class="w"> </span><span class="p">{</span><span class="n">product_id</span><span class="p">,</span><span class="w"> </span><span class="n">account_id</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">get_product_and_account_ids</span><span class="p">(</span><span class="n">event</span><span class="o">.</span><span class="n">variant_id</span><span class="p">)</span> <span class="w"> </span><span class="n">changeset</span><span class="w"> </span><span class="o">=</span> <span class="w"> </span><span class="p">%</span><span class="nc">InventoryEvent</span><span class="p">{}</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Ecto.Changeset</span><span class="o">.</span><span class="n">change</span><span class="p">(%{</span> <span class="w"> </span><span class="ss">variant_id</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">product_id</span><span class="p">:</span><span class="w"> </span><span class="n">product_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">account_id</span><span class="p">:</span><span class="w"> </span><span class="n">account_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">order_id</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">order_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">return_id</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">return_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">reason</span><span class="p">:</span><span class="w"> </span><span class="n">to_string</span><span class="p">(</span><span class="n">event</span><span class="o">.</span><span class="n">reason</span><span class="p">),</span> <span class="w"> </span><span class="ss">actor_id</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">actor_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">actor_type</span><span class="p">:</span><span class="w"> </span><span class="n">to_string</span><span class="p">(</span><span class="n">event</span><span class="o">.</span><span class="n">actor_type</span><span class="p">),</span> <span class="w"> </span><span class="ss">quantity_remaining</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">quantity_remaining</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_adjustment</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">quantity_adjustment</span><span class="p">,</span> <span class="w"> </span><span class="ss">was_sold_out</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">was_sold_out</span><span class="p">,</span> <span class="w"> </span><span class="ss">is_sold_out</span><span class="p">:</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">is_sold_out</span> <span class="w"> </span><span class="p">})</span> <span class="w"> </span><span class="nc">Ecto.Multi</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="n">multi</span><span class="p">,</span><span class="w"> </span><span class="ss">:inventory_event</span><span class="p">,</span><span class="w"> </span><span class="n">changeset</span><span class="p">)</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="w"> </span><span class="kd">defp</span><span class="w"> </span><span class="n">get_product_and_account_ids</span><span class="p">(</span><span class="n">variant_id</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">query</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">from</span><span class="w"> </span><span class="n">v</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nc">ProductVariant</span><span class="p">,</span> <span class="w"> </span><span class="ss">join</span><span class="p">:</span><span class="w"> </span><span class="n">p</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="n">assoc</span><span class="p">(</span><span class="n">v</span><span class="p">,</span><span class="w"> </span><span class="ss">:product</span><span class="p">),</span> <span class="w"> </span><span class="ss">where</span><span class="p">:</span><span class="w"> </span><span class="n">v</span><span class="o">.</span><span class="n">id</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="o">^</span><span class="n">variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">select</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="n">p</span><span class="o">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="o">.</span><span class="n">account_id</span><span class="p">}</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">one</span><span class="p">(</span><span class="n">query</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="p">{</span><span class="no">nil</span><span class="p">,</span><span class="w"> </span><span class="no">nil</span><span class="p">}</span> <span class="w"> </span><span class="k">end</span> <span class="k">end</span> </pre></div> <p>This is a key design decision: commands only need <code>variant_id</code>, and the projector derives additional context. This keeps commands simple and decoupled. We could have passed in <code>product_id</code> and <code>account_id</code> as part of the command and event, but that seemed like unnecessary proliferation of data, especially when they can be easily and consistently derived.</p> <h3 id=the-service-layer-encapsulating-cqrs-complexity>The Service Layer: Encapsulating CQRS Complexity</h3><p>Client code shouldn't need to know about commands, aggregates, or Commanded. The service layer provides a clean API:</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.Services.Inventory</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kn">alias</span><span class="w"> </span><span class="nc">Amplify.CQRS.Commands.Inventory</span><span class="o">.</span><span class="p">{</span><span class="nc">RecordSale</span><span class="p">,</span><span class="w"> </span><span class="nc">RecordReturn</span><span class="p">,</span><span class="w"> </span><span class="n">...</span><span class="p">}</span> <span class="w"> </span><span class="kn">alias</span><span class="w"> </span><span class="nc">Amplify.Context.Orders</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">record_order_sales</span><span class="p">(</span><span class="n">order_id</span><span class="p">,</span><span class="w"> </span><span class="n">opts</span><span class="w"> </span><span class="p">\\</span><span class="w"> </span><span class="p">[])</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">order</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Orders</span><span class="o">.</span><span class="n">get_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> <span class="w"> </span><span class="n">dispatch_opts</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">opts</span><span class="p">[</span><span class="ss">:consistency</span><span class="p">]</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="ss">:strong</span><span class="p">,</span> <span class="w"> </span><span class="ss">do</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="ss">consistency</span><span class="p">:</span><span class="w"> </span><span class="ss">:strong</span><span class="p">],</span> <span class="w"> </span><span class="ss">else</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span> <span class="w"> </span><span class="nc">Enum</span><span class="o">.</span><span class="n">each</span><span class="p">(</span><span class="n">order</span><span class="o">.</span><span class="n">line_items</span><span class="p">,</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="n">line_item</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="n">cmd</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">%</span><span class="nc">RecordSale</span><span class="p">{</span> <span class="w"> </span><span class="ss">variant_id</span><span class="p">:</span><span class="w"> </span><span class="n">line_item</span><span class="o">.</span><span class="n">variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">order_id</span><span class="p">:</span><span class="w"> </span><span class="n">order_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_sold</span><span class="p">:</span><span class="w"> </span><span class="n">line_item</span><span class="o">.</span><span class="n">quantity</span><span class="p">,</span> <span class="w"> </span><span class="ss">actor_id</span><span class="p">:</span><span class="w"> </span><span class="no">nil</span><span class="p">,</span> <span class="w"> </span><span class="ss">actor_type</span><span class="p">:</span><span class="w"> </span><span class="ss">:system</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="nc">Amplify.CommandedApplication</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">cmd</span><span class="p">,</span><span class="w"> </span><span class="n">dispatch_opts</span><span class="p">)</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="w"> </span><span class="ss">:ok</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">record_admin_adjustment</span><span class="p">(</span><span class="n">variant_id</span><span class="p">,</span><span class="w"> </span><span class="n">quantity_remaining</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">...</span> <span class="w"> </span><span class="k">end</span> <span class="k">end</span> </pre></div> <p>Compare what client code looks like with and without the service layer:</p> <p><strong>Without service layer:</strong></p> <div class="highlight"><pre><span></span><span class="c1"># In AMQP worker - messy, repeated, error-prone</span> <span class="n">order</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Orders</span><span class="o">.</span><span class="n">get_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> <span class="nc">Enum</span><span class="o">.</span><span class="n">each</span><span class="p">(</span><span class="n">order</span><span class="o">.</span><span class="n">line_items</span><span class="p">,</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="n">li</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="n">cmd</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">%</span><span class="nc">RecordSale</span><span class="p">{</span> <span class="w"> </span><span class="ss">variant_id</span><span class="p">:</span><span class="w"> </span><span class="n">li</span><span class="o">.</span><span class="n">variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">order_id</span><span class="p">:</span><span class="w"> </span><span class="n">order_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_sold</span><span class="p">:</span><span class="w"> </span><span class="n">li</span><span class="o">.</span><span class="n">quantity</span><span class="p">,</span> <span class="w"> </span><span class="ss">actor_id</span><span class="p">:</span><span class="w"> </span><span class="no">nil</span><span class="p">,</span> <span class="w"> </span><span class="ss">actor_type</span><span class="p">:</span><span class="w"> </span><span class="ss">:system</span><span class="p">,</span> <span class="w"> </span><span class="ss">metadata</span><span class="p">:</span><span class="w"> </span><span class="p">%{}</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="nc">Amplify.CommandedApplication</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">cmd</span><span class="p">)</span> <span class="k">end</span><span class="p">)</span> </pre></div> <p><strong>With service layer:</strong></p> <div class="highlight"><pre><span></span><span class="c1"># Clean, single line</span> <span class="nc">Inventory</span><span class="o">.</span><span class="n">record_order_sales</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> </pre></div> <hr /> <h2 id=design-decisions-and-trade-offs>Design Decisions and Trade-offs</h2><h3 id=small-vs-large-aggregates-a-deep-dive>Small vs Large Aggregates: A Deep Dive</h3><p>This was our most impactful architectural decision.</p> <p>Eric Evans' "Blue Book" tends toward larger aggregates that enforce invariants across related entities. An Order aggregate containing LineItems ensures order totals stay consistent. This makes sense when you need transactional guarantees across the whole.</p> <p>For inventory tracking, we chose one aggregate per variant rather than per-order or per-product:</p> <ol> <li><p><strong>Natural Domain Boundaries</strong>: When someone buys 2 GA and 1 VIP ticket, those are independent inventory operations. There's no invariant requiring atomic updates across variants.</p> </li> <li><p><strong>Concurrency and Contention</strong>: During a hot ticket sale, hundreds of concurrent purchases hit the system. With per-product aggregates, every purchase would serialize. With per-variant, GA and VIP process in parallel.</p> </li> <li><p><strong>Aggregate Loading Cost</strong>: Commanded rebuilds aggregate state by replaying events. Large aggregates accumulate more events, making each command slower.</p> </li> <li><p><strong>Cognitive Simplicity</strong>: Each <code>VariantInventory</code> answers one question: "What happened to this variant's inventory?"</p> </li> </ol> <p>The trade-off here can be seen in ticket swaps where that affect two variants, but due to our design we can only update one aggregate atomically. The solution is to dispatch two commands (<code>RecordSwapOut</code>, <code>RecordSwapIn</code>). We lose atomic guarantee but can correlate via <code>order_id</code>. In this case, eventual consistency is more than acceptable. My personal view is that eventually consistency is often acceptable and developers tend to over-index to strong consistency models only out of habit or an unfounded fear. We sometimes forget that not too long ago, almost everything was a batch job and never strongly consistent. I digress.</p> <h3 id=strong-vs-eventual-consistency>Strong vs Eventual Consistency</h3><p>CQRS often emphasizes eventual consistency, but we needed both:</p> <ul> <li>For admin adjustments through the UI, we need strong consistency as users expect to see their change immediately as they're waiting on a screen.</li> <li>Background order processing can be oK with eventual consistency as there is no user waiting so we can focus on maximizing throughput.</li> <li>For tests we need strong consistency so we can have deterministic assertions without sleep calls (e.g., waiting for background processes to finish making for brittle tests)</li> </ul> <div class="highlight"><pre><span></span><span class="c1"># Background jobs: eventual (default)</span> <span class="nc">Inventory</span><span class="o">.</span><span class="n">record_order_sales</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> <span class="c1"># Admin UI: strong</span> <span class="nc">Inventory</span><span class="o">.</span><span class="n">record_admin_adjustment</span><span class="p">(</span><span class="n">variant_id</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="n">user_id</span><span class="p">)</span><span class="w"> </span><span class="c1"># Always strong</span> <span class="c1"># Tests: strong for determinism</span> <span class="nc">Inventory</span><span class="o">.</span><span class="n">record_order_sales</span><span class="p">(</span><span class="n">order_id</span><span class="p">,</span><span class="w"> </span><span class="ss">consistency</span><span class="p">:</span><span class="w"> </span><span class="ss">:strong</span><span class="p">)</span> </pre></div> <h3 id=multiple-sources-of-truth-for-inventory>Multiple Sources of Truth for Inventory</h3><p>As it stands, we have two sources of truth for the inventory number. The first is the value in the <code>inventory_quantity</code> column in the table, and the second is the aggregate. This is an acceptable trade-off as we use the aggregate event sourcing to determine moment in time actions and auditability, while the <code>inventory_quantity</code> field can really be thought of as a read projection which will eventually go away.</p> <hr /> <h2 id=integration-and-testing>Integration and Testing</h2><h3 id=amqp-message-handlers>AMQP Message Handlers</h3><p>Sometimes new events can come in from other systems via a queue, and our inventory service integrates cleanly with AMQP workers listening for messages. In both examples below we use eventual consistency due to reasons stated earlier.</p> <div class="highlight"><pre><span></span><span class="kd">def</span><span class="w"> </span><span class="n">handle_deliver</span><span class="p">(%{</span><span class="ss">queue</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;new_order_queue&quot;</span><span class="p">},</span><span class="w"> </span><span class="n">message</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">order_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">message</span><span class="o">.</span><span class="n">payload</span> <span class="w"> </span><span class="c1"># ... other order processing ...</span> <span class="w"> </span><span class="c1"># One line to record all inventory changes</span> <span class="w"> </span><span class="nc">Inventory</span><span class="o">.</span><span class="n">record_order_sales</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> <span class="w"> </span><span class="ss">:ok</span> <span class="k">end</span> <span class="kd">def</span><span class="w"> </span><span class="n">handle_deliver</span><span class="p">(%{</span><span class="ss">queue</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;return_processed_queue&quot;</span><span class="p">},</span><span class="w"> </span><span class="n">message</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">return_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">message</span><span class="o">.</span><span class="n">payload</span> <span class="w"> </span><span class="c1"># ... refund processing ...</span> <span class="w"> </span><span class="nc">Inventory</span><span class="o">.</span><span class="n">record_return</span><span class="p">(</span><span class="n">return_id</span><span class="p">)</span> <span class="w"> </span><span class="ss">:ok</span> <span class="k">end</span> </pre></div> <h3 id=end-to-end-testing-without-sleep-calls>End-to-End Testing Without Sleep Calls</h3><p>One of the best aspects of this architecture is testability. Look at this test:</p> <div class="highlight"><pre><span></span><span class="n">test</span><span class="w"> </span><span class="s2">&quot;records sale for each line item in order&quot;</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="c1"># Setup: create test data</span> <span class="w"> </span><span class="n">account</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:account</span><span class="p">)</span> <span class="w"> </span><span class="n">product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:event_product</span><span class="p">,</span><span class="w"> </span><span class="ss">account</span><span class="p">:</span><span class="w"> </span><span class="n">account</span><span class="p">)</span> <span class="w"> </span><span class="n">variant1</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:product_variant</span><span class="p">,</span><span class="w"> </span><span class="ss">product</span><span class="p">:</span><span class="w"> </span><span class="n">product</span><span class="p">,</span><span class="w"> </span><span class="ss">inventory_quantity</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">)</span> <span class="w"> </span><span class="n">variant2</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:product_variant</span><span class="p">,</span><span class="w"> </span><span class="ss">product</span><span class="p">:</span><span class="w"> </span><span class="n">product</span><span class="p">,</span><span class="w"> </span><span class="ss">inventory_quantity</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">)</span> <span class="w"> </span><span class="n">customer</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:customer</span><span class="p">)</span> <span class="w"> </span><span class="n">order</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">create_order</span><span class="p">(</span><span class="ss">customer</span><span class="p">:</span><span class="w"> </span><span class="n">customer</span><span class="p">)</span> <span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:line_item</span><span class="p">,</span><span class="w"> </span><span class="ss">order</span><span class="p">:</span><span class="w"> </span><span class="n">order</span><span class="p">,</span><span class="w"> </span><span class="ss">variant</span><span class="p">:</span><span class="w"> </span><span class="n">variant1</span><span class="p">,</span><span class="w"> </span><span class="ss">quantity</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">)</span> <span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:line_item</span><span class="p">,</span><span class="w"> </span><span class="ss">order</span><span class="p">:</span><span class="w"> </span><span class="n">order</span><span class="p">,</span><span class="w"> </span><span class="ss">variant</span><span class="p">:</span><span class="w"> </span><span class="n">variant2</span><span class="p">,</span><span class="w"> </span><span class="ss">quantity</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">)</span> <span class="w"> </span><span class="c1"># Execute: call the service layer with strong consistency</span> <span class="w"> </span><span class="ss">:ok</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Inventory</span><span class="o">.</span><span class="n">record_order_sales</span><span class="p">(</span><span class="n">order</span><span class="o">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="ss">consistency</span><span class="p">:</span><span class="w"> </span><span class="ss">:strong</span><span class="p">)</span> <span class="w"> </span><span class="c1"># Assert: query the read model immediately - no sleep needed!</span> <span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">all</span><span class="p">(</span><span class="n">from</span><span class="w"> </span><span class="n">e</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nc">InventoryEvent</span><span class="p">,</span><span class="w"> </span><span class="ss">order_by</span><span class="p">:</span><span class="w"> </span><span class="n">e</span><span class="o">.</span><span class="n">inserted_at</span><span class="p">)</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">length</span><span class="p">(</span><span class="n">events</span><span class="p">)</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="mi">2</span> <span class="w"> </span><span class="p">[</span><span class="n">event1</span><span class="p">,</span><span class="w"> </span><span class="n">event2</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">events</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event1</span><span class="o">.</span><span class="n">variant_id</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">variant1</span><span class="o">.</span><span class="n">id</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event1</span><span class="o">.</span><span class="n">quantity_adjustment</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="o">-</span><span class="mi">2</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event1</span><span class="o">.</span><span class="n">reason</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s2">&quot;sale&quot;</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event1</span><span class="o">.</span><span class="n">order_id</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">order</span><span class="o">.</span><span class="n">id</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event2</span><span class="o">.</span><span class="n">variant_id</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">variant2</span><span class="o">.</span><span class="n">id</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event2</span><span class="o">.</span><span class="n">quantity_adjustment</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="o">-</span><span class="mi">3</span> <span class="k">end</span> </pre></div> <p>This test exercises the <em>entire</em> CQRS stack:</p> <ol> <li>Service Layer (<code>Inventory.record_order_sales</code>) loads the order and constructs commands</li> <li>Router routes commands to the correct aggregate instances</li> <li>Aggregate (<code>VariantInventory.execute</code>) produces events</li> <li>EventStore persists the events</li> <li>Projector writes to the <code>inventory_events</code> table</li> <li>Database stores the read model</li> </ol> <p>And we can assert immediately after the call because <code>consistency: :strong</code> blocks until the projection completes. No <code>Process.sleep(100)</code> hoping the async work finished.</p> <p>The naive approach with eventual consistency:</p> <div class="highlight"><pre><span></span><span class="c1"># Bad: flaky, slow, non-deterministic</span> <span class="nc">Inventory</span><span class="o">.</span><span class="n">record_order_sales</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> <span class="nc">Process</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">100</span><span class="p">)</span><span class="w"> </span><span class="c1"># Hope 100ms is enough... it often isn&#39;t</span> <span class="n">event</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">one</span><span class="p">(</span><span class="nc">InventoryEvent</span><span class="p">)</span><span class="w"> </span><span class="c1"># Might still be nil!</span> </pre></div> <p>Problems with sleep:</p> <ul> <li>Unreliable: 100ms might not be enough under load, and you're always hitting the "floor" waiting time even though you may not need it. And there's no guarantees it's enough.</li> <li>Slow: 100ms per test × hundreds of tests = minutes wasted during CI and in local development.</li> <li>It is inherently non-deterministic making continuous integration flaky and unreliable.</li> </ul> <p>Strong consistency in tests solves all of this.</p> <hr /> <h2 id=closing-the-loop-waitlist-notifications>Closing the Loop: Waitlist Notifications</h2><p>Remember the business problem from the introduction? We needed to notify customers when sold-out tickets become available. With our CQRS architecture in place, we now have all the pieces to solve this.</p> <h3 id=the-missing-piece-event-handlers>The Missing Piece: Event Handlers</h3><p>Commanded provides <a href="proxy.php?url=https%3A%2F%2Fhexdocs.pm%2Fcommanded%2Fevents.html%23event-handlers">Event Handlers</a> that subscribe to the event stream and react to events. Unlike projectors (which build read models), handlers execute side effects which is perfect for triggering notifications.</p> <p>Here's our waitlist notification handler:</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.CQRS.Handlers.InventoryHandler</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="na">@moduledoc</span><span class="w"> </span><span class="sh">&quot;&quot;&quot;</span> <span class="w"> </span><span class="sh">Event handler that monitors inventory changes and triggers waitlist notifications</span> <span class="sh"> when inventory becomes available (transitions from sold out to available).</span> <span class="w"> </span><span class="sh">&quot;&quot;&quot;</span> <span class="w"> </span><span class="kn">use</span><span class="w"> </span><span class="nc">Commanded.Event.Handler</span><span class="p">,</span> <span class="w"> </span><span class="ss">application</span><span class="p">:</span><span class="w"> </span><span class="nc">Amplify.CommandedApplication</span><span class="p">,</span> <span class="w"> </span><span class="ss">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;InventoryHandler&quot;</span><span class="p">,</span> <span class="w"> </span><span class="ss">consistency</span><span class="p">:</span><span class="w"> </span><span class="ss">:strong</span> <span class="w"> </span><span class="kn">alias</span><span class="w"> </span><span class="nc">Amplify.CQRS.Events.InventoryChanged</span> <span class="w"> </span><span class="kn">require</span><span class="w"> </span><span class="nc">Logger</span> <span class="w"> </span><span class="na">@impl</span><span class="w"> </span><span class="nc">Commanded.Event.Handler</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">handle</span><span class="p">(%</span><span class="nc">InventoryChanged</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">event</span><span class="p">,</span><span class="w"> </span><span class="n">_metadata</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">inventory_became_available?</span><span class="p">(</span><span class="n">event</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="c1"># write code to handle waitlist notifications</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="ss">:ok</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="c1"># Check if inventory transitioned from sold out to available</span> <span class="w"> </span><span class="kd">defp</span><span class="w"> </span><span class="n">inventory_became_available?</span><span class="p">(%</span><span class="nc">InventoryChanged</span><span class="p">{</span><span class="ss">was_sold_out</span><span class="p">:</span><span class="w"> </span><span class="no">true</span><span class="p">,</span><span class="w"> </span><span class="ss">is_sold_out</span><span class="p">:</span><span class="w"> </span><span class="no">false</span><span class="p">})</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="no">true</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="kd">defp</span><span class="w"> </span><span class="n">inventory_became_available?</span><span class="p">(</span><span class="n">_event</span><span class="p">),</span><span class="w"> </span><span class="ss">do</span><span class="p">:</span><span class="w"> </span><span class="no">false</span> <span class="k">end</span> </pre></div> <h3 id=the-power-of-event-driven-design>The Power of Event-Driven Design</h3><p>Notice what's happening here. We didn't have to:</p> <ol> <li>Modify any existing code as the handler subscribes to the same events the projector already receives</li> <li>Add notification logic to business operations and the service layer doesn't know or care about waitlists</li> <li>Track "previous state" manually as the aggregate already computed <code>was_sold_out</code> and <code>is_sold_out</code></li> </ol> <p>The pattern match is elegant: <code>%InventoryChanged{was_sold_out: true, is_sold_out: false}</code> captures exactly the transition we care about which is inventory that <em>was</em> sold out but <em>isn't anymore</em>.</p> <h3 id=testing-event-transitions>Testing Event Transitions</h3><p>We verify the handler's detection logic by testing the events it would receive. The beautify of <code>ExUnit</code> and how easily it integrates with databases gives confidence to our tests. We do almost zero manual testing of even the most complex use cases due to a strong integration test suite.</p> <div class="highlight"><pre><span></span><span class="n">test</span><span class="w"> </span><span class="s2">&quot;event correctly tracks sold out to available transition&quot;</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">account</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:account</span><span class="p">)</span> <span class="w"> </span><span class="n">product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:event_product</span><span class="p">,</span><span class="w"> </span><span class="ss">account</span><span class="p">:</span><span class="w"> </span><span class="n">account</span><span class="p">)</span> <span class="w"> </span><span class="n">variant</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:product_variant</span><span class="p">,</span><span class="w"> </span><span class="ss">product</span><span class="p">:</span><span class="w"> </span><span class="n">product</span><span class="p">,</span><span class="w"> </span><span class="ss">inventory_quantity</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span> <span class="w"> </span><span class="n">user</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">insert</span><span class="p">(</span><span class="ss">:user</span><span class="p">)</span> <span class="w"> </span><span class="c1"># First, record sold out state</span> <span class="w"> </span><span class="ss">:ok</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Inventory</span><span class="o">.</span><span class="n">record_admin_adjustment</span><span class="p">(</span><span class="n">variant</span><span class="o">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="w"> </span><span class="c1"># Now increase inventory - this creates the waitlist trigger event</span> <span class="w"> </span><span class="ss">:ok</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Inventory</span><span class="o">.</span><span class="n">record_admin_adjustment</span><span class="p">(</span><span class="n">variant</span><span class="o">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w"> </span><span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">all</span><span class="p">(</span><span class="n">from</span><span class="w"> </span><span class="n">e</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nc">InventoryEvent</span><span class="p">,</span><span class="w"> </span><span class="ss">order_by</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="ss">desc</span><span class="p">:</span><span class="w"> </span><span class="n">e</span><span class="o">.</span><span class="n">inserted_at</span><span class="p">],</span><span class="w"> </span><span class="ss">limit</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span> <span class="w"> </span><span class="p">[</span><span class="n">event</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">events</span> <span class="w"> </span><span class="c1"># This event represents the exact transition the handler looks for</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">was_sold_out</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="no">true</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">is_sold_out</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="no">false</span> <span class="w"> </span><span class="n">assert</span><span class="w"> </span><span class="n">event</span><span class="o">.</span><span class="n">quantity_remaining</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="mi">50</span> <span class="k">end</span> </pre></div> <h3 id=why-this-architecture-shines>Why This Architecture Shines</h3><p>This is where CQRS pays off. The business asked: "Can we notify people when tickets become available?" With traditional CRUD, we'd need to:</p> <ol> <li>Find every place inventory gets updated</li> <li>Add "was it sold out before?" checks to each location</li> <li>Hope we didn't miss any code paths</li> <li>Couple notification logic to inventory operations</li> </ol> <p>With event sourcing, we added one handler that subscribes to the event stream. Every inventory change - sales, returns, swaps, admin adjustments - flows through the same pipeline. The handler sees them all, filters for the transition it cares about, and triggers notifications.</p> <p>The aggregate already tracked the state transition (<code>was_sold_out</code> → <code>is_sold_out</code>) because we designed events to capture complete before/after context. We can't anticipate what features are needed next, but this design gives us extensibility as new features become subscribers to existing events, not modifications to existing code. This is fundamentally why we decided the complexity was worth it.</p> <hr /> <h2 id=challenges-and-resolutions>Challenges and Resolutions</h2><h3 id=eventstore-setup-on-managed-postgresql>EventStore Setup on Managed PostgreSQL</h3><p>When deploying to production on DigitalOcean's managed PostgreSQL, we hit an issue:</p> <div class="highlight"><pre><span></span>** (Postgrex.Error) ERROR 3D000 (invalid_catalog_name): database &quot;postgres&quot; does not exist </pre></div> <p>The Problem was that <code>EventStore.Tasks.Create.exec</code> connects to a <code>postgres</code> maintenance database to create the EventStore database. Managed PostgreSQL often doesn't have this default database so we needed to specify a <code>default_database</code> in our event store configuration which wasn't needed locally.</p> <h3 id=swap-operations-spanning-two-variants>Swap Operations Spanning Two Variants</h3><p>Ticket swaps move inventory from one variant to another. But with per-variant aggregates, we can't atomically update both.</p> <p>The solution, as earlier touched on, was to dispatch two commands and correlate via <code>order_id</code>:</p> <div class="highlight"><pre><span></span><span class="kd">def</span><span class="w"> </span><span class="n">record_swap</span><span class="p">(</span><span class="n">order_id</span><span class="p">,</span><span class="w"> </span><span class="n">swapped_out_variant_id</span><span class="p">,</span><span class="w"> </span><span class="n">swapped_in_variant_id</span><span class="p">,</span><span class="w"> </span><span class="n">opts</span><span class="w"> </span><span class="p">\\</span><span class="w"> </span><span class="p">[])</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">order</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Orders</span><span class="o">.</span><span class="n">get_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> <span class="w"> </span><span class="n">quantity</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">get_swap_quantity</span><span class="p">(</span><span class="n">order</span><span class="p">,</span><span class="w"> </span><span class="n">swapped_out_variant_id</span><span class="p">)</span> <span class="w"> </span><span class="c1"># Two separate commands, same order_id for correlation</span> <span class="w"> </span><span class="n">dispatch</span><span class="p">(%</span><span class="nc">RecordSwapOut</span><span class="p">{</span> <span class="w"> </span><span class="ss">variant_id</span><span class="p">:</span><span class="w"> </span><span class="n">swapped_out_variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">order_id</span><span class="p">:</span><span class="w"> </span><span class="n">order_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_returned</span><span class="p">:</span><span class="w"> </span><span class="n">quantity</span> <span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="n">opts</span><span class="p">)</span> <span class="w"> </span><span class="n">dispatch</span><span class="p">(%</span><span class="nc">RecordSwapIn</span><span class="p">{</span> <span class="w"> </span><span class="ss">variant_id</span><span class="p">:</span><span class="w"> </span><span class="n">swapped_in_variant_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">order_id</span><span class="p">:</span><span class="w"> </span><span class="n">order_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">quantity_sold</span><span class="p">:</span><span class="w"> </span><span class="n">quantity</span> <span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="n">opts</span><span class="p">)</span> <span class="w"> </span><span class="ss">:ok</span> <span class="k">end</span> </pre></div> <p>For audit purposes, this is fine. We can query both events by <code>order_id</code> to see the complete swap.</p> <hr /> <h2 id=lessons-learned>Lessons Learned</h2><h3 id=start-simple>Start Simple</h3><p>A single event type with a <code>reason</code> field was the right starting point. We can always split into <code>InventorySold</code>, <code>InventoryReturned</code>, etc. later if we need stronger typing. Starting with many event types adds complexity before you understand the domain.</p> <h3 id=commands-capture-intent-events-capture-facts>Commands Capture Intent, Events Capture Facts</h3><p>Commands describe what you want to do: "Record a sale of 2 tickets." Events describe what happened: "Inventory changed, reason: sale, adjustment: -2." This separation is where the audit value comes from.</p> <h3 id=projector-enrichment-is-powerful>Projector Enrichment is Powerful</h3><p>Keeping commands minimal (<code>variant_id</code> only) and letting the projector derive <code>product_id</code> and <code>account_id</code> kept the command interface clean. The projector can afford the extra query; commands should be lightweight.</p> <h3 id=make-room-for-side-effects>Make Room for Side Effects</h3><p>The handlers feature for Commander is critical for implementing side effects. It is an extensible escape hatch where you can do whatever you like (within reason) and aren't tied to CQRS rules like avoiding side effects in aggregate state mutations, always ensuring methods which transform commands to events don't fail, etc.</p> <h3 id=strong-consistency-has-its-place>Strong Consistency Has Its Place</h3><p>Despite CQRS literature emphasizing eventual consistency, having the option for strong consistency was essential for:</p> <ul> <li>Admin UI responsiveness</li> <li>Deterministic tests</li> <li>Critical operations where "fire and forget" isn't acceptable</li> </ul> <hr /> <h2 id=conclusion>Conclusion</h2><h3 id=the-aggregate-design-decision>The Aggregate Design Decision</h3><p>The most impactful choice wasn't whether to use CQRS - it was aggregate sizing.</p> <table> <thead> <tr> <th>Consideration</th> <th>Large Aggregates</th> <th>Small Aggregates</th> </tr> </thead> <tbody> <tr> <td>Invariant enforcement</td> <td>Strong (atomic)</td> <td>Weak (eventual)</td> </tr> <tr> <td>Contention under load</td> <td>High</td> <td>Low</td> </tr> <tr> <td>Event stream size</td> <td>Large, slow rebuild</td> <td>Small, fast rebuild</td> </tr> <tr> <td>Cognitive load</td> <td>Higher</td> <td>Lower</td> </tr> <tr> <td>Cross-entity operations</td> <td>Single command</td> <td>Multiple commands</td> </tr> </tbody> </table> <p>For inventory tracking, small aggregates (per-variant) won because:</p> <ol> <li>No cross-variant invariants require atomic enforcement</li> <li>High concurrency demands low contention</li> <li>Simple aggregates are easier to debug and evolve</li> </ol> <h3 id=key-takeaways>Key Takeaways</h3><ul> <li>Start with the smallest aggregate boundary that makes sense for your domain</li> <li>Use strong consistency selectively: admin UIs and tests, not background processing</li> <li>Encapsulate CQRS behind a service layer: callers shouldn't know about commands</li> <li>Design events with before/after context - you'll thank yourself when new features need state transitions</li> <li>The "right" aggregate size depends on your invariants, not DDD orthodoxy</li> <li>Decide what is a "running total" versus what is a snapshot in your read-only projections; this will depend on the business problem you're trying to solve</li> </ul> <h3 id=should-you-use-cqrs>Should You Use CQRS?</h3><p>Before reaching for CQRS, evaluate whether your audit needs justify the complexity.</p> <p>If you need "who changed what when", simple logging might suffice. If you need "why did this change and what was the business intent", CQRS shines. If you need "react to state transitions across the system", CQRS with event handlers is ideal.</p> <p>For our inventory tracking, the explicit command-driven approach forces developers to think about <em>why</em> inventory changes. That's where the audit value comes from. And when the business asked "can you also notify waitlisted customers when tickets are no longer available?" we added a single event handler with no modifications to existing code. That's the real payoff of event-driven architecture, and that's why the added complexity was worth it.</p> 2025-12-29T16:27:00+00:00 https://zarar.dev/building-a-double-entry-payment-system-in-elixir/ Building a Double-Entry Payment System in Elixir 2025-12-31T03:01:16.616379+00:00 zarar hidden <p>Multi-tenant platforms which facilitate e-commerce transactions hold people's money. Getting that money to the right people, in the right amounts, at the right time, is the core trust contract with your partners. This is the most critical and sensitive component in our system as if it fails, trust erodes quickly.</p> <p>We needed a payment engine that could collect funds from multiple sources, track what's owed across different transaction types, batch payments to minimize fees, and handle the messy reality of refunds, multi-currency, and timing mismatches.</p> <p>This post covers how we:</p> <ol> <li>Implementing double-entry bookkeeping in software: advantages, challenges, and key design decisions</li> <li>Database schema design for ledgers, transactions, and journal entries</li> <li>Transaction states, batching strategy, and idempotency</li> <li>The complete payout pipeline from event completion to funds disbursement</li> <li>Extensibility: how the system accommodates new transaction types cleanly through extension</li> </ol> <h2 id=the-business-problem>The Business Problem</h2><h3 id=paying-people-accurately-and-on-time>Paying People Accurately and On Time</h3><p>Event organizers, comedians, and venue partners rely on receiving exactly what they're owed, when they expect it. Someone who performed on Saturday expects their cut by Tuesday at the latest, as cash flow in the industry is generally tight. We are dealing with people's livelihoods and they have trusted us with one of the most important parts of their business: selling shows and collecting funds.</p> <h3 id=money-flows-both-directions>Money Flows Both Directions</h3><p>Customers buy tickets (revenue flows in), but accounts also purchase card readers, run ads, or get charged service fees (charges flow out). Meanwhile, they earn referral bonuses and tips. The net result: at any given moment, someone either owes us money or we owe them and this account needs to be settled cleanly. Often times clubs have multiple shows where different accounts need to be paid even though all the performances were at the same club.</p> <h3 id=minimizing-transaction-costs>Minimizing Transaction Costs</h3><p>Tipalti, our disbursement provider, charges $2.10 per outgoing payment. If we paid each transaction individually, we would bleed money as 50 payouts would cost us $105 in fees, making the business model untenable. Batching them into one payment at the right time and covering a reasonable span of transactions is key to minimizing transaction costs. Across hundreds of accounts paying out weekly, this compounds into significant savings, but this has to be balanced with timing.</p> <h3 id=multi-currency-multi-region-support>Multi-Currency, Multi-Region Support</h3><p>Events happen in USD, CAD, GBP, EUR. An organizer in London running shows might have ticket sales in pounds and euros, or if they're Canadian, in CAD. We must track revenue per currency, convert correctly, and pay in their preferred currency. Tipalti handles the actual disbursement mechanics, but we must get the numbers right.</p> <h3 id=one-invoice-many-line-items>One Invoice, Many Line Items</h3><p>An organizer's weekly payout isn't just "event revenue." It might include:</p> <ul> <li>Event ticket sales: +$500</li> <li>Tips earned: +$45</li> <li>Service fee split: +$30</li> <li>Card reader purchase: -$75</li> <li>Meta ad spend: -$120</li> <li><strong>Net payout: $380</strong></li> </ul> <p>All of these must appear on the same statement, clearly itemized.</p> <h3 id=the-refund-timing-problem>The Refund Timing Problem</h3><p>A customer requests a refund on Monday. The event happened Saturday. We already paid the organizer on Sunday. That refund must be accounted for retroactively—deducted from their next payout—and clearly tracked so they understand why this week's payment is lower.</p> <h3 id=why-not-off-the-shelf>Why Not Off-the-Shelf?</h3><p>Payment platforms like Stripe Connect handle simpler splits, but our requirements of having multiple transaction types flowing both directions, batched payouts to external payment processors, multi-currency with conversion, and retroactive adjustments, require a purpose-built accounting layer.</p> <p>We explored Stripe Connected Accounts feature but payment processing delays, and Stripe's unintuitive Connected Account interface was causing a lot of confusion for customers. Their onboarding process was also problematic and often required manual intervention which our customers found annoying. We also explored paying people using services like Interac E-Transfer but hit usage limits very quickly.</p> <h2 id=double-entry-bookkeeping-key-design-decisions>Double-Entry Bookkeeping & Key Design Decisions</h2><h3 id=why-double-entry>Why Double-Entry?</h3><p>We took our inspiration from <a href="proxy.php?url=https%3A%2F%2Fdeveloper.squareup.com%2Fblog%2Fbooks-an-immutable-double-entry-accounting-database-service%2F">Square</a> and the <a href="proxy.php?url=https%3A%2F%2Fnews.alvaroduran.com%2F">Payments Engineer blog</a> which have some guidance on how to think about the complexity tradeoffs that double-entry bookkeeping presents. In short, it's worth it if you can reason about it.</p> <p>Every money movement creates balanced journal entries. If cash increases (debit), accounts_payable increases (credit). The books always balance, providing an immutable audit trail and catching errors immediately. This is an unintuitive approach unless you're an accountant or in finance, but it creates immutability and an audit trail of what happened and why.</p> <p>By thinking about money movements as inflows and outflows in certain ledgers, rather than the maintenance of a specific balance, we're able to "replay" history more easily and derive rather than store balances.</p> <h3 id=advantages-and-challenges-in-an-it-system>Advantages and Challenges in an IT System</h3><p>Though double-entry bookkeeping is much more complex to implement than maintaining a simple balance amount, it has its clear advantages:</p> <ul> <li><p><strong>Self-validating</strong> — If debits don't equal credits, something is wrong. No silent data corruption. Your accounting becomes intrinsically verifiable by examining individual transactions and the ledgers they impacted, rather than reverse-engineering why a balance is what it is.</p> </li> <li><p><strong>Complete temporal audit trail</strong> — Every cent is traceable to its source transaction. Every money movement is the result of <em>something</em> changing, and that something is reflected as a transaction. The transaction stores what's changed, not the current state, so we can always ask the question "what was the balance three days ago" because we'd examine the "projections" (CQRS anyone?) up until three days ago.</p> </li> <li><p><strong>Flexible reporting</strong> — Generate balance sheets, cash flow statements, and account summaries from the same data becomes easy due to the comprehensive audit trail.</p> </li> <li><p><strong>Refunds are natural</strong> — A refund is simply the reverse entry pattern. No special logic needed. We just increment/decrement (or debit/credit) in the opposite manner.</p> </li> </ul> <p>And challenges:</p> <ul> <li><p><strong>Cognitive overhead</strong> — Developers must think in debits/credits, not just "add money" or "subtract money." This is not an easy hurdle to get past, and having a good understanding of core accounting principles helps (I did an MBA a while back and that came in handy here).</p> </li> <li><p><strong>Schema complexity</strong> — More tables, more joins, more migrations than a simple <code>balance</code> column.</p> </li> <li><p><strong>Query complexity</strong> — Calculating a balance requires summing journal entries, not reading a single field. It is easy to get the queries wrong if you don't understand how a "purchase" vs a "charge" is recorded, i.e., what ledgers are modified and how.</p> </li> <li><p><strong>Eventual consistency concerns</strong> — Must ensure both sides of an entry are written atomically (solved with database transactions). And in cases where transactions span network boundaries, compensation is required. In our case, using the <a href="proxy.php?url=https%3A%2F%2Fhexdocs.pm%2Freactor%2Freadme.html">Reactor</a> library.</p> </li> </ul> <h2 id=database-design-data-model>Database Design & Data Model</h2><p>Here's the key elements of the data model and a brief description, more on this in the later sections:</p> <ul> <li>Account - a user's account in the system</li> <li>Payment Destination - something like PayPal, a bank account</li> <li>Tip, MetaAd - business objects which demand a need for payments</li> <li>Transaction - models a singular business money movement</li> <li>LedgerType - an accounting book, e.g., accounts payable</li> <li>Ledger - an accounting book for a particular account</li> <li>PayoutTransaction - shares the same physical table as Transaction (with a different type) but models outgoing money movements and is composed of other transaction types (e.g., event revenue). This is a powerful concept in the system.</li> </ul> <h3 id=entity-relationship-diagram>Entity Relationship Diagram</h3><p><img src="proxy.php?url=https%3A%2F%2Fbear-images.sfo2.cdn.digitaloceanspaces.com%2Fzarar%2Fpayment-system-double-entry-accounting-er-diagram.svg" alt="er-diagram" /></p> <div style="margin: 4px 0; text-align: center"> <a href="proxy.php?url=https%3A%2F%2Fbear-images.sfo2.cdn.digitaloceanspaces.com%2Fzarar%2Fpayment-system-double-entry-accounting-er-diagram.svg" target="_blank">Open in New Window</a> </div> <h3 id=transaction-types-and-journal-entry-patterns>Transaction Types and Journal Entry Patterns</h3><table> <thead> <tr> <th>Transaction Type</th> <th>Debit</th> <th>Credit</th> <th>Business Meaning</th> </tr> </thead> <tbody> <tr> <td>event_revenue</td> <td>cash</td> <td>accounts_payable</td> <td>We collected ticket sales, owe organizer</td> </tr> <tr> <td>tips_earned</td> <td>cash</td> <td>accounts_payable</td> <td>We collected tips, owe organizer</td> </tr> <tr> <td>gift_card_revenue</td> <td>cash</td> <td>accounts_payable</td> <td>Gift card redeemed for event</td> </tr> <tr> <td>service_fee_split</td> <td>cash</td> <td>accounts_payable</td> <td>Organizer's share of service fees</td> </tr> <tr> <td>customer_cashback</td> <td>accounts_payable</td> <td>cash</td> <td>Referral reward owed to customer</td> </tr> <tr> <td>refund</td> <td>accounts_payable</td> <td>cash</td> <td>We refunded customer, owe organizer less</td> </tr> <tr> <td>purchase</td> <td>accounts_payable</td> <td>cash</td> <td>Organizer bought something (card reader)</td> </tr> <tr> <td>ads</td> <td>accounts_payable</td> <td>cash</td> <td>Ad spend charged to organizer</td> </tr> <tr> <td>payout</td> <td>payouts</td> <td>cash</td> <td>We disbursed funds to organizer</td> </tr> </tbody> </table> <h3 id=core-bookkeeping-function>Core Bookkeeping Function</h3><div class="highlight"><pre><span></span><span class="kd">defp</span><span class="w"> </span><span class="n">create_journal_entry</span><span class="p">(</span><span class="n">ledger</span><span class="p">,</span><span class="w"> </span><span class="n">transaction</span><span class="p">,</span><span class="w"> </span><span class="n">entry_type</span><span class="p">,</span><span class="w"> </span><span class="p">%{</span> <span class="w"> </span><span class="s2">&quot;amount&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span> <span class="w"> </span><span class="s2">&quot;currency&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">currency</span> <span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="p">%</span><span class="nc">JournalEntry</span><span class="p">{</span> <span class="w"> </span><span class="ss">ledger</span><span class="p">:</span><span class="w"> </span><span class="n">ledger</span><span class="p">,</span> <span class="w"> </span><span class="ss">transaction</span><span class="p">:</span><span class="w"> </span><span class="n">transaction</span><span class="p">,</span> <span class="w"> </span><span class="ss">type</span><span class="p">:</span><span class="w"> </span><span class="n">entry_type</span><span class="p">,</span> <span class="w"> </span><span class="ss">amount</span><span class="p">:</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span> <span class="w"> </span><span class="ss">currency</span><span class="p">:</span><span class="w"> </span><span class="n">currency</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">insert!</span><span class="p">()</span> <span class="k">end</span> </pre></div> <p>This simple function is the foundation of the entire system. Every money movement flows through it. Two ledger entries are created in the appropriate ledgers for every transaction, providing us complete traceability on which direction money moved, what account was impacted, and for what reason. The <code>JournalEntry</code> -> <code>Transaction</code> -> <code>Business Table</code> relationship is at the heart of the system and provides verifiability and reversibility at any point.</p> <h2 id=transaction-states-batching-idempotency>Transaction States, Batching & Idempotency</h2><h3 id=finite-state-machine-for-transactions>Finite State Machine for Transactions</h3><p>Most transaction types, for example <code>event_revenue</code> which represents money earned from an event follows this (simplified) pattern:</p> <div class="highlight"><pre><span></span>pending → waiting_for_payout → settled </pre></div> <ul> <li><strong>pending</strong> — Transaction recorded, awaiting payout batch</li> <li><strong>waiting_for_payout</strong> — Included in a payout batch, not yet sent</li> <li><strong>settled</strong> — Payment executed successfully</li> </ul> <p>States prevent double-payments, enable clear reporting ("what's been paid vs pending"), and allow recovery if processes fails mid-batch. Being in a particular state implies that all previous rules concerning that state have been satisfied. For example, if a transaction of type <code>event_revenue</code> is in <code>waiting_for_payout</code> we can assume that the total payment amount has been calculated, the account has not been paid, and will be paid on the next scheduled run. These are corollaries of a transaction of a particular type being in a particular state.</p> <p>The combination of transaction type plus transaction state gives us a very clear view on how the money is moving.</p> <h3 id=the-batching-algorithm>The Batching Algorithm</h3><p>To minimize transaction costs, handle multi currencies, and allow the same account to have funds dispersed to many different bank accounts, we batch the transactions and introduce the concept of a <code>payment_destination</code> which has many-to-one relation with an <code>account</code>.</p> <p>The <code>create_pending_payout_transactions/0</code> groups all pending transactions by <code>(currency, payment_destination_id, account_id)</code>. Each group becomes one payout transaction, regardless of how many underlying transactions exist.</p> <div class="highlight"><pre><span></span><span class="kd">def</span><span class="w"> </span><span class="n">create_pending_payout_transactions</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">transaction</span><span class="p">(</span><span class="k">fn</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="c1"># Fetch all pending transactions grouped by currency, payment_destination_id, and account_id</span> <span class="w"> </span><span class="n">pending_transactions</span><span class="w"> </span><span class="o">=</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">all</span><span class="p">(</span> <span class="w"> </span><span class="n">from</span><span class="w"> </span><span class="n">je</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nc">JournalEntry</span><span class="p">,</span> <span class="w"> </span><span class="ss">join</span><span class="p">:</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="n">assoc</span><span class="p">(</span><span class="n">je</span><span class="p">,</span><span class="w"> </span><span class="ss">:transaction</span><span class="p">),</span> <span class="w"> </span><span class="ss">join</span><span class="p">:</span><span class="w"> </span><span class="n">l</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="n">assoc</span><span class="p">(</span><span class="n">je</span><span class="p">,</span><span class="w"> </span><span class="ss">:ledger</span><span class="p">),</span> <span class="w"> </span><span class="ss">where</span><span class="p">:</span> <span class="w"> </span><span class="n">t</span><span class="o">.</span><span class="n">status</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="w"> </span><span class="ow">and</span> <span class="w"> </span><span class="n">t</span><span class="o">.</span><span class="n">type</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="s2">&quot;event_revenue&quot;</span><span class="p">,</span> <span class="w"> </span><span class="s2">&quot;refund&quot;</span><span class="p">,</span> <span class="w"> </span><span class="n">...</span> <span class="w"> </span><span class="p">],</span> <span class="w"> </span><span class="ss">group_by</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="n">fragment</span><span class="p">(</span><span class="s2">&quot;lower(?)&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">je</span><span class="o">.</span><span class="n">currency</span><span class="p">),</span><span class="w"> </span><span class="n">t</span><span class="o">.</span><span class="n">payment_destination_id</span><span class="p">,</span><span class="w"> </span><span class="n">l</span><span class="o">.</span><span class="n">account_id</span><span class="p">],</span> <span class="w"> </span><span class="ss">select</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="n">fragment</span><span class="p">(</span><span class="s2">&quot;lower(?)&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">je</span><span class="o">.</span><span class="n">currency</span><span class="p">),</span><span class="w"> </span><span class="n">t</span><span class="o">.</span><span class="n">payment_destination_id</span><span class="p">,</span><span class="w"> </span><span class="n">l</span><span class="o">.</span><span class="n">account_id</span><span class="p">}</span> <span class="w"> </span><span class="p">)</span> <span class="w"> </span><span class="c1"># Process each group to create a payout transaction</span> <span class="w"> </span><span class="nc">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">pending_transactions</span><span class="p">,</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="p">{</span><span class="n">currency</span><span class="p">,</span><span class="w"> </span><span class="n">payment_destination_id</span><span class="p">,</span><span class="w"> </span><span class="n">account_id</span><span class="p">}</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="n">payout_amount</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">calculate_payout_amount</span><span class="p">(</span><span class="n">account_id</span><span class="p">,</span><span class="w"> </span><span class="n">currency</span><span class="p">,</span><span class="w"> </span><span class="n">payment_destination_id</span><span class="p">)</span> <span class="w"> </span><span class="c1"># ... create or update payout transaction</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="k">end</span> </pre></div> <p>This is the central algorithm which looks at transactions and their journal entries, and calculates how much is owed to each account, and within each account, how funds must be disbursed to different payment destinations (e.g., PayPal, Bank).</p> <h3 id=idempotency-by-design>Idempotency by Design</h3><p>It is important that these processes are not time-dependent and can be run at any time without affecting overall amounts. Processes/jobs should be able to be run multiple times without negative consequences, i.e., they need to be idempotent.</p> <ul> <li><code>create_pending_payout_transactions/0</code> is idempotent—if a pending payout already exists for a destination, it updates the amount rather than creating a duplicate.</li> <li>If a job fails and retries, or runs twice, the result is the same.</li> <li>No orchestration dependencies—event revenue, tips, refunds, and ads can be processed in any sequence. The batching step always queries current state and produces correct results.</li> </ul> <div class="highlight"><pre><span></span><span class="n">transaction</span><span class="w"> </span><span class="o">=</span> <span class="w"> </span><span class="k">case</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">get_by</span><span class="p">(</span><span class="nc">Transaction</span><span class="p">,</span> <span class="w"> </span><span class="ss">type</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;payout&quot;</span><span class="p">,</span> <span class="w"> </span><span class="ss">status</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span> <span class="w"> </span><span class="ss">payment_destination_id</span><span class="p">:</span><span class="w"> </span><span class="n">payment_destination_id</span> <span class="w"> </span><span class="p">)</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">preload</span><span class="p">(</span><span class="ss">:journal_entries</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="no">nil</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="c1"># Create new payout transaction</span> <span class="w"> </span><span class="p">%</span><span class="nc">Transaction</span><span class="p">{</span> <span class="w"> </span><span class="ss">type</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;payout&quot;</span><span class="p">,</span> <span class="w"> </span><span class="ss">status</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span> <span class="w"> </span><span class="ss">payment_destination_id</span><span class="p">:</span><span class="w"> </span><span class="n">payment_destination_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">transaction_date</span><span class="p">:</span><span class="w"> </span><span class="nc">DateTime</span><span class="o">.</span><span class="n">utc_now</span><span class="p">()</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">insert!</span><span class="p">()</span> <span class="w"> </span><span class="n">existing_pending_payout_transaction</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="c1"># Update existing - idempotent!</span> <span class="w"> </span><span class="nc">Enum</span><span class="o">.</span><span class="n">each</span><span class="p">(</span><span class="n">existing_pending_payout_transaction</span><span class="o">.</span><span class="n">journal_entries</span><span class="p">,</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="n">je</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="n">je</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Ecto.Changeset</span><span class="o">.</span><span class="n">change</span><span class="p">(%{</span><span class="ss">amount</span><span class="p">:</span><span class="w"> </span><span class="n">payout_amount</span><span class="p">})</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">update!</span><span class="p">()</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="w"> </span><span class="n">existing_pending_payout_transaction</span> <span class="w"> </span><span class="k">end</span> </pre></div> <h3 id=calculating-net-payout>Calculating Net Payout</h3><p>Calculating payouts is all about consulting the <code>accounts_payable</code> ledger as that keeps track of how much money is owed to a customer:</p> <div class="highlight"><pre><span></span><span class="kd">defp</span><span class="w"> </span><span class="n">calculate_payout_amount</span><span class="p">(</span><span class="n">account_id</span><span class="p">,</span><span class="w"> </span><span class="n">currency</span><span class="p">,</span><span class="w"> </span><span class="n">_payment_destination_id</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">from</span><span class="p">(</span><span class="n">je</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nc">JournalEntry</span><span class="p">,</span> <span class="w"> </span><span class="ss">join</span><span class="p">:</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="n">assoc</span><span class="p">(</span><span class="n">je</span><span class="p">,</span><span class="w"> </span><span class="ss">:transaction</span><span class="p">),</span> <span class="w"> </span><span class="ss">join</span><span class="p">:</span><span class="w"> </span><span class="n">l</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="n">assoc</span><span class="p">(</span><span class="n">je</span><span class="p">,</span><span class="w"> </span><span class="ss">:ledger</span><span class="p">),</span> <span class="w"> </span><span class="ss">join</span><span class="p">:</span><span class="w"> </span><span class="n">lt</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="n">assoc</span><span class="p">(</span><span class="n">l</span><span class="p">,</span><span class="w"> </span><span class="ss">:ledger_type</span><span class="p">),</span> <span class="w"> </span><span class="ss">where</span><span class="p">:</span> <span class="w"> </span><span class="n">lt</span><span class="o">.</span><span class="n">type</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s2">&quot;accounts_payable&quot;</span><span class="w"> </span><span class="ow">and</span> <span class="w"> </span><span class="n">l</span><span class="o">.</span><span class="n">account_id</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="o">^</span><span class="n">account_id</span><span class="w"> </span><span class="ow">and</span> <span class="w"> </span><span class="n">fragment</span><span class="p">(</span><span class="s2">&quot;lower(?)&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">je</span><span class="o">.</span><span class="n">currency</span><span class="p">)</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">fragment</span><span class="p">(</span><span class="s2">&quot;lower(?)&quot;</span><span class="p">,</span><span class="w"> </span><span class="o">^</span><span class="n">currency</span><span class="p">)</span><span class="w"> </span><span class="ow">and</span> <span class="w"> </span><span class="n">t</span><span class="o">.</span><span class="n">status</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;waiting_for_payout&quot;</span><span class="p">],</span> <span class="w"> </span><span class="ss">group_by</span><span class="p">:</span><span class="w"> </span><span class="n">je</span><span class="o">.</span><span class="n">type</span><span class="p">,</span> <span class="w"> </span><span class="ss">select</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="n">je</span><span class="o">.</span><span class="n">type</span><span class="p">,</span><span class="w"> </span><span class="n">sum</span><span class="p">(</span><span class="n">je</span><span class="o">.</span><span class="n">amount</span><span class="p">)}</span> <span class="w"> </span><span class="p">)</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">all</span><span class="p">()</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Enum</span><span class="o">.</span><span class="n">into</span><span class="p">(%{})</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="n">then</span><span class="p">(</span><span class="k">fn</span><span class="w"> </span><span class="n">totals</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="p">(</span><span class="n">totals</span><span class="p">[</span><span class="ss">:cr</span><span class="p">]</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="p">(</span><span class="n">totals</span><span class="p">[</span><span class="ss">:dr</span><span class="p">]</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="k">end</span> </pre></div> <p>Result can be positive (we owe them), zero (nothing to pay), or negative (they owe us—carried forward).</p> <hr /> <h2 id=key-design-decisions>Key Design Decisions</h2><h4 id=1-transaction-type-drives-journal-entries>1. Transaction Type Drives Journal Entries</h4><p>We faced a choice: should the system rely on tracking events by creating specific ledgers for different transactions (e.g., tips, hardware purchases etc.) and then summing debits and credits, or by looking at transaction type and status? We chose the latter for its simplicity while .</p> <p>Transaction type and journal entries are related but orthogonal concepts:</p> <ul> <li><strong>Transaction type</strong> answers: "What happened?" (event_revenue, refund, purchase, payout)</li> <li><strong>Journal entries</strong> answer: "How did it affect money movement?"</li> </ul> <p>When creating a transaction, the type determines which journal entry pattern to apply:</p> <div class="highlight"><pre><span></span>event_revenue → DR cash, CR accounts_payable refund → DR accounts_payable, CR cash payout → DR payouts, CR cash </pre></div> <p>Alternately, we could have created a <code>event_cash</code> and <code>event_payable</code> book and examine those, but we found it overkill to reason about so many ledgers. Besides, we can always extend them at no cost. But querying <code>WHERE type = 'event_revenue' AND status = 'pending'</code> is far more intuitive than traversing specific journals based on type to reconstruct intent. Type alone is sufficient.</p> <h4 id=2-bridging-accounting-and-business-domains-via-codetransaction_idcode>2. Bridging Accounting and Business Domains via <code>transaction_id</code></h4><p>Transactions are generic accounting records as they know about amounts, currencies, and ledger entries. But the business needs to answer questions like "which tips are included in this payout?" or "what ad campaigns does this charge cover?"</p> <p>We solve this by adding <code>transaction_id</code> as a foreign key on business domain tables:</p> <table> <thead> <tr> <th>Business Table</th> <th>Links To</th> <th>Purpose</th> </tr> </thead> <tbody> <tr> <td><code>Tip</code></td> <td>Transaction</td> <td>Which tips were paid in this transaction</td> </tr> <tr> <td><code>ProfitSharingFee</code></td> <td>Transaction</td> <td>Which service fee splits are included</td> </tr> <tr> <td><code>ReferralReward</code></td> <td>Transaction</td> <td>Which cashback rewards were processed</td> </tr> <tr> <td><code>MetaAd</code></td> <td>Transaction</td> <td>Which ad charges are covered</td> </tr> <tr> <td><code>VariantTransaction</code></td> <td>Transaction + Variant</td> <td>Which event variants generated this revenue</td> </tr> </tbody> </table> <p>This creates bidirectional traceability:</p> <ul> <li>Accounting → Business: Given a transaction, find all the tips/ads/rewards it covers</li> <li>Business → Accounting: Given a tip, find which transaction (and ultimately which payout) included it</li> </ul> <p>When creating a transaction, we update the business records:</p> <div class="highlight"><pre><span></span><span class="c1"># In create_tips_earned_transaction</span> <span class="nc">Repo</span><span class="o">.</span><span class="n">update_all</span><span class="p">(</span> <span class="w"> </span><span class="n">from</span><span class="p">(</span><span class="n">t</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nc">Tip</span><span class="p">,</span><span class="w"> </span><span class="ss">where</span><span class="p">:</span><span class="w"> </span><span class="n">t</span><span class="o">.</span><span class="n">id</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="o">^</span><span class="n">tip_ids</span><span class="p">),</span> <span class="w"> </span><span class="ss">set</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="ss">transaction_id</span><span class="p">:</span><span class="w"> </span><span class="n">transaction</span><span class="o">.</span><span class="n">id</span><span class="p">]</span> <span class="p">)</span> </pre></div> <p>This means payment summaries can show "Tips from 12 orders: $45" with full drill-down capability.</p> <h4 id=3-per-account-ledgers-not-global-ledgers>3. Per-Account Ledgers, Not Global Ledgers</h4><p>Each account gets its own set of ledgers (cash, accounts_payable, payouts). We chose per-account because:</p> <ul> <li>Queries for account balances are simpler</li> <li>Natural isolation between accounts</li> <li>Ledger-level reporting is account-scoped by default</li> <li><code>setup_ledgers(account_id)</code> lazily creates ledgers on first transaction—no upfront provisioning needed</li> </ul> <h4 id=4-payment-destination-as-the-batching-key>4. Payment Destination as the Batching Key</h4><p>Transactions are grouped by <code>(currency, payment_destination_id, account_id)</code>, not just account. This allows:</p> <ul> <li>One account to have multiple payment destinations (UK bank for GBP, US bank for USD)</li> <li>Different payout schedules per destination if needed</li> <li>Clean separation when an account changes bank details mid-cycle</li> <li>Allows for different events to be paid to different payment destinations within the same account</li> </ul> <h4 id=5-amounts-in-cents-as-integers>5. Amounts in Cents as Integers</h4><p>All amounts are stored as integers representing the smallest currency unit (cents, pence). No floating point arithmetic means no rounding errors accumulating across thousands of transactions.</p> <h4 id=6-status-lives-on-transaction-not-journalentry>6. Status Lives on Transaction, Not JournalEntry</h4><p>Journal entries don't have independent status—they inherit from their parent transaction. This avoids the complexity of partially-settled transactions and keeps state management in one place.</p> <h4 id=7-join-table-for-payout-associations>7. Join Table for Payout Associations</h4><p>Rather than adding <code>payout_transaction_id</code> to the Transaction table, we use a separate <code>PayoutTransaction</code> join table. This:</p> <ul> <li>Preserves the original transaction record unchanged</li> <li>Allows a transaction to theoretically be part of multiple payout attempts (retry scenarios)</li> <li>Makes "what was included in this payout?" a clean query</li> </ul> <h4 id=8-atomic-transaction-creation>8. Atomic Transaction Creation</h4><p>Every accounting operation wraps in <code>Repo.transaction/1</code>. If creating the second journal entry fails, the first is rolled back. The books are never unbalanced, even momentarily. As mentioned earlier, the Reactor library is used to compensate/undo transactions when API calls to disbursement system fails.</p> <h4 id=9-tax-handling>9. Tax Handling</h4><p>There were two main strategies of handling tax. Either reflect them in the ledgers as separate transactions and have their own "tax_payable" or similar book. Or reflect tax amounts as part of the business data, e.g, the <code>event_revenue</code> table's <code>tax_collected</code> field.</p> <p>Either would work when it comes to creating invoices and audit trails, as the business data is simply a <code>transaction_id</code> in a business table from the actual transaction which paid the money out. We chose to go with calculating tax amounts on the business side and having the transaction ledger amounts reflect the full amount including tax (not as a separate ledger entry). If we want tax-specific details, consult the business tables.</p> <h3 id=our-ledger-types>Our Ledger Types</h3><ul> <li><strong>cash</strong> — Money we hold (bank account)</li> <li><strong>accounts_payable</strong> — Money we owe to organizers</li> <li><strong>payouts</strong> — Money we've disbursed</li> </ul> <p><strong>The golden rule:</strong> Every transaction creates exactly two journal entries that sum to zero.</p> <h2 id=the-payout-pipeline-execution>The Payout Pipeline & Execution</h2><h3 id=payment-flow-sequence>Payment Flow Sequence</h3><p><img src="proxy.php?url=https%3A%2F%2Fbear-images.sfo2.cdn.digitaloceanspaces.com%2Fzarar%2Fpayment-system-sequence-diagram.svg" alt="sequence-diagram" /></p> <div style="text-align: center; margin: 4px 0"> <a href="proxy.php?url=https%3A%2F%2Fbear-images.sfo2.cdn.digitaloceanspaces.com%2Fzarar%2Fpayment-system-sequence-diagram.svg" target="_blank">Open in New Window</a></div> <h3 id=scheduled-orchestration>Scheduled Orchestration</h3><p>An Oban worker runs on schedule, executing the full payout flow:</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">Amplify.ScheduledJobs.EventPayoutTipalti</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kn">use</span><span class="w"> </span><span class="nc">Oban.Worker</span><span class="p">,</span><span class="w"> </span><span class="ss">queue</span><span class="p">:</span><span class="w"> </span><span class="ss">:event_payouts</span><span class="p">,</span><span class="w"> </span><span class="ss">max_attempts</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span> <span class="w"> </span><span class="kn">require</span><span class="w"> </span><span class="nc">Logger</span> <span class="w"> </span><span class="na">@impl</span><span class="w"> </span><span class="nc">Oban.Worker</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">perform</span><span class="p">(</span><span class="n">_args</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="nc">Logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&quot;Starting Creating Payout Transactions&quot;</span><span class="p">)</span> <span class="w"> </span><span class="nc">Amplify.EventPaymentMaker</span><span class="o">.</span><span class="n">create_event_revenue_transactions</span><span class="p">()</span> <span class="w"> </span><span class="nc">Logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&quot;Finished Creating Event Revenue Transactions&quot;</span><span class="p">)</span> <span class="w"> </span><span class="nc">Amplify.GiftCardPaymentMaker</span><span class="o">.</span><span class="n">create_gift_card_transactions</span><span class="p">()</span> <span class="w"> </span><span class="nc">Logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&quot;Finished Creating Gift Card Transactions&quot;</span><span class="p">)</span> <span class="w"> </span><span class="nc">Amplify.DigitalProductPaymentMaker</span><span class="o">.</span><span class="n">create_digital_product_transactions</span><span class="p">()</span> <span class="w"> </span><span class="nc">Logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&quot;Finished Creating Digital Product Transactions&quot;</span><span class="p">)</span> <span class="w"> </span><span class="nc">Amplify.CustomerCashbackTransactionCreator</span><span class="o">.</span><span class="n">create_customer_cashback_transactions</span><span class="p">()</span> <span class="w"> </span><span class="nc">Logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&quot;Finished Creating Customer Cashback Transactions&quot;</span><span class="p">)</span> <span class="w"> </span><span class="nc">Amplify.AccountingContext</span><span class="o">.</span><span class="n">create_pending_payout_transactions</span><span class="p">()</span> <span class="w"> </span><span class="nc">Logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&quot;Finished creating pending payout transactions&quot;</span><span class="p">)</span> <span class="w"> </span><span class="c1"># Start ProcessAdPayments job to handle Meta ad billing</span> <span class="w"> </span><span class="p">%{}</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">ProcessAdPayments</span><span class="o">.</span><span class="n">new</span><span class="p">()</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Oban</span><span class="o">.</span><span class="n">insert</span><span class="p">()</span> <span class="w"> </span><span class="nc">Logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&quot;Enqueued ProcessAdPayments job&quot;</span><span class="p">)</span> <span class="w"> </span><span class="ss">:ok</span> <span class="w"> </span><span class="k">end</span> <span class="k">end</span> </pre></div> <h3 id=payment-execution>Payment Execution</h3><p><code>TransactionPaymentMaker.pay/0</code> iterates pending payout transactions created earlier to disburse funds and update statuses:</p> <div class="highlight"><pre><span></span><span class="kd">def</span><span class="w"> </span><span class="n">pay</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="n">result</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">AccountingContext</span><span class="o">.</span><span class="n">get_pending_payout_transactions</span><span class="p">()</span> <span class="w"> </span><span class="n">result</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="k">fn</span><span class="w"> </span><span class="p">{</span><span class="n">account_id</span><span class="p">,</span><span class="w"> </span><span class="n">payment_destination_id</span><span class="p">,</span><span class="w"> </span><span class="n">transactions</span><span class="p">}</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">transaction</span><span class="p">(</span><span class="k">fn</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="nc">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">transactions</span><span class="p">,</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="n">transaction</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">transaction</span><span class="o">.</span><span class="n">journal_entries</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">List</span><span class="o">.</span><span class="n">first</span><span class="p">()</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Map</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="ss">:amount</span><span class="p">)</span> <span class="w"> </span><span class="n">currency</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">transaction</span><span class="o">.</span><span class="n">journal_entries</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">List</span><span class="o">.</span><span class="n">first</span><span class="p">()</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Map</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="ss">:currency</span><span class="p">)</span> <span class="w"> </span><span class="n">payment_destination</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">get!</span><span class="p">(</span><span class="nc">PaymentDestination</span><span class="p">,</span><span class="w"> </span><span class="n">payment_destination_id</span><span class="p">)</span> <span class="w"> </span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="p">[%</span><span class="nc">Transaction</span><span class="p">{}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">payout_transaction</span><span class="p">]}</span><span class="w"> </span><span class="o">=</span> <span class="w"> </span><span class="k">case</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="ow">when</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="nc">AccountingContext</span><span class="o">.</span><span class="n">settle_payout_transactions</span><span class="p">([</span><span class="n">transaction</span><span class="o">.</span><span class="n">id</span><span class="p">])</span> <span class="w"> </span><span class="bp">_</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="p">[</span><span class="n">transaction</span><span class="p">]}</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="n">batch_reference_id</span><span class="p">}</span><span class="w"> </span><span class="o">=</span> <span class="w"> </span><span class="k">case</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="ow">when</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="nc">Tipalti</span><span class="o">.</span><span class="n">make_payment</span><span class="p">(%{</span> <span class="w"> </span><span class="s2">&quot;amount&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span> <span class="w"> </span><span class="s2">&quot;currency&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">currency</span><span class="p">,</span> <span class="w"> </span><span class="n">...</span> <span class="w"> </span><span class="p">})</span> <span class="w"> </span><span class="bp">_</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="no">nil</span><span class="p">}</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">amount</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="nc">Tipalti</span><span class="o">.</span><span class="n">create_payout_history</span><span class="p">(%{</span> <span class="w"> </span><span class="ss">payment_destination_id</span><span class="p">:</span><span class="w"> </span><span class="n">payment_destination_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">transaction_id</span><span class="p">:</span><span class="w"> </span><span class="n">payout_transaction</span><span class="o">.</span><span class="n">id</span><span class="p">,</span> <span class="w"> </span><span class="ss">batch_reference_number</span><span class="p">:</span><span class="w"> </span><span class="n">batch_reference_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">amount</span><span class="p">:</span><span class="w"> </span><span class="n">amount</span><span class="p">,</span> <span class="w"> </span><span class="ss">currency</span><span class="p">:</span><span class="w"> </span><span class="n">currency</span> <span class="w"> </span><span class="p">})</span> <span class="w"> </span><span class="n">send_payment_summary_email</span><span class="p">(%{</span> <span class="w"> </span><span class="ss">payout_transactions</span><span class="p">:</span><span class="w"> </span><span class="n">payout_transaction</span><span class="o">.</span><span class="n">payout_transactions</span><span class="p">,</span> <span class="w"> </span><span class="ss">batch_reference_id</span><span class="p">:</span><span class="w"> </span><span class="n">batch_reference_id</span><span class="p">,</span> <span class="w"> </span><span class="ss">account_id</span><span class="p">:</span><span class="w"> </span><span class="n">account_id</span> <span class="w"> </span><span class="p">})</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="n">payout_transaction</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="k">end</span> </pre></div> <h3 id=handling-edge-cases>Handling Edge Cases</h3><p><strong>Post-payment refunds</strong> — Creates a new refund transaction that reduces the next payout automatically. The DR accounts_payable, CR cash pattern naturally reduces the amount owed.</p> <p><strong>Zero/negative payouts</strong> — No Tipalti call; balance carries forward to the next payout cycle.</p> <p><strong>Missing payment destinations</strong> — Skip and notify via email; transactions remain pending until the organizer adds payment info.</p> <hr /> <h2 id=extensibility-adding-new-transaction-types>Extensibility: Adding New Transaction Types</h2><h3 id=the-model-accommodates-change>The Model Accommodates Change</h3><p>New revenue streams and charge types are inevitable. The system was designed so adding a new transaction type doesn't require schema changes or breaking existing logic.</p> <h3 id=adding-a-new-transaction-type-requires>Adding a New Transaction Type Requires:</h3><ol> <li>Define the type string (e.g., <code>&quot;membership_fee&quot;</code>)</li> <li>Decide the journal entry pattern (revenue: DR cash, CR accounts_payable; charge: reverse)</li> <li>Create a function which creates a transaction using an established pattern</li> <li>Add the type to the batching query's <code>type in [...]</code> list</li> <li>Optionally: add <code>transaction_id</code> to a business domain table for traceability</li> </ol> <h3 id=example-adding-subscription-revenue>Example: Adding Subscription Revenue</h3><div class="highlight"><pre><span></span><span class="kd">def</span><span class="w"> </span><span class="n">create_subscription_revenue_transaction</span><span class="p">(%{</span><span class="s2">&quot;subscription_ids&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">subscription_ids</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">params</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">transaction</span><span class="p">(</span><span class="k">fn</span><span class="w"> </span><span class="o">-&gt;</span> <span class="w"> </span><span class="n">setup_ledgers</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="s2">&quot;account_id&quot;</span><span class="p">])</span> <span class="w"> </span><span class="n">transaction</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">create_transaction</span><span class="p">(</span><span class="n">params</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Map</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="s2">&quot;type&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;subscription_revenue&quot;</span><span class="p">))</span> <span class="w"> </span><span class="n">cash_ledger</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">get_ledger</span><span class="p">(</span><span class="s2">&quot;cash&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">params</span><span class="p">[</span><span class="s2">&quot;account_id&quot;</span><span class="p">])</span> <span class="w"> </span><span class="n">accounts_payable_ledger</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">get_ledger</span><span class="p">(</span><span class="s2">&quot;accounts_payable&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">params</span><span class="p">[</span><span class="s2">&quot;account_id&quot;</span><span class="p">])</span> <span class="w"> </span><span class="n">create_journal_entry</span><span class="p">(</span><span class="n">cash_ledger</span><span class="p">,</span><span class="w"> </span><span class="n">transaction</span><span class="p">,</span><span class="w"> </span><span class="ss">:dr</span><span class="p">,</span><span class="w"> </span><span class="n">params</span><span class="p">)</span> <span class="w"> </span><span class="n">create_journal_entry</span><span class="p">(</span><span class="n">accounts_payable_ledger</span><span class="p">,</span><span class="w"> </span><span class="n">transaction</span><span class="p">,</span><span class="w"> </span><span class="ss">:cr</span><span class="p">,</span><span class="w"> </span><span class="n">params</span><span class="p">)</span> <span class="w"> </span><span class="c1"># Link to business domain for traceability</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">update_all</span><span class="p">(</span> <span class="w"> </span><span class="n">from</span><span class="p">(</span><span class="n">s</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nc">Subscription</span><span class="p">,</span><span class="w"> </span><span class="ss">where</span><span class="p">:</span><span class="w"> </span><span class="n">s</span><span class="o">.</span><span class="n">id</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="o">^</span><span class="n">subscription_ids</span><span class="p">),</span> <span class="w"> </span><span class="ss">set</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="ss">transaction_id</span><span class="p">:</span><span class="w"> </span><span class="n">transaction</span><span class="o">.</span><span class="n">id</span><span class="p">]</span> <span class="w"> </span><span class="p">)</span> <span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">get!</span><span class="p">(</span><span class="nc">Transaction</span><span class="p">,</span><span class="w"> </span><span class="n">transaction</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nc">Repo</span><span class="o">.</span><span class="n">preload</span><span class="p">([</span><span class="ss">:journal_entries</span><span class="p">,</span><span class="w"> </span><span class="ss">:subscriptions</span><span class="p">])</span> <span class="w"> </span><span class="k">end</span><span class="p">)</span> <span class="k">end</span> </pre></div> <p>The batching and payout logic automatically picks it up and no changes needed downstream.</p> <p>For variations within a type (e.g., differentiating "premium event" vs "standard event" revenue), we can always:</p> <ul> <li>Add a <code>subtype</code> field to Transaction</li> <li>Store additional context in a JSONB <code>metadata</code> field</li> <li>Link to domain-specific tables via join tables</li> </ul> <p>The core accounting remains unchanged; subtypes are for reporting and business logic. More important than what needs to change is what doesn't need to change when new payment requirements inevitably popup:</p> <ul> <li>Database schema (unless linking to new domain entities)</li> <li>Batching logic (queries by status, not type)</li> <li>Payout execution (reads journal entries, agnostic to transaction type)</li> <li>State machine (all types follow the same pending → waiting_for_payout → settled flow)</li> </ul> <p>This has been tested thoroughly as since inception we've added support for the following transaction types cleanly:</p> <ul> <li><strong>customer_cashback</strong> — Referral rewards paid to customers (reverse flow)</li> <li><strong>ads</strong> — Meta ad spend charged to accounts</li> <li><strong>service_fee_split</strong> — Revenue sharing with partners</li> </ul> <p>Each took hours to implement, not days, because the foundation was solid.</p> <hr /> <h2 id=lessons-learned>Lessons Learned</h2><h3 id=1-invest-in-the-foundation-early>1. Invest in the Foundation Early</h3><p>Building double-entry bookkeeping felt like over-engineering initially. A simple <code>balance</code> column would have shipped faster. But when we needed to add refunds, then tips, then ad charges, then multi-currency, the foundation paid for itself many times over. Each new transaction type slots in cleanly instead of requiring architectural surgery.</p> <h3 id=2-idempotency-is-worth-the-complexity>2. Idempotency Is Worth the Complexity</h3><p>Making operations idempotent required more upfront thought—checking for existing records, updating instead of inserting, querying current state rather than relying on sequence. But it eliminated an entire class of bugs: duplicate payments, orphaned records, and state corruption from retried jobs. When your system handles money, "ran twice by accident" cannot mean "paid twice."</p> <h3 id=3-separate-accounting-from-business-logic>3. Separate Accounting from Business Logic</h3><p>The <code>transaction_id</code> foreign key pattern was a late addition. Initially, we tried to make transactions self-describing with rich metadata. But queries like "show me all tips in this payout" became convoluted. Keeping transactions as clean accounting records and linking them to business entities via foreign keys gave us the best of both worlds: simple accounting logic and rich business context.</p> <h3 id=4-status-is-easier-than-ledger-math>4. Status Is Easier Than Ledger Math</h3><p>We debated whether to derive "what's pending" by summing ledger entries (the pure accounting approach) versus querying transaction status (the pragmatic approach). Status won. Developers can reason about <code>status = 'pending'</code> without understanding debits and credits. The journal entries remain the source of truth for amounts; status is the source of truth for workflow state.</p> <h3 id=5-batching-is-a-product-feature-not-just-a-cost-optimization>5. Batching Is a Product Feature, Not Just a Cost Optimization</h3><p>We initially thought of batching as purely an internal optimization to reduce Tipalti fees. But organizers love seeing a single weekly payment with a detailed breakdown rather than dozens of micro-deposits. The batching architecture became a product feature: predictable payment schedules with comprehensive statements.</p> <h3 id=6-plan-for-retroactive-adjustments>6. Plan for Retroactive Adjustments</h3><p>The refund-after-payment scenario seemed like an edge case but it's actually quite popular. Chargebacks, late refund requests, and corrections happen regularly. Because every adjustment is just another transaction with the appropriate DR/CR pattern, the system handles them naturally. The next payout simply reflects the updated balance.</p> <h3 id=7-currency-handling-is-subtle>7. Currency Handling Is Subtle</h3><p>We underestimated currency complexity. It's not just conversion rates, it's also case sensitivity (<code>USD</code> vs <code>usd</code>), timing of conversion (at transaction time vs payout time), and reporting currency vs payment currency. Using <code>fragment(&quot;lower(?)&quot;, je.currency)</code> everywhere and storing the payment destination's preferred currency solved most issues, but we wish we'd been more deliberate from the start.</p> <h3 id=8-traceability-saves-support-time>8. Traceability Saves Support Time</h3><p>Every support ticket about payments boils down to "why did I get this amount?" Full traceability—from payout email → payout transaction → source transactions → individual tips/events/charges → original orders—means support can answer definitively instead of guessing. The join tables and foreign keys that felt like extra work during development save hours of investigation weekly.</p> <hr /> <h2 id=conclusion>Conclusion</h2><p>Building a payment engine from scratch is a significant undertaking, but for multi-sided marketplaces, it may be unavoidable. Off-the-shelf solutions optimize for simpler flows like point-to-point atomic transactions. However, when money moves in multiple directions, across currencies, with retroactive adjustments and batched payouts, you need control over the accounting layer.</p> <p>The key architectural decisions that served us well:</p> <ol> <li><strong>Double-entry bookkeeping</strong> provides self-validation and an immutable audit trail</li> <li><strong>Transaction types drive journal entries</strong>, making business logic explicit</li> <li><strong>Linking accounting to business domains</strong> via <code>transaction_id</code> enables full traceability</li> <li><strong>Per-account ledgers</strong> simplify queries and provide natural isolation</li> <li><strong>Idempotent operations</strong> eliminate duplicate payment risks</li> <li><strong>State machine for transactions</strong> makes workflow explicit and prevents double-processing</li> <li><strong>Batching by payment destination</strong> minimizes fees while improving the organizer experience</li> </ol> <p>The system has processed millions of dollars across multiple currencies, handled thousands of refunds (including post-payment adjustments), and scaled to support new transaction types as the business evolved. The upfront investment in a solid foundation made all of this possible.</p> <p>If you're building something similar, start with the accounting model. Get the ledgers, journal entries, and transaction patterns right first. Everything else like batching, scheduling, and notifications builds cleanly on top of a sound foundation.</p> <hr /> <p><em>Built with Elixir, Phoenix, Ecto, Reactor and Oban. Accounts Receivable via Stripe and PayPal, Accounts Payable via Tipalti.</em></p> 2025-12-27T22:16:00+00:00 https://zarar.dev/building-embeddable-widgets-with-phoenix-channels/ Building Embeddable Widgets with Phoenix Channels 2025-12-25T20:00:42.979271+00:00 zarar hidden <p>When you're selling stuff, every click away from your website is a potential lost sale. Customers kept asking us to allow people to buy tickets without leaving their branded site. They loved the service, but wanted to retain brand presence and coherence.</p> <p>The ask seemed simple enough. Drop a widget on any webpage, show some events, let people check out. But as we dug into it, the technical challenges started stacking up. How do you maintain cart state across page refreshes? How do you track marketing attribution when the purchase happens on someone else's domain? And how do you process payments securely in what is essentially a third party context?</p> <p>This is the quick post of how we tried a bunch of approaches, hit walls, and eventually landed on Phoenix Channels as our solution.</p> <h2 id=the-business-problem>The Business Problem</h2><p>Our platform handles ticketing for comedy shows and events. Most of our customers have their own websites where they promote their shows, but when someone wants to buy a ticket, they get redirected to our checkout flow. That redirect isn't an issue if you're coming from Google or some social media platform, but it is a point of friction if you're coming from someone else's website and already viewing event information there.</p> <p>The numbers were fairly clear: cart abandonment spiked whenever users had to leave the original site. People would click "Buy Tickets", land on our domain, get distracted, and never complete the purchase. Our customers wanted a way to keep buyers on their own turf through the entire flow.</p> <p>So we set out to build an embeddable widget that could handle the complete purchase experience. Browse events, add to cart, enter customer info, pay with Stripe, get confirmation. All without leaving the host page.</p> <h2 id=what-we-needed-to-solve>What We Needed to Solve</h2><p>Beyond the basic checkout flow, we had a bunch of technical requirements that made this tricky.</p> <p>First, we needed to track marketing parameters. When someone lands on a page with utm_campaign or fbclid in the URL, those values need to follow the user all the way through checkout so we can attribute the sale correctly. Same deal with referral codes and other tracking identifiers.</p> <p>Second, we needed location detection. Different regions have different currencies and tax rules. We needed to figure out where the user was coming from and apply the right pricing.</p> <p>Third, cart persistence. If someone adds tickets to their cart and then refreshes the page, that cart better still be there. And ideally, if they come back the next day, same thing.</p> <p>Fourth, conversion tracking. When an order completes, we need to fire Meta Pixel events, Google Tag conversions, Reddit Pixel tracking. All the usual suspects for marketing attribution.</p> <p>And finally, all of this had to work on any website, regardless of what tech stack they're running. WordPress, Squarespace, custom Rails apps, static HTML. The widget needed to just work.</p> <h2 id=the-approaches-we-tried>The Approaches We Tried</h2><h3 id=web-components-with-rest>Web Components with REST</h3><p>Our first instinct was the obvious one. Build a Web Component that makes REST calls to our API for everything. Add to cart? POST request. Update quantity? PUT request. Checkout? More requests.</p> <p>We actually had an older implementation using this pattern from a few years back, built with <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2Fionic-team%2Fstencil">Stencil</a>. We got a prototype working pretty quickly. The problem was state management. Keeping the web component UI state in sync required us to retrieve data every time something changed and the component became a giant HTTP client with tons of boilerplate and error handling code. This isn't necessarily bad, but a core tenet of Elixir development is to reduce boilerplate and opt for messaging over RPC, so we quickly ditched this approach.</p> <p>The bigger issue was that we were essentially rebuilding our entire checkout flow in JavaScript. All the price formatting logic, the discount calculations, the conditional display rules based on product configuration. We had all of this working beautifully in Elixir already. Duplicating it on the client felt wrong. The client side code didn't get gnarly, but there was considerable duplication of what the web component was rendering, and what our website was rendering.</p> <h3 id=iframes>iframes</h3><p>Next we tried the iframe approach. Just embed our existing checkout in an iframe on the host page. This way we could reuse all our LiveView code with zero modifications.</p> <p>The cross origin communication was the first problem. Getting the parent page and the iframe to talk to each other meant a bunch of postMessage calls with careful origin checking. But the real killer was third party cookies.</p> <p>Modern browsers are cracking down on third party cookies hard. Safari was already blocking them by default, Chrome is phasing them out, Firefox has restrictions. Our session management relied on cookies, and suddenly half our users couldn't stay logged in through the checkout flow. The iframe approach was dead on arrival even though the PoC worked. Chasing the JavaScript stack and tracking all the latest tweaks browsers and libraries make isn't a business worth getting into.</p> <h3 id=liveview-inside-web-components>LiveView Inside Web Components</h3><p>This one seemed promising. Phoenix LiveView gives you that real time reactivity we wanted. What if we could mount a LiveView inside a Web Component's Shadow DOM?</p> <p>We spent a good chunk of time trying to make this work. The problem is that LiveView's DOM patching fundamentally conflicts with Shadow DOM encapsulation. LiveView needs to own the DOM tree it's managing, and Shadow DOM creates a boundary that the patching algorithm can't cross properly. Event delegation breaks. The morphdom updates don't penetrate the shadow boundary correctly.</p> <p>We looked at the Phoenix source code, read through the forums and Discord. The consensus was clear: this isn't a supported pattern and probably never will be even though libraries like <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2Fdoofinder%2Flive_view_portal">live_portal</a> are trying hard to solve it.</p> <h3 id=other-libraries>Other Libraries</h3><p>We evaluated a bunch of other options. <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2Fbartblast%2Fhologram">Hologram</a> looked interesting but connecting the web component's socket to what Hologram seemed brittle and intrusive, especially as Hologram is relatively early stage and APIs are likely to change. <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2FValian%2Flive_vue">LiveVue</a> and LiveSvelte are designed for using those frameworks inside LiveView, not the other way around.</p> <p>None of them gave us what we needed.</p> <h2 id=why-phoenix-channels-worked>Why Phoenix Channels Worked</h2><p>The breakthrough came when we stopped trying to embed LiveView and started thinking about what we actually needed from the server.</p> <p>We didn't need the full LiveView abstraction. We needed real time communication with state management. We needed the server to be able to push updates when things changed. And we already had this infrastructure running: Phoenix Channels.</p> <p>Channels give you a persistent WebSocket connection with built in topic subscription, message passing, and presence tracking. We use them extensively for other features in the platform. Why not for the widget?</p> <p>The key insight was realizing we could send rendered HTML over the channel instead of JSON. This sounds backwards at first. Aren't you supposed to send data and let the client render it? But hear me out.</p> <p>By rendering HTML on the server, we get to reuse all our existing Elixir code. Price formatting that handles multiple currencies and locales? Already written. Display logic that varies based on brand settings? Just a function call away. Conditional rendering for sold out events versus available ones? Same templates we use everywhere else.</p> <p>If we sent JSON, we'd have to duplicate all of this in JavaScript. And then keep the two implementations in sync. This might follow some esoteric separation of concern goals, but it feels wonky and unnecessary. Also, we're essentially the same app.</p> <p>The other thing about WebSockets is they're fast. Once that connection is established, you're not paying the TCP handshake cost on every interaction. Headers get stripped down to almost nothing. For something like a cart that might see dozens of updates in a session, that overhead reduction adds up. A typical response to a call on the channel side looks like:</p> <div class="highlight"><pre><span></span><span class="w"> </span><span class="na">@impl</span><span class="w"> </span><span class="no">true</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">handle_in</span><span class="p">(</span><span class="s2">&quot;event&quot;</span><span class="p">,</span><span class="w"> </span><span class="p">%{</span><span class="s2">&quot;type&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s2">&quot;go-to-checkout&quot;</span><span class="p">},</span><span class="w"> </span><span class="n">socket</span><span class="p">)</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="n">cart_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="p">[</span><span class="ss">:cart_id</span><span class="p">]</span> <span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">cart_id</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="n">cart</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">CartContext</span><span class="o">.</span><span class="n">get_cart</span><span class="p">(</span><span class="n">cart_id</span><span class="p">)</span> <span class="w"> </span><span class="p">{</span><span class="n">socket</span><span class="p">,</span><span class="w"> </span><span class="n">response</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">prepare_checkout_view</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span><span class="w"> </span><span class="n">cart_id</span><span class="p">,</span><span class="w"> </span><span class="n">cart</span><span class="o">.</span><span class="n">region_id</span><span class="p">)</span><span class="w"> </span><span class="c1"># Full HTML </span> <span class="w"> </span><span class="p">{</span><span class="ss">:reply</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="n">response</span><span class="p">},</span><span class="w"> </span><span class="n">socket</span><span class="p">}</span> <span class="w"> </span><span class="k">else</span> <span class="w"> </span><span class="p">{</span><span class="ss">:reply</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="ss">:error</span><span class="p">,</span><span class="w"> </span><span class="p">%{</span><span class="ss">reason</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;No cart available&quot;</span><span class="p">}},</span><span class="w"> </span><span class="n">socket</span><span class="p">}</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="k">end</span> </pre></div> <p>Browser support used to be a concern but WebSockets have been solid across all major browsers since 2011. This isn't experimental technology.</p> <h2 id=the-architecture>The Architecture</h2><p>Here's how the pieces fit together.</p> <p>On the client side, we have a Web Component called <code>product-list-widget</code>. You drop it on any page with a script tag and a custom element, pass it a widget ID as an attribute, and it handles the rest.</p> <p>We debated between <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2Flit%2Flit">Lit</a> and <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2Fionic-team%2Fstencil">Stencil</a> for building the Web Component. We had used Stencil in our original REST based implementation years ago, but this time we went with Lit. Stencil is more oriented toward building design systems and component libraries, with its own compiler and build toolchain. For our use case of a single focused widget, Lit's lighter weight approach made more sense. You just extend a base class and you're done.</p> <p>When the component mounts, it establishes a WebSocket connection to our Phoenix application and joins a channel specific to that widget. The channel handler on the server looks up the widget configuration, fetches the relevant products, renders the initial HTML, and sends it back.</p> <p>From there, every user interaction goes through the channel. Click add to cart? The component sends a message, the server updates the cart, renders the new state, and replies with HTML. During checkout it's the same pattern. The server handles the business logic and responds with the next view to display.</p> <div class="highlight"><pre><span></span>Host Website (any domain) └── product-list-widget (Web Component) └── Phoenix Channel Connection └── ProductListChannel └── CheckoutService (shared with main site) └── All our existing Elixir code </pre></div> <p>The Web Component provides encapsulation so our styles don't leak into the host page and vice versa. The Shadow DOM keeps everything contained.</p> <h3 id=handling-parameters>Handling Parameters</h3><p>One of the first problems we hit was tracking parameters. When someone lands on a page with marketing UTMs in the URL, we need to capture those and associate them with any purchase that happens.</p> <p>The solution is straightforward. When the widget initializes, it reads the current URL and extracts any parameters we care about. UTM campaign, source, medium. Facebook click IDs. Referral codes from our affiliate program. Venue access codes for special pricing.</p> <p>These get sent along when joining the channel. On the server side, we stash them in the socket assigns and later write them into the cart context when the cart gets created.</p> <div class="highlight"><pre><span></span><span class="kd">const</span><span class="w"> </span><span class="nx">trackingParams</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">utm_campaign</span><span class="o">:</span><span class="w"> </span><span class="kt">url.searchParams.get</span><span class="p">(</span><span class="s1">&#39;utm_campaign&#39;</span><span class="p">),</span> <span class="w"> </span><span class="nx">utm_source</span><span class="o">:</span><span class="w"> </span><span class="kt">url.searchParams.get</span><span class="p">(</span><span class="s1">&#39;utm_source&#39;</span><span class="p">),</span> <span class="w"> </span><span class="nx">fbclid</span><span class="o">:</span><span class="w"> </span><span class="kt">url.searchParams.get</span><span class="p">(</span><span class="s1">&#39;fbclid&#39;</span><span class="p">),</span> <span class="w"> </span><span class="nx">customer_referral_code</span><span class="o">:</span><span class="w"> </span><span class="kt">url.searchParams.get</span><span class="p">(</span><span class="s1">&#39;customer_referral_code&#39;</span><span class="p">),</span> <span class="w"> </span><span class="nx">ref</span><span class="o">:</span><span class="w"> </span><span class="kt">url.searchParams.get</span><span class="p">(</span><span class="s1">&#39;ref&#39;</span><span class="p">)</span> <span class="p">};</span> <span class="nx">channel</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s2">&quot;product_list:1&quot;</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">tracking_params</span><span class="o">:</span><span class="w"> </span><span class="kt">trackingParams</span><span class="w"> </span><span class="p">});</span> </pre></div> <p>When the order eventually completes, all these parameters are sitting in the cart context, ready for our analytics pipeline to process.</p> <h3 id=location-detection>Location Detection</h3><p>Currency and tax rules depend on where the buyer is located. We handle this by grabbing the IP address from the socket connection and running it through a geolocation service.</p> <p>The channel join handler pulls the IP from the socket, looks up the location, and uses that to determine the default currency and region. If we detect someone in Canada, they see Canadian dollars. Someone in the US sees USD.</p> <p>This happens transparently on the first connection. The user doesn't have to select their country from a dropdown or anything like that. We just figure it out and show them relevant pricing.</p> <h3 id=cart-persistence>Cart Persistence</h3><p><code>LocalStorage</code> handles cart persistence on the client side. When we create a cart on the server, we get back a cart ID. The widget stores that ID in LocalStorage keyed by the widget ID.</p> <p>Next time the page loads, the widget checks for an existing cart ID and sends it along when joining the channel. The server validates that the cart exists and is still usable, then restores the previous state. If the cart has expired or been completed, we just start fresh.</p> <div class="highlight"><pre><span></span><span class="k">private</span><span class="w"> </span><span class="nx">getStoredCartId</span><span class="p">()</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="kc">null</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nx">localStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="sb">`widget_cart_</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">widgetId</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span> <span class="p">}</span> <span class="k">private</span><span class="w"> </span><span class="nx">storeCartId</span><span class="p">(</span><span class="nx">cartId</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">)</span><span class="o">:</span><span class="w"> </span><span class="ow">void</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">localStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="sb">`widget_cart_</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">widgetId</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span><span class="w"> </span><span class="nx">cartId</span><span class="p">);</span> <span class="p">}</span> </pre></div> <p>This works around the third party cookie problem entirely. <code>LocalStorage</code> is partitioned by origin, but the widget JavaScript runs in the context of the host page, so it has access to that page's storage.</p> <h3 id=dom-diffing-with-idiomorph>DOM Diffing with Idiomorph</h3><p>Here's a detail that makes a huge difference in user experience. When the server sends back new HTML, we don't just blast it into the DOM with innerHTML. That would reset focus on input fields, lose scroll position, and generally feel janky.</p> <p>Instead we use <a href="proxy.php?url=https%3A%2F%2Fgithub.com%2Fbigskysoftware%2Fidiomorph">Idiomorph</a>, a library that does intelligent DOM diffing and morphing. You give it the current DOM and the new HTML you want, and it figures out the minimal set of changes needed to transform one into the other. Elements that haven't changed stay put. Focus stays on the input you were typing in. Scroll position is preserved.</p> <div class="highlight"><pre><span></span><span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">Idiomorph</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s1">&#39;idiomorph&#39;</span><span class="p">;</span> <span class="k">private</span><span class="w"> </span><span class="nx">updateContent</span><span class="p">(</span><span class="nx">html</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">)</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">Idiomorph</span><span class="p">.</span><span class="nx">morph</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">contentContainer</span><span class="p">,</span><span class="w"> </span><span class="nx">html</span><span class="p">,</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">morphStyle</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;innerHTML&#39;</span> <span class="w"> </span><span class="p">});</span> <span class="p">}</span> </pre></div> <p>This gives us LiveView style reactivity without actually using LiveView. When you update your cart quantity, the rest of the page stays exactly where it was. You don't lose your place or have to refocus on the field. It feels smooth.</p> <h3 id=payment-integration>Payment Integration</h3><p>Stripe Elements handles the payment UI. After the customer fills in their info and clicks continue, we create a PaymentIntent on the server and send back the client secret along with instructions to show the payment form.</p> <p>The widget mounts Stripe Elements into a container within the Shadow DOM. User enters their card, we confirm the payment with Stripe, and on success we send a message back through the channel to complete the order.</p> <p>Free orders skip the Stripe step entirely. If the cart total is zero (maybe they used a 100% off promo code), we just submit the order directly without involving payment processing.</p> <h3 id=conversion-tracking>Conversion Tracking</h3><p>Getting conversion pixels to fire correctly took some thought. On our main site, we use LiveView's <code>push_event/3</code> to send conversion data to a JavaScript hook that fires the various tracking pixels. But in the widget context, we don't have LiveView hooks.</p> <p>The solution is to include the conversion event data in the order success response. When an order completes, the server looks up what analytics elements are configured for that account, builds out the event payloads for each one, and includes them in the response.</p> <div class="highlight"><pre><span></span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="n">conversion_events</span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nc">CheckoutService</span><span class="o">.</span><span class="n">get_order_conversion_data</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> <span class="p">{</span><span class="ss">:reply</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span><span class="w"> </span><span class="p">%{</span> <span class="w"> </span><span class="ss">html</span><span class="p">:</span><span class="w"> </span><span class="n">render_success_view</span><span class="p">(</span><span class="n">order_data</span><span class="p">),</span> <span class="w"> </span><span class="ss">view</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;success&quot;</span><span class="p">,</span> <span class="w"> </span><span class="ss">conversion_events</span><span class="p">:</span><span class="w"> </span><span class="n">conversion_events</span> <span class="p">}},</span><span class="w"> </span><span class="n">socket</span><span class="p">}</span> </pre></div> <p>On the client side, we iterate through those events and fire them using the appropriate global functions. <code>fbq</code> for Meta, <code>gtag</code> for Google, <code>rdt</code> for Reddit.</p> <div class="highlight"><pre><span></span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">conversion_events</span><span class="o">?</span><span class="p">.</span><span class="nx">length</span><span class="p">)</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">setTimeout</span><span class="p">(()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">this</span><span class="p">.</span><span class="nx">fireConversionEvents</span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">conversion_events</span><span class="p">);</span> <span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="mf">2000</span><span class="p">);</span> <span class="p">}</span> </pre></div> <p>The two second delay matches what we do on the main site. It gives the page time to stabilize before we start making network requests to third party tracking services.</p> <p>One caveat here: the host page needs to have these tracking libraries loaded. If they haven't included the Facebook Pixel script, our <code>fbq</code> calls just silently fail. We log a warning to the console but don't crash.</p> <h2 id=reducing-duplication>Reducing Duplication</h2><p>One of the things we were most worried about was maintaining two parallel implementations. The main site checkout and the widget checkout doing the same things in slightly different ways.</p> <p>We solved this by extracting shared logic into a CheckoutService module. Cart validation, customer adding, Stripe setup, order completion. All the core checkout operations live in one place and get called from both the LiveView code and the channel handlers.</p> <div class="highlight"><pre><span></span><span class="kd">defmodule</span><span class="w"> </span><span class="nc">AmplifyWeb.CheckoutService</span><span class="w"> </span><span class="k">do</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">complete_cart_with_validation</span><span class="p">(</span><span class="n">cart_id</span><span class="p">)</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">setup_stripe_payment</span><span class="p">(</span><span class="n">cart_id</span><span class="p">,</span><span class="w"> </span><span class="n">currency_code</span><span class="p">)</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">add_customer_to_cart</span><span class="p">(</span><span class="n">cart_id</span><span class="p">,</span><span class="w"> </span><span class="n">customer_params</span><span class="p">,</span><span class="w"> </span><span class="n">opts</span><span class="p">)</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">validate_customer_params</span><span class="p">(</span><span class="n">params</span><span class="p">)</span> <span class="w"> </span><span class="kd">def</span><span class="w"> </span><span class="n">get_order_conversion_data</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span> <span class="k">end</span> </pre></div> <p>Before this refactor, we had the same cart completion logic written out in three different places. Each one had slight variations that had accumulated over time. Consolidating them into a single service eliminated about a hundred lines of duplicated code and gave us one canonical implementation to maintain.</p> <p>The conversion event building is a good example. Both the <code>CartComplete</code> LiveView and the widget channel need to fire the same tracking pixels with the same data. Now they both call the same <code>get_order_conversion_data/1</code> function and get the same events back.</p> <p>What we didn't try to share is the template rendering. The widget uses string interpolation templates since it's returning raw HTML over the channel. The main site uses HEEx templates with all the LiveView compile time goodness. These are fundamentally different rendering contexts, and trying to abstract over them would have been more trouble than it's worth. DRY isn't always good, sometimes some duplication is OK.</p> <h2 id=monorepo-structure>Monorepo Structure</h2><p>We keep the Lit widget code in the same repository as the Phoenix application. The widget lives in <code>assets/widget/</code> with its own <code>package.json</code>, TypeScript config, and build scripts. This wasn't an obvious choice at first since we could have created a separate npm package and published it independently.</p> <p>The monorepo approach won out for a few reasons. First, the widget and the server are tightly coupled. When we change how the channel sends data, we often need to update how the widget handles it. Having both in the same repo means we can make those changes atomically in a single commit. No version coordination, no wondering if the deployed widget matches the deployed server.</p> <p>Second, shared types. We generate TypeScript interfaces from our Elixir structs for the channel message formats. Keeping everything together means the types stay in sync automatically. When we add a field to a response, the TypeScript compiler immediately tells us everywhere that needs updating.</p> <p>Third, simpler CI. One repo means one pipeline. We run the Elixir tests, build the widget, and deploy everything together. If the widget build fails, the whole deploy fails. No chance of shipping a broken widget because someone forgot to bump a version number.</p> <p>The build process is straightforward. We have a mix task that shells out to npm to build the widget, then copies the output to <code>priv/static/widget/</code>. The widget JS and CSS get served as static assets. When someone includes our script tag, they're pulling from the same CDN that serves the rest of our static files.</p> <div class="highlight"><pre><span></span>amplify/ ├── lib/ # Elixir code │ └── amplify_web/ │ └── channels/ │ └── product_list_channel.ex ├── assets/ │ ├── js/ # Main app JavaScript │ └── widget/ # Widget package │ ├── src/ │ │ └── product-list.ts │ ├── package.json │ └── tsconfig.json └── priv/static/widget/ # Built widget output </pre></div> <p>This also helps with our customer UX as they just have to paste a script tag into their Squarespace site. Hosting the widget ourselves and keeping it in the monorepo gives us full control over the experience.</p> <h2 id=what-we-learned>What We Learned</h2><p><strong>Start with the primitives you already have.</strong> We wasted some time in trying to shoehorn LiveView into a context it wasn't designed for. Phoenix Channels were sitting right there the whole time, battle tested and ready to go. Sometimes the answer isn't a new library or a clever workaround. It's the boring infrastructure you've been using for years.</p> <p><strong>Server rendered HTML over WebSockets is underrated.</strong> The conventional wisdom says send JSON and render on the client. But that only makes sense if you don't already have rendering logic on the server. We had years of Elixir code handling edge cases in price formatting, discount calculations, and conditional display logic. Sending HTML meant we could reuse all of it. The widget got our production tested rendering for free.</p> <p><strong>DOM diffing libraries are table stakes for any dynamic UI.</strong> We almost shipped with raw innerHTML updates. The first time we saw a user lose their place in a form because the whole container re-rendered, we knew we needed Idiomorph. The integration took maybe two hours. The UX improvement was immediate and obvious. If you're updating DOM content dynamically, use a morphing library. It's not optional.</p> <p><strong>LocalStorage beats cookies for cross origin state.</strong> The iframe approach died because of third party cookie restrictions. LocalStorage doesn't have that problem since the widget runs in the host page's context. The tradeoff is that carts are now per device rather than per user session, but for our use case that's actually fine. Most ticket purchases happen in a single session anyway.</p> <p><strong>Monorepo simplifies everything when client and server are tightly coupled.</strong> We briefly considered publishing the widget as a separate npm package. The coordination overhead would have been brutal. Every channel message format change would require syncing versions across repos. Keeping everything together means atomic commits and one deployment pipeline. The simplicity is worth the slightly larger repo.</p> <h2 id=wrapping-up>Wrapping Up</h2><p>Phoenix Channels gave us the foundation we needed to build an embeddable widget that actually works. The persistent WebSocket connection handles real time updates elegantly. Sending rendered HTML lets us reuse our existing Elixir codebase. Idiomorph keeps the DOM updates feeling smooth.</p> <p>If you're building something similar with Elixir and Phoenix, consider whether channels might be a better fit than trying to embed LiveView directly. The pattern of Web Component plus Channel plus server rendered HTML is surprisingly powerful and sidesteps a lot of the complexity we ran into with other approaches.</p> <p>The widget is now running in production on customer sites across different platforms and tech stacks. Drop in a script tag, add the custom element, and you've got a full checkout flow without sending users anywhere. Here's what the final widget looks like:</p> <p><img src="proxy.php?url=https%3A%2F%2Fbear-images.sfo2.cdn.digitaloceanspaces.com%2Fzarar%2Fwidget.webp" alt="widget" /></p> 2025-12-24T19:03:00+00:00