Designing Fantasy League Architecture
November 5, 2023
This is a working document where I'm trying to design what data tables would be needed for a hypothetical fantasy league site.
I'd love any feedback or opinions you might have (especially if they are technically informed!).
Unique Features
Here are some things that this architecture allows:
- Full record of past lineups and matchup results from all past seasons.
- Full team history for a franchise.
- Unlimited co-managers per team.
- Unlimited commissioners per league.
- Commissioners not associated with a team.
- Three (or more) team trades
LEAGUES
A league is the obvious structure to start off with.
However, my plan would be to push most of the settings down to the season level. Every season, your league can change its name or teams or scoring settings, and the full history is preserved.
- League ID (PK)
- Public (T/F)
So this table is really just for linking all of the seasons in one place.
SEASONS
- Season ID (PK)
- League ID (FK)
- Year ("year of draft" for sports that span multiple calendar years)
- Name
- Sport
- Scoring ("roto", "h2h", etc.)
- Playoffs (Y/N)
FRANCHISES
- Franchise ID (PK)
TEAMS
Like with leagues, we're going to build multi-year continuity into teams right from the start. Team names and managers can change from year-to-year, and the franchise history is all preserved.
- Team ID (PK)
- Season ID (FK)
- Franchise ID (FK)
- Team Name
MANAGERS
It's tempting to design a 1-to-1 relationship between teams and managers, but people will definitely want co-managers. Better build that from the get-go.
- Team ID (FK)
- User ID (FK)
COMMISSIONERS
We could have a column on the MANAGERS table that grants commissioner power. But what if we wanted to have a commissioner without a team?
- League ID (FK)
- User ID (FK)
DIVISIONS
This one is optional. But, you could end up with some novel ideas: Roto leagues with divisions and a playoff?
- Division ID (PK)
- Season ID (FK)
- Name
CATEGORIES
This groups rotisserie and points together.
- Season ID (PK)
- Category (PK)
- Category Value (points-leagues only)
- Direction ("postive" or "negative", roto only)
POSITIONS
- Season ID
- Position
- Position Count
SCORING_PERIODS
There would be a couple of period templates (daily, weekly) that would be pulled from for creating leagues. But leagues could customize the periods around uneven "week" sizes, shorter/longer playoffs, or ending early to avoid players resting at the end of the season.
For daily leagues, the start date and end date for a period would be the same day.
- Scoring Period ID (PK)
- Season ID (FK)
- Start Date
- End Date
LINEUP_PERIODS
Some leagues may choose to have weekly matchups but allow daily lineup changes. This would start with the same daily/weekly templates as SEASON_SCORING_PERIODS, but could also be customized.
Application logic would need to make sure that each lineup period is contained within a single scoring period.
- Lineup Period ID (PK)
- Season ID (FK)
- Start Date
- End Date
MATCHUPS
This is optional. We'll assume that a matchup can only have two teams, so we'll add those at columns. (We can call them "home team" and "away team" just for fun, but it's no different from "team1" and "team2.")
- Scoring Period ID
- Home Team (Team ID)
- Away Team (Team ID)
- Playoffs (Y/N)
LINEUPS
- Team ID (PK)
- Lineup Period ID (PK)
- Player ID
- Position
TRANSACTIONS
We could have separate tables for add/drops and trades, but my initial thought is to create a single TRANSACTIONS table that handles both.
- League ID
- Year
- Transaction ID
- Request Datetime
- Accept Datetime (trades only)
- Effective Datetime
- Status ("requested","processed","denied","rejected","accepted","vetoed")
TRANSACTION_DETAILS
Each transaction could have multiple rows of TRANSACTION_DETAILS. In theory, this could allow trades involving more than two teams.
- League ID
- Year
- Transaction ID
- Team ID
- Player (Player ID)
- Action ("add","drop","give","receive")
TRANSACTION_VETOES
- League ID
- Year
- Transaction ID
- Vetoed By (Franchise ID)
PLAYERS
The PLAYERS table would be global for all leagues. Having a "Position" column assumes that leagues couldn't set a custom cutoff for positions, which some sites allow.
It would need rebuilt every day to update teams (for trades) and occasional name changes.
- Player ID
- Name
- Position
- Team
- Birthday
SCHEDULE
This is another global table, built during each offseason when the league releases the schedule for the upcoming season.
This is mostly just to help users with setting their lineups, to know who is playing.
Each morning, we would want to check and update the current day's schedule to catch rescheduled games.
- Game ID
- Home Team
- Away Team
- Start Datetime
DAILY_STATS_HITTING, DAILY_STATS_PITCHING, DAILY_STATS_OFFENSE, DAILY_STATS_KICKERS, DAILY_STATS_DEFENSE, DAILY_STATS_BASKETBALL
These are global tables that would ideally be updated for the current day every 10-15 minutes.
An additional update (full rebuild?) would run at the end of the scoring period to catch any stat adjustments.
If a player has multiple games on the same day, we'll compress the stats for all games into a single row per player.
Does IDP need a separate table from team defense?
- Player ID
- Date
- [relevant categories by sport]
USERS
One more global table. This is your standard user authentication system with stuff like email addresses for password resets.
Computed Data
Good database design says you shouldn't store data that can be computed from more basic data. If you do, you could end up with data that's out of sync because the computed values didn't get updated.
However, there's going to be some frequently accessed computed data that maybe we want to have ready without computing every time. Here's what I'd maybe consider storing.
Team Stats By Date
We can calculate a team's stats for a date range by matching a team's LINEUPS that fall within the SCORING_PERIOD, and then summing the stats from DAILY_STATS_[SPORT] for active players.
Team W/L Records
For head-to-head scoring, we need to get the team stats by period (above), then use the MATCHUPS table to determine wins and losses.
Standings
For rotisserie, we need to get the team stats for the whole season (above) and then calculating rotisserie points.
For head-to-head, we need to get the win-loss records (above) and rank by winning percentage.
Current Rosters
User History
User winning percentage, trophies, etc.
Time Triggers
A fantasy league needs scheduled jobs to process various regular events. Here's what I've thought of so far:
Season Creation
- Build in-season matchups
End of Day
- If end of lineup period and a team hasn't set a lineup for the next period, copy over the current lineup
- If end of lineup period and best ball, update current team lineups to match highest scorers
- If end of lineup period and the next period is playoffs, build upcoming matchups
- Process waiver requests and update
- Process unvetoed trades
- Rebuild daily schedule for upcoming day