I've spent the last while building a fantasy basketball platform (Fantasy Dynasty — my project, so calibrate accordingly), and the fantasy-scoring part turned out to be the boring bit. The genuinely hard, interesting part was this: turn the real NBA Collective Bargaining Agreement into a deterministic engine that validates every roster move.
Most fantasy apps track stats. The moment you add a real salary cap, multi-year contracts, and dead money, you stop building a stats app and start building a constraint solver with a domain that actively resists you. Here are the lessons that survived contact with production. (No internal schema or queries here — just the patterns, in general terms.)
The problem: a cap is a web of interacting constraints
In a normal fantasy app, "add a player to a roster" is an insert. Under a real cap, that same action has to pass a gauntlet:
- Does the team have cap space? (soft cap)
- If not, does it have an exception that covers the signing? (MLE, Bi-Annual, Room…)
- Does the move cross the apron? If it does, a whole set of other moves becomes illegal.
- Is the target a restricted free agent another team can still match?
Each of those is a rule with its own edge cases, and they interact. You can't validate them independently — the apron changes which exceptions are legal, which changes what counts as usable cap space. It's a rules engine, not a pile of if statements.
Dead money: the mechanic that makes every signing a real decision
The single feature that changes everything is dead money. In the real NBA, if you waive a player on a guaranteed contract, the remaining money doesn't disappear — it stays on your cap. That's why GMs agonize over long deals, and it's what turns a fantasy roster into a real balance sheet.
The modeling insight that matters isn't the arithmetic (guaranteed years count, non-guaranteed years don't, the current year hits at full value and future years at a reduced rate). It's the lifecycle consequence: a waived contract has to keep existing. You cannot treat "released" as "gone", because the dead-cap accounting and the franchise history both still depend on it. That one requirement quietly shapes the whole persistence model — which leads straight to the bug below.
The gotcha that ate a day: soft-deletes + global query filters
Because a terminated contract must live on, I model it as a soft-delete rather than a hard delete. To keep the rest of the app clean, a global query filter hides "deleted" rows from ordinary reads. In EF Core that's the textbook pattern:
// Illustrative — the standard EF Core soft-delete filter.
modelBuilder.Entity<Record>().HasQueryFilter(e => !e.IsDeleted);
This is great — until you write the code that needs the deleted rows. Dead money is computed from records that are, by definition, terminated. So an aggregate that should include them silently under-counts, because the global filter is hiding exactly the rows that carry the penalty. The fix is to opt out explicitly:
// Any aggregate/history read that needs the hidden rows must opt out.
var everything = await db.Records
.IgnoreQueryFilters()
.Where(e => e.OwnerId == ownerId)
.ToListAsync();
The bug is nasty precisely because it's invisible in every normal flow — the cap looks correct right up until someone cuts a player, and then money quietly goes missing. If you use global query filters for soft-deletes, audit every aggregate and history read for the IgnoreQueryFilters() it needs. Assume the filter is working against you in exactly the places that matter most.
Making the whole CBA opt-in per league
Here's the design tension that shaped the architecture: I wanted one platform that a casual group could run as a simple redraft league, and that a hardcore group could run as a full front-office simulation — on the same foundation, without ever migrating.
The answer was to make every advanced mechanic a per-league toggle, and compose the validation pipeline from only the rules a league has enabled:
// Compose the pipeline from only the rules this league turned on.
var active = ruleCatalog.Where(r => r.EnabledFor(league));
var result = active
.Select(r => r.Check(move, state))
.FirstOrDefault(r => !r.Ok) ?? ValidationResult.Ok;
Restricted free agency, Bird rights, the apron, trade salary-matching, traded-player exceptions, the stretch provision — each is a self-contained rule that opts itself in based on the league's configuration. A redraft league runs with none of them; a GM-sim league runs with all of them. Same code path, radically different games. The alternative — branching on league type deep inside every handler — would have been unmaintainable within a month.
The war story: making an irreversible off-season transition undoable
Every off-season a commissioner "finalizes keepers" — a destructive transition that rewrites contracts across the league, decrements years, and clears out stale cap holds. Commissioners get it wrong (wrong keepers, mistimed click), and originally there was no undo. In a dynasty league, one bad finalize poisons multiple future seasons. Unacceptable.
I deliberately did not try to invert the mutation. Inverting a large, partially-lossy state change is fragile — you're one forgotten side-effect away from a corrupt restore. Instead, before touching anything, I snapshot the exact pre-transition state and store it as an opaque blob. Undo then becomes trivial and total: reload the snapshot, discard everything created after it, done.
The lesson generalizes well beyond fantasy sports: for a destructive, hard-to-invert operation, snapshotting the pre-state is far more robust than writing a reverse migration. You trade a little storage for a guaranteed, exact rollback — and you stop trying to enumerate every side-effect you'd otherwise have to undo by hand.
A performance note: N+1 loves an innocent-looking helper
The draft engine constantly asks a boring question — "can the draft continue, i.e. does any team still have an open roster slot?" The first version did the obvious thing: loop the teams and count each roster. A dozen teams meant a dozen round-trips, on a path that fires on every single pick.
The fix was nothing clever — collapse it into a single grouped aggregate that counts every team's roster in one query, then answer the question in memory. The takeaway is the reminder: N+1 hides beautifully inside a boolean helper that "just checks something." The hot paths worth profiling are rarely the ones that look expensive.
The stack, briefly
- ASP.NET Core + EF Core, CQRS via MediatR.
- React front end; SignalR for the real-time auction draft (all managers bidding live on a shared clock).
- ML.NET (gradient-boosted trees + Defense-vs-Position adjustments) for the trade analyzer and projections.
- Full NBA and WNBA support — the WNBA runs the same engine with a scaled cap.
Takeaways
If you ever model a real-world rulebook as software:
- The domain is the hard part, not the framework. The CBA fought back far more than any library did.
- Soft-deletes + global query filters are great until an aggregate needs the hidden rows. Audit for the opt-out.
- Snapshot, don't invert, for destructive operations you might need to undo.
- Make rules composable and opt-in if you want one system to serve both casual and hardcore users.
If you want to see the engine in action — including the full opt-in CBA layer and the WNBA side — it's live and free at fantasy-dynasty.com. Happy to talk through the cap modeling or the real-time draft in the comments; that's the part I find genuinely interesting.
Top comments (0)