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

  1. Prerequisites
  2. Step 1 – Scaffold the feature
  3. Step 2 – Define the model
  4. Step 3 – Add a custom query to the service
  5. Step 4 – Define the routes
  6. Step 5 – Create the templates
  7. Step 6 – Register hooks
  8. Step 7 – Integrate into the SPL
  9. Step 8 – Add the feature to the product
  10. Step 9 – Generate and apply the migration
  11. Step 10 – Verify in the browser
  12. Step 11 – Inspect the composition
  13. Step 12 – View the feature contract
  14. What you learned
  15. 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.


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es