Extend with refinement

Create splent_feature_notes_tags – a refinement feature that adds tags to notes without forking the original feature.


Table of contents

  1. Prerequisites
  2. The problem
  3. Step 1 – Scaffold the refinement feature
  4. Step 2 – Configure the refinement
    1. Option A: Using the wizard (recommended)
    2. Option B: Manual configuration
    3. What happens at startup
  5. Step 3 – Write the model mixin
  6. Step 4 – Write the service override
  7. Step 5 – Add the tag filter route
  8. Step 6 – Write the migration
  9. Step 7 – Add to the SPL
  10. Step 8 – Verify the refinement
  11. Step 9 – Verify in the browser
  12. Step 10 – Test the uninstall cycle
  13. Step 11 – Re-add the feature
  14. What you learned
  15. Next

Prerequisites

You have completed Tutorial 5 – Release and deploy. You are inside the CLI container with my_first_app selected and splent_feature_notes in the product.

(my_first_app) /workspace$

If you removed splent_feature_notes during Tutorial 5 (to skip the release or to deploy without it), add it back before continuing:

splent feature:add splent-io/splent_feature_notes

The problem

You want to add tags to notes: a tags column on the Notes model, a service method to filter by tag, and a route to display tagged notes. But you do not want to fork splent_feature_notes – other products use it as-is and your changes are specific to my_first_app.

SPLENT’s refinement system solves this. A refinement feature declares what it overrides and extends in a base feature. The framework applies these changes at startup – the base feature’s source code is never modified.


Step 1 – Scaffold the refinement feature

Deselect the product first (scaffolding happens at workspace level), then create the feature:

splent product:deselect
splent feature:create splent-io/splent_feature_notes_tags

This creates the standard feature skeleton at /workspace/splent_feature_notes_tags/.


Step 2 – Configure the refinement

Re-select the product and run the refinement wizard:

splent product:select my_first_app
splent feature:refine splent_feature_notes_tags

The wizard reads the contracts of all features in the product and presents their extensible points interactively:

  Step 1: Which feature do you want to refine?

    1. auth
       1 service(s), 1 model(s), 2 template(s), 4 hook(s), routes
    2. notes
       1 service(s), 1 model(s), 3 template(s), 1 hook(s), routes
    3. profile
       1 service(s), 1 model(s), 1 template(s), routes

  Select: 2

Select notes. Then the wizard asks what you want to extend or override:

  • Models: select Notes (the wizard generates a mixin class name: NotesTagsMixin)
  • Services: select NotesService (the wizard generates: NotesServiceWithTags)
  • Templates, hooks, routes: skip for now (press 0)

At the end, the wizard previews the generated TOML and asks for confirmation. Press Y — it writes the [tool.splent.refinement] section to pyproject.toml and scaffolds skeleton mixin and service classes in models.py and services.py.

Option B: Manual configuration

If you prefer to configure the refinement manually, open splent_feature_notes_tags/pyproject.toml and add:

[tool.splent.refinement]
refines = "splent_feature_notes"

[tool.splent.refinement.extends]
models = [{ target = "Notes", mixin = "NotesTagsMixin" }]

[tool.splent.refinement.overrides]
services = [{ target = "NotesService", replacement = "NotesServiceWithTags" }]

You will also need to create the mixin and service classes manually in Steps 3 and 4. After writing the code, update the contract to reflect the changes:

splent feature:contract splent_feature_notes_tags --write

What happens at startup

Three things happen when the framework reads the refinement config:

  1. Model extension: The mixin class is applied to the existing Notes model, adding new columns without altering the base feature’s models.py.
  2. Service override: The replacement service replaces NotesService in the service locator, so every route that looks up NotesService gets your enhanced version.
  3. Validation: The framework checks that Notes and NotesService actually exist in the base feature’s contract.

Step 3 – Write the model mixin

A mixin is a plain Python class that adds columns and methods to an existing model without modifying the original file. At startup, the framework takes the mixin’s attributes and injects them into the base model class. The result is as if the base model had always had those columns and methods — but the base feature’s code stays untouched.

This is the core idea behind refinement: you extend someone else’s model from your own feature, and if your feature is removed, the base model goes back to its original state.

If you used the wizard in Step 2, a skeleton mixin was already scaffolded in models.py. Replace its content with the code below.

Edit splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/models.py:

from splent_framework.db import db


