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, redis, and profile.
Step 1. Scaffold the feature
First, deselect the product (feature creation happens at workspace level).
splent product:deselect
Then create the feature.
splent feature:create splent-io/splent_feature_notes
This generates a full feature scaffold at /workspace/splent_feature_notes/.
splent_feature_notes/
├── pyproject.toml
└── 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
├── templates/ # Jinja2 templates
├── assets/ # JS and CSS
├── migrations/ # Alembic migrations
│ └── env.py
└── tests/
├── conftest.py
├── unit/
├── integration/
├── functional/
└── e2e/
Every file has working boilerplate. You will now 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 from splent_feature_auth. This cross-feature dependency is why UVL constraints matter. They guarantee auth is migrated before notes.
Step 3. Add a custom query to the service
The scaffold already generated services.py with a basic NotesService that inherits CRUD operations from BaseService. You need to add one domain-specific method to filter notes by user.
Edit splent_feature_notes/src/splent_io/splent_feature_notes/services.py and add get_by_user.
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() out of the box. The only thing you add here is get_by_user(), a domain-specific query that the routes will use to show notes belonging to the logged-in user.
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>
{% 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) }}">
<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') }}">
<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 %}
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>
This registers a sidebar link that appears for authenticated users. The hook name layout.sidebar.authenticated.items is a product layout extension point. Any feature can register items into it.
Step 7. Integrate into the SPL
Your feature has a foreign key to user.id (from auth), so the SPL needs to know that notes depends on auth. Without this constraint, product:validate would not enforce the dependency and feature:order could load notes before auth.
Deselect the product (SPL commands require detached mode).
splent product:deselect
Add the feature to the SPL.
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
✅ Feature 'notes' added to sample_splent_spl.
Constraints: notes => auth
spl:add-feature scanned the source code of splent_feature_notes, found db.ForeignKey("user.id") in models.py, looked up which feature owns the user table (auth), and added the constraint notes => auth automatically.
Verify the result.
splent spl:features sample_splent_spl
notes now appears in the feature list. Check the dependency.
splent spl:deps notes sample_splent_spl
Dependencies of 'notes' (features it requires):
- auth
Re-select the product.
splent product:select my_first_app
Step 8. Add the feature to the product
splent feature:add splent-io/splent_feature_notes
This declares the feature in pyproject.toml and creates the symlink in my_first_app/features/splent_io/. Since the feature lives at workspace root (editable mode), the symlink points directly there.
Step 9. Generate and apply the migration
splent db:migrate splent_feature_notes
SPLENT auto-detects which tables belong to the feature by scanning the imported models. You should see this output.
Detected added table 'notes'
📝 splent_feature_notes: new migration generated
db:migrate only generates the migration file. It does not apply it yet. Check the status.
splent db:status
splent_feature_notes — ee9262d3b6c0 ⚠ not applied
1 feature(s) out of sync. Run 'splent db:upgrade' to apply pending migrations.
Now apply the pending migration.
splent db:upgrade
Check the status again. It is now synced.
splent db:status
splent_feature_notes ee9262d3b6c0 ee9262d3b6c0 ✔ synced
The migration ran in UVL order. Auth’s tables were already in place, so the user.id foreign key resolved correctly.
Step 10. Verify in the browser
The feature:add command already reinstalled the feature and restarted Flask automatically. Open the browser (use splent product:port if you need the URL) and log in with the seeded test user.
- Email is
user1@example.com - Password is
1234
You will see Notes in the sidebar. Click it, create a note, verify it appears in the list. The feature is fully integrated.
Step 11. Inspect the composition
splent feature:xray
You will see a warning about a stale contract for splent_feature_notes.
⚠ 1 feature(s) with potentially stale contracts:
- splent_feature_notes
Update stale contracts now? [Y/n]:
This is normal. We added models, routes, and hooks after the initial scaffold, so the contract (which describes what the feature provides) is out of date. Press Y to update it automatically.
After the update, the notes feature appears in the composition map with its routes (/notes/, /notes/create, /notes/<id>/delete), its model (Notes), and its hook registration (layout.sidebar.authenticated.items).
Step 12. View the feature contract
splent feature:contract splent_feature_notes
feature:contract — splent_feature_notes
────────────────────────────────────────────────────────────
[tool.splent.contract.provides]
routes = ["/notes/", "/notes/<int:note_id>/delete", "/notes/create"]
blueprints = []
models = ["Notes"]
commands = ["hello"]
hooks = ["layout.sidebar.authenticated.items"]
services = ["NotesService"]
signals = []
translations = []
docker = []
[tool.splent.contract.requires]
features = []
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 = False
The contract is the formal interface of the feature, describing what it provides (routes, models, hooks, services), what it requires (other features, env vars, signals), and what it exposes as extensible for refinement features.
commands = ["hello"]comes from the sample command the scaffold ships incommands.py; remove that stub (or runfeature:clean) and it disappears from the contract.
What you learned
| Concept | Summary |
|---|---|
| Scaffold | feature:create generates a complete feature package |
| Model | Standard SQLAlchemy models with db.Model, cross-feature FKs are valid |
| 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, use @login_required from auth |
| Hooks | register_template_hook() injects content into product layout extension points |
| UVL | Features and constraints are declared in the SPL’s .uvl file |
| Migration | db:migrate generates, db:upgrade applies, UVL order ensures FK safety |
Next
Continue with Tutorial 4, Testing, to write unit, integration, and functional tests for the notes feature.