Bringing Open-Sourced Elegance to Excel Generation
From intern frustration to an open-source production DSL: turning days of pain into minutes of freedom
Nov 10, 2025 — 17 min read

Written by
The Abstraction Problem
There's a moment in every programmer's career when they realize the tool everyone uses is the wrong abstraction. Not buggy. Not slow. Just... wrong. The fundamental model doesn't match how you need to think about the problem. For me, that moment came while building spreadsheets programmatically.
The Breaking Point
I was an intern at AngelList in summer 2023, working on the backend systems that handle accounting for venture capital funds. The task at hand seemed straightforward: automate support for complex carried interest structures in our quarterly financial statements. Carried interest (or "carry") is how venture fund managers can get paid: typically 20% of a fund’s profits. But it can get complicated. Hurdle rates. Catchup provisions. Preferred returns. Fund managers negotiate these complex terms carefully, and our accountants wanted to eliminate the manual work modifying financial statements to reflect these accurately.
I thought deriving broadly applicable formulas would be the hard part. It took deep thought and a couple white-boarding sessions, but I was able to break through. The trough of sorrow had been crossed…or so I thought. Implementing these financial models using caxlsx, the most common Ruby library for generating Excel files, was the final step.
Unfortunately, that's when I discovered what "wrong abstraction" really means.
A Paralyzing Web of String Manipulation
Here's what building a very simple profit calculation looks like in caxlsx:
# caxlsx - the old wayworksheet.add_row ["Revenue", 100000]worksheet.add_row ["Expenses", -60000]worksheet.add_row ["Profit", "=B1-B2"]worksheet.add_row ["Carry (20%)", "=IF(B3>0,MAX(B3*0.2,0),0)"]
Looks simple? Now try to:
- Insert a row between Revenue and Expenses (every formula below silently breaks)
- Add cross-sheet references to pull data from an Income Statement
- Debug why
B3returns zero (is it row 3 or the third data row? Don’t get 1-indexed Excel mixed up with 0-indexed Ruby!) - Refactor when product changes the flow of data between the sheets
I mean, to capture complex carry structures spanning multiple sheets with conditional logic, I was constructing things like:
IF(SUM(SUM(F4:K4)-SUM (E4*2*0.2/0.2, E4*1*0.3/0.3, E4*0.5*0.4/0.4), E4*2*0.2, E4*1*0.3, E4*0.5*0.4)-SUM (F4:K4)*0.5>0,0, MIN(0, MIN(SUM(F4:K4)-SUM(S4*-1/0.1, T4*-1/0.2,U4*-1/0.3), E4*0.5)*0.4*-1))
Unsurprisingly, I walked into a maelstrom of issues. Off-by-one errors everywhere. Formula strings that broke with every structural change. Mental coordinate tracking that made adding features feel archaeological. When I reached out asking "there must be a better way," the consensus was: "Nope, building spreadsheets is just brutal."
I wasn’t satisfied with the answer. But with the next cycle of quarterly reporting looming for our accountants, I shipped my changes, which would work effectively in the medium term, even if not scalable for the long term.