class NotesTagsMixin:
    """Mixin applied to the Notes model at startup.

    Adds a comma-separated tags column. The base Notes model is not modified --
    this mixin is injected by the refinement system via apply_model_mixin().
    """

    tags = db.Column(db.String(500), nullable=True, default="")

    def get_tags_list(self):
        """Return tags as a Python list."""
        if not self.tags:
            return []
        return [t.strip() for t in self.tags.split(",") if t.strip()]

    def has_tag(self, tag):
        return tag.lower() in [t.lower() for t in self.get_tags_list()]

The tags column and the two methods will be injected into the Notes class. Existing code that queries Notes continues to work – tags is nullable and defaults to empty.


Step 4 – Write the service override

If you used the wizard in Step 2, a skeleton service was already scaffolded in services.py. Replace its content with the code below.

Edit splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/services.py:

from sqlalchemy import select

from splent_framework.db import db
from splent_framework.services.service_locator import get_service_class


class NotesServiceWithTags:
    """Extends NotesService with tag-based queries.

    Inherits from the base NotesService at runtime (resolved from the service
    locator) so it picks up all existing methods.
    """

    def __init__(self, repository):
        super().__init__(repository)

    def get_by_tag(self, user_id, tag):
        """Return all notes for a user that contain the given tag."""
        Notes = self.repository.model
        stmt = (
            select(Notes)
            .where(Notes.user_id == user_id)
            .where(Notes.tags.ilike(f"%{tag}%"))
        )
        return list(db.session.scalars(stmt).all())

    def get_all_tags(self, user_id):
        """Return a sorted list of unique tags across all notes for a user."""
        Notes = self.repository.model
        stmt = select(Notes.tags).where(Notes.user_id == user_id).where(Notes.tags != "")
        rows = db.session.scalars(stmt).all()
        tags = set()
        for row in rows:
            for tag in row.split(","):
                tag = tag.strip()
                if tag:
                    tags.add(tag)
        return sorted(tags)

    def create(self, **kwargs):
        """Normalize tags before creating."""
        if "tags" in kwargs and kwargs["tags"]:
            kwargs["tags"] = ", ".join(
                t.strip() for t in kwargs["tags"].split(",") if t.strip()
            )
        return super().create(**kwargs)

If you used the wizard in Step 2, __init__.py was already generated with the correct imports, apply_model_mixin(), and register_service() calls. You do not need to edit it. If you configured the refinement manually, edit __init__.py to match the generated code below.

The wizard generates __init__.py with the following structure:

from splent_framework.blueprints.base_blueprint import create_blueprint
from splent_framework.refinement import refine_model, refine_service

from .models import NotesTagsMixin
from .services import NotesServiceWithTags

notes_tags_bp = create_blueprint(__name__)


def init_feature(app):
    refine_model("Notes", NotesTagsMixin)
    refine_service(app, "NotesService", NotesServiceWithTags)


def inject_context_vars(app):
    return {}

Two lines — that’s it. refine_model injects the mixin’s columns and methods into the base model. refine_service makes the replacement class inherit from the base service at runtime and registers it under the same name. You do not import the base feature directly — the framework resolves it.


Step 5 – Add the tag filter route

Edit splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/routes.py:

from flask import render_template, current_app
from flask_login import current_user, login_required

from splent_framework.services.service_locator import get_service_class
from splent_io.splent_feature_notes_tags import notes_tags_bp


@notes_tags_bp.route("/tag/<string:tag>")
@login_required
def by_tag(tag):
    """Show all notes with a given tag."""
    svc_cls = get_service_class(current_app, "NotesService")
    svc = svc_cls()
    notes = svc.get_by_tag(current_user.id, tag)
    all_tags = svc.get_all_tags(current_user.id)
    return render_template(
        "notes_tags/by_tag.html",
        notes=notes,
        current_tag=tag,
        all_tags=all_tags,
    )

Create the template at splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/templates/notes_tags/by_tag.html:

{% extends "base_template.html" %}

{% block title %}Notes tagged "{{ current_tag }}"{% endblock %}

