Markdown Editor

Feature Store System Design Template (Online, Offline, Point-in-Time)

ML feature store: registry, batch and streaming pipelines, offline and online stores, point-in-time joins with TTL, training-serving skew, monitoring

系统设计完整designfeaturestoremath

使用方法: Use it when designing or reviewing a shared feature platform for machine learning models, or preparing the feature store interview question. Fill the online fan-out estimate and the point-in-time worked example with your own features first. The main pitfall is building training sets with plain joins on the latest value, which leaks future data into training.

预览

System Design: [Feature store name]

Reference design for a machine learning feature store: one registry of feature definitions, batch and streaming pipelines that compute features once, an offline store with full history for training, an online store with the latest values for low-latency serving, and point-in-time correct joins that build training sets without leaking future data. It covers the registry, materialisation, freshness, training and serving consistency, backfills and feature monitoring. This is an original design that draws on common practice and public write-ups (Uber's Michelangelo post of 2017 and the Feast documentation on point-in-time joins); no standard governs feature stores. It follows the structure of the System Design Document template, adds a 45-minute interview path, access-pattern and failure-mode tables and a cited "How real systems do it" box, and is pre-filled with a worked design to adapt. Replace every [bracketed] value and every row marked Example; values marked Example are assumptions, not benchmarks.

Document control

FieldValue
Document ID[SDD-FS-001]
System / feature[Shared feature platform for ranking and risk models]
Version[0.1]
Status[Draft / In review / Approved / Implemented / Superseded]
Owner[@owner]
Author(s)[@author]
Reviewers[@reviewer-1], [@reviewer-2]
Last updatedYYYY-MM-DD
Classification[Internal]
DifficultyAdvanced
Main consumers[Ranking service, fraud scoring, demand forecasting]
Related[ML system design], [Model monitoring plan], [ADR: online store choice]

Approvals

RoleNameDecisionDate
Tech lead[@name][Approved / Changes requested]YYYY-MM-DD
ML platform lead[@name][Decision]YYYY-MM-DD
Data platform lead[@name][Decision]YYYY-MM-DD
SRE / operations[@name][Decision]YYYY-MM-DD
Privacy and data protection[@name][Decision]YYYY-MM-DD

Revision history

VersionDateAuthorChange summary
0.1YYYY-MM-DD[@name]First draft for design review
0.2YYYY-MM-DD[@name]Example: Added in-process cache for merchant features after fan-out estimate
0.3YYYY-MM-DD[@name]Example: Training sets filter on created timestamp to exclude late corrections
1.0YYYY-MM-DD[@name]Example: Approved

Contents

1. Overview

1.1 Problem statement

[Example: Each of nine model teams computes its own user and merchant features: once in SQL for training and again in service code for serving. The two versions drift; last quarter a ranking model lost a measured share of its offline lift in production because a "last 7 days" count was computed in UTC offline and in local time online. Training sets are built with plain joins on the latest value, so some features leak information from after the label time, and offline metrics look better than production.]

1.2 Proposed solution

[Example: Teams declare features once in a registry: entity, source, transformation, owner, freshness target and time-to-live. Batch jobs and streaming jobs compute the features and write every value with its event time to an offline store (columnar tables) and the latest value per entity to an online key-value store. Models fetch online features through one serving API with a multi-get per request. Training sets are built by a point-in-time join that, for each labelled event, picks the latest feature value at or before the event time within the time-to-live. The serving path logs the feature vector it returned, so training can later use exactly what the model saw.]

1.3 Decisions requested from reviewers

IDQuestionRecommended optionSection
Q-01Example: Build, adopt open source, or buy?Adopt an open-source framework for the registry and SDK, run our own stores10
Q-02Example: Online store technology?In-memory key-value cluster, 4 shards with 3 replicas3.2, 5.2
Q-03Example: Where do transformations run?In the shared pipelines, not in model code; on-demand transforms only for request data7.3
Q-04Example: How are training sets built?Point-in-time join with TTL, filtered on created timestamp7.2
[Q-nn][Question][Option][Section]

1.4 45-minute interview path

Use this order when the document serves an interview or a short design review. The written sections below are the long form of each step.

MinutesCoverOutput on the whiteboard
0 to 5Scope: online serving, offline training, freshness, who defines features; below-the-line items (2.3)Two consumers, three requirements
5 to 10Estimates: online store size, lookups per second, offline history (section 3)"Fan-out per request, not request rate, drives the online store"
10 to 15Interfaces: feature definition, get_online_features, get_historical_features (section 4)Three calls
15 to 25High-level design: registry, batch and stream pipelines, offline and online stores (section 6)One diagram
25 to 40One deep dive: point-in-time joins and leakage (default, 7.2), or training and serving consistency (7.3), or streaming freshness (7.4)Options, choice, reason
40 to 45Failures (stale online values, pipeline lag), the 10x row in section 10Two failure rows

Start the default deep dive with the naive answer: join each training label to the current feature table on the entity key. Show with the table in 7.2 how that copies the future into the past, then build the as-of join.

2. Requirements

2.1 Core functional requirements

IDRequirementPriority
FR-01Example: Declare features once with entity, source, transformation, owner, TTL and freshness targetMust
FR-02Example: Serve the latest values for a list of entities and features in one callMust
FR-03Example: Build training sets with point-in-time correct joinsMust
FR-04Example: Batch features (daily) and streaming features (seconds)Must
FR-05Example: Backfill a new feature over 2 years of historyMust
FR-06Example: Search and reuse features across teams, with lineage to sourcesShould
FR-07Example: On-demand features computed from request data at serving timeShould
FR-08Example: Log served feature vectors for training and monitoringShould
[FR-nn][Requirement][Must / Should / Could]

2.2 Non-functional requirements

IDQualityTargetMeasured as
NFR-01Online latencyExample: p99 at or below 10 ms for one request of up to 51 entitiesServing API timing
NFR-02FreshnessExample: Streaming features p99 at or below 60 s from event; batch features by 06:00 UTCEvent time vs. write time
NFR-03AvailabilityExample: 99.95% for the online APISuccessful responses
NFR-04CorrectnessExample: Zero future values in training setsAutomated leakage test (7.2)
NFR-05ConsistencyExample: Online and offline values for the same entity and time differ in under 0.1% of samplesDaily skew job (7.3)
[NFR-nn][Quality][Target][Measurement]

2.3 Below the line (out of scope)

Name these explicitly so reviewers and interviewers do not pull the design toward them.

  • [Example: model training, registry of models and model serving (see the ML system design)]
  • [Example: embedding similarity search (see the vector database design)]
  • [Example: raw data ingestion from source systems (owned by the data platform)]
  • [Example: label generation and annotation]
  • [Item deferred, with the release that will take it]

3. Capacity estimation

3.1 Assumptions

InputSymbolValueSource
User entitiesUUExample: 50 millionUser table
Merchant entitiesMMExample: 2 millionMerchant table
Online bytes per user (300 features x 8 B + 200 B key and overhead)sus_uExample: 2 600 BFeature registry
Online bytes per merchant (100 features x 8 B + 200 B)sms_mExample: 1 000 BFeature registry
Peak predictions per secondqqExample: 20 000Ranking service metrics
Entities per prediction (1 user + 50 candidate merchants)kkExample: 51Ranking design
History kept offlineExample: 2 years of daily user snapshotsRetention policy
Streaming events feeding featuresExample: 200 000 events/s at peakEvent bus metrics
Online store write rate for bulk loadsExample: 50 000 rows/sLoad test

3.2 Online store

Sonline=Usu+Msm=5×107×2 600+2×106×1 000 B=132 GBS_{online} = U s_u + M s_m = 5 \times 10^{7} \times 2\,600 + 2 \times 10^{6} \times 1\,000 \ \text{B} = 132 \ \text{GB}

With three replicas that is 396 GB. On nodes with 64 GB of memory filled to 70%, about 9 nodes are the minimum; choose 4 shards with 3 replicas (12 nodes, about 33 GB per shard) for headroom and even resharding.

3.3 Online lookups

K=q×k=20 000×51=1.02×106 entity lookups/sK = q \times k = 20\,000 \times 51 = 1.02 \times 10^{6} \ \text{entity lookups/s}

The fan-out, not the request rate, dominates. Merchant features are only 2 GB (2×106×1 0002 \times 10^{6} \times 1\,000 B) and change daily, so each serving host caches all of them in process; the online store then serves only the user lookups, 20 000 multi-gets per second. This is the main reason the online store stays small.

3.4 Offline store and pipelines

  • History: 5×107×730=36.55 \times 10^{7} \times 730 = 36.5 billion user rows over two years, about 94.9 TB uncompressed at 2 600 B, about 19 TB at a 5:1 columnar compression ratio (Example, measure yours).
  • Daily materialisation: loading 50 million user rows at 50 000 rows/s takes 5×107/5×104=1 0005 \times 10^{7} / 5 \times 10^{4} = 1\,000 s, about 17 minutes, which fits the 06:00 deadline with room for one retry.
  • Streaming: 200 000 events/s update windowed aggregates; writes to the online store are coalesced per entity per second, so the write rate is bounded by active entities, not events.

3.5 Summary

QuantityEstimateDesign consequence
Online data132 GB (396 GB with replicas)In-memory store, 4 shards x 3 replicas
Lookups1.02 million entities/sMulti-get per request; cache merchant features in process
Remote reads20 000 multi-gets/sSmall online cluster
Offline historyAbout 19 TB compressedPartition by date, cluster by entity for as-of joins
Daily loadAbout 17 minBulk load with versioned swap

4. API design

4.1 Feature definition

Features are declared as code, reviewed in pull requests and applied to the registry by CI:

{"featureView": "user_order_stats", "version": 3, "entity": "user_id", "source": {"type": "batch", "table": "warehouse.orders_daily", "timestampField": "event_ts", "createdField": "created_ts"}, "features": [{"name": "orders_7d", "type": "int32"}, {"name": "spend_30d", "type": "float64"}], "ttl": "2d", "freshness": "daily by 06:00 UTC", "owner": "team-growth", "online": true, "tags": {"pii": "none"}}

4.2 Serving and training calls

InterfaceOperationPurpose
gRPCGetOnlineFeatures(entities[], feature_refs[]) -> rowsLatest values for up to 100 entities, with per-value age
SDKget_historical_features(entity_df, feature_refs, filter_by_created=True)Point-in-time join for a training set
RESTGET /v1/features?search=Discovery with owner, lineage, freshness and usage
RESTPOST /v1/backfillsRequest a backfill for a feature view and time range
Eventfeature_vector_servedLogged vector per prediction (FR-08)
{"entities": [{"user_id": "u_42"}], "features": ["user_order_stats:orders_7d", "user_order_stats:spend_30d", "user_rt:clicks_10m"], "results": [{"user_id": "u_42", "values": [5, 182.4, 7], "ageSeconds": [52200, 52200, 14], "status": ["PRESENT", "PRESENT", "PRESENT"]}]}

Each value carries its age and a status (PRESENT, EXPIRED, MISSING) so models can apply a defined default instead of silently reading zero.

5. Data model

5.1 Entities

The registry holds definitions; the offline store holds every value with two timestamps; the online store holds only the latest value per entity.

5.2 Storage choices

DataStoreKeyWhy
RegistryExample: Relational database behind the registry serviceview_name, versionSmall, versioned, audited
Offline valuesExample: Columnar tables in the lakehousePartitioned by date of event_ts, clustered by entity keyScans and as-of joins over years
Online valuesExample: In-memory key-value cluster{entity_key}:{view_name} hash with one field per featureOne multi-get per request, sub-millisecond
Served vectorsEvent log to the offline storePrediction ID, timeTraining on served values, skew checks

5.3 Access patterns

Access patternFrequencyKey or index that serves itPartition key rationale
Latest features of one user for a prediction20 000/s{user_id}:{view} hash, multi-get across viewsEntity key spreads load evenly; views of one entity fetched together
Latest features of 50 merchants1 million entity reads/s, served from process cacheLocal map refreshed after each daily loadSmall and slow-changing, so no remote call
As-of join for a training setBatch, hundreds of millions of label rowsOffline tables partitioned by date, clustered by entityJoin scans only the dates within label time minus TTL
Backfill a new featureOccasionalSource tables by dateRuns as normal batch over historical partitions
Discover featuresInteractiveRegistry search index on name, tags, ownerSmall catalog
Compare served and offline valuesDailyServed-vector log joined to offline rows by entity and timeSame as-of join logic

6. High-level design

ComponentResponsibilityScales by
RegistryDefinitions, versions, owners, lineage, access rulesSmall
Batch pipelinesCompute daily and hourly features from warehouse tablesWarehouse compute
Streaming pipelinesWindowed aggregates from events, written within secondsEvent partitions
Offline storeAll values with event and created timestampsStorage and scan compute
Online storeLatest value per entity and viewMemory, shards
Feature serving APIMulti-get, defaults, ages, logging, access controlStateless instances
Point-in-time join engineTraining sets from label rows and feature historyBatch compute
Feature monitoringFreshness, null rates, distribution drift, skewBatch jobs and metrics

7. Detailed design

The deep dives in this section, each with options, choice and reason:

Deep diveOptions consideredChoiceReasonSection
Training set correctnessJoin on latest value, daily snapshots, point-in-time joinPoint-in-time join with TTL and created-time filterNo leakage, no snapshot explosion7.2
Training and serving consistencySeparate code paths, shared definitions, logging served valuesShared definitions plus served-value loggingOne computation, measurable skew7.3
Streaming freshnessMicro-batch, streaming aggregates, compute at request timeStreaming aggregates with sliding windowsSeconds of freshness without request-time load7.4
Online loadingIn-place upserts, versioned table swapVersioned swap for batch, upserts for streamsConsistent daily snapshots, no half-loaded state7.5

7.1 Feature lifecycle

A feature becomes Published only after backfill and validation (null rate, value ranges, offline and online agreement on a sample). Deprecated features keep serving but warn every consumer; deletion waits until reads stop.

7.2 Point-in-time joins

For a label event of entity ee at time tt, the joined value of a feature is the one with the latest event time tit_i at or before tt, provided it is not older than the TTL τ\tau:

v(e,t)=vjwheretj=max⁡{ti≤t : t−ti≤τ}v(e, t) = v_{j} \quad \text{where} \quad t_{j} = \max \{ t_i \le t \ : \ t - t_i \le \tau \}

If no value qualifies the result is null (status EXPIRED or MISSING), and the model sees the same default it sees online.

Worked example for feature orders_7d of user u_42, TTL 2 days (Example):

Feature rowEvent timeCreated timeValue
FV-12026-03-01 00:002026-03-01 02:103
FV-22026-03-02 00:002026-03-02 02:055
FV-32026-03-03 00:002026-03-03 02:206
FV-4 (late correction of FV-2)2026-03-02 00:002026-03-04 09:004
Label eventNaive join (latest value)Point-in-time joinWith created-time filter
LE-1 at 2026-03-02 14:306 (leaks FV-3, from the future)4 (FV-4 replaces FV-2 for the same event time)5 (FV-4 was created after the label time)
LE-2 at 2026-03-06 10:006Null: FV-3 is 3 days 10 hours old, beyond the 2-day TTLNull

The created-time filter reproduces what the online store held at the time of the label, which is what the model will see in production. Without it, corrections written later improve offline metrics but not live behaviour. The Feast documentation describes the same backward scan bounded by TTL and an optional created-timestamp filter.

Implementation: sort label rows and feature rows by entity and time and run an as-of merge per entity partition; read only feature partitions from tmin−τt_{min} - \tau to tmaxt_{max}. Add an automated leakage test to CI: a synthetic feature equal to its own event time must never exceed the label time in any built training set (NFR-04).

7.3 Training and serving consistency

Skew has three usual causes: different code (SQL offline, service code online), different data (late events, time zones, defaults) and different timing (online value older than assumed). Controls:

  • Transformations live in the feature definition and run in the shared pipelines; models read finished features, never raw tables.
  • On-demand features (computed from request data such as cart size) use one function packaged for both the serving API and the join engine.
  • The serving API logs the vector it returned; the daily skew job compares logged values with the point-in-time join for the same entities and times and alerts above 0.1% mismatch (NFR-05).
  • Defaults for missing values are defined once in the registry.

7.4 Streaming features and freshness

Streaming pipelines maintain sliding-window aggregates (for example clicks in the last 10 minutes) keyed by entity, with event-time windows and a watermark that accepts events up to 30 s late (Example). Each update is written to the online store with its event time and appended to the offline store, so training sees the same values. Writes are coalesced per entity per second. Freshness is measured as now minus the latest event time per view and exported as a metric (NFR-02).

7.5 Online materialisation

  • Batch views: the daily job writes into a new versioned key space, checks row counts and sample values, then switches the serving API to the new version in one step. Rollback switches back. Merchant caches reload on the switch event.
  • Streaming views: upserts with a monotonic check on event time, so an out-of-order write never replaces a newer value.
  • TTL: online values carry an expiry equal to the view TTL; expired values return EXPIRED, never a stale number.

7.6 How real systems do it

How real systems do it (paraphrased, not copied). Uber's post "Meet Michelangelo: Uber's Machine Learning Platform" (Mike Del Balso and Jeremy Hermann, 5 September 2017) describes a shared feature store where teams publish curated features with an owner, description and SLA, and models refer to features by name. Offline features are computed by scheduled Spark and Hive jobs over the HDFS data lake and used for training and batch prediction. For online prediction, features are precomputed and kept in Cassandra: batch features are bulk loaded every few hours or daily, and near-real-time features are computed by streaming jobs from Kafka, written to Cassandra and logged back to HDFS so training uses the same data. The post reports about 10 000 shared features at the time. The open-source Feast documentation describes point-in-time joins as a backward scan from each entity row's timestamp bounded by a TTL. Read the originals in 12.3; this box keeps only the general idea.

8. Scalability and performance

ConcernApproachLimit before redesign
Online fan-outMulti-get per request; process cache for small entity setsExample: 100 entities per call
Online memoryShards added by resharding; compact encoding (no feature names in values)Example: About 64 GB per node
As-of joinsPartition pruning by date and TTL, clustering by entityLabel sets of several billion rows per job
Streaming stateKeyed state per entity, partitioned by entityEvent partitions and state size
Registry growthSearch index, ownership, deprecationThousands of feature views

Online latency budget (target p99 10 ms):

StepBudget
Request parsing and authorisation0.5 ms
Merchant features from process cache0.5 ms
User multi-get to online store3 ms
On-demand transforms2 ms
Response and logging (asynchronous)1 ms
Headroom3 ms

9. Reliability, security and operations

9.1 Failure modes

ComponentFailureDetectionBlast radiusMitigationRTO
Daily batch jobLate or failedFreshness metric past deadlineEvery model using the viewKeep serving the previous version within TTL; page owner; models see agesExample: 2 h
Streaming pipelineLag or stopConsumer lag, freshness above 60 sReal-time viewsValues expire by TTL and return EXPIRED; models fall back to batch featuresExample: 15 min
Online shardNode lossHealth checksOne quarter of users on one replica setReplica promotionExample: 30 s
Online storeFull outageError rateAll online modelsServing API returns defaults with MISSING; models degrade to a fallback modelExample: 30 min
Bad feature valuesBug in a definitionDistribution drift and null-rate monitorsModels using the featureRoll back the view version; block the definitionExample: 1 h
LeakageJoin bugCI leakage testEvery training set built sinceRebuild affected training sets; flag models trained on themExample: 1 day
[Component][Failure][Signal][Scope][Mitigation][Time]

9.2 Security and privacy

ThreatControl
Personal data reused for a purpose it was not collected forRegistry tags for personal data and purpose; access approval per consumer
Over-broad online accessPer-service allow lists of feature views in the serving API
Deletion requests not reaching featuresEntity deletion propagated to offline and online stores and to served-vector logs
Sensitive attributes used as proxiesFairness review of feature lists for regulated uses
[Threat][Control]

9.3 Observability

SignalMetric or logAlert threshold
FreshnessAge of latest value per viewAbove its target
Online latencyp99 of GetOnlineFeaturesAbove 10 ms for 10 min
Missing and expired valuesShare by viewDoubling vs. 7-day baseline
DriftDistribution distance per feature, dailyAbove the threshold set per feature
SkewServed vs. offline mismatch rateAbove 0.1%

9.4 Cost estimate

ItemUnit costQuantityMonthly cost
Online store nodes[$/node-month]Example: 12[$]
Offline storage[$/TB-month]Example: About 19 TB plus served-vector logs[$]
Batch and streaming compute[$/hour][Hours][$]
Total[$]

10. Trade-offs and alternatives

DecisionOptionsChosenWhyWhat we give up
PlatformBuild; open-source framework; managed service[Choice][Reason][Cost]
Training dataPoint-in-time join; daily snapshots of all features; served-value logs onlyPoint-in-time join plus served logsCorrect history for new features and exact served values for retrainingJoin compute cost
TransformationsIn pipelines; in model code; in the store at read timeIn pipelines, on-demand only for request dataOne definition, measurable skewLess freedom per team
Online storeDisk-based wide-column store; in-memory key-valueIn-memory key-value132 GB fits memory; lowest latencyHigher cost per GB
Merchant featuresOnline store; process cacheProcess cacheRemoves 1 million lookups/sCache reload logic
What changes at 10x / 0.1x10x (200 000 predictions/s, 10.2 million entity lookups/s): merchant cache still removes most lookups, but user multi-gets reach 200 000/s, so add shards and replicas; at 10x users the online store passes 1 TB and a disk-backed store becomes cheaper. 0.1x (2 000 predictions/s): one small replicated node, or serve features from the warehouse with a cacheReview at each doubling of predictions or entitiesFan-out and entity count grow first[Note]
[Decision][Options][Chosen][Why][Cost]

11. Delivery plan and risks

11.1 Milestones

MilestoneScopeExit criterionTarget date
M1Registry, batch views, offline store, point-in-time joinFirst team builds a training set with zero leakage in CIYYYY-MM-DD
M2Online store and serving API for batch viewsRanking model served at p99 under 10 msYYYY-MM-DD
M3Streaming viewsFreshness at or below 60 s p99YYYY-MM-DD
M4Monitoring and skew jobSkew below 0.1% for 30 daysYYYY-MM-DD

11.2 Rollout and rollback

[Example: Each model migrates behind a flag. For 14 days the model reads features from both the old code path and the feature store, logs both and uses the old one; the switch happens when values agree for 99.9% of requests. Rollback flips the flag.]

11.3 Risks

IDRiskLikelihoodImpactMitigationOwner
R-01Example: Teams keep private features outside the storeHighMediumMake discovery and backfill easier than private pipelines[@name]
R-02Example: Hidden leakage in legacy training setsMediumHighRebuild key training sets with point-in-time joins; compare metrics[@name]
R-03Example: Online cost grows with unused featuresMediumMediumUsage tracking; deprecate unread views[@name]
[R-nn][Risk][L / M / H][L / M / H][Mitigation][@name]

12. Appendices

12.1 Open questions

IDQuestionOwnerDue
OQ-01Example: Do embeddings belong in this store or in the vector database?[@name]YYYY-MM-DD
OQ-02Example: How long are served-vector logs kept, given data protection limits?[@name]YYYY-MM-DD
[OQ-nn][Question][@name]YYYY-MM-DD

12.2 Glossary

TermMeaning
EntityThe thing a feature describes, identified by a join key (user, merchant)
Feature viewA group of features from one source with one entity, TTL and schedule
Event timeWhen the fact the value describes happened or became true
Created timeWhen the value was written to the store
Point-in-time joinAs-of join that uses only values known at each label time
TTLMaximum age of a value that may be joined or served
MaterialisationCopying the latest values into the online store
Training and serving skewA difference between values used in training and values served in production
[Term][Meaning]

12.3 References

ReferenceUse
Del Balso and Hermann, "Meet Michelangelo: Uber's Machine Learning Platform", Uber blog, 5 September 20177.6
Feast documentation, "Point-in-time joins"7.2 semantics, TTL and created-timestamp filter
ML System Design (ai-data/ml-system-design)The model side of the platform
Model Monitoring Plan (ai-data/model-monitoring-plan)Drift and skew alerts in 9.3
ML Reproducibility Checklist (ai-data/ml-reproducibility-checklist)Versioned training sets
Experiment Tracking Report (ai-data/experiment-tracking-report)Recording which feature versions an experiment used
Recommendation System Design (system-design/design-recommendation-system)Main online consumer
Fraud Detection System Design (system-design/design-fraud-detection)Streaming features consumer
Stream Processing Engine Design (system-design/design-stream-processing-engine)Windows and watermarks for 7.4
Data Lineage Specification (data/data-lineage-specification)Lineage from sources to features

Standard and licence note

Based on common industry practice for machine learning feature platforms; no formal standard governs feature stores. The Uber engineering post and the Feast documentation cited in 12.3 are paraphrased in original words; no text, figures or code were copied, and all numbers marked Example are illustrative. Product capabilities change; check the current documentation of any framework you adopt. Original text written for this collection. Template licence: MIT.

相关模板