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:
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:
Detected added table 'note'
📝 splent_feature_notes: new migration generated
✅ splent_feature_notes → a35d10f0157f
Check the migration status:
splent db:status
splent_feature_notes a35d10f0157f a35d10f0157f ✔ 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:
user1@example.com - Password:
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 = ["notes_bp"]
models = ["Notes"]
commands = []
hooks = ["layout.sidebar.authenticated.items"]
services = ["NotesService"]
signals = ["item-created"]
translations = []
docker = []
[tool.splent.contract.requires]
features = []
env_vars = ["MY_VAR"]
signals = ["user-registered"]
[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
The contract is the formal interface of the feature — what it provides (routes, models, hooks, services), what it requires (other features, env vars, signals), and what it exposes as extensible for refinement features.
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
Tutorial 4: Testing – write unit, integration, and functional tests for the notes feature.