{% block content %}
<div class="container">
    <div class="d-flex justify-content-between align-items-center mb-4">
        <h1 class="h2">Notes tagged "{{ current_tag }}"</h1>
        <a href="{{ url_for('notes.index') }}" class="btn btn-outline-secondary">All Notes</a>
    </div>

    <div class="mb-3">
        <strong>Filter by tag:</strong>
        {% for tag in all_tags %}
            <a href="{{ url_for('notes_tags.by_tag', tag=tag) }}"
               class="badge {{ 'bg-primary' if tag == current_tag else 'bg-secondary' }} text-decoration-none">{{ tag }}</a>
        {% endfor %}
    </div>

    {% if notes %}
        <div class="list-group">
            {% for note in notes %}
            <div class="list-group-item">
                <h5 class="mb-1">{{ note.title }}</h5>
                <p class="mb-1 text-muted">{{ note.content or "No content" }}</p>
                {% for t in note.get_tags_list() %}
                    <span class="badge bg-info text-dark">{{ t }}</span>
                {% endfor %}
                <br><small class="text-muted">{{ note.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
            </div>
            {% endfor %}
        </div>
    {% else %}
        <p class="text-muted">No notes with this tag.</p>
    {% endif %}
</div>
{% endblock %}

{% block scripts %}
{% endblock %}

Step 6 – Write the migration

Refinement features that add columns to another feature’s table need a manual migration. Alembic cannot auto-generate this because the mixin columns are injected at runtime into a table owned by the base feature.

First, generate an empty migration:

splent db:migrate splent_feature_notes_tags -m "add tags column to notes"

Then edit the generated file in splent_feature_notes_tags/.../migrations/versions/ and replace the upgrade() and downgrade() functions:

def upgrade():
    op.add_column("notes", sa.Column("tags", sa.String(500), nullable=True))


def downgrade():
    op.drop_column("notes", "tags")

The table name is notes (the base feature’s table), not notes_tags. You are adding a column to an existing table, not creating a new one.

Apply it:

splent db:upgrade

Step 7 – Add to the SPL

The SPL needs to know that notes_tags exists and depends on notes. Deselect the product and use spl:add-feature:

splent product:deselect
splent spl:add-feature sample_splent_spl splent_feature_notes_tags

The command scans the source code, detects that notes_tags refines notes, and adds the constraint notes_tags => notes to the UVL file automatically.

Re-select the product and validate:

splent product:select my_first_app
splent product:validate

Step 8 – Verify the refinement

splent feature:xray --validate

This shows the refinement registry – which features refine which, what models are extended, and what services are overridden:

  splent_feature_notes (refined by splent_feature_notes_tags)
       service: NotesService  <- overridden by splent_feature_notes_tags
         model: Notes  <- extended by splent_feature_notes_tags
        ...

  splent_feature_notes_tags (refines splent_feature_notes)
       service: NotesService -> NotesServiceWithTags
         model: Notes + NotesTagsMixin

  2 refinement(s) across 1 feature(s).

  -- Validation --

  ...
  splent_feature_notes_tags overrides splent_feature_notes/NotesService -- allowed
  splent_feature_notes_tags overrides splent_feature_notes/Notes -- allowed

  All 10 checks passed.

Step 9 – Verify in the browser

Check the URL with splent product:port and open it in the browser. The product should work as before — the refinement added a tags column to the database and a NotesServiceWithTags service with tag-based queries, but the UI has not changed yet. That is the topic of Tutorial 7: Frontend refinement.


Step 10 – Test the uninstall cycle

Refinements are fully reversible. Roll back the migration, then remove the feature:

splent db:rollback splent_feature_notes_tags

This drops the tags column from the note table.

splent feature:remove splent_feature_notes_tags

This unlinks the feature from the product’s pyproject.toml and removes the symlink.

Verify the cleanup is complete:

splent product:validate

The product is valid again without notes_tags. The Notes model has no tags column, and NotesService is back to its original implementation.


Step 11 – Re-add the feature

The next tutorial builds on notes_tags, so add it back:

splent feature:add splent-io/splent_feature_notes_tags
splent db:upgrade

What you learned

Concept What it means
Refinement A feature that extends another without modifying its source code
Model mixin refine_model() injects columns and methods into existing SQLAlchemy models
Service override refine_service() replaces a service class — all consumers get the new version
Refinement wizard feature:refine reads contracts, generates pyproject, scaffolds code, and configures migrations
UVL constraint notes_tags => notes ensures the dependency is formally declared and validated
Reversible uninstall db:rollback + feature:remove cleanly undoes everything

Next

Tutorial 7 – Frontend refinement: override templates, replace hooks, and add custom JavaScript – the frontend side of the refinement system.


Back to top

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