Create a feature
Build splent_feature_notes from scratch, a note-taking feature with models, services, routes, templates, hooks, and UVL integration.
Table of contents
- Prerequisites
- Step 1. Scaffold the feature
- Step 2. Define the model
- Step 3. Add a custom query to the service
- Step 4. Define the routes
- Step 5. Create the templates
- Step 6. Register hooks
- Step 7. Integrate into the SPL
- Step 8. Add the feature to the product
- Step 9. Generate and apply the migration
- Step 10. Verify in the browser
- Step 11. Inspect the composition
- Step 12. View the feature contract
- What you learned
- Next
Prerequisites
You completed Tutorial 2 and have my_first_app running with auth, public, theme, settings, redis, session_redis and profile.
Step 1. Scaffold the feature
Deselect the product first. feature:create works either way and always scaffolds at the workspace root, but spl:add-feature in Step 7 refuses to run with a product selected, so the tutorial stays detached until then.
splent product:deselect
Then create the feature.
splent feature:create splent-io/splent_feature_notes
✅ Feature 'splent-io/splent_feature_notes' created (full)
/workspace/splent_feature_notes
git a repository on main, the scaffold its first commit (<hex>)
The feature is also a git repository, with the scaffold as its first commit on main. The scaffold at /workspace/splent_feature_notes/ looks like this.
splent_feature_notes/
├── pyproject.toml # Package metadata + the feature contract
├── README.md # What the feature is, how to use, test and release it
└── src/
└── splent_io/
└── splent_feature_notes/
├── __init__.py # Blueprint + init_feature()
├── models.py # SQLAlchemy models
├── repositories.py # Data access layer
├── services.py # Business logic
├── routes.py # URL endpoints
├── hooks.py # Template hook registrations
├── seeders.py # Test data seeders
├── forms.py # WTForms definitions
├── commands.py # CLI commands (a sample `hello`)
├── config.py # Env vars read into app.config
├── signals.py # Signals emitted / listened to
├── templates/ # Jinja2 templates
├── assets/ # JS (scripts.js and webpack.config.js)
├── translations/ # i18n catalogs
├── migrations/ # Alembic migrations
│ └── env.py
└── tests/
├── conftest.py
├── unit/
├── integration/
├── functional/
├── e2e/
└── load/
Every file has working boilerplate. The next steps fill in the real logic.
Step 2. Define the model
Edit splent_feature_notes/src/splent_io/splent_feature_notes/models.py.
from datetime import datetime
import pytz
from splent_framework.db import db
class Notes(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(pytz.utc))
user = db.relationship("User", backref=db.backref("notes", lazy=True))
def __repr__(self):
return f"<Notes {self.title}>"
The foreign key user.id references the User model of splent_feature_auth. The UVL constraint you add in Step 7 makes sure auth is migrated before notes.
Step 3. Add a custom query to the service
The scaffold’s NotesService inherits CRUD from BaseService. Add one query that filters notes by user to splent_feature_notes/src/splent_io/splent_feature_notes/services.py.
from splent_io.splent_feature_notes.repositories import NotesRepository
from splent_framework.services.BaseService import BaseService
class NotesService(BaseService):
def __init__(self):
super().__init__(NotesRepository())
def get_by_user(self, user_id):
return self.repository.get_by_column("user_id", user_id)
BaseService already provides create(), get_by_id(), update(), delete() and count(). The routes use get_by_user() to show the logged-in user’s notes.
Step 4. Define the routes
Edit splent_feature_notes/src/splent_io/splent_feature_notes/routes.py.
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from splent_io.splent_feature_notes import notes_bp
from splent_io.splent_feature_notes.services import NotesService
notes_service = NotesService()
@notes_bp.route("/notes/", methods=["GET"])
@login_required
def index():
notes = notes_service.get_by_user(current_user.id)
return render_template("notes/index.html", notes=notes)
@notes_bp.route("/notes/create", methods=["GET", "POST"])
@login_required
def create():
if request.method == "POST":
title = request.form.get("title", "").strip()
content = request.form.get("content", "").strip()
if not title:
flash("Title is required.", "danger")
return render_template("notes/create.html")
notes_service.create(
user_id=current_user.id,
title=title,
content=content,
)
flash("Note created.", "success")
return redirect(url_for("notes.index"))
return render_template("notes/create.html")
@notes_bp.route("/notes/<int:note_id>/delete", methods=["POST"])
@login_required
def delete(note_id):
note = notes_service.get_or_404(note_id)
if note.user_id != current_user.id:
flash("Access denied.", "danger")
return redirect(url_for("notes.index"))
notes_service.delete(note_id)
flash("Note deleted.", "success")
return redirect(url_for("notes.index"))
Step 5. Create the templates
Create splent_feature_notes/src/splent_io/splent_feature_notes/templates/notes/index.html.
{% extends "base_template.html" %}
{% block title %}My Notes{% endblock %}
{% block content %}
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">My Notes</h1>
<a href="{{ url_for('notes.create') }}" class="btn btn-primary">New Note</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% if notes %}
<div class="list-group">
{% for note in notes %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="mb-1">{{ note.title }}</h5>
<p class="mb-1 text-muted">{{ note.content or "No content" }}</p>
<small class="text-muted">{{ note.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
<form method="POST" action="{{ url_for('notes.delete', note_id=note.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No notes yet. Create your first one.</p>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{% endblock %}
Create splent_feature_notes/src/splent_io/splent_feature_notes/templates/notes/create.html.
{% extends "base_template.html" %}
{% block title %}New Note{% endblock %}
{% block content %}
<div class="container">
<h1 class="h2 mb-3">New Note</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('notes.create') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" name="title" required>
</div>
<div class="mb-3">
<label for="content" class="form-label">Content</label>
<textarea class="form-control" id="content" name="content" rows="5"></textarea>
</div>
<a href="{{ url_for('notes.index') }}" class="btn btn-secondary me-2">Cancel</a>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
{% endblock %}
{% block scripts %}
{% endblock %}
Every POST form carries a hidden csrf_token field. CSRF protection is on for the whole app, so a form without it gets 400 Bad Request.
Step 6. Register hooks
Edit splent_feature_notes/src/splent_io/splent_feature_notes/hooks.py.
from splent_framework.hooks.template_hooks import register_template_hook
from flask import render_template
def notes_sidebar_item():
return render_template("hooks/sidebar_notes.html")
register_template_hook("layout.sidebar.authenticated.items", notes_sidebar_item)
Create the hook template at splent_feature_notes/src/splent_io/splent_feature_notes/templates/hooks/sidebar_notes.html.
<li class="sidebar-item {{ 'active' if request.endpoint and request.endpoint.startswith('notes.') else '' }}">
<a class="sidebar-link" href="{{ url_for('notes.index') }}">
<i class="align-middle" data-feather="file-text"></i> <span class="align-middle">Notes</span>
</a>
</li>
It adds a sidebar link for authenticated users. layout.sidebar.authenticated.items is a slot of the product layout, and any feature can add items to it.
Step 7. Integrate into the SPL
Notes has a foreign key to auth’s user.id, so the SPL must say that notes depends on auth. Without that constraint, product:validate would not enforce the dependency and feature:order could load notes before auth. You are still detached since Step 1, as SPL commands need.
splent spl:add-feature sample_splent_spl splent_feature_notes
Adding notes to sample_splent_spl
Scanning for dependencies...
Detected dependencies:
notes => auth
Add 'notes' to sample_splent_spl? [Y/n]: y
sample_splent_spl came from the cache (.splent_cache/spls/sample_splent_spl@v4/sample_splent_spl.uvl), which is never edited.
Created your working copy at splent_spl_sample_splent/ and changed it there. It now wins over the cache for every product that uses this line.
✅ Feature 'notes' added to sample_splent_spl.
Model: splent_spl_sample_splent/sample_splent_spl.uvl
Constraints: notes => auth
spl:add-feature scanned splent_feature_notes, found db.ForeignKey("user.id") in models.py, looked up the feature that owns the user table (auth) and added notes => auth.
It never edits the model in .splent_cache/, since a later download or a cleared cache would undo the change. The first time you change a model, it copies it to your own working copy, splent_spl_sample_splent/ at the workspace root, and changes that. From now on every command that reads sample_splent_spl reads your working copy.
splent spl:features sample_splent_spl
notes appears in the list. Check its dependency.
splent spl:deps notes sample_splent_spl
SPL deps
SPL : sample_splent_spl
UVL : /workspace/splent_spl_sample_splent/sample_splent_spl.uvl
Features : 18
Dependencies of 'notes' (features it requires):
- auth
Select the product again.
splent product:select my_first_app
Step 8. Add the feature to the product
splent feature:add splent-io/splent_feature_notes
notes added to features
reinstalling in web container...
done.
It declares the feature in pyproject.toml and creates its link in my_first_app/features/splent_io/, pointing at the editable checkout at the workspace root.
Step 9. Generate and apply the migration
splent db:migrate splent_feature_notes
SPLENT finds the feature’s tables from its models. After Alembic’s log lines you see this (the revision ID is random).
INFO [alembic.autogenerate.compare.tables] Detected added table 'notes'
Generating /workspace/my_first_app/features/splent_io/splent_feature_notes/src/splent_io/splent_feature_notes/migrations/versions/ee9262d3b6c0_splent_feature_notes.py ... done
📝 splent_feature_notes: new migration generated
db:migrate only generates the migration. Check the status.
splent db:status
📊 Migration Status
Feature Applied Latest Status
----------------------- -------------- -------------- ------------
splent_feature_auth auth0009_email auth0009_email ✔ synced
splent_feature_notes — ee9262d3b6c0 ⚠ not applied
splent_feature_profile 29f0c82ca134 29f0c82ca134 ✔ synced
splent_feature_settings settings0001 settings0001 ✔ synced
1 feature(s) out of sync. Run 'splent db:upgrade' to apply pending migrations.
Apply it.
splent db:upgrade
The status is now synced.
splent db:status
splent_feature_notes ee9262d3b6c0 ee9262d3b6c0 ✔ synced
Migrations run in UVL order, so auth’s user table was already there for the foreign key.
Step 10. Verify in the browser
feature:add already reinstalled the feature and restarted Flask. Open http://localhost:5591/login (splent product:port prints the URL) and log in with the seeded test user.
- Email is
user1@example.com - Password is
1234
The login lands on the public home page, whose site shell has no sidebar. Go to http://localhost:5591/notes/. It extends base_template.html, the product’s app layout, whose sidebar now lists Notes. Click New Note and save one, and it appears with “Note created.”. Delete it and you get “Note deleted.”.
Step 11. Inspect the composition
splent feature:xray
It warns that the contract of splent_feature_notes is stale.
⚠ 1 feature(s) with potentially stale contracts:
- splent_feature_notes
Update stale contracts now? [Y/n]:
You added models, routes and hooks after the scaffold, so the contract that describes what the feature provides is out of date. Press Y to update it.
✔ splent_feature_notes contract updated.
The x-ray shows notes in the layout’s sidebar slot, next to profile.
my_first_app (product layout)
template: base_template.html
...
hook: layout.sidebar.authenticated.items ← splent_feature_profile, splent_feature_notes
...
splent_feature_notes
hook: layout.sidebar.authenticated.items ← splent_feature_profile
Step 12. View the feature contract
splent feature:contract splent_feature_notes
feature:contract — splent_feature_notes
────────────────────────────────────────────────────────────
🔍 Scanning source code…
Inferred contract:
[tool.splent.contract]
archetype = "full"
[tool.splent.contract.provides]
routes = ["/notes/", "/notes/<int:note_id>/delete", "/notes/create"]
blueprints = ["notes_bp"]
models = ["Notes"]
commands = ["hello"]
hooks = ["layout.sidebar.authenticated.items"]
services = ["NotesService"]
signals = []
translations = []
docker = []
[tool.splent.contract.requires]
features = ["auth"]
features_optional = []
env_vars = []
signals = []
[tool.splent.contract.extensible]
services = ["NotesService"]
templates = ["hooks/sidebar_notes.html", "notes/create.html", "notes/index.html"]
models = ["Notes"]
hooks = ["layout.sidebar.authenticated.items"]
routes = True
✅ Contract is already up to date.
The contract is the feature’s formal interface. It lists what the feature provides, what it requires and what it lets refinement features extend. requires.features = ["auth"] comes from the user.id foreign key, as in Step 7.
commands = ["hello"]is the sample command of the scaffold’scommands.py. Replace it with your own command or delete the file, then runsplent feature:contract splent_feature_notes --write.feature:clean --applydeletes an untouchedcommands.pyalong with every other unused stub. Run it without--applyfirst to see the list, then refresh the contract.
What you learned
| Concept | Summary |
|---|---|
| Scaffold | feature:create generates a complete feature package |
| Model | Standard SQLAlchemy models with db.Model. Foreign keys across features are fine |
| Repository | Extends BaseRepository for data access (CRUD + custom queries) |
| Service | Extends BaseService for business logic, delegates to repository |
| Routes | Standard Flask routes on a BaseBlueprint, protected with Flask-Login’s @login_required (auth provides the login flow and the user model) |
| Hooks | register_template_hook() injects content into product layout extension points |
| UVL | Features and constraints live in the SPL’s .uvl file, edited in your working copy (splent_spl_sample_splent/), never in the cache |
| Migration | db:migrate generates, db:upgrade applies, and UVL order keeps foreign keys safe |
Next
Continue with Tutorial 4, Testing, to test the notes feature.