Yet I could feel the number of sheets and references between them were only growing over time (and this prediction came true—just look at that PCB-esque diagram to see how complex our workbooks are now!). A need for a more scalable approach was looming on the horizon. We engineers thought we found a silver bullet in named ranges, a feature in Excel which allows you to provide a name for a range of cells and reference that name rather than their coordinates. But we soon discovered both our in-house accountants and external auditors were not fans, as they didn’t provide the level of transparency that using traditional cell coordinate references do.
Alas, I accepted reality, powered through, and shipped working code. But I had a lingering thought that things could and should be better. Then, our quarterly hack week arrived.
The Insight: Spreadsheets as Directed Graphs
During that hack week, my colleague Zach approached me with a question that changed everything:
What if we could build spreadsheets like a finance professional would? Let's treat the flow of data as a directed graph rather than a 3D array.
With the visual feedback they get in Excel as they build out a workbook, finance professionals don't have to think in cell coordinates. They just think: "Revenue minus Expenses equals Profit. If Profit is positive, calculate Carry as 20% of Profit." For them, these are relationships between named concepts, the coordinate arithmetic is just a means to an end.
Well, what if our code looked like this instead?
member_row = sheet.add_row!(:member_row).add!(:name, value: "John Doe").add!(:net_profit,Zaxcel::Functions.sum(Zaxcel::Lang.range(row.ref(:revenue), row.ref(:mgmt_expense))))profit = member_row.ref(:net_profit)carry = sheet.add!(:carry,Zaxcel::Lang.if(Zaxcel::BinaryExpressions.greater_than(profit, 0)).then(Zaxcel::Functions.max(profit * 0.2, 0)).else(0))
The difference is night and day. Instead of string coordinates, we have Ruby objects. Instead of cell positions, we have named references. Instead of praying our formula strings are correct, we have well-defined abstractions that handle all the gnarly string manipulation under the hood.
Zach came into the week with a rough proof-of-concept with several competing ideas on solving our Excel pains. By the end of hack week, we had a clear vision and a plan to execute collaboratively. By the end of the quarter, we were producing financial statements in production with our brand-new DSL, which we’d aptly named Zaxcel (Zach + Excel! wasn’t my idea but a catchy portmanteau nonetheless). By the next quarter-end, with some elbow grease from a principal engineer, all of our old financial statement generation logic had been ripped out and Zaxcel was being used elsewhere (e.g. exportable expense schedules). Success!
Our Design Philosophy: Match the Domain, Not the Tool
Through building Zaxcel, we developed a core conviction:
Figuring out the business logic is meant to be the hard part. Displaying it in a spreadsheet should be easy.
In the world of financial reporting, Excel is inevitable. Customers demand it. Investors expect it. Auditors require it. The solution isn't to avoid Excel—it's to build better abstractions for generating it.
This meant designing a DSL that mirrors how finance professionals actually think and communicate:
- When an accountant says "calculate carry on net profit," we write
carry_row.add!(carry_on(net_profit.ref(:amount))) - When they say "if the hurdle is met," we write
Zaxcel::Lang.if(Zaxcel::BinaryExpressions.greater_than(profit, hurdle)).then(...) - When they say "sum across the income statement for the last fiscal year," we write
Zaxcel::Functions.sum(Zaxcel::Lang.range(income_statement.column_ref(time_range)))
The code should read like the specification. If a finance professional can understand the logic in English, an engineer should be able to trace it in code without mentally translating cell coordinates.
Technical Architecture: Three Core Decisions
Zaxcel's effectiveness comes from three key engineering decisions:
- Graph-based reference tracking (rather than string manipulation + coordinate calculation)
- Leveraging Ruby's flexibility as a DSL foundation (operator overloading, module composition)
- Sorbet empowering rapid development (catch errors before Excel does)
Decision 1: Graph-Based Reference Tracking
When you write profit.ref(:amount), you're not calculating a cell position—you're creating an edge in a directed graph. The graph tracks relationships between named entities.
# Building the graphrevenue = sheet.add_row!(:revenue).add!(:amount, value: 100_000)expenses = sheet.add_row!(:expenses).add!(:amount, value: 60_000)# Creating relationships (graph edges)profit = sheet.add_row!(:profit).add!(:amount, value: revenue.ref(:amount) - expenses.ref(:amount))
The magic happens at “compile time”. Zaxcel:
- Walks the graph to determine row/column positions
- Resolves all references to their Excel coordinates
- Generates formula strings with correct cell addresses
- Handles cross-sheet references automatically
Insert a row? Graph updates automatically. Restyle a column? System catches every reference. Add a new sheet? Cross-references just work. Accidentally add a circular reference? Graph traversal detects it automatically.
This is why refactoring is safe—the graph structure is independent of physical positions.
Decision 2: Leveraging Ruby's Flexibility as a DSL Foundation
Zaxcel’s DSL abstractions guarantee the production of well-formatted formulas, regardless of complexity. The flexibility of Ruby is what enabled three critical DSL patterns to facilitate that production idiomatically:
Operator Overloading via Numeric#coerce:
module Zaxcel::Arithmeticdef coerce(other)[CoercedValue.new(value: other), self]enddef +(other)Zaxcel::BinaryExpressions::Addition.new(self, other)end# Similar for -, *, /end
This lets you write revenue.ref(:amount) + expenses.ref(:amount) and have it generate =B2+B3. The math feels natural because it is Ruby's native arithmetic.
Builder Patterns for Complex Logic:
Zaxcel::Lang.if(Zaxcel::BinaryExpressions.not_equal(revenue.ref(:amount), 0)).then((profit.ref(:amount) / revenue.ref(:amount)).round(precision: 4)).else(0)
This generates =IF(B2<>0,ROUND(B4/B2,4),0) but reads like a specification, not implementation.
Module Composition for Capabilities:
module Zaxcel::Roundabledef round(precision: 0)Zaxcel::Functions::Round.new(self, precision: precision)endend
Any arithmetic expression can be .round(precision: 4) because it mixes in Roundable. Adding capabilities feels natural and discoverable.
Decision 3: Sorbet Empowering Rapid Development
Type safety is crucial when your Excel DSL is so readable it stops looking like Excel.
Knowing What You're Working With:
# This reads like English, but what types are we actually dealing with?profit.ref(:amount) - expenses.ref(:amount)# Sorbet tells you:sig { returns(Zaxcel::Cell::Reference) } # profit.ref returns a referencesig { params(other: T.any(Numeric, Zaxcel::Arithmetic)).returns(Zaxcel::CellFormula) } # subtraction returns a formula
When code reads like a financial specification, type signatures become your map. Without them, you're guessing whether profit.ref(:amount) returns a cell, a reference, a value, or something else entirely.
IDE Autocomplete as Documentation:
profit.ref(...) # IDE shows: :amount, :description, :category# You don't need to remember column names - autocomplete shows valid optionsZaxcel::Functions. # IDE shows: sum, max, min, round, if_error, xirr...# Sorbet-powered discoverability without documentation diving
The DSL deliberately blurs the line between business logic and implementation. Sorbet keeps you oriented when carry_calculation.round(precision: 4) could just as easily be Ruby math or Excel formula generation.
The DSL as Leverage: What Makes Zaxcel Different
With those decisions locked in, we live in a world where Zaxcel is more than a library, it's a domain-specific language designed around how finance professionals think.
- Domain Language: Speak in financial terms (
:revenue,:profit_margin) not implementation details ("B2", "=C3/A3"). - Declarative Syntax: Describe what you want, not how to build it. The DSL handles Excel complexity.
- Fluent & Chainable: Read top-to-bottom like a specification. If you can read Ruby, you can read our financial statements.
This is Ruby's superpower: building DSLs that match your domain. Rails did it for web apps. RSpec did it for testing. Zaxcel aims to do it for financial reporting.
The best DSLs don't feel like you're using a library—they feel like the language was designed for your problem. That's the standard we aimed for.
Production at Scale: 28+ Sheet Types, Thousands of Workbooks
Zaxcel now powers AngelList's entire financial statement generation pipeline, with each of these workbooks dynamically generated based on fund structure, investor classes, and accounting periods. A typical workbook has 15+ interconnected sheets with hundreds of cross-sheet references. Thanks to Zaxcel, we’re able to produce thousands of financial statements, saving our accountants tens of thousands of hours of manual work every quarter.
Built-in DSL abstractions and type safety allow us to execute refactors with a very high degree of confidence. Feature development that used to take days (or even weeks), now takes a single engineer, a matter of a couple hours, if not minutes.
Real-World Complexity Example
Here's a simplified version of how we generate Capital Account Summaries with carry allocations:
# Pull profit from Income Statementprofit = income_statement_sheet.row_ref(:net_income).cell_ref(:current_period)# Calculate if hurdle is methurdle_met = Zaxcel::Lang.if(Zaxcel::BinaryExpressions.greater_than(profit, hurdle)).then(true).else(false)# Carry calculation with catchupcarry_cell = Zaxcel::Lang.if(hurdle_met).then(Zaxcel::Functions.min((profit_cell - hurdle_amount) * carry_rate,max_carry_amount)).else(0)# Add to capital account sheetcarry_row = sheet.add_row!(:carried_interest).add!(:description, value: 'Carried Interest (20%)').add!(:amount, value: carry_cell)
This generates formulas that:
- Reference cells across sheets
- Implement complex conditional logic
- Handle edge cases (zero profits, hurdle thresholds)
- Remain auditable (all formulas visible in Excel)
Without Zaxcel, this would be 50+ lines of string manipulation prone to subtle bugs.
Results: The Development Cycle Transformation
The impact is best measured in before-and-after timelines:
Pre-Zaxcel Era (2023)
- Days (if not weeks) to add features to financial statements
- Most development time spent debugging cell coordinates, not writing business logic
- Every refactor was risky—change one row, potentially break dozens of formulas
- New engineers avoided the Excel generation codebase
Post-Zaxcel Launch (Late 2023-2024)
- Hours instead of days for the same changes
- Development time now spent on actual financial logic
- Readable code means new engineers contribute immediately
- Refactoring is routine—the type system catches issues
Present Day (2025)
- 28+ different worksheet types powered by Zaxcel
- Thousands of workbooks generated quarterly
- New engineers can ship Zaxcel features in week one
- Agentic coding leveraging the DSL empowers feature changes in a handful of minutes
That last point matters: good abstractions help everyone, both humans and machines. When your code reads like its specification, it's easier to understand, maintain, and extend.
The Human Side: Team Dynamics and Adoption
Technical excellence doesn't matter if nobody uses it. Zaxcel's success came from tight collaboration between engineering and our fund administration team.
- Early Adopters: Our accountants and financial operations team were skeptical at first (rightfully so—they'd seen "improvements" break production before). We brought them into the design process early, demoing working sheets, incorporating their feedback, and most importantly: maintaining feature parity with existing statements during migration.
- Migration Strategy: We didn't do a big-bang rewrite. We migrated one sheet type at a time, starting with simpler statements (Cover Sheet, Balance Sheet) before tackling complex ones (Schedule of Investments, Capital Account Statements). Each migration proved the approach and built confidence.
- Developer Onboarding: New engineers on the finance engineering team now spend their first week working on a Zaxcel-powered feature. It's become our onboarding project because it teaches them about fund accounting while demonstrating clean Ruby/DSL design.
Open Source: Sharing the tool we wish we had from the start
If this had existed out in the world, we would have certainly saved ourselves a lot of work with our Excel workbook generation functionality. The AngelList engineering team would love to save others from the pain we had building spreadsheets pre-Zaxcel.
So on that note, we’re excited to announce: Zaxcel is now open-source!
- Zaxcel on RubyGems: You can now install it directly from RubyGems and start generating Excel workbooks right away.
- Zaxcel GitHub Repository: We’re also opening the doors for contributions and collaboration. If you’d like to explore the internals, report issues, or add new features, check out the source code on GitHub.
Check out our live launch from the SF Ruby meetup on Oct 30th 2025MIT-licensed. Production-tested at scale. Fully typed with Sorbet.
What's Next
Our engineering team has built a lot of cool stuff over the past couple years, and over the coming months we’d love to share some more stories about how we’ve built:
- An automated tax filing platform submitting 300K+ K-1s annually
- An army of AI agents resolving >65% of customer support queries
- Automated accounting infrastructure empowered by live event-sourcing
- Direct banking integrations automatically navigating complex regulatory requirements
The pattern is consistent: take complex financial problems, build high-leverage technical solutions, and build on what we learn.
If you're interested in solving problems like these, we're hiring. Reach out directly to me or other engineers on our team—we're always looking for great colleagues who appreciate good abstractions and hard problems.
Acknowledgements
Zaxcel wouldn't exist without the collaboration and conviction of several key people:
Zach Schnell conceived the core insight during hack week and architected the graph-based reference system. His willingness to bet engineering time on this problem fundamentally reshaped AngelList's financial infrastructure.
Alex Stathis delivered a big boost to driving full adoption of Zaxcel across our financial reporting surfaces after my internship ended. His efforts significantly improved the library ergonomics and ensured cross-functional buy-in.
The AngelList engineering team—particularly those maintaining our 15-year-old Rails monolith—provided the production environment and feedback loops that refined Zaxcel from prototype to foundation.
Our accounting and operations teams, especially those who patiently worked with early versions and helped us understand what "audit-ready" really means in practice.
And to the SF Ruby community for providing a venue to share this work—looking forward to the discussion.







