Repository: LuteOrg/lute-v3 (language: python)

Issue:
Refactor: remove implicit use of db and db.session in model, controllers, etc
**BIG NOTE: This work is in progress in branch `issue_503_db_session_refactoring`**

This is a general issue to group several independent issues with the code.  In summary, there are many places where there is an implicit dependency on the `db.session` which Flask manages, which therefore couples the code to Flask unnecessarily.

For example, the various services have `from lute import db`, and then service methods use `db.session.query`.  It feels to me that instead the service should be classes instantiated with a `session` object, and use `self.session` in each method as needed.  Then the flask controllers should instantiate the services with the `db.session`, and use them as before.

**Notes on the work:**

* I think that each of these items should be handled separately.
* If anyone wants to try working on any of them, I'll create a separate issue and assign it to you (or me), so that we can stay reasonably organized.
* Each change to any object API will impact unit tests as well.
* I think that these should be tackled in the order presented (approximately), as changes to lower-level items can generally be done gradually before the higher-level items are changed

I'll add to the list of items below as I come across places to work on.

## business repos

- [x] lute/term/model.py, `Repository` : change the constructor to take a session, and use self.session instead of self.db.session
- [x] lute/book/model.py, `Repository` : change the constructor to take a session, and use self.session instead of self.db.session

## ORM-level repositories in lute/models/

Many classes have static methods (e.g. `find` etc) that use a `db.session`.  I'd like to take all of those and move them to Repository classes, e.g, `BookTag` will have `BookTagRepository` which will take a session arg in its constructor, and then all static methods can be moved out of BookTag and into the new repo class as instance methods.

- [x] book.py BookTagRepository
- [x] book.py BookRepository
- [x] book.py TextRepository
- [x] language.py LanguageRepository
- [x] term.py TermTagRepository
- [x] term.py StatusRepository
- [x] term.py TermRepository
- [x] setting.py ... hmmm not sure about this one, separate repos for each one?

## Change parser settings reads

- [x] currently the Japanese parser reads from the db, but it uses the db.session ... I feel that the settings should be passed in perhaps as a setter method.  Different parsers might want different settings ... or perhaps the settings should be stored in environment variables when the settings are set (or loaded)

## Convert services to classes (?) or pass session explicitly

Change "service" modules to objects that take a db.session as a parameter, and use that `self.session` everywhere instead of `db.session` ?? Optionally, pass the session as a parameter to each function that needs it.

- [x] lute/termimport/service.py
- [x] lute/term_parent_map/service.py
- [x] lute/read/service.py
- [x] lute/read/render/service.py
- [x] lute/language/service.py
- [x] lute/book/service.py
- [x] lute/backup/service.py
- [x] lute/themes/service.py
- [x] lute/stats/service.py

## remove db imports where possible

- [x] lute/db/data_cleanup.py:from lute.db import db
- [x] lute/db/management.py:from lute.db import db
- [x] lute/db/demo.py:from lute.db import db


Discussion / hints:

Fix patch:
diff --git a/lute/app_factory.py b/lute/app_factory.py
index 6b127ab9..7d6893ff 100644
--- a/lute/app_factory.py
+++ b/lute/app_factory.py
@@ -25,8 +25,9 @@
 from lute.config.app_config import AppConfig
 from lute.db import db
 from lute.db.setup.main import setup_db
+from lute.db.management import add_default_user_settings
 from lute.db.data_cleanup import clean_data
-import lute.backup.service as backupservice
+from lute.backup.service import Service as BackupService
 import lute.db.demo
 import lute.utils.formutils
 
@@ -34,8 +35,9 @@
 
 from lute.models.book import Book
 from lute.models.language import Language
-from lute.models.setting import BackupSettings, UserSetting
-from lute.book.stats import mark_stale
+from lute.settings.current import refresh_global_settings, current_settings
+from lute.models.repositories import UserSettingRepository
+from lute.book.stats import Service as StatsService
 
 from lute.book.routes import bp as book_bp
 from lute.bookmarks.routes import bp as bookmarks_bp
@@ -111,7 +113,8 @@ def inject_menu_bar_vars():
         """
         Inject backup settings into the all templates for the menu bar.
         """
-        bs = BackupSettings.get_backup_settings()
+        us_repo = UserSettingRepository(db.session)
+        bs = us_repo.get_backup_settings()
         have_languages = len(db.session.query(Language).all()) > 0
         ret = {
             "have_languages": have_languages,
@@ -119,27 +122,31 @@ def inject_menu_bar_vars():
             "backup_directory": bs.backup_dir,
             "backup_last_display_date": bs.last_backup_display_date,
             "backup_time_since": bs.time_since_last_backup,
-            "user_settings": json.dumps(UserSetting.all_settings()),
+            "user_settings": json.dumps(current_settings),
         }
         return ret
 
     @app.route("/")
     def index():
-        is_production = not lute.db.demo.contains_demo_data()
-        bkp_settings = BackupSettings.get_backup_settings()
+        is_production = not lute.db.demo.contains_demo_data(db.session)
+        us_repo = UserSettingRepository(db.session)
+        bkp_settings = us_repo.get_backup_settings()
 
         have_books = len(db.session.query(Book).all()) > 0
         have_languages = len(db.session.query(Language).all()) > 0
-        language_choices = lute.utils.formutils.language_choices("(all languages)")
-        current_language_id = lute.utils.formutils.valid_current_language_id()
+        language_choices = lute.utils.formutils.language_choices(
+            db.session, "(all languages)"
+        )
+        current_language_id = lute.utils.formutils.valid_current_language_id(db.session)
 
-        should_run_auto_backup = backupservice.should_run_auto_backup(bkp_settings)
+        bs = BackupService(db.session)
+        should_run_auto_backup = bs.should_run_auto_backup(bkp_settings)
         # Only back up if we have books, otherwise the backup is
         # kicked off when the user empties the demo database.
         if is_production and have_books and should_run_auto_backup:
             return redirect("/backup/backup", 302)
 
-        warning_msg = backupservice.backup_warning(bkp_settings)
+        warning_msg = bs.backup_warning(bkp_settings)
         backup_show_warning = (
             bkp_settings.backup_warn
             and bkp_settings.backup_enabled
@@ -152,7 +159,7 @@ def index():
                 hide_homelink=True,
                 dbname=app_config.dbname,
                 datapath=app_config.datapath,
-                tutorial_book_id=lute.db.demo.tutorial_book_id(),
+                tutorial_book_id=lute.db.demo.tutorial_book_id(db.session),
                 have_books=have_books,
                 have_languages=have_languages,
                 language_choices=language_choices,
@@ -167,14 +174,15 @@ def index():
     @app.route("/refresh_all_stats")
     def refresh_all_stats():
         books_to_update = db.session.query(Book).filter(Book.archived == 0).all()
+        svc = StatsService(db.session)
         for book in books_to_update:
-            mark_stale(book)
+            svc.mark_stale(book)
         return redirect("/", 302)
 
     @app.route("/wipe_database")
     def wipe_db():
-        if lute.db.demo.contains_demo_data():
-            lute.db.demo.delete_demo_data()
+        if lute.db.demo.contains_demo_data(db.session):
+            lute.db.demo.delete_demo_data(db.session)
             msg = """
             The database has been wiped clean.  Have fun! <br /><br />
             <i>(Lute has automatically enabled backups --
@@ -185,8 +193,8 @@ def wipe_db():
 
     @app.route("/remove_demo_flag")
     def remove_demo():
-        if lute.db.demo.contains_demo_data():
-            lute.db.demo.remove_flag()
+        if lute.db.demo.contains_demo_data(db.session):
+            lute.db.demo.remove_flag(db.session)
             msg = """
             Demo mode deactivated. Have fun! <br /><br />
             <i>(Lute has automatically enabled backups --
@@ -305,9 +313,10 @@ def _pragmas_on_connect(dbapi_con, con_record):  # pylint: disable=unused-argume
 
     with app.app_context():
         db.create_all()
-        UserSetting.load()
+        add_default_user_settings(db.session, app_config.default_user_backup_path)
+        refresh_global_settings(db.session)
         # TODO valid parsers: do parser check, mark valid as active, invalid as inactive.
-        clean_data()
+        clean_data(db.session)
     app.db = db
 
     _add_base_routes(app, app_config)
@@ -392,6 +401,7 @@ def null_print(s):  # pylint: disable=unused-argument
     outfunc("Initializing app.")
     app = _create_app(app_config, extra_config)
 
+    # Plugins are loaded after the app, as they may use settings etc.
     _init_parser_plugins(app_config.plugin_datapath, outfunc)
 
     return app
diff --git a/lute/backup/routes.py b/lute/backup/routes.py
index ffaf647d..1fc10992 100644
--- a/lute/backup/routes.py
+++ b/lute/backup/routes.py
@@ -16,20 +16,28 @@
     send_file,
     flash,
 )
-from lute.models.setting import BackupSettings
-from lute.backup.service import create_backup, skip_this_backup, list_backups
+from lute.db import db
+from lute.models.repositories import UserSettingRepository
+from lute.backup.service import Service
 
 
 bp = Blueprint("backup", __name__, url_prefix="/backup")
 
 
+def _get_settings():
+    "Get backup settings."
+    repo = UserSettingRepository(db.session)
+    return repo.get_backup_settings()
+
+
 @bp.route("/index")
 def index():
     """
     List all backups.
     """
-    settings = BackupSettings.get_backup_settings()
-    backups = list_backups(settings.backup_dir)
+    settings = _get_settings()
+    service = Service(db.session)
+    backups = service.list_backups(settings.backup_dir)
     backups.sort(reverse=True)
 
     return render_template(
@@ -40,7 +48,7 @@ def index():
 @bp.route("/download/<filename>")
 def download_backup(filename):
     "Download the given backup file."
-    settings = BackupSettings.get_backup_settings()
+    settings = _get_settings()
     fullpath = os.path.join(settings.backup_dir, filename)
     return send_file(fullpath, as_attachment=True)
 
@@ -56,7 +64,7 @@ def backup():
     if "type" in request.args:
         backuptype = "manual"
 
-    settings = BackupSettings.get_backup_settings()
+    settings = _get_settings()
     return render_template(
         "backup/backup.html", backup_folder=settings.backup_dir, backuptype=backuptype
     )
@@ -73,10 +81,11 @@ def do_backup():
         backuptype = prms["type"]
 
     c = current_app.env_config
-    settings = BackupSettings.get_backup_settings()
+    settings = _get_settings()
+    service = Service(db.session)
     is_manual = backuptype.lower() == "manual"
     try:
-        f = create_backup(c, settings, is_manual=is_manual)
+        f = service.create_backup(c, settings, is_manual=is_manual)
         flash(f"Backup created: {f}", "notice")
         return jsonify(f)
     except Exception as e:  # pylint: disable=broad-exception-caught
@@ -87,5 +96,6 @@ def do_backup():
 @bp.route("/skip_this_backup", methods=["GET"])
 def handle_skip_this_backup():
     "Update last backup date so backup not attempted again."
-    skip_this_backup()
+    service = Service(db.session)
+    service.skip_this_backup()
     return redirect("/", 302)
diff --git a/lute/backup/service.py b/lute/backup/service.py
index 60cf95ce..a3461bb2 100644
--- a/lute/backup/service.py
+++ b/lute/backup/service.py
@@ -10,8 +10,7 @@
 import time
 from typing import List, Union
 
-from lute.db import db
-from lute.models.setting import SystemSetting
+from lute.models.repositories import UserSettingRepository
 from lute.models.book import Book
 from lute.models.term import Term
 
@@ -70,124 +69,125 @@ def size(self) -> str:
         return f"{s} bytes"
 
 
-def create_backup(app_config, settings, is_manual=False, suffix=None):
-    """
-    Create backup using current app config, settings.
-
-    is_manual is True if this is a user-triggered manual
-    backup, otherwise is False.
-
-    suffix can be specified for test.
+class Service:
+    "Service."
 
-    settings are from Setting.get_backup_settings().
-      - backup_enabled
-      - backup_dir
-      - backup_auto
-      - backup_warn
-      - backup_count
-      - last_backup_datetime
-    """
-    if not os.path.exists(settings.backup_dir):
-        raise BackupException("Missing directory " + settings.backup_dir)
-
-    ### Timing helper for when implement audio backup.
-    # def _print_now(msg):
-    #     now = datetime.now().strftime("%H-%M-%S")
-    #     print(f"{now} - {msg}", flush=True)
+    def __init__(self, session):
+        self.session = session
 
-    _mirror_images_dir(app_config.userimagespath, settings.backup_dir)
+    def create_backup(self, app_config, settings, is_manual=False, suffix=None):
+        """
+        Create backup using current app config, settings.
 
-    prefix = "manual_" if is_manual else ""
-    if suffix is None:
-        suffix = datetime.now().strftime("%Y-%m-%d_%H%M%S")
-    fname = f"{prefix}lute_backup_{suffix}.db"
-    backupfile = os.path.join(settings.backup_dir, fname)
+        is_manual is True if this is a user-triggered manual
+        backup, otherwise is False.
 
-    f = _create_db_backup(app_config.dbfilename, backupfile)
-    _remove_excess_backups(settings.backup_count, settings.backup_dir)
-    return f
+        suffix can be specified for test.
 
+        settings are from BackupSettings.
+          - backup_enabled
+          - backup_dir
+          - backup_auto
+          - backup_warn
+          - backup_count
+          - last_backup_datetime
+        """
+        if not os.path.exists(settings.backup_dir):
+            raise BackupException("Missing directory " + settings.backup_dir)
 
-def should_run_auto_backup(backup_settings):
-    """
-    True (if applicable) if last backup was old.
-    """
-    bs = backup_settings
-    if bs.backup_enabled is False or bs.backup_auto is False:
-        return False
+        ### Timing helper for when implement audio backup.
+        # def _print_now(msg):
+        #     now = datetime.now().strftime("%H-%M-%S")
+        #     print(f"{now} - {msg}", flush=True)
 
-    last = bs.last_backup_datetime
-    if last is None:
-        return True
+        self._mirror_images_dir(app_config.userimagespath, settings.backup_dir)
 
-    curr = int(time.time())
-    diff = curr - last
-    return diff > 24 * 60 * 60
+        prefix = "manual_" if is_manual else ""
+        if suffix is None:
+            suffix = datetime.now().strftime("%Y-%m-%d_%H%M%S")
+        fname = f"{prefix}lute_backup_{suffix}.db"
+        backupfile = os.path.join(settings.backup_dir, fname)
 
+        f = self._create_db_backup(app_config.dbfilename, backupfile)
+        self._remove_excess_backups(settings.backup_count, settings.backup_dir)
+        return f
 
-def backup_warning(backup_settings):
-    "Get warning if needed."
-    if not backup_settings.backup_warn:
-        return ""
+    def should_run_auto_backup(self, backup_settings):
+        """
+        True (if applicable) if last backup was old.
+        """
+        bs = backup_settings
+        if bs.backup_enabled is False or bs.backup_auto is False:
+            return False
+
+        last = bs.last_backup_datetime
+        if last is None:
+            return True
+
+        curr = int(time.time())
+        diff = curr - last
+        return diff > 24 * 60 * 60
+
+    def backup_warning(self, backup_settings):
+        "Get warning if needed."
+        if not backup_settings.backup_warn:
+            return ""
+
+        have_books = self.session.query(self.session.query(Book).exists()).scalar()
+        have_terms = self.session.query(self.session.query(Term).exists()).scalar()
+        if have_books is False and have_terms is False:
+            return ""
+
+        last = backup_settings.last_backup_datetime
+        if last is None:
+            return "Never backed up."
+
+        curr = int(time.time())
+        diff = curr - last
+        old_backup_msg = "Last backup was more than 1 week ago."
+        if diff > 7 * 24 * 60 * 60:
+            return old_backup_msg
 
-    have_books = db.session.query(db.session.query(Book).exists()).scalar()
-    have_terms = db.session.query(db.session.query(Term).exists()).scalar()
-    if have_books is False and have_terms is False:
         return ""
 
-    last = backup_settings.last_backup_datetime
-    if last is None:
-        return "Never backed up."
-
-    curr = int(time.time())
-    diff = curr - last
-    old_backup_msg = "Last backup was more than 1 week ago."
-    if diff > 7 * 24 * 60 * 60:
-        return old_backup_msg
-
-    return ""
-
-
-def _create_db_backup(dbfilename, backupfile):
-    "Make a backup."
-    shutil.copy(dbfilename, backupfile)
-    f = f"{backupfile}.gz"
-    with open(backupfile, "rb") as in_file, gzip.open(
-        f, "wb", compresslevel=4
-    ) as out_file:
-        shutil.copyfileobj(in_file, out_file)
-    os.remove(backupfile)
-    SystemSetting.set_last_backup_datetime(int(time.time()))
-    return f
-
-
-def skip_this_backup():
-    "Set the last backup time to today."
-    SystemSetting.set_last_backup_datetime(int(time.time()))
-
-
-def _remove_excess_backups(count, outdir):
-    "Remove old backups."
-    files = [f for f in list_backups(outdir) if not f.is_manual]
-    files.sort(reverse=True)
-    to_remove = files[count:]
-    for f in to_remove:
-        os.remove(f.filepath)
-
-
-def _mirror_images_dir(userimagespath, outdir):
-    "Copy the images to backup."
-    target_dir = os.path.join(outdir, "userimages_backup")
-    target_dir = os.path.abspath(target_dir)
-    if not os.path.exists(target_dir):
-        os.mkdir(target_dir)
-    shutil.copytree(userimagespath, target_dir, dirs_exist_ok=True)
-
-
-def list_backups(outdir) -> List[DatabaseBackupFile]:
-    "List all backup files."
-    return [
-        DatabaseBackupFile(os.path.join(outdir, f))
-        for f in os.listdir(outdir)
-        if re.match(r"(manual_)?lute_backup_", f)
-    ]
+    def _create_db_backup(self, dbfilename, backupfile):
+        "Make a backup."
+        shutil.copy(dbfilename, backupfile)
+        f = f"{backupfile}.gz"
+        with open(backupfile, "rb") as in_file, gzip.open(
+            f, "wb", compresslevel=4
+        ) as out_file:
+            shutil.copyfileobj(in_file, out_file)
+        os.remove(backupfile)
+        r = UserSettingRepository(self.session)
+        r.set_last_backup_datetime(int(time.time()))
+        return f
+
+    def skip_this_backup(self):
+        "Set the last backup time to today."
+        r = UserSettingRepository(self.session)
+        r.set_last_backup_datetime(int(time.time()))
+
+    def _remove_excess_backups(self, count, outdir):
+        "Remove old backups."
+        files = [f for f in self.list_backups(outdir) if not f.is_manual]
+        files.sort(reverse=True)
+        to_remove = files[count:]
+        for f in to_remove:
+            os.remove(f.filepath)
+
+    def _mirror_images_dir(self, userimagespath, outdir):
+        "Copy the images to backup."
+        target_dir = os.path.join(outdir, "userimages_backup")
+        target_dir = os.path.abspath(target_dir)
+        if not os.path.exists(target_dir):
+            os.mkdir(target_dir)
+        shutil.copytree(userimagespath, target_dir, dirs_exist_ok=True)
+
+    def list_backups(self, outdir) -> List[DatabaseBackupFile]:
+        "List all backup files."
+        return [
+            DatabaseBackupFile(os.path.join(outdir, f))
+            for f in os.listdir(outdir)
+            if re.match(r"(manual_)?lute_backup_", f)
+        ]
diff --git a/lute/book/datatables.py b/lute/book/datatables.py
index a4b799f1..726ad1be 100644
--- a/lute/book/datatables.py
+++ b/lute/book/datatables.py
@@ -2,11 +2,10 @@
 Show books in datatables.
 """
 
-from lute.db import db
 from lute.utils.data_tables import DataTablesSqliteQuery, supported_parser_type_criteria
 
 
-def get_data_tables_list(parameters, is_archived):
+def get_data_tables_list(parameters, is_archived, session):
     "Book json data for datatables."
     archived = "true" if is_archived else "false"
 
@@ -70,7 +69,5 @@ def get_data_tables_list(parameters, is_archived):
     if language_id != 0:
         base_sql += f" and LgID = {language_id}"
 
-    session = db.session
     connection = session.connection()
-
     return DataTablesSqliteQuery.get_data(base_sql, parameters, connection)
diff --git a/lute/book/forms.py b/lute/book/forms.py
index 39c32013..2ed147ef 100644
--- a/lute/book/forms.py
+++ b/lute/book/forms.py
@@ -9,7 +9,7 @@
 from wtforms.validators import DataRequired, Length, NumberRange
 from flask_wtf import FlaskForm
 from flask_wtf.file import FileField, FileAllowed
-from lute.book import service
+from lute.book.service import Service
 
 
 class NewBookForm(FlaskForm):
@@ -78,6 +78,7 @@ def _values(field_data):
 
         obj.book_tags = _values(self.book_tags.data)
 
+        service = Service()
         if self.textfile.data:
             obj.text = service.get_file_content(self.textfile.data)
         f = self.audiofile.data
@@ -149,6 +150,7 @@ def _values(field_data):
         obj.book_tags = _values(self.book_tags.data)
 
         f = self.audiofile.data
+        service = Service()
         if f:
             obj.audio_filename = service.save_audio_file(f)
             obj.audio_bookmarks = None
diff --git a/lute/book/model.py b/lute/book/model.py
index e80e4dcd..872a948e 100644
--- a/lute/book/model.py
+++ b/lute/book/model.py
@@ -3,7 +3,11 @@
 """
 
 from lute.models.book import Book as DBBook, BookTag
-from lute.models.language import Language
+from lute.models.repositories import (
+    BookRepository,
+    BookTagRepository,
+    LanguageRepository,
+)
 
 
 class Book:  # pylint: disable=too-many-instance-attributes
@@ -40,26 +44,27 @@ class Repository:
     Maps Book BO to and from lute.model.Book.
     """
 
-    def __init__(self, _db):
-        self.db = _db
+    def __init__(self, _session):
+        self.session = _session
+        self.book_repo = BookRepository(self.session)
 
     def load(self, book_id):
         "Loads a Book business object for the DBBook."
-        dbb = DBBook.find(book_id)
+        dbb = self.book_repo.find(book_id)
         if dbb is None:
             raise ValueError(f"No book with id {book_id} found")
         return self._build_business_book(dbb)
 
     def find_by_title(self, book_title, language_id):
         "Loads a Book business object for the book with a given title."
-        dbb = DBBook.find_by_title(book_title, language_id)
+        dbb = self.book_repo.find_by_title(book_title, language_id)
         if dbb is None:
             return None
         return self._build_business_book(dbb)
 
     def get_book_tags(self):
         "Get all available book tags, helper method."
-        bts = self.db.session.query(BookTag).all()
+        bts = self.session.query(BookTag).all()
         return [t.text for t in bts]
 
     def add(self, book):
@@ -69,7 +74,7 @@ def add(self, book):
         clients should not change it.
         """
         dbbook = self._build_db_book(book)
-        self.db.session.add(dbbook)
+        self.session.add(dbbook)
         return dbbook
 
     def delete(self, book):
@@ -78,38 +83,40 @@ def delete(self, book):
         """
         if book.id is None:
             raise ValueError(f"book {book.title} not saved")
-        b = DBBook.find(book.id)
-        self.db.session.delete(b)
+        b = self.book_repo.find(book.id)
+        self.session.delete(b)
 
     def commit(self):
         """
         Commit everything.
         """
-        self.db.session.commit()
+        self.session.commit()
 
     def _build_db_book(self, book):
         "Convert a book business object to a DBBook."
 
+        lang_repo = LanguageRepository(self.session)
         lang = None
         if book.language_id:
-            lang = Language.find(book.language_id)
+            lang = lang_repo.find(book.language_id)
         elif book.language_name:
-            lang = Language.find_by_name(book.language_name)
+            lang = lang_repo.find_by_name(book.language_name)
 
         b = None
         if book.id is None:
             b = DBBook.create_book(book.title, lang, book.text, book.max_page_tokens)
         else:
-            b = DBBook.find(book.id)
+            b = self.book_repo.find(book.id)
         b.title = book.title
         b.source_uri = book.source_uri
         b.audio_filename = book.audio_filename
         b.audio_current_pos = book.audio_current_pos
         b.audio_bookmarks = book.audio_bookmarks
 
+        btr = BookTagRepository(self.session)
         booktags = []
         for s in book.book_tags:
-            booktags.append(BookTag.find_or_create_by_text(s))
+            booktags.append(btr.find_or_create_by_text(s))
         b.remove_all_book_tags()
         for tt in booktags:
             b.add_book_tag(tt)
diff --git a/lute/book/routes.py b/lute/book/routes.py
index 8271bbfe..809a15a7 100644
--- a/lute/book/routes.py
+++ b/lute/book/routes.py
@@ -12,16 +12,19 @@
     flash,
 )
 from lute.utils.data_tables import DataTablesFlaskParamParser
-from lute.book import service
+from lute.book.service import Service, BookImportException
 from lute.book.datatables import get_data_tables_list
 from lute.book.forms import NewBookForm, EditBookForm
-from lute.book.stats import get_stats
+from lute.book.stats import Service as StatsService
 import lute.utils.formutils
 from lute.db import db
 
 from lute.models.language import Language
-from lute.models.book import Book as DBBook
-from lute.models.setting import UserSetting
+from lute.models.repositories import (
+    BookRepository,
+    UserSettingRepository,
+    LanguageRepository,
+)
 from lute.book.model import Book, Repository
 
 
@@ -46,7 +49,7 @@ def datatables_source(is_archived):
     # (currently unused)
     parameters = DataTablesFlaskParamParser.parse_params(request.form)
     _load_term_custom_filters(request.form, parameters)
-    data = get_data_tables_list(parameters, is_archived)
+    data = get_data_tables_list(parameters, is_archived, db.session)
     return jsonify(data)
 
 
@@ -59,8 +62,10 @@ def datatables_active_source():
 @bp.route("/archived", methods=["GET"])
 def archived():
     "List archived books."
-    language_choices = lute.utils.formutils.language_choices("(all languages)")
-    current_language_id = lute.utils.formutils.valid_current_language_id()
+    language_choices = lute.utils.formutils.language_choices(
+        db.session, "(all languages)"
+    )
+    current_language_id = lute.utils.formutils.valid_current_language_id(db.session)
 
     return render_template(
         "book/index.html",
@@ -80,9 +85,10 @@ def datatables_archived_source():
 def _book_from_url(url):
     "Create a new book, or flash an error if can't parse."
     b = Book()
+    service = Service()
     try:
         b = service.book_from_url(url)
-    except service.BookImportException as e:
+    except BookImportException as e:
         flash(e.message, "notice")
         b = Book()
     return b
@@ -107,8 +113,8 @@ def new():
         b = _book_from_url(import_url)
 
     form = NewBookForm(obj=b)
-    form.language_id.choices = lute.utils.formutils.language_choices()
-    repo = Repository(db)
+    form.language_id.choices = lute.utils.formutils.language_choices(db.session)
+    repo = Repository(db.session)
 
     if form.validate_on_submit():
         try:
@@ -116,11 +122,12 @@ def new():
             book = repo.add(b)
             repo.commit()
             return redirect(f"/read/{book.id}/page/1", 302)
-        except service.BookImportException as e:
+        except BookImportException as e:
             flash(e.message, "notice")
 
     # Don't set the current language before submit.
-    current_language_id = int(UserSetting.get_value("current_language_id"))
+    usrepo = UserSettingRepository(db.session)
+    current_language_id = int(usrepo.get_value("current_language_id"))
     form.language_id.data = current_language_id
 
     return render_template(
@@ -136,7 +143,7 @@ def new():
 @bp.route("/edit/<int:bookid>", methods=["GET", "POST"])
 def edit(bookid):
     "Edit a book - can only change a few fields."
-    repo = Repository(db)
+    repo = Repository(db.session)
     b = repo.load(bookid)
     form = EditBookForm(obj=b)
 
@@ -147,7 +154,8 @@ def edit(bookid):
         flash(f"{b.title} updated.")
         return redirect("/", 302)
 
-    lang = Language.find(b.language_id)
+    lang_repo = LanguageRepository(db.session)
+    lang = lang_repo.find(b.language_id)
     return render_template(
         "book/edit.html",
         book=b,
@@ -162,10 +170,16 @@ def import_webpage():
     return render_template("book/import_webpage.html")
 
 
+def _find_book(bookid):
+    "Find book from db."
+    br = BookRepository(db.session)
+    return br.find(bookid)
+
+
 @bp.route("/archive/<int:bookid>", methods=["POST"])
 def archive(bookid):
     "Archive a book."
-    b = DBBook.find(bookid)
+    b = _find_book(bookid)
     b.archived = True
     db.session.add(b)
     db.session.commit()
@@ -175,7 +189,7 @@ def archive(bookid):
 @bp.route("/unarchive/<int:bookid>", methods=["POST"])
 def unarchive(bookid):
     "Archive a book."
-    b = DBBook.find(bookid)
+    b = _find_book(bookid)
     b.archived = False
     db.session.add(b)
     db.session.commit()
@@ -185,7 +199,7 @@ def unarchive(bookid):
 @bp.route("/delete/<int:bookid>", methods=["POST"])
 def delete(bookid):
     "Archive a book."
-    b = DBBook.find(bookid)
+    b = _find_book(bookid)
     db.session.delete(b)
     db.session.commit()
     return redirect("/", 302)
@@ -194,8 +208,15 @@ def delete(bookid):
 @bp.route("/table_stats/<int:bookid>", methods=["GET"])
 def table_stats(bookid):
     "Get the stats, return ajax."
-    b = DBBook.find(bookid)
-    stats = get_stats(b)
+    b = _find_book(bookid)
+    if b is None or b.language is None:
+        # Playwright tests were sometimes passing an id that didn't exist ...
+        # I believe this is due to page caching, i.e. the book listing
+        # is showing books and IDs that no longer exist after cache reset.
+        # TODO fix_hack: get rid of this hack.
+        return jsonify({})
+    svc = StatsService(db.session)
+    stats = svc.get_stats(b)
     ret = {
         "distinctterms": stats.distinctterms,
         "distinctunknowns": stats.distinctunknowns,
diff --git a/lute/book/service.py b/lute/book/service.py
index e957d534..b1e37de4 100644
--- a/lute/book/service.py
+++ b/lute/book/service.py
@@ -29,179 +29,174 @@ def __init__(self, message="A custom error occurred", cause=None):
         super().__init__(message)
 
 
-def _secure_unique_fname(filename):
-    """
-    Return secure name pre-pended with datetime string.
-    """
-    current_datetime = datetime.now()
-    formatted_datetime = current_datetime.strftime("%Y%m%d_%H%M%S")
-    f = "_".join([formatted_datetime, secure_filename(filename)])
-    return f
+class Service:
+    "Service."
 
+    def _secure_unique_fname(self, filename):
+        """
+        Return secure name pre-pended with datetime string.
+        """
+        current_datetime = datetime.now()
+        formatted_datetime = current_datetime.strftime("%Y%m%d_%H%M%S")
+        f = "_".join([formatted_datetime, secure_filename(filename)])
+        return f
 
-def save_audio_file(audio_file_field_data):
-    """
-    Save the file to disk, return its filename.
-    """
-    filename = _secure_unique_fname(audio_file_field_data.filename)
-    fp = os.path.join(current_app.env_config.useraudiopath, filename)
-    audio_file_field_data.save(fp)
-    return filename
+    def save_audio_file(self, audio_file_field_data):
+        """
+        Save the file to disk, return its filename.
+        """
+        filename = self._secure_unique_fname(audio_file_field_data.filename)
+        fp = os.path.join(current_app.env_config.useraudiopath, filename)
+        audio_file_field_data.save(fp)
+        return filename
 
+    def get_file_content(self, filefielddata):
+        """
+        Get the content of the file.
+        """
+        content = None
+        _, ext = os.path.splitext(filefielddata.filename)
+        ext = (ext or "").lower()
+        if ext == ".txt":
+            content = self.get_textfile_content(filefielddata)
+        if ext == ".epub":
+            content = self.get_epub_content(filefielddata)
+        if ext == ".pdf":
+            msg = """
+            Note: pdf imports can be inaccurate, due to how PDFs are encoded.
+            Please be aware of this while reading.
+            """
+            flash(msg, "notice")
+            content = self.get_pdf_content_from_form(filefielddata)
+        if ext == ".srt":
+            content = self.get_srt_content(filefielddata)
+        if ext == ".vtt":
+            content = self.get_vtt_content(filefielddata)
+
+        if content is None:
+            raise ValueError(f'Unknown file extension "{ext}"')
+        if content.strip() == "":
+            raise BookImportException(f"{filefielddata.filename} is empty.")
+        return content
 
-def get_file_content(filefielddata):
-    """
-    Get the content of the file.
-    """
-    content = None
-    _, ext = os.path.splitext(filefielddata.filename)
-    ext = (ext or "").lower()
-    if ext == ".txt":
-        content = get_textfile_content(filefielddata)
-    if ext == ".epub":
-        content = get_epub_content(filefielddata)
-    if ext == ".pdf":
-        msg = """
-        Note: pdf imports can be inaccurate, due to how PDFs are encoded.
-        Please be aware of this while reading.
+    def get_textfile_content(self, filefielddata):
+        "Get content as a single string."
+        content = ""
+        try:
+            content = filefielddata.read()
+            return str(content, "utf-8")
+        except UnicodeDecodeError as e:
+            f = filefielddata.filename
+            msg = f"{f} is not utf-8 encoding, please convert it to utf-8 first (error: {str(e)})"
+            raise BookImportException(message=msg, cause=e) from e
+
+    def get_epub_content(self, epub_file_field_data):
         """
-        flash(msg, "notice")
-        content = get_pdf_content_from_form(filefielddata)
-    if ext == ".srt":
-        content = get_srt_content(filefielddata)
-    if ext == ".vtt":
-        content = get_vtt_content(filefielddata)
-
-    if content is None:
-        raise ValueError(f'Unknown file extension "{ext}"')
-    if content.strip() == "":
-        raise BookImportException(f"{filefielddata.filename} is empty.")
-    return content
-
-
-def get_textfile_content(filefielddata):
-    "Get content as a single string."
-    content = ""
-    try:
-        content = filefielddata.read()
-        return str(content, "utf-8")
-    except UnicodeDecodeError as e:
-        f = filefielddata.filename
-        msg = f"{f} is not utf-8 encoding, please convert it to utf-8 first (error: {str(e)})"
-        raise BookImportException(message=msg, cause=e) from e
-
-
-def get_epub_content(epub_file_field_data):
-    """
-    Get the content of the epub as a single string.
-    """
-    content = ""
-    try:
-        if hasattr(epub_file_field_data.stream, "seekable"):
-            epub = Epub(stream=epub_file_field_data.stream)
-            content = epub.get_text()
-        else:
-            # We get a SpooledTemporaryFile from the form but this doesn't
-            # implement all file-like methods until python 3.11. So we need
-            # to rewrite it into a TemporaryFile
-            with TemporaryFile() as tf:
-                epub_file_field_data.stream.seek(0)
-                tf.write(epub_file_field_data.stream.read())
-                epub = Epub(stream=tf)
+        Get the content of the epub as a single string.
+        """
+        content = ""
+        try:
+            if hasattr(epub_file_field_data.stream, "seekable"):
+                epub = Epub(stream=epub_file_field_data.stream)
                 content = epub.get_text()
-    except EpubError as e:
-        msg = f"Could not parse {epub_file_field_data.filename} (error: {str(e)})"
-        raise BookImportException(message=msg, cause=e) from e
-    return content
-
-
-def get_pdf_content_from_form(pdf_file_field_data):
-    "Get content as a single string from a PDF file using PyPDF2."
-    content = ""
-    try:
-        pdf_reader = PdfReader(pdf_file_field_data)
-
-        for page in pdf_reader.pages:
-            content += page.extract_text()
-
+            else:
+                # We get a SpooledTemporaryFile from the form but this doesn't
+                # implement all file-like methods until python 3.11. So we need
+                # to rewrite it into a TemporaryFile
+                with TemporaryFile() as tf:
+                    epub_file_field_data.stream.seek(0)
+                    tf.write(epub_file_field_data.stream.read())
+                    epub = Epub(stream=tf)
+                    content = epub.get_text()
+        except EpubError as e:
+            msg = f"Could not parse {epub_file_field_data.filename} (error: {str(e)})"
+            raise BookImportException(message=msg, cause=e) from e
         return content
-    except Exception as e:
-        msg = f"Could not parse {pdf_file_field_data.filename} (error: {str(e)})"
-        raise BookImportException(message=msg, cause=e) from e
-
-
-def get_srt_content(srt_file_field_data):
-    """
-    Get the content of the srt as a single string.
-    """
-    content = ""
-    try:
-        srt_content = srt_file_field_data.read().decode("utf-8-sig")
-
-        parser = SrtParser(StringIO(srt_content))
-        parser.parse()
 
-        content = "\n".join(subtitle.text for subtitle in parser.subtitles)
+    def get_pdf_content_from_form(self, pdf_file_field_data):
+        "Get content as a single string from a PDF file using PyPDF2."
+        content = ""
+        try:
+            pdf_reader = PdfReader(pdf_file_field_data)
 
-        return content
-    except Exception as e:
-        msg = f"Could not parse {srt_file_field_data.filename} (error: {str(e)})"
-        raise BookImportException(message=msg, cause=e) from e
+            for page in pdf_reader.pages:
+                content += page.extract_text()
 
+            return content
+        except Exception as e:
+            msg = f"Could not parse {pdf_file_field_data.filename} (error: {str(e)})"
+            raise BookImportException(message=msg, cause=e) from e
 
-def get_vtt_content(vtt_file_field_data):
-    """
-    Get the content of the vtt as a single string.
-    """
-    content = ""
-    try:
-        vtt_content = vtt_file_field_data.read().decode("utf-8-sig")
+    def get_srt_content(self, srt_file_field_data):
+        """
+        Get the content of the srt as a single string.
+        """
+        content = ""
+        try:
+            srt_content = srt_file_field_data.read().decode("utf-8-sig")
 
-        # Check if it is from YouTube
-        lines = vtt_content.split("\n")
-        if lines[1].startswith("Kind:") and lines[2].startswith("Language:"):
-            vtt_content = "\n".join(lines[:1] + lines[3:])
+            parser = SrtParser(StringIO(srt_content))
+            parser.parse()
 
-        parser = WebVttParser(StringIO(vtt_content))
-        parser.parse()
+            content = "\n".join(subtitle.text for subtitle in parser.subtitles)
 
-        content = "\n".join(subtitle.text for subtitle in parser.subtitles)
+            return content
+        except Exception as e:
+            msg = f"Could not parse {srt_file_field_data.filename} (error: {str(e)})"
+            raise BookImportException(message=msg, cause=e) from e
 
-        return content
-    except Exception as e:
-        msg = f"Could not parse {vtt_file_field_data.filename} (error: {str(e)})"
-        raise BookImportException(message=msg, cause=e) from e
-
-
-def book_from_url(url):
-    "Parse the url and load a new Book."
-    s = None
-    try:
-        timeout = 20  # seconds
-        response = requests.get(url, timeout=timeout)
-        response.raise_for_status()
-        s = response.text
-    except requests.exceptions.RequestException as e:
-        msg = f"Could not parse {url} (error: {str(e)})"
-        raise BookImportException(message=msg, cause=e) from e
-
-    soup = BeautifulSoup(s, "html.parser")
-    extracted_text = []
-
-    # Add elements in order found.
-    for element in soup.descendants:
-        if element.name in ("h1", "h2", "h3", "h4", "p"):
-            extracted_text.append(element.text)
-
-    title_node = soup.find("title")
-    orig_title = title_node.string if title_node else url
-
-    short_title = orig_title[:150]
-    if len(orig_title) > 150:
-        short_title += " ..."
-
-    b = Book()
-    b.title = short_title
-    b.source_uri = url
-    b.text = "\n\n".join(extracted_text)
-    return b
+    def get_vtt_content(self, vtt_file_field_data):
+        """
+        Get the content of the vtt as a single string.
+        """
+        content = ""
+        try:
+            vtt_content = vtt_file_field_data.read().decode("utf-8-sig")
+
+            # Check if it is from YouTube
+            lines = vtt_content.split("\n")
+            if lines[1].startswith("Kind:") and lines[2].startswith("Language:"):
+                vtt_content = "\n".join(lines[:1] + lines[3:])
+
+            parser = WebVttParser(StringIO(vtt_content))
+            parser.parse()
+
+            content = "\n".join(subtitle.text for subtitle in parser.subtitles)
+
+            return content
+        except Exception as e:
+            msg = f"Could not parse {vtt_file_field_data.filename} (error: {str(e)})"
+            raise BookImportException(message=msg, cause=e) from e
+
+    def book_from_url(self, url):
+        "Parse the url and load a new Book."
+        s = None
+        try:
+            timeout = 20  # seconds
+            response = requests.get(url, timeout=timeout)
+            response.raise_for_status()
+            s = response.text
+        except requests.exceptions.RequestException as e:
+            msg = f"Could not parse {url} (error: {str(e)})"
+            raise BookImportException(message=msg, cause=e) from e
+
+        soup = BeautifulSoup(s, "html.parser")
+        extracted_text = []
+
+        # Add elements in order found.
+        for element in soup.descendants:
+            if element.name in ("h1", "h2", "h3", "h4", "p"):
+                extracted_text.append(element.text)
+
+        title_node = soup.find("title")
+        orig_title = title_node.string if title_node else url
+
+        short_title = orig_title[:150]
+        if len(orig_title) > 150:
+            short_title += " ..."
+
+        b = Book()
+        b.title = short_title
+        b.source_uri = url
+        b.text = "\n\n".join(extracted_text)
+        return b
diff --git a/lute/book/stats.py b/lute/book/stats.py
index f1a69c67..94702ad5 100644
--- a/lute/book/stats.py
+++ b/lute/book/stats.py
@@ -4,145 +4,138 @@
 
 import json
 from sqlalchemy import select, text
-from lute.read.render.service import get_multiword_indexer, get_textitems
-from lute.db import db
-from lute.models.book import Book
-from lute.models.setting import UserSetting
+from lute.read.render.service import Service as RenderService
+from lute.models.book import Book, BookStats
+from lute.models.repositories import UserSettingRepository
 
 # from lute.utils.debug_helpers import DebugTimer
 
 
-def _last_n_pages(book, txindex, n):
-    "Get next n pages, or at least n pages."
-    start_index = max(0, txindex - n)
-    end_index = txindex + n
-    texts = book.texts[start_index:end_index]
-    return texts[-n:]
-
-
-def calc_status_distribution(book):
-    """
-    Calculate statuses and count of unique words per status.
-
-    Does a full render of a small number of pages
-    to calculate the distribution.
-    """
-
-    # DebugTimer.clear_total_summary()
-    # dt = DebugTimer("get_status_distribution", display=False)
-
-    txindex = 0
-    if (book.current_tx_id or 0) != 0:
-        for t in book.texts:
-            if t.id == book.current_tx_id:
-                break
-            txindex += 1
-
-    sample_size = int(UserSetting.get_value("stats_calc_sample_size") or 5)
-    texts = _last_n_pages(book, txindex, sample_size)
-
-    # Getting the individual paragraphs per page, and then combining,
-    # is much faster than combining all pages into one giant page.
-    mw = get_multiword_indexer(book.language)
-    textitems = []
-    for tx in texts:
-        textitems.extend(get_textitems(tx.text, book.language, mw))
-    # # Old slower code:
-    # text_sample = "\n".join([t.text for t in texts])
-    # paras = get_paragraphs(text_sample, book.language) ... etc.
-    # dt.step("get_paragraphs")
-
-    textitems = [ti for ti in textitems if ti.is_word]
-    statterms = {0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 98: [], 99: []}
-    for ti in textitems:
-        statterms[ti.wo_status or 0].append(ti.text_lc)
-
-    stats = {}
-    for statusval, allterms in statterms.items():
-        uniques = list(set(allterms))
-        statterms[statusval] = uniques
-        stats[statusval] = len(uniques)
-
-    # dt.step("compiled")
-    # DebugTimer.total_summary()
-
-    return stats
-
-
-##################################################
-# Stats table refresh.
-
-
-class BookStats(db.Model):
-    "The stats table."
-    __tablename__ = "bookstats"
-
-    BkID = db.Column(db.Integer, primary_key=True)
-    distinctterms = db.Column(db.Integer)
-    distinctunknowns = db.Column(db.Integer)
-    unknownpercent = db.Column(db.Integer)
-    status_distribution = db.Column(db.String, nullable=True)
-
-
-def refresh_stats():
-    "Refresh stats for all books requiring update."
-    sql = "delete from bookstats where status_distribution is null"
-    db.session.execute(text(sql))
-    db.session.commit()
-    book_ids_with_stats = select(BookStats.BkID).scalar_subquery()
-    books_to_update = (
-        db.session.query(Book).filter(~Book.id.in_(book_ids_with_stats)).all()
-    )
-    books = [b for b in books_to_update if b.is_supported]
-    for book in books:
-        stats = _calculate_stats(book)
-        _update_stats(book, stats)
-
-
-def mark_stale(book):
-    "Mark a book's stats as stale to force refresh."
-    bk_id = book.id
-    db.session.query(BookStats).filter_by(BkID=bk_id).delete()
-    db.session.commit()
-
-
-def get_stats(book):
-    "Gets stats from the cache if available, or calculates."
-    bk_id = book.id
-    stats = db.session.query(BookStats).filter_by(BkID=bk_id).first()
-    if stats is None or stats.status_distribution is None:
-        newstats = _calculate_stats(book)
-        _update_stats(book, newstats)
-        stats = db.session.query(BookStats).filter_by(BkID=bk_id).first()
-    return stats
-
-
-def _calculate_stats(book):
-    "Calc stats for the book using the status distribution."
-    status_distribution = calc_status_distribution(book)
-    unknowns = status_distribution[0]
-    allunique = sum(status_distribution.values())
-
-    percent = 0
-    if allunique > 0:  # In case not parsed.
-        percent = round(100.0 * unknowns / allunique)
-
-    return {
-        "allunique": allunique,
-        "unknowns": unknowns,
-        "percent": percent,
-        "distribution": json.dumps(status_distribution),
-    }
-
-
-def _update_stats(book, stats):
-    "Update BookStats for the given book."
-    s = db.session.query(BookStats).filter_by(BkID=book.id).first()
-    if s is None:
-        s = BookStats(BkID=book.id)
-    s.distinctterms = stats["allunique"]
-    s.distinctunknowns = stats["unknowns"]
-    s.unknownpercent = stats["percent"]
-    s.status_distribution = stats["distribution"]
-    db.session.add(s)
-    db.session.commit()
+class Service:
+    "Service."
+
+    def __init__(self, session):
+        self.session = session
+
+    def _last_n_pages(self, book, txindex, n):
+        "Get next n pages, or at least n pages."
+        start_index = max(0, txindex - n)
+        end_index = txindex + n
+        texts = book.texts[start_index:end_index]
+        return texts[-n:]
+
+    def _get_sample_texts(self, book):
+        "Get texts to use as sample."
+        txindex = 0
+        if (book.current_tx_id or 0) != 0:
+            for t in book.texts:
+                if t.id == book.current_tx_id:
+                    break
+                txindex += 1
+
+        repo = UserSettingRepository(self.session)
+        sample_size = int(repo.get_value("stats_calc_sample_size") or 5)
+        texts = self._last_n_pages(book, txindex, sample_size)
+        return texts
+
+    def calc_status_distribution(self, book):
+        """
+        Calculate statuses and count of unique words per status.
+
+        Does a full render of a small number of pages
+        to calculate the distribution.
+        """
+
+        # DebugTimer.clear_total_summary()
+        # dt = DebugTimer("get_status_distribution", display=False)
+        texts = self._get_sample_texts(book)
+
+        # Getting the individual paragraphs per page, and then combining,
+        # is much faster than combining all pages into one giant page.
+        service = RenderService(self.session)
+        mw = service.get_multiword_indexer(book.language)
+        textitems = []
+        for tx in texts:
+            textitems.extend(service.get_textitems(tx.text, book.language, mw))
+        # # Old slower code:
+        # text_sample = "\n".join([t.text for t in texts])
+        # paras = get_paragraphs(text_sample, book.language) ... etc.
+        # dt.step("get_paragraphs")
+
+        textitems = [ti for ti in textitems if ti.is_word]
+        statterms = {0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 98: [], 99: []}
+        for ti in textitems:
+            statterms[ti.wo_status or 0].append(ti.text_lc)
+
+        stats = {}
+        for statusval, allterms in statterms.items():
+            uniques = list(set(allterms))
+            statterms[statusval] = uniques
+            stats[statusval] = len(uniques)
+
+        # dt.step("compiled")
+        # DebugTimer.total_summary()
+
+        return stats
+
+    ##################################################
+    # Stats table refresh.
+
+    def refresh_stats(self):
+        "Refresh stats for all books requiring update."
+        sql = "delete from bookstats where status_distribution is null"
+        self.session.execute(text(sql))
+        self.session.commit()
+        book_ids_with_stats = select(BookStats.BkID).scalar_subquery()
+        books_to_update = (
+            self.session.query(Book).filter(~Book.id.in_(book_ids_with_stats)).all()
+        )
+        books = [b for b in books_to_update if b.is_supported]
+        for book in books:
+            stats = self._calculate_stats(book)
+            self._update_stats(book, stats)
+
+    def mark_stale(self, book):
+        "Mark a book's stats as stale to force refresh."
+        bk_id = book.id
+        self.session.query(BookStats).filter_by(BkID=bk_id).delete()
+        self.session.commit()
+
+    def get_stats(self, book):
+        "Gets stats from the cache if available, or calculates."
+        bk_id = book.id
+        stats = self.session.query(BookStats).filter_by(BkID=bk_id).first()
+        if stats is None or stats.status_distribution is None:
+            newstats = self._calculate_stats(book)
+            self._update_stats(book, newstats)
+            stats = self.session.query(BookStats).filter_by(BkID=bk_id).first()
+        return stats
+
+    def _calculate_stats(self, book):
+        "Calc stats for the book using the status distribution."
+        status_distribution = self.calc_status_distribution(book)
+        unknowns = status_distribution[0]
+        allunique = sum(status_distribution.values())
+
+        percent = 0
+        if allunique > 0:  # In case not parsed.
+            percent = round(100.0 * unknowns / allunique)
+
+        return {
+            "allunique": allunique,
+            "unknowns": unknowns,
+            "percent": percent,
+            "distribution": json.dumps(status_distribution),
+        }
+
+    def _update_stats(self, book, stats):
+        "Update BookStats for the given book."
+        s = self.session.query(BookStats).filter_by(BkID=book.id).first()
+        if s is None:
+            s = BookStats(BkID=book.id)
+        s.distinctterms = stats["allunique"]
+        s.distinctunknowns = stats["unknowns"]
+        s.unknownpercent = stats["percent"]
+        s.status_distribution = stats["distribution"]
+        self.session.add(s)
+        self.session.commit()
diff --git a/lute/bookmarks/datatables.py b/lute/bookmarks/datatables.py
index f70c8fcc..6dcdeac2 100644
--- a/lute/bookmarks/datatables.py
+++ b/lute/bookmarks/datatables.py
@@ -2,11 +2,10 @@
 Show bookmarks in datatables.
 """
 
-from lute.db import db
 from lute.utils.data_tables import DataTablesSqliteQuery
 
 
-def get_data_tables_list(parameters, book_id):
+def get_data_tables_list(parameters, book_id, session):
     "Bookmark json data for datatables."
 
     base_sql = f"""
@@ -16,7 +15,5 @@ def get_data_tables_list(parameters, book_id):
       WHERE tx.TxBkID = { book_id }
     """
 
-    session = db.session
     connection = session.connection()
-
     return DataTablesSqliteQuery.get_data(base_sql, parameters, connection)
diff --git a/lute/bookmarks/routes.py b/lute/bookmarks/routes.py
index bb1ed9b9..e9b9b00f 100644
--- a/lute/bookmarks/routes.py
+++ b/lute/bookmarks/routes.py
@@ -4,7 +4,8 @@
 
 from flask import Blueprint, request, render_template, jsonify
 from lute.bookmarks.datatables import get_data_tables_list
-from lute.models.book import Book, Text, TextBookmark
+from lute.models.book import Text, TextBookmark
+from lute.models.repositories import BookRepository
 from lute.utils.data_tables import DataTablesFlaskParamParser
 from lute.db import db
 
@@ -15,14 +16,15 @@
 def datatables_bookmarks(bookid):
     "Get datatables json for bookmarks."
     parameters = DataTablesFlaskParamParser.parse_params(request.form)
-    data = get_data_tables_list(parameters, bookid)
+    data = get_data_tables_list(parameters, bookid, db.session)
     return jsonify(data)
 
 
 @bp.route("/<int:bookid>", methods=["GET"])
 def bookmarks(bookid):
     "Get all bookarks for given bookid."
-    book = Book.find(bookid)
+    br = BookRepository(db.session)
+    book = br.find(bookid)
 
     text_dir = "rtl" if book.language.right_to_left else "ltr"
     return render_template("bookmarks/list.html", book=book, text_dir=text_dir)
diff --git a/lute/cli/import_books.py b/lute/cli/import_books.py
index a044f381..bd582c66 100644
--- a/lute/cli/import_books.py
+++ b/lute/cli/import_books.py
@@ -8,7 +8,7 @@
 
 from lute.book.model import Book, Repository
 from lute.db import db
-from lute.models.language import Language
+from lute.models.repositories import LanguageRepository
 
 
 def import_books_from_csv(file, language, tags, commit):
@@ -26,7 +26,9 @@ def import_books_from_csv(file, language, tags, commit):
                 database. If false, a list of books to be imported will be
                 printed out, but no changes will be made.
     """
-    repo = Repository(db)
+    repo = Repository(db.session)
+    lang_repo = LanguageRepository(db.session)
+
     count = 0
     with open(file, newline="", encoding="utf-8") as f:
         r = csv.DictReader(f)
@@ -37,7 +39,7 @@ def import_books_from_csv(file, language, tags, commit):
             if not book.language_name:
                 print(f"Skipping book with unspecified language: {book.title}")
                 continue
-            lang = Language.find_by_name(book.language_name)
+            lang = lang_repo.find_by_name(book.language_name)
             if not lang:
                 print(
                     f"Skipping book with unknown language ({book.language_name}): {book.title}"
diff --git a/lute/cli/language_term_export.py b/lute/cli/language_term_export.py
index 2a5aad2b..47fde2f3 100644
--- a/lute/cli/language_term_export.py
+++ b/lute/cli/language_term_export.py
@@ -15,7 +15,7 @@
 import csv
 from lute.db import db
 from lute.models.book import Book
-from lute.read.render.service import get_textitems, get_multiword_indexer
+from lute.read.render.service import Service
 
 
 def _add_term_to_dict(t, terms):
@@ -54,11 +54,12 @@ def _process_book(b, terms, multiword_indexer):
     "Process pages in book, add to output."
     print(f"Processing {b.title} ...")
     i = 0
+    service = Service(db.session)
     for text in b.texts:
         i += 1
         if i % 10 == 0:
             print(f"  page {i} of {b.page_count}", end="\r")
-        textitems = get_textitems(text.text, b.language, multiword_indexer)
+        textitems = service.get_textitems(text.text, b.language, multiword_indexer)
         displayed_terms = [
             ti.term for ti in textitems if ti.is_word and ti.term is not None
         ]
@@ -111,10 +112,11 @@ def _finalize_output(terms):
 
 def _load_indexers(books):
     "Load multiword indexers for book languages."
+    service = Service(db.session)
     ret = {}
     lang_map = {book.language.id: book.language for book in books}
     for langid, lang in lang_map.items():
-        ret[langid] = get_multiword_indexer(lang)
+        ret[langid] = service.get_multiword_indexer(lang)
     return ret
 
 
diff --git a/lute/db/data_cleanup.py b/lute/db/data_cleanup.py
index 2c7aef95..91f1c169 100644
--- a/lute/db/data_cleanup.py
+++ b/lute/db/data_cleanup.py
@@ -5,11 +5,10 @@
 These cleanup routines will be called by the app_factory.
 """
 
-from lute.db import db
 from lute.models.book import Text
 
 
-def _set_texts_word_count():
+def _set_texts_word_count(session):
     """
     texts.TxWordCount should be set for all texts.
 
@@ -18,7 +17,7 @@ def _set_texts_word_count():
 
     Ref https://github.com/jzohrab/lute-v3/issues/95
     """
-    calc_counts = db.session.query(Text).filter(Text.word_count.is_(None)).all()
+    calc_counts = session.query(Text).filter(Text.word_count.is_(None)).all()
 
     # Don't recalc with invalid parsers!!!!
     recalc = [t for t in calc_counts if t.book.language.is_supported]
@@ -31,10 +30,10 @@ def _set_texts_word_count():
         pt = t.book.language.get_parsed_tokens(t.text)
         words = [w for w in pt if w.is_word]
         t.word_count = len(words)
-        db.session.add(t)
-    db.session.commit()
+        session.add(t)
+    session.commit()
 
 
-def clean_data():
+def clean_data(session):
     "Clean all data as required."
-    _set_texts_word_count()
+    _set_texts_word_count(session)
diff --git a/lute/db/demo.py b/lute/db/demo.py
index 01b706c9..98f7e570 100644
--- a/lute/db/demo.py
+++ b/lute/db/demo.py
@@ -9,11 +9,10 @@
 """
 
 from sqlalchemy import text
-import lute.language.service
+from lute.language.service import Service
 from lute.book.model import Repository
-from lute.book.stats import refresh_stats
-from lute.models.setting import SystemSetting
-from lute.db import db
+from lute.book.stats import Service as StatsService
+from lute.models.repositories import SystemSettingRepository
 import lute.db.management
 
 
@@ -39,97 +38,100 @@ def _demo_languages():
     ]
 
 
-def contains_demo_data():
+def contains_demo_data(session):
     """
     True if IsDemoData setting is present.
     """
-    ss = SystemSetting.get_value("IsDemoData")
+    repo = SystemSettingRepository(session)
+    ss = repo.get_value("IsDemoData")
     if ss is None:
         return False
     return True
 
 
-def remove_flag():
+def remove_flag(session):
     """
     Remove IsDemoData setting.
     """
-    if not contains_demo_data():
+    if not contains_demo_data(session):
         raise RuntimeError("Can't delete non-demo data.")
 
-    SystemSetting.delete_key("IsDemoData")
-    db.session.commit()
+    repo = SystemSettingRepository(session)
+    repo.delete_key("IsDemoData")
+    session.commit()
 
 
-def tutorial_book_id():
+def tutorial_book_id(session):
     """
     Return the book id of the tutorial.
     """
-    if not contains_demo_data():
+    if not contains_demo_data(session):
         return None
     sql = """select BkID from books
     inner join languages on LgID = BkLgID
     where LgName = 'English' and BkTitle = 'Tutorial'
     """
-    r = db.session.execute(text(sql)).first()
+    r = session.execute(text(sql)).first()
     if r is None:
         return None
     return int(r[0])
 
 
-def delete_demo_data():
+def delete_demo_data(session):
     """
     If this is a demo, wipe everything.
     """
-    if not contains_demo_data():
+    if not contains_demo_data(session):
         raise RuntimeError("Can't delete non-demo data.")
-    remove_flag()
-    lute.db.management.delete_all_data()
+    remove_flag(session)
+    lute.db.management.delete_all_data(session)
 
 
 # Loading demo data.
 
 
-def load_demo_languages():
+def load_demo_languages(session):
     """
     Load selected predefined languages.  Assume everything is supported.
 
     This method will also be called during acceptance tests, so it's public.
     """
     demo_langs = _demo_languages()
-    langs = [
-        lute.language.service.get_language_def(langname)["language"]
-        for langname in demo_langs
-    ]
+    service = Service(session)
+    langs = [service.get_language_def(langname)["language"] for langname in demo_langs]
     supported = [lang for lang in langs if lang.is_supported]
     for lang in supported:
-        db.session.add(lang)
-    db.session.commit()
+        session.add(lang)
+    session.commit()
 
 
-def load_demo_stories():
+def load_demo_stories(session):
     "Load the stories."
     demo_langs = _demo_languages()
-    langdefs = [
-        lute.language.service.get_language_def(langname) for langname in demo_langs
-    ]
+    service = Service(session)
+    langdefs = [service.get_language_def(langname) for langname in demo_langs]
     langdefs = [d for d in langdefs if d["language"].is_supported]
 
-    r = Repository(db)
+    r = Repository(session)
     for d in langdefs:
         for b in d["books"]:
             r.add(b)
     r.commit()
 
-    SystemSetting.set_value("IsDemoData", True)
-    db.session.commit()
-    refresh_stats()
+    repo = SystemSettingRepository(session)
+    repo.set_value("IsDemoData", True)
+    session.commit()
+
+    svc = StatsService(session)
+    svc.refresh_stats()
 
 
-def load_demo_data():
+def load_demo_data(session):
     """
     Load the data.
     """
-    load_demo_languages()
-    load_demo_stories()
-    SystemSetting.set_value("IsDemoData", True)
-    db.session.commit()
+    load_demo_languages(session)
+    load_demo_stories(session)
+    repo = SystemSettingRepository(session)
+    repo.set_value("IsDemoData", True)
+    session.commit()
diff --git a/lute/db/management.py b/lute/db/management.py
index 81bd3ccf..bf03eb5b 100644
--- a/lute/db/management.py
+++ b/lute/db/management.py
@@ -2,12 +2,14 @@
 Db management.
 """
 
+import os
 from sqlalchemy import text
-from lute.db import db
+from flask import current_app
 from lute.models.setting import UserSetting
+from lute.models.repositories import UserSettingRepository
 
 
-def delete_all_data():
+def delete_all_data(session):
     """
     DANGEROUS!  Delete everything, restore user settings, clear sys settings.
 
@@ -23,6 +25,119 @@ def delete_all_data():
         "delete from settings",
     ]
     for s in statements:
-        db.session.execute(text(s))
-    db.session.commit()
-    UserSetting.load()
+        session.execute(text(s))
+    session.commit()
+    add_default_user_settings(session, current_app.env_config.default_user_backup_path)
+
+
+def _revised_mecab_path(repo):
+    """
+    Change the mecab_path if it's not found, and a
+    replacement is found.
+
+    Lute Docker images are built to be multi-arch, and
+    interestingly (annoyingly), mecab libraries are installed into
+    different locations depending on the architecture, even with
+    the same Dockerfile and base image.
+
+    Returns: new mecab path if old one is missing _and_
+    new one found, otherwise just return the old one.
+    """
+
+    mp = repo.get_value("mecab_path")
+    if mp is not None and os.path.exists(mp):
+        return mp
+
+    # See develop docs for notes on how to find the libmecab path!
+    candidates = [
+        # linux/arm64
+        "/lib/aarch64-linux-gnu/libmecab.so.2",
+        # linux/amd64
+        "/lib/x86_64-linux-gnu/libmecab.so.2",
+        # github CI, ubuntu-latest
+        "/lib/x86_64-linux-gnu/libmecab.so.2",
+    ]
+    replacements = [p for p in candidates if os.path.exists(p)]
+    if len(replacements) > 0:
+        return replacements[0]
+    # Replacement not found, leave current value as-is.
+    return mp
+
+
+def add_default_user_settings(session, default_user_backup_path):
+    """
+    Load missing user settings with default values.
+    """
+    repo = UserSettingRepository(session)
+
+    # These keys are rendered into the global javascript namespace var
+    # LUTE_USER_SETTINGS, so if any of these keys change, check the usage
+    # of that variable as well.
+    keys_and_defaults = {
+        "backup_enabled": True,
+        "backup_auto": True,
+        "backup_warn": True,
+        "backup_dir": default_user_backup_path,
+        "backup_count": 5,
+        "lastbackup": None,
+        "mecab_path": None,
+        "japanese_reading": "hiragana",
+        "current_theme": "-",
+        "custom_styles": "/* Custom css to modify Lute's appearance. */",
+        "show_highlights": True,
+        "current_language_id": 0,
+        # Behaviour:
+        "open_popup_in_new_tab": False,
+        "stop_audio_on_term_form_open": True,
+        "stats_calc_sample_size": 5,
+        # Keyboard shortcuts.  These have default values assigned
+        # as they were the hotkeys defined in the initial Lute
+        # release.
+        "hotkey_StartHover": "escape",
+        "hotkey_PrevWord": "arrowleft",
+        "hotkey_NextWord": "arrowright",
+        "hotkey_StatusUp": "arrowup",
+        "hotkey_StatusDown": "arrowdown",
+        "hotkey_Bookmark": "b",
+        "hotkey_CopySentence": "c",
+        "hotkey_CopyPara": "shift+c",
+        "hotkey_TranslateSentence": "t",
+        "hotkey_TranslatePara": "shift+t",
+        "hotkey_NextTheme": "m",
+        "hotkey_ToggleHighlight": "h",
+        "hotkey_ToggleFocus": "f",
+        "hotkey_Status1": "1",
+        "hotkey_Status2": "2",
+        "hotkey_Status3": "3",
+        "hotkey_Status4": "4",
+        "hotkey_Status5": "5",
+        "hotkey_StatusIgnore": "i",
+        "hotkey_StatusWellKnown": "w",
+        # New hotkeys.  These must have empty values, because
+        # users may have already setup their hotkeys, and we can't
+        # assume that a given key combination is free:
+        "hotkey_CopyPage": "",
+        "hotkey_DeleteTerm": "",
+        "hotkey_EditPage": "",
+        "hotkey_TranslatePage": "",
+        "hotkey_PrevUnknownWord": "",
+        "hotkey_NextUnknownWord": "",
+        "hotkey_PrevSentence": "",
+        "hotkey_NextSentence": "",
+    }
+    for k, v in keys_and_defaults.items():
+        if not repo.key_exists(k):
+            s = UserSetting()
+            s.key = k
+            s.value = v
+            session.add(s)
+    session.commit()
+
+    # Revise the mecab path if necessary.
+    # Note this is done _after_ the defaults are loaded,
+    # because the user may have already loaded the defaults
+    # (e.g. on machine upgrade) and stored them in the db,
+    # so we may have to _update_ the existing setting.
+    revised_mecab_path = _revised_mecab_path(repo)
+    repo.set_value("mecab_path", revised_mecab_path)
+    session.commit()
diff --git a/lute/db/schema/migrations/20241103_change_lastbackup_to_user_setting.sql b/lute/db/schema/migrations/20241103_change_lastbackup_to_user_setting.sql
new file mode 100644
index 00000000..df3463a0
--- /dev/null
+++ b/lute/db/schema/migrations/20241103_change_lastbackup_to_user_setting.sql
@@ -0,0 +1,3 @@
+-- Change lastbackup to from system to user key.
+
+update settings set StKeyType = 'user' where StKey = 'lastbackup';
diff --git a/lute/dev_api/routes.py b/lute/dev_api/routes.py
index 3f3016c6..7c5a543a 100644
--- a/lute/dev_api/routes.py
+++ b/lute/dev_api/routes.py
@@ -18,7 +18,7 @@
 from sqlalchemy import text
 from flask import Blueprint, current_app, Response, jsonify, redirect, flash
 from lute.models.language import Language
-from lute.models.setting import UserSetting
+from lute.models.repositories import UserSettingRepository
 import lute.parse.registry
 from lute.db import db
 import lute.db.management
@@ -38,7 +38,7 @@ def _ensure_is_test_db():
 @bp.route("/wipe_db", methods=["GET"])
 def wipe_db():
     "Clean it all."
-    lute.db.management.delete_all_data()
+    lute.db.management.delete_all_data(db.session)
     flash("db wiped")
     return redirect("/", 302)
 
@@ -46,8 +46,8 @@ def wipe_db():
 @bp.route("/load_demo", methods=["GET"])
 def load_demo():
     "Clean out everything, and load the demo."
-    lute.db.management.delete_all_data()
-    lute.db.demo.load_demo_data()
+    lute.db.management.delete_all_data(db.session)
+    lute.db.demo.load_demo_data(db.session)
     flash("demo loaded")
     return redirect("/", 302)
 
@@ -55,8 +55,8 @@ def load_demo():
 @bp.route("/load_demo_languages", methods=["GET"])
 def load_demo_languages():
     "Clean out everything, and load the demo langs with dummy dictionaries."
-    lute.db.management.delete_all_data()
-    lute.db.demo.load_demo_languages()
+    lute.db.management.delete_all_data(db.session)
+    lute.db.demo.load_demo_languages(db.session)
     langs = db.session.query(Language).all()
     for lang in langs:
         d = lang.dictionaries[0]
@@ -70,7 +70,7 @@ def load_demo_languages():
 @bp.route("/load_demo_stories", methods=["GET"])
 def load_demo_stories():
     "Stories only.  No db wipe."
-    lute.db.demo.load_demo_stories()
+    lute.db.demo.load_demo_stories(db.session)
     flash("stories loaded")
     return redirect("/", 302)
 
@@ -148,7 +148,8 @@ def disable_parser(parsername, renameto):
 @bp.route("/disable_backup", methods=["GET"])
 def disable_backup():
     "Disables backup -- tests don't need to back up."
-    UserSetting.set_value("backup_enabled", False)
+    repo = UserSettingRepository(db.session)
+    repo.set_value("backup_enabled", False)
     db.session.commit()
     flash("backup disabled")
     return redirect("/", 302)
diff --git a/lute/language/routes.py b/lute/language/routes.py
index c877f98d..0eecbd5d 100644
--- a/lute/language/routes.py
+++ b/lute/language/routes.py
@@ -6,8 +6,8 @@
 from sqlalchemy.exc import IntegrityError
 from flask import Blueprint, current_app, render_template, redirect, url_for, flash
 from lute.models.language import Language
-from lute.models.setting import UserSetting
-import lute.language.service
+from lute.models.repositories import LanguageRepository, UserSettingRepository
+from lute.language.service import Service
 from lute.language.forms import LanguageForm
 from lute.db import db
 from lute.parse.registry import supported_parsers
@@ -116,7 +116,8 @@ def new(langname):
     """
     Create a new language.
     """
-    predefined = lute.language.service.predefined_languages()
+    service = Service(db.session)
+    predefined = service.predefined_languages()
     language = Language()
     if langname is not None:
         candidates = [lang for lang in predefined if lang.name == langname]
@@ -135,7 +136,8 @@ def new(langname):
         # adds language Y, the filter stays on X, which may be
         # disconcerting/confusing.  Forcing a reselect is painless and
         # unambiguous.
-        UserSetting.set_value("current_language_id", 0)
+        repo = UserSettingRepository(db.session)
+        repo.set_value("current_language_id", 0)
         db.session.commit()
         return redirect("/")
 
@@ -154,14 +156,16 @@ def delete(langid):
     language = db.session.get(Language, langid)
     if not language:
         flash(f"Language {langid} not found")
-    Language.delete(language)
+    r = LanguageRepository(db.session)
+    r.delete(language)
     return redirect(url_for("language.index"))
 
 
 @bp.route("/list_predefined", methods=["GET"])
 def list_predefined():
     "Show predefined languages that are not already in the db."
-    predefined = lute.language.service.predefined_languages()
+    service = Service(db.session)
+    predefined = service.predefined_languages()
     existing_langs = db.session.query(Language).all()
     existing_names = [l.name for l in existing_langs]
     new_langs = [p for p in predefined if p.name not in existing_names]
@@ -171,8 +175,10 @@ def list_predefined():
 @bp.route("/load_predefined/<langname>", methods=["GET"])
 def load_predefined(langname):
     "Load a predefined language and its stories."
-    lang_id = lute.language.service.load_language_def(langname)
-    UserSetting.set_value("current_language_id", lang_id)
+    service = Service(db.session)
+    lang_id = service.load_language_def(langname)
+    repo = UserSettingRepository(db.session)
+    repo.set_value("current_language_id", lang_id)
     db.session.commit()
     flash(f"Loaded {langname} and sample book(s)")
     return redirect("/")
diff --git a/lute/language/service.py b/lute/language/service.py
index 7c5231bb..744a3269 100644
--- a/lute/language/service.py
+++ b/lute/language/service.py
@@ -6,75 +6,75 @@
 import yaml
 from lute.models.language import Language
 from lute.book.model import Book, Repository
-from lute.db import db
 
 
-def get_supported_defs():
-    "Return supported language definitions."
-    ret = []
-    def_glob = os.path.join(_language_defs_path(), "**", "definition.yaml")
-    for f in glob(def_glob):
-        lang = None
-        with open(f, "r", encoding="utf-8") as df:
-            d = yaml.safe_load(df)
-            lang = Language.from_dict(d)
-        if lang.is_supported:
-            entry = {"language": lang, "books": _get_books(f, lang.name)}
-            ret.append(entry)
-    ret.sort(key=lambda x: x["language"].name)
-    return ret
+class Service:
+    "Service."
 
+    def __init__(self, session):
+        self.session = session
 
-def predefined_languages():
-    "Languages defined in yaml files."
-    return [d["language"] for d in get_supported_defs()]
+    def get_supported_defs(self):
+        "Return supported language definitions."
+        ret = []
+        def_glob = os.path.join(self._language_defs_path(), "**", "definition.yaml")
+        for f in glob(def_glob):
+            lang = None
+            with open(f, "r", encoding="utf-8") as df:
+                d = yaml.safe_load(df)
+                lang = Language.from_dict(d)
+            if lang.is_supported:
+                entry = {"language": lang, "books": self._get_books(f, lang.name)}
+                ret.append(entry)
+        ret.sort(key=lambda x: x["language"].name)
+        return ret
 
+    def predefined_languages(self):
+        "Languages defined in yaml files."
+        return [d["language"] for d in self.get_supported_defs()]
 
-def _get_books(lang_definition_filename, lang_name):
-    "Get the stories in the same directory as the definition.yaml."
-    books = []
-    d, f = os.path.split(lang_definition_filename)
-    story_glob = os.path.join(d, "*.txt")
-    for filename in glob(story_glob):
-        with open(filename, "r", encoding="utf-8") as f:
-            content = f.read()
-        title_match = re.search(r"title:\s*(.*)\n", content)
-        title = title_match.group(1).strip()
-        content = re.sub(r"#.*\n", "", content)
-        b = Book()
-        b.language_name = lang_name
-        b.title = title
-        b.text = content
-        books.append(b)
-    return books
+    def _get_books(self, lang_definition_filename, lang_name):
+        "Get the stories in the same directory as the definition.yaml."
+        books = []
+        d, f = os.path.split(lang_definition_filename)
+        story_glob = os.path.join(d, "*.txt")
+        for filename in glob(story_glob):
+            with open(filename, "r", encoding="utf-8") as f:
+                content = f.read()
+            title_match = re.search(r"title:\s*(.*)\n", content)
+            title = title_match.group(1).strip()
+            content = re.sub(r"#.*\n", "", content)
+            b = Book()
+            b.language_name = lang_name
+            b.title = title
+            b.text = content
+            books.append(b)
+        return books
 
+    def get_language_def(self, lang_name):
+        "Get a lang def and its stories."
+        defs = self.get_supported_defs()
+        ret = [d for d in defs if d["language"].name == lang_name]
+        if len(ret) == 0:
+            raise RuntimeError(f"Missing language def name {lang_name}")
+        return ret[0]
 
-def get_language_def(lang_name):
-    "Get a lang def and its stories."
-    defs = get_supported_defs()
-    ret = [d for d in defs if d["language"].name == lang_name]
-    if len(ret) == 0:
-        raise RuntimeError(f"Missing language def name {lang_name}")
-    return ret[0]
+    def load_language_def(self, lang_name):
+        "Load a language def and its stories, save to database."
+        load_def = self.get_language_def(lang_name)
+        lang = load_def["language"]
+        self.session.add(lang)
+        self.session.commit()
 
+        r = Repository(self.session)
+        for b in load_def["books"]:
+            r.add(b)
+        r.commit()
 
-def load_language_def(lang_name):
-    "Load a language def and its stories, save to database."
-    load_def = get_language_def(lang_name)
-    lang = load_def["language"]
-    db.session.add(lang)
-    db.session.commit()
+        return lang.id
 
-    r = Repository(db)
-    for b in load_def["books"]:
-        r.add(b)
-    r.commit()
-
-    return lang.id
-
-
-def _language_defs_path():
-    "Path to the definitions and stories."
-    thisdir = os.path.dirname(__file__)
-    d = os.path.join(thisdir, "..", "db", "language_defs")
-    return os.path.abspath(d)
+    def _language_defs_path(self):
+        "Path to the definitions and stories."
+        thisdir = os.path.dirname(__file__)
+        d = os.path.join(thisdir, "..", "db", "language_defs")
+        return os.path.abspath(d)
diff --git a/lute/models/book.py b/lute/models/book.py
index b25a7de4..da75bba0 100644
--- a/lute/models/book.py
+++ b/lute/models/book.py
@@ -2,7 +2,6 @@
 Book entity.
 """
 
-from sqlalchemy import and_
 from lute.db import db
 from lute.parse.base import SentenceGroupIterator
 
@@ -30,19 +29,6 @@ def make_book_tag(text, comment=""):
         tt.comment = comment
         return tt
 
-    @staticmethod
-    def find_by_text(text):
-        "Find a tag by text, or None if not found."
-        return db.session.query(BookTag).filter(BookTag.text == text).first()
-
-    @staticmethod
-    def find_or_create_by_text(text):
-        "Return tag or create one."
-        ret = BookTag.find_by_text(text)
-        if ret is not None:
-            return ret
-        return BookTag.make_book_tag(text)
-
 
 class Book(
     db.Model
@@ -194,20 +180,6 @@ def split_text_at_page_breaks(txt):
 
         return b
 
-    @staticmethod
-    def find(book_id):
-        "Get by ID."
-        return db.session.query(Book).filter(Book.id == book_id).first()
-
-    @staticmethod
-    def find_by_title(book_title, language_id):
-        "Get by title."
-        return (
-            db.session.query(Book)
-            .filter(and_(Book.title == book_title, Book.language_id == language_id))
-            .first()
-        )
-
 
 # TODO zzfuture fix: rename class and table to Page/pages
 class Text(db.Model):
@@ -321,11 +293,6 @@ def _remove_sentences(self):
             sentence.text = None
         self.sentences = []
 
-    @staticmethod
-    def find(text_id):
-        "Get by ID."
-        return db.session.query(Text).filter(Text.id == text_id).first()
-
 
 class Sentence(db.Model):
     """
@@ -390,3 +357,14 @@ class TextBookmark(db.Model):
     title = db.Column("TbTitle", db.Text, nullable=False)
 
     text = db.relationship("Text", back_populates="bookmarks")
+
+
+class BookStats(db.Model):
+    "The stats table."
+    __tablename__ = "bookstats"
+
+    BkID = db.Column(db.Integer, primary_key=True)
+    distinctterms = db.Column(db.Integer)
+    distinctunknowns = db.Column(db.Integer)
+    unknownpercent = db.Column(db.Integer)
+    status_distribution = db.Column(db.String, nullable=True)
diff --git a/lute/models/language.py b/lute/models/language.py
index 3da47b3e..a85d0d23 100644
--- a/lute/models/language.py
+++ b/lute/models/language.py
@@ -3,7 +3,6 @@
 """
 
 import re
-from sqlalchemy import text, func
 from lute.db import db
 from lute.parse.registry import get_parser, is_supported
 
@@ -127,30 +126,6 @@ def all_dictionaries(cls):
             }
         return lang_dicts
 
-    @staticmethod
-    def delete(language):
-        """
-        Hacky method to delete language and all terms, books, and dicts
-        associated with it.
-
-        There is _certainly_ a better way to do this using
-        Sqlalchemy relationships and cascade deletes, but I
-        was running into problems with it (things not cascading,
-        or warnings ("SAWarning: Object of type <Term> not in
-        session, add operation along 'Language.terms' will not
-        proceed") during test runs.  It would be nice to have
-        a "correct" mapping, but this is good enough for now.
-
-        TODO zzfuture fix: fix Language-Book and -Term mappings.
-        """
-        sqls = [
-            "pragma foreign_keys = ON",
-            f"delete from languages where LgID = {language.id}",
-        ]
-        for s in sqls:
-            db.session.execute(text(s))
-        db.session.commit()
-
     @property
     def parser(self):
         "Note: this throws if the parser is not supported!!!"
@@ -167,20 +142,6 @@ def get_parsed_tokens(self, s):
     def get_lowercase(self, s) -> str:
         return self.parser.get_lowercase(s)
 
-    @staticmethod
-    def find(language_id):
-        "Get by ID."
-        return db.session.query(Language).filter(Language.id == language_id).first()
-
-    @staticmethod
-    def find_by_name(name):
-        "Get by name."
-        return (
-            db.session.query(Language)
-            .filter(func.lower(Language.name) == func.lower(name))
-            .first()
-        )
-
     def to_dict(self):
         "Return dictionary of data, for serialization."
         ret = {}
diff --git a/lute/models/repositories.py b/lute/models/repositories.py
new file mode 100644
index 00000000..271d9bc0
--- /dev/null
+++ b/lute/models/repositories.py
@@ -0,0 +1,253 @@
+"""
+Repositories.
+"""
+
+from sqlalchemy import text as sqltext, and_, func
+from lute.db import db
+from lute.models.setting import UserSetting, BackupSettings, SystemSetting
+from lute.models.language import Language
+from lute.models.term import Term, TermTag
+from lute.models.book import Book, BookTag
+
+
+class SettingRepositoryBase:
+    "Repository."
+
+    def __init__(self, session, classtype):
+        self.session = session
+        self.classtype = classtype
+
+    def key_exists_precheck(self, keyname):
+        """
+        Check key validity for certain actions.
+        """
+
+    def set_value(self, keyname, keyvalue):
+        "Set, but don't save, a setting."
+        self.key_exists_precheck(keyname)
+        s = (
+            self.session.query(self.classtype)
+            .filter(self.classtype.key == keyname)
+            .first()
+        )
+        if s is None:
+            s = self.classtype()
+            s.key = keyname
+        s.value = keyvalue
+        self.session.add(s)
+
+    def key_exists(self, keyname):
+        "True if exists."
+        s = (
+            self.session.query(self.classtype)
+            .filter(self.classtype.key == keyname)
+            .first()
+        )
+        no_key = s is None
+        return not no_key
+
+    def get_value(self, keyname):
+        "Get the saved key, or None if it doesn't exist."
+        self.key_exists_precheck(keyname)
+        s = (
+            self.session.query(self.classtype)
+            .filter(self.classtype.key == keyname)
+            .first()
+        )
+        if s is None:
+            return None
+        return s.value
+
+    def delete_key(self, keyname):
+        "Delete a key."
+        s = (
+            self.session.query(self.classtype)
+            .filter(self.classtype.key == keyname)
+            .first()
+        )
+        if s is not None:
+            self.session.delete(s)
+
+
+class MissingUserSettingKeyException(Exception):
+    """
+    Cannot set or get unknown user keys.
+    """
+
+
+class UserSettingRepository(SettingRepositoryBase):
+    "Repository."
+
+    def __init__(self, session):
+        super().__init__(session, UserSetting)
+
+    def key_exists_precheck(self, keyname):
+        """
+        User keys must exist.
+        """
+        if not self.key_exists(keyname):
+            raise MissingUserSettingKeyException(keyname)
+
+    def get_backup_settings(self):
+        "Convenience method."
+        bs = BackupSettings()
+
+        def _bool(v):
+            return v in (1, "1", "y", True)
+
+        bs.backup_enabled = _bool(self.get_value("backup_enabled"))
+        bs.backup_auto = _bool(self.get_value("backup_auto"))
+        bs.backup_warn = _bool(self.get_value("backup_warn"))
+        bs.backup_dir = self.get_value("backup_dir")
+        bs.backup_count = int(self.get_value("backup_count") or 5)
+        bs.last_backup_datetime = self.get_last_backup_datetime()
+        return bs
+
+    def get_last_backup_datetime(self):
+        "Get the last_backup_datetime as int, or None."
+        v = self.get_value("lastbackup")
+        if v is None:
+            return None
+        return int(v)
+
+    def set_last_backup_datetime(self, v):
+        "Set and save the last backup time."
+        self.set_value("lastbackup", v)
+        self.session.commit()
+
+
+class SystemSettingRepository(SettingRepositoryBase):
+    "Repository."
+
+    def __init__(self, session):
+        super().__init__(session, SystemSetting)
+
+
+class LanguageRepository:
+    "Repository."
+
+    def __init__(self, session):
+        self.session = session
+
+    def find(self, language_id):
+        "Get by ID."
+        return self.session.query(Language).filter(Language.id == language_id).first()
+
+    def find_by_name(self, name):
+        "Get by name."
+        return (
+            self.session.query(Language)
+            .filter(func.lower(Language.name) == func.lower(name))
+            .first()
+        )
+
+    def delete(self, language):
+        """
+        Hacky method to delete language and all terms, books, and dicts
+        associated with it.
+
+        There is _certainly_ a better way to do this using
+        Sqlalchemy relationships and cascade deletes, but I
+        was running into problems with it (things not cascading,
+        or warnings ("SAWarning: Object of type <Term> not in
+        session, add operation along 'Language.terms' will not
+        proceed") during test runs.  It would be nice to have
+        a "correct" mapping, but this is good enough for now.
+
+        TODO zzfuture fix: fix Language-Book and -Term mappings.
+        """
+        sqls = [
+            "pragma foreign_keys = ON",
+            f"delete from languages where LgID = {language.id}",
+        ]
+        for s in sqls:
+            self.session.execute(sqltext(s))
+        self.session.commit()
+
+
+class TermTagRepository:
+    "Repository."
+
+    def __init__(self, session):
+        self.session = session
+
+    def find(self, termtag_id):
+        "Get by ID."
+        return self.session.query(TermTag).filter(TermTag.id == termtag_id).first()
+
+    def find_by_text(self, text):
+        "Find a tag by text, or None if not found."
+        return self.session.query(TermTag).filter(TermTag.text == text).first()
+
+    def find_or_create_by_text(self, text):
+        "Return tag or create one."
+        ret = self.find_by_text(text)
+        if ret is not None:
+            return ret
+        return TermTag(text)
+
+
+class TermRepository:
+    "Repository."
+
+    def __init__(self, session):
+        self.session = session
+
+    def find_by_spec(self, spec):
+        """
+        Find by the given spec term's language ID and text.
+        Returns None if not found.
+        """
+        langid = spec.language.id
+        text_lc = spec.text_lc
+        query = self.session.query(Term).filter(
+            and_(Term.language_id == langid, Term.text_lc == text_lc)
+        )
+        terms = query.all()
+        if not terms:
+            return None
+        return terms[0]
+
+    def delete_empty_images(self):
+        """
+        Data clean-up: delete empty images.
+
+        The code was leaving empty images in the db, which are obviously no good.
+        This is a hack to clean up the data.
+        """
+        sql = "delete from wordimages where trim(WiSource) = ''"
+        self.session.execute(sqltext(sql))
+        self.session.commit()
+
+
+class BookTagRepository:
+    "Repository."
+
+    def __init__(self, session):
+        self.session = session
+
+    def find_or_create_by_text(self, text):
+        "Return tag or create one."
+        ret = db.session.query(BookTag).filter(BookTag.text == text).first()
+        if ret is not None:
+            return ret
+        return BookTag.make_book_tag(text)
+
+
+class BookRepository:
+    "Repository."
+
+    def __init__(self, session):
+        self.session = session
+
+    def find(self, book_id):
+        "Get by ID."
+        return self.session.query(Book).filter(Book.id == book_id).first()
+
+    def find_by_title(self, book_title, language_id):
+        "Get by title."
+        return (
+            self.session.query(Book)
+            .filter(and_(Book.title == book_title, Book.language_id == language_id))
+            .first()
+        )
diff --git a/lute/models/setting.py b/lute/models/setting.py
index 118363cc..dcb573d4 100644
--- a/lute/models/setting.py
+++ b/lute/models/setting.py
@@ -2,10 +2,8 @@
 Lute settings, in settings key-value table.
 """
 
-import os
 import datetime
 import time
-from flask import current_app
 from lute.db import db
 
 
@@ -23,237 +21,12 @@ class SettingBase(db.Model):
     value = db.Column("StValue", db.String, nullable=False)
     __mapper_args__ = {"polymorphic_on": keytype}
 
-    @classmethod
-    def key_exists_precheck(cls, keyname):
-        """
-        Check key validity for certain actions.
-        """
-
-    @classmethod
-    def set_value_post(cls, keyname, keyvalue):
-        """
-        Post-setting value for certain keys."
-        """
-
-    @classmethod
-    def set_value(cls, keyname, keyvalue):
-        "Set, but don't save, a setting."
-        cls.key_exists_precheck(keyname)
-        s = db.session.query(cls).filter(cls.key == keyname).first()
-        if s is None:
-            s = cls()
-            s.key = keyname
-        s.value = keyvalue
-        db.session.add(s)
-        cls.set_value_post(keyname, keyvalue)
-
-    @classmethod
-    def key_exists(cls, keyname):
-        "True if exists."
-        s = db.session.query(cls).filter(cls.key == keyname).first()
-        no_key = s is None
-        return not no_key
-
-    @classmethod
-    def get_value(cls, keyname):
-        "Get the saved key, or None if it doesn't exist."
-        cls.key_exists_precheck(keyname)
-        s = db.session.query(cls).filter(cls.key == keyname).first()
-        if s is None:
-            return None
-        return s.value
-
-    @classmethod
-    def delete_key(cls, keyname):
-        "Delete a key."
-        s = db.session.query(cls).filter(cls.key == keyname).first()
-        if s is not None:
-            db.session.delete(s)
-
-
-class MissingUserSettingKeyException(Exception):
-    """
-    Cannot set or get unknown user keys.
-    """
-
 
 class UserSetting(SettingBase):
     "User setting."
     __tablename__ = None
     __mapper_args__ = {"polymorphic_identity": "user"}
 
-    @classmethod
-    def key_exists_precheck(cls, keyname):
-        """
-        User keys must exist.
-        """
-        if not UserSetting.key_exists(keyname):
-            raise MissingUserSettingKeyException(keyname)
-
-    @classmethod
-    def set_value_post(cls, keyname, keyvalue):
-        """
-        Setting some keys runs other code.
-        """
-        if keyname == "mecab_path":
-            mp = "MECAB_PATH"
-            if keyvalue is None or keyvalue == "":
-                if mp in os.environ:
-                    del os.environ[mp]
-            else:
-                os.environ[mp] = keyvalue.strip()
-
-    @staticmethod
-    def _revised_mecab_path():
-        """
-        Change the mecab_path if it's not found, and a
-        replacement is found.
-
-        Lute Docker images are built to be multi-arch, and
-        interestingly (annoyingly), mecab libraries are installed into
-        different locations depending on the architecture, even with
-        the same Dockerfile and base image.
-
-        Returns: new mecab path if old one is missing _and_
-        new one found, otherwise just return the old one.
-        """
-
-        mp = UserSetting.get_value("mecab_path")
-        if mp is not None and os.path.exists(mp):
-            return mp
-
-        # See develop docs for notes on how to find the libmecab path!
-        candidates = [
-            # linux/arm64
-            "/lib/aarch64-linux-gnu/libmecab.so.2",
-            # linux/amd64
-            "/lib/x86_64-linux-gnu/libmecab.so.2",
-            # github CI, ubuntu-latest
-            "/lib/x86_64-linux-gnu/libmecab.so.2",
-        ]
-        replacements = [p for p in candidates if os.path.exists(p)]
-        if len(replacements) > 0:
-            return replacements[0]
-        # Replacement not found, leave current value as-is.
-        return mp
-
-    @staticmethod
-    def load():
-        """
-        Load missing user settings with default values.
-        """
-        app_config = current_app.env_config
-
-        # These keys are rendered into the global javascript namespace var
-        # LUTE_USER_SETTINGS, so if any of these keys change, check the usage
-        # of that variable as well.
-        keys_and_defaults = {
-            "backup_enabled": True,
-            "backup_auto": True,
-            "backup_warn": True,
-            "backup_dir": app_config.default_user_backup_path,
-            "backup_count": 5,
-            "mecab_path": None,
-            "japanese_reading": "hiragana",
-            "current_theme": "-",
-            "custom_styles": "/* Custom css to modify Lute's appearance. */",
-            "show_highlights": True,
-            "current_language_id": 0,
-            # Behaviour:
-            "open_popup_in_new_tab": False,
-            "stop_audio_on_term_form_open": True,
-            "stats_calc_sample_size": 5,
-            # Keyboard shortcuts.  These have default values assigned
-            # as they were the hotkeys defined in the initial Lute
-            # release.
-            "hotkey_StartHover": "escape",
-            "hotkey_PrevWord": "arrowleft",
-            "hotkey_NextWord": "arrowright",
-            "hotkey_StatusUp": "arrowup",
-            "hotkey_StatusDown": "arrowdown",
-            "hotkey_Bookmark": "b",
-            "hotkey_CopySentence": "c",
-            "hotkey_CopyPara": "shift+c",
-            "hotkey_TranslateSentence": "t",
-            "hotkey_TranslatePara": "shift+t",
-            "hotkey_NextTheme": "m",
-            "hotkey_ToggleHighlight": "h",
-            "hotkey_ToggleFocus": "f",
-            "hotkey_Status1": "1",
-            "hotkey_Status2": "2",
-            "hotkey_Status3": "3",
-            "hotkey_Status4": "4",
-            "hotkey_Status5": "5",
-            "hotkey_StatusIgnore": "i",
-            "hotkey_StatusWellKnown": "w",
-            # New hotkeys.  These must have empty values, because
-            # users may have already setup their hotkeys, and we can't
-            # assume that a given key combination is free:
-            "hotkey_CopyPage": "",
-            "hotkey_DeleteTerm": "",
-            "hotkey_EditPage": "",
-            "hotkey_TranslatePage": "",
-            "hotkey_PrevUnknownWord": "",
-            "hotkey_NextUnknownWord": "",
-            "hotkey_PrevSentence": "",
-            "hotkey_NextSentence": "",
-        }
-        for k, v in keys_and_defaults.items():
-            if not UserSetting.key_exists(k):
-                s = UserSetting()
-                s.key = k
-                s.value = v
-                db.session.add(s)
-        db.session.commit()
-
-        # Revise the mecab path if necessary.
-        # Note this is done _after_ the defaults are loaded,
-        # because the user may have already loaded the defaults
-        # (e.g. on machine upgrade) and stored them in the db,
-        # so we may have to _update_ the existing setting.
-        revised_mecab_path = UserSetting._revised_mecab_path()
-        UserSetting.set_value("mecab_path", revised_mecab_path)
-        db.session.commit()
-
-    @staticmethod
-    def all_settings():
-        """
-        Get dict of all settings, for rendering into Javascript global space.
-        """
-        settings = db.session.query(UserSetting).all()
-        ret = {}
-        for s in settings:
-            ret[s.key] = s.value
-
-        # Convert some ints into bools.
-        boolkeys = ["open_popup_in_new_tab", "stop_audio_on_term_form_open"]
-        for k in boolkeys:
-            ret[k] = ret[k] == "1"
-
-        return ret
-
-
-class SystemSetting(SettingBase):
-    "System setting."
-    __tablename__ = None
-    __mapper_args__ = {"polymorphic_identity": "system"}
-
-    # Helpers for certain sys settings.
-
-    @classmethod
-    def get_last_backup_datetime(cls):
-        "Get the last_backup_datetime as int, or None."
-        v = cls.get_value("lastbackup")
-        if v is None:
-            return None
-        return int(v)
-
-    @classmethod
-    def set_last_backup_datetime(cls, v):
-        "Set and save the last backup time."
-        cls.set_value("lastbackup", v)
-        db.session.commit()
-
 
 class BackupSettings:
     """
@@ -262,16 +35,12 @@ class BackupSettings:
     """
 
     def __init__(self):
-        def _bool(k):
-            v = UserSetting.get_value(k)
-            return v in (1, "1", "y", True)
-
-        self.backup_enabled = _bool("backup_enabled")
-        self.backup_auto = _bool("backup_auto")
-        self.backup_warn = _bool("backup_warn")
-        self.backup_dir = UserSetting.get_value("backup_dir")
-        self.backup_count = int(UserSetting.get_value("backup_count") or 5)
-        self.last_backup_datetime = SystemSetting.get_last_backup_datetime()
+        self.backup_enabled = None
+        self.backup_auto = None
+        self.backup_warn = None
+        self.backup_dir = None
+        self.backup_count = None
+        self.last_backup_datetime = None
 
     @property
     def last_backup_display_date(self):
@@ -281,11 +50,6 @@ def last_backup_display_date(self):
             return None
         return datetime.datetime.fromtimestamp(t).strftime("%Y-%m-%d %H:%M:%S")
 
-    @staticmethod
-    def get_backup_settings():
-        "Get BackupSettings."
-        return BackupSettings()
-
     @property
     def time_since_last_backup(self):
         """
@@ -320,3 +84,9 @@ def time_since_last_backup(self):
             message = f"{abs(delta)} seconds"
 
         return message + " ago"
+
+
+class SystemSetting(SettingBase):
+    "System setting."
+    __tablename__ = None
+    __mapper_args__ = {"polymorphic_identity": "system"}
diff --git a/lute/models/term.py b/lute/models/term.py
index 2204828f..1d284b44 100644
--- a/lute/models/term.py
+++ b/lute/models/term.py
@@ -2,7 +2,6 @@
 Term entity.
 """
 
-from sqlalchemy import text as sqltext, and_
 from lute.db import db
 
 wordparents = db.Table(
@@ -72,24 +71,6 @@ def comment(self, c):
         "Set cleaned comment."
         self._comment = c if c is not None else ""
 
-    @staticmethod
-    def find(termtag_id):
-        "Get by ID."
-        return db.session.query(TermTag).filter(TermTag.id == termtag_id).first()
-
-    @staticmethod
-    def find_by_text(text):
-        "Find a tag by text, or None if not found."
-        return db.session.query(TermTag).filter(TermTag.text == text).first()
-
-    @staticmethod
-    def find_or_create_by_text(text):
-        "Return tag or create one."
-        ret = TermTag.find_by_text(text)
-        if ret is not None:
-            return ret
-        return TermTag(text)
-
 
 class TermTextChangedException(Exception):
     """
@@ -309,18 +290,6 @@ def set_current_image(self, s):
             ti.source = s.strip()
             self.images.append(ti)
 
-    @staticmethod
-    def delete_empty_images():
-        """
-        Data clean-up: delete empty images.
-
-        The code was leaving empty images in the db, which are obviously no good.
-        This is a hack to clean up the data.
-        """
-        sql = "delete from wordimages where trim(WiSource) = ''"
-        db.session.execute(sqltext(sql))
-        db.session.commit()
-
     def get_flash_message(self):
         "Get the flash message."
         if not self.term_flash_message:
@@ -343,27 +312,6 @@ def pop_flash_message(self):
         self.term_flash_message = None
         return m
 
-    @staticmethod
-    def find(term_id):
-        "Get by ID."
-        return db.session.query(Term).filter(Term.id == term_id).first()
-
-    @staticmethod
-    def find_by_spec(spec):
-        """
-        Find by the given spec term's language ID and text.
-        Returns None if not found.
-        """
-        langid = spec.language.id
-        text_lc = spec.text_lc
-        query = db.session.query(Term).filter(
-            and_(Term.language_id == langid, Term.text_lc == text_lc)
-        )
-        terms = query.all()
-        if not terms:
-            return None
-        return terms[0]
-
 
 class Status(db.Model):  # pylint: disable=too-few-public-methods
     """
@@ -379,8 +327,3 @@ class Status(db.Model):  # pylint: disable=too-few-public-methods
     id = db.Column("StID", db.SmallInteger, primary_key=True)
     text = db.Column("StText", db.String(250))
     abbreviation = db.Column("StAbbreviation", db.String(250))
-
-    @staticmethod
-    def all():
-        "Get all statuses."
-        return db.session.query(Status).all()
diff --git a/lute/parse/mecab_parser.py b/lute/parse/mecab_parser.py
index dbe0b0b8..b3faf8a1 100644
--- a/lute/parse/mecab_parser.py
+++ b/lute/parse/mecab_parser.py
@@ -18,7 +18,7 @@
 from natto import MeCab
 import jaconv
 from lute.parse.base import ParsedToken, AbstractParser
-from lute.models.setting import UserSetting, MissingUserSettingKeyException
+from lute.settings.current import current_settings
 
 
 class JapaneseParser(AbstractParser):
@@ -30,7 +30,7 @@ class JapaneseParser(AbstractParser):
     The parser uses natto-py library, and so should
     be able to find mecab automatically; if it can't,
     you may need to set the MECAB_PATH env variable,
-    managed by UserSetting.set_value("mecab_path", p)
+    managed by UserSettingRepository.set_value("mecab_path", p)
     """
 
     _is_supported = None
@@ -42,13 +42,21 @@ def is_supported(cls):
         True if a natto MeCab can be instantiated,
         otherwise false.
         """
-        mecab_path = os.environ.get("MECAB_PATH", "<NOTSET>")
-        if (
-            mecab_path == JapaneseParser._old_mecab_path
-        ) and JapaneseParser._is_supported is not None:
+
+        mecab_path = current_settings.get("mecab_path", "") or ""
+        mecab_path = mecab_path.strip()
+        path_unchanged = mecab_path == JapaneseParser._old_mecab_path
+        if path_unchanged and JapaneseParser._is_supported is not None:
             return JapaneseParser._is_supported
 
-        b = False
+        # Natto uses the MECAB_PATH env key if it's set.
+        env_key = "MECAB_PATH"
+        if mecab_path != "":
+            os.environ[env_key] = mecab_path
+        else:
+            os.environ.pop(env_key, None)
+
+        mecab_works = False
 
         # Calling MeCab() prints to stderr even if the
         # exception is caught.  Suppress that output noise.
@@ -56,15 +64,15 @@ def is_supported(cls):
         try:
             sys.stderr = temp_err
             MeCab()
-            b = True
+            mecab_works = True
         except:  # pylint: disable=bare-except
-            b = False
+            mecab_works = False
         finally:
             sys.stderr = sys.__stderr__
 
         JapaneseParser._old_mecab_path = mecab_path
-        JapaneseParser._is_supported = b
-        return b
+        JapaneseParser._is_supported = mecab_works
+        return mecab_works
 
     @classmethod
     def name(cls):
@@ -135,13 +143,9 @@ def get_reading(self, text: str):
         if self._string_is_hiragana(text):
             return None
 
-        jp_reading_setting = ""
-        try:
-            jp_reading_setting = UserSetting.get_value("japanese_reading")
-        except MissingUserSettingKeyException:
-            # During loading of demo data, the key isn't set, but the
-            # reading isn't needed either, as this is only called when
-            # calculating stats.
+        jp_reading_setting = current_settings.get("japanese_reading", "").strip()
+        if jp_reading_setting == "":
+            # Don't set reading if nothing specified.
             return None
 
         flags = r"-O yomi"
diff --git a/lute/read/render/service.py b/lute/read/render/service.py
index 3216d8e8..dafaf584 100644
--- a/lute/read/render/service.py
+++ b/lute/read/render/service.py
@@ -10,209 +10,209 @@
 from lute.parse.base import ParsedToken
 from lute.read.render.calculate_textitems import get_textitems as calc_get_textitems
 from lute.read.render.multiword_indexer import MultiwordTermIndexer
-from lute.db import db
 
 # from lute.utils.debug_helpers import DebugTimer
 
 
-def find_all_Terms_in_string(s, language):  # pylint: disable=too-many-locals
-    """
-    Find all terms contained in the string s.
+class Service:
+    "Service."
 
-    For example
-    - given s = "Here is a cat"
-    - given terms in the db: [ "cat", "a cat", "dog" ]
+    def __init__(self, session):
+        self.session = session
 
-    This would return the terms "cat" and "a cat".
-    """
-    cleaned = re.sub(r" +", " ", s)
-    tokens = language.get_parsed_tokens(cleaned)
-    return _find_all_terms_in_tokens(tokens, language)
+    def find_all_Terms_in_string(self, s, language):  # pylint: disable=too-many-locals
+        """
+        Find all terms contained in the string s.
+
+        For example
+        - given s = "Here is a cat"
+        - given terms in the db: [ "cat", "a cat", "dog" ]
+
+        This would return the terms "cat" and "a cat".
+        """
+        cleaned = re.sub(r" +", " ", s)
+        tokens = language.get_parsed_tokens(cleaned)
+        return self._find_all_terms_in_tokens(tokens, language)
+
+    def _get_multiword_terms(self, language):
+        "Get all multiword terms."
+        sql = sqltext(
+            """
+            SELECT WoID, WoTextLC FROM words
+            WHERE WoLgID=:language_id and WoTokenCount>1
+            """
+        )
+        sql = sql.bindparams(language_id=language.id)
+        return self.session.execute(sql).all()
+
+    def _find_all_multi_word_term_text_lcs_in_content(self, text_lcs, language):
+        "Find multiword terms, return list of text_lcs."
+
+        # There are a few ways of finding multi-word Terms
+        # (with token_count > 1) in the content:
+        #
+        # 1. load each mword term text_lc via sql and check.
+        # 2. using the model
+        # 3. SQL with "LIKE"
+        #
+        # During reasonable test runs with my data, the times in seconds
+        # for each are similar (~0.02, ~0.05, ~0.025).  This method is
+        # only used for small amounts of data, and the user experience hit
+        # is negligible, so I'll use the first method which IMO is the clearest
+        # code.
+
+        zws = "\u200B"  # zero-width space
+        content = zws + zws.join(text_lcs) + zws
+
+        # Method 1:
+        reclist = self._get_multiword_terms(language)
+        return [p[1] for p in reclist if f"{zws}{p[1]}{zws}" in content]
+
+        ## # Method 2: use the model.
+        ## contained_term_qry = self.session.query(Term).filter(
+        ##     Term.language == language,
+        ##     Term.token_count > 1,
+        ##     func.instr(content, Term.text_lc) > 0,
+        ## )
+        ## return [r.text_lc for r in contained_term_qry.all()]
+
+        ## # Method 3: Query with LIKE
+        ## sql = sqltext(
+        ##     """
+        ##     SELECT WoTextLC FROM words
+        ##     WHERE WoLgID=:lid and WoTokenCount>1
+        ##     AND :content LIKE '%' || :zws || WoTextLC || :zws || '%'
+        ##     """
+        ## )
+        ## sql = sql.bindparams(lid=language.id, content=content, zws=zws)
+        ## recs = self.session.execute(sql).all()
+        ## return [r[0] for r in recs]
+
+    def _find_all_terms_in_tokens(self, tokens, language, kwtree=None):
+        """
+        Find all terms contained in the (ordered) parsed tokens tokens.
+
+        For example
+        - given tokens = "Here", " ", "is", " ", "a", " ", "cat"
+        - given terms in the db: [ "cat", "a/ /cat", "dog" ]
+
+        This would return the terms "cat" and "a/ /cat".
 
+        Method:
+        - build list of lowercase text in the tokens
+        - append all multword term strings that exist in the content
+        - query for Terms that exist in the list
 
-def _get_multiword_terms(language):
-    "Get all multiword terms."
-    sql = sqltext(
+        Note: this method only uses indexes for multiword terms, as any
+        content analyzed is first parsed into tokens before being passed
+        to this routine.  There's no need to search for single-word Terms
+        in the tokenized strings, they can be found by a simple query.
         """
-        SELECT WoID, WoTextLC FROM words
-        WHERE WoLgID=:language_id and WoTokenCount>1
+
+        # Performance: About half of the time in this routine is spent in
+        # Step 1 (finding multiword terms), the rest in step 2 (the actual
+        # query).
+        # dt = DebugTimer("_find_all_terms_in_tokens", display=True)
+
+        parser = language.parser
+        text_lcs = [parser.get_lowercase(t.token) for t in tokens]
+
+        # Step 1: get the multiwords in the content.
+        if kwtree is None:
+            mword_terms = self._find_all_multi_word_term_text_lcs_in_content(
+                text_lcs, language
+            )
+        else:
+            results = kwtree.search_all(text_lcs)
+            mword_terms = [r[0] for r in results]
+        # dt.step("filtered mword terms")
+
+        # Step 2: load the Term objects.
+        #
+        # The Term fetch is actually performant -- there is no
+        # real difference between loading the Term objects versus
+        # loading raw data with SQL and getting dicts.
+        #
+        # Code for getting raw data:
+        # param_keys = [f"w{i}" for i, _ in enumerate(text_lcs)]
+        # keys_placeholders = ','.join([f":{k}" for k in param_keys])
+        # param_dict = dict(zip(param_keys, text_lcs))
+        # param_dict["langid"] = language.id
+        # sql = sqltext(f"""SELECT WoID, WoTextLC FROM words
+        #     WHERE WoLgID=:langid and WoTextLC in ({keys_placeholders})""")
+        # sql = sql.bindparams(language.id, *text_lcs)
+        # results = self.session.execute(sql, param_dict).fetchall()
+        text_lcs.extend(mword_terms)
+        tok_strings = list(set(text_lcs))
+        terms_matching_tokens_qry = self.session.query(Term).filter(
+            Term.text_lc.in_(tok_strings), Term.language == language
+        )
+        all_terms = terms_matching_tokens_qry.all()
+        # dt.step("exec query")
+
+        return all_terms
+
+    ## Getting paragraphs ##############################
+
+    def get_textitems(self, s, language, multiword_term_indexer=None):
+        """
+        Get array of TextItems for the string s.
+
+        The multiword_term_indexer is a big performance boost, but takes
+        time to initialize.
+        """
+        # Hacky reset of state of ParsedToken state.
+        # _Shouldn't_ be needed but doesn't hurt, even if it's lame.
+        ParsedToken.reset_counters()
+
+        cleaned = re.sub(r" +", " ", s)
+        tokens = language.get_parsed_tokens(cleaned)
+        terms = self._find_all_terms_in_tokens(tokens, language, multiword_term_indexer)
+        textitems = calc_get_textitems(tokens, terms, language, multiword_term_indexer)
+        return textitems
+
+    def get_multiword_indexer(self, language):
+        "Return indexer loaded with all multiword terms."
+        mw = MultiwordTermIndexer()
+        for r in self._get_multiword_terms(language):
+            mw.add(r[1])
+        return mw
+
+    def get_paragraphs(self, s, language):
         """
-    )
-    sql = sql.bindparams(language_id=language.id)
-    return db.session.execute(sql).all()
-
-
-def _find_all_multi_word_term_text_lcs_in_content(text_lcs, language):
-    "Find multiword terms, return list of text_lcs."
-
-    # There are a few ways of finding multi-word Terms
-    # (with token_count > 1) in the content:
-    #
-    # 1. load each mword term text_lc via sql and check.
-    # 2. using the model
-    # 3. SQL with "LIKE"
-    #
-    # During reasonable test runs with my data, the times in seconds
-    # for each are similar (~0.02, ~0.05, ~0.025).  This method is
-    # only used for small amounts of data, and the user experience hit
-    # is negligible, so I'll use the first method which IMO is the clearest
-    # code.
-
-    zws = "\u200B"  # zero-width space
-    content = zws + zws.join(text_lcs) + zws
-
-    # Method 1:
-    reclist = _get_multiword_terms(language)
-    return [p[1] for p in reclist if f"{zws}{p[1]}{zws}" in content]
-
-    ## # Method 2: use the model.
-    ## contained_term_qry = db.session.query(Term).filter(
-    ##     Term.language == language,
-    ##     Term.token_count > 1,
-    ##     func.instr(content, Term.text_lc) > 0,
-    ## )
-    ## return [r.text_lc for r in contained_term_qry.all()]
-
-    ## # Method 3: Query with LIKE
-    ## sql = sqltext(
-    ##     """
-    ##     SELECT WoTextLC FROM words
-    ##     WHERE WoLgID=:lid and WoTokenCount>1
-    ##     AND :content LIKE '%' || :zws || WoTextLC || :zws || '%'
-    ##     """
-    ## )
-    ## sql = sql.bindparams(lid=language.id, content=content, zws=zws)
-    ## recs = db.session.execute(sql).all()
-    ## return [r[0] for r in recs]
-
-
-def _find_all_terms_in_tokens(tokens, language, kwtree=None):
-    """
-    Find all terms contained in the (ordered) parsed tokens tokens.
-
-    For example
-    - given tokens = "Here", " ", "is", " ", "a", " ", "cat"
-    - given terms in the db: [ "cat", "a/ /cat", "dog" ]
-
-    This would return the terms "cat" and "a/ /cat".
-
-    Method:
-    - build list of lowercase text in the tokens
-    - append all multword term strings that exist in the content
-    - query for Terms that exist in the list
-
-    Note: this method only uses indexes for multiword terms, as any
-    content analyzed is first parsed into tokens before being passed
-    to this routine.  There's no need to search for single-word Terms
-    in the tokenized strings, they can be found by a simple query.
-    """
-
-    # Performance: About half of the time in this routine is spent in
-    # Step 1 (finding multiword terms), the rest in step 2 (the actual
-    # query).
-    # dt = DebugTimer("_find_all_terms_in_tokens", display=True)
-
-    parser = language.parser
-    text_lcs = [parser.get_lowercase(t.token) for t in tokens]
-
-    # Step 1: get the multiwords in the content.
-    if kwtree is None:
-        mword_terms = _find_all_multi_word_term_text_lcs_in_content(text_lcs, language)
-    else:
-        results = kwtree.search_all(text_lcs)
-        mword_terms = [r[0] for r in results]
-    # dt.step("filtered mword terms")
-
-    # Step 2: load the Term objects.
-    #
-    # The Term fetch is actually performant -- there is no
-    # real difference between loading the Term objects versus
-    # loading raw data with SQL and getting dicts.
-    #
-    # Code for getting raw data:
-    # param_keys = [f"w{i}" for i, _ in enumerate(text_lcs)]
-    # keys_placeholders = ','.join([f":{k}" for k in param_keys])
-    # param_dict = dict(zip(param_keys, text_lcs))
-    # param_dict["langid"] = language.id
-    # sql = sqltext(f"""SELECT WoID, WoTextLC FROM words
-    #     WHERE WoLgID=:langid and WoTextLC in ({keys_placeholders})""")
-    # sql = sql.bindparams(language.id, *text_lcs)
-    # results = db.session.execute(sql, param_dict).fetchall()
-    text_lcs.extend(mword_terms)
-    tok_strings = list(set(text_lcs))
-    terms_matching_tokens_qry = db.session.query(Term).filter(
-        Term.text_lc.in_(tok_strings), Term.language == language
-    )
-    all_terms = terms_matching_tokens_qry.all()
-    # dt.step("exec query")
-
-    return all_terms
-
-
-## Getting paragraphs ##############################
-
-
-def get_textitems(s, language, multiword_term_indexer=None):
-    """
-    Get array of TextItems for the string s.
-
-    The multiword_term_indexer is a big performance boost, but takes
-    time to initialize.
-    """
-    # Hacky reset of state of ParsedToken state.
-    # _Shouldn't_ be needed but doesn't hurt, even if it's lame.
-    ParsedToken.reset_counters()
-
-    cleaned = re.sub(r" +", " ", s)
-    tokens = language.get_parsed_tokens(cleaned)
-    terms = _find_all_terms_in_tokens(tokens, language, multiword_term_indexer)
-    textitems = calc_get_textitems(tokens, terms, language, multiword_term_indexer)
-    return textitems
-
-
-def get_multiword_indexer(language):
-    "Return indexer loaded with all multiword terms."
-    mw = MultiwordTermIndexer()
-    for r in _get_multiword_terms(language):
-        mw.add(r[1])
-    return mw
-
-
-def get_paragraphs(s, language):
-    """
-    Get array of arrays of TextItems for the given string s.
-
-    This doesn't use an indexer, as it should only be used
-    for a single page of text!
-    """
-    textitems = get_textitems(s, language)
-
-    def _split_textitems_by_paragraph(textitems):
-        "Split by ¶"
-        ret = []
-        curr_para = []
-        for t in textitems:
-            if t.text == "¶":
+        Get array of arrays of TextItems for the given string s.
+
+        This doesn't use an indexer, as it should only be used
+        for a single page of text!
+        """
+        textitems = self.get_textitems(s, language)
+
+        def _split_textitems_by_paragraph(textitems):
+            "Split by ¶"
+            ret = []
+            curr_para = []
+            for t in textitems:
+                if t.text == "¶":
+                    ret.append(curr_para)
+                    curr_para = []
+                else:
+                    curr_para.append(t)
+            if len(curr_para) > 0:
                 ret.append(curr_para)
-                curr_para = []
-            else:
-                curr_para.append(t)
-        if len(curr_para) > 0:
-            ret.append(curr_para)
-        return ret
-
-    def _split_by_sentence_number(p):
-        sentences = [
-            list(sentence)
-            for _, sentence in itertools.groupby(p, key=lambda t: t.sentence_number)
+            return ret
+
+        def _split_by_sentence_number(p):
+            sentences = [
+                list(sentence)
+                for _, sentence in itertools.groupby(p, key=lambda t: t.sentence_number)
+            ]
+            for s in sentences:
+                s[0].add_html_class("sentencestart")
+            return sentences
+
+        paras = [
+            _split_by_sentence_number(list(sentences))
+            for sentences in _split_textitems_by_paragraph(textitems)
         ]
-        for s in sentences:
-            s[0].add_html_class("sentencestart")
-        return sentences
-
-    paras = [
-        _split_by_sentence_number(list(sentences))
-        for sentences in _split_textitems_by_paragraph(textitems)
-    ]
 
-    return paras
+        return paras
diff --git a/lute/read/routes.py b/lute/read/routes.py
index d763a701..f3f24fad 100644
--- a/lute/read/routes.py
+++ b/lute/read/routes.py
@@ -4,12 +4,13 @@
 
 from datetime import datetime
 from flask import Blueprint, flash, request, render_template, redirect, jsonify
-from lute.read.service import set_unknowns_to_known, start_reading, get_popup_data
+from lute.read.service import Service
 from lute.read.forms import TextForm
 from lute.term.model import Repository
 from lute.term.routes import handle_term_form
-from lute.models.book import Book, Text
-from lute.models.setting import UserSetting
+from lute.settings.current import current_settings
+from lute.models.book import Text
+from lute.models.repositories import BookRepository
 from lute.db import db
 
 
@@ -21,7 +22,7 @@ def _render_book_page(book, pagenum):
     Render a particular book page.
     """
     lang = book.language
-    show_highlights = bool(int(UserSetting.get_value("show_highlights")))
+    show_highlights = current_settings["show_highlights"]
     term_dicts = lang.all_dictionaries()[lang.id]["term"]
 
     return render_template(
@@ -39,6 +40,12 @@ def _render_book_page(book, pagenum):
     )
 
 
+def _find_book(bookid):
+    "Find book from db."
+    br = BookRepository(db.session)
+    return br.find(bookid)
+
+
 @bp.route("/<int:bookid>", methods=["GET"])
 def read(bookid):
     """
@@ -46,7 +53,7 @@ def read(bookid):
 
     This is called from the book listing, on Lute index.
     """
-    book = Book.find(bookid)
+    book = _find_book(bookid)
     if book is None:
         flash(f"No book matching id {bookid}")
         return redirect("/", 302)
@@ -54,7 +61,7 @@ def read(bookid):
     page_num = 1
     text = book.texts[0]
     if book.current_tx_id:
-        text = Text.find(book.current_tx_id)
+        text = db.session.get(Text, book.current_tx_id)
         page_num = text.order
 
     return _render_book_page(book, page_num)
@@ -67,7 +74,7 @@ def read_page(bookid, pagenum):
 
     Called from term Sentences link.
     """
-    book = Book.find(bookid)
+    book = _find_book(bookid)
     if book is None:
         flash(f"No book matching id {bookid}")
         return redirect("/", 302)
@@ -84,13 +91,14 @@ def page_done():
     pagenum = int(data.get("pagenum"))
     restknown = data.get("restknown")
 
-    book = Book.find(bookid)
+    book = _find_book(bookid)
     text = book.text_at_page(pagenum)
     text.read_date = datetime.now()
     db.session.add(text)
     db.session.commit()
+    service = Service(db.session)
     if restknown:
-        set_unknowns_to_known(text)
+        service.set_unknowns_to_known(text)
     return jsonify("ok")
 
 
@@ -99,7 +107,7 @@ def delete_page(bookid, pagenum):
     """
     Delete page.
     """
-    book = Book.find(bookid)
+    book = _find_book(bookid)
     if book is None:
         flash(f"No book matching id {bookid}")
         return redirect("/", 302)
@@ -119,7 +127,7 @@ def delete_page(bookid, pagenum):
 def new_page(bookid, position, pagenum):
     "Create a new page."
     form = TextForm()
-    book = Book.find(bookid)
+    book = _find_book(bookid)
 
     if form.validate_on_submit():
         t = None
@@ -149,7 +157,7 @@ def save_player_data():
     "Save current player position, bookmarks.  Called on a loop by the player."
     data = request.json
     bookid = int(data.get("bookid"))
-    book = Book.find(bookid)
+    book = _find_book(bookid)
     book.audio_current_pos = float(data.get("position"))
     book.audio_bookmarks = data.get("bookmarks")
     db.session.add(book)
@@ -160,11 +168,12 @@ def save_player_data():
 @bp.route("/renderpage/<int:bookid>/<int:pagenum>", methods=["GET"])
 def render_page(bookid, pagenum):
     "Method called by ajax, render the given page."
-    book = Book.find(bookid)
+    book = _find_book(bookid)
     if book is None:
         flash(f"No book matching id {bookid}")
         return redirect("/", 302)
-    paragraphs = start_reading(book, pagenum, db.session)
+    service = Service(db.session)
+    paragraphs = service.start_reading(book, pagenum)
     return render_template("read/page_content.html", paragraphs=paragraphs)
 
 
@@ -180,13 +189,14 @@ def term_form(langid, text):
     Create a multiword term for the given text, replacing the LUTESLASH hack.
     """
     usetext = text.replace("LUTESLASH", "/")
-    repo = Repository(db)
+    repo = Repository(db.session)
     term = repo.find_or_new(langid, usetext)
     if term.status == 0:
         term.status = 1
     return handle_term_form(
         term,
         repo,
+        db.session,
         "/read/frameform.html",
         render_template("/read/updated.html", term_text=term.text),
         embedded_in_reading_frame=True,
@@ -198,7 +208,7 @@ def edit_term_form(term_id):
     """
     Edit a term.
     """
-    repo = Repository(db)
+    repo = Repository(db.session)
     term = repo.load(term_id)
     # print(f"editing term {term_id}", flush=True)
     if term.status == 0:
@@ -206,6 +216,7 @@ def edit_term_form(term_id):
     return handle_term_form(
         term,
         repo,
+        db.session,
         "/read/frameform.html",
         render_template("/read/updated.html", term_text=term.text),
         embedded_in_reading_frame=True,
@@ -217,7 +228,8 @@ def term_popup(termid):
     """
     Get popup html for DBTerm, or None if nothing should be shown.
     """
-    d = get_popup_data(termid)
+    service = Service(db.session)
+    d = service.get_popup_data(termid)
     if d is None:
         return ""
     return render_template(
@@ -240,7 +252,7 @@ def flashcopied():
 @bp.route("/editpage/<int:bookid>/<int:pagenum>", methods=["GET", "POST"])
 def edit_page(bookid, pagenum):
     "Edit the text on a page."
-    book = Book.find(bookid)
+    book = _find_book(bookid)
     text = book.text_at_page(pagenum)
     if text is None:
         return redirect("/", 302)
diff --git a/lute/read/service.py b/lute/read/service.py
index 0f72b308..7225135a 100644
--- a/lute/read/service.py
+++ b/lute/read/service.py
@@ -5,182 +5,189 @@
 import functools
 from lute.models.term import Term, Status
 from lute.models.book import Text
-from lute.book.stats import mark_stale
-from lute.read.render.service import get_paragraphs, find_all_Terms_in_string
+from lute.book.stats import Service as StatsService
+from lute.read.render.service import Service as RenderService
 from lute.read.render.calculate_textitems import get_string_indexes
 from lute.term.model import Repository
-from lute.db import db
 
 # from lute.utils.debug_helpers import DebugTimer
 
 
-def set_unknowns_to_known(text: Text):
-    """
-    Given a text, create new Terms with status Well-Known
-    for any new Terms.
-    """
-    paragraphs = get_paragraphs(text.text, text.book.language)
-    _save_new_status_0_terms(paragraphs)
-
-    unknowns = [
-        ti.term
-        for para in paragraphs
-        for sentence in para
-        for ti in sentence
-        if ti.is_word and ti.term.status == 0
-    ]
-
-    batch_size = 100
-    i = 0
-
-    for t in unknowns:
-        t.status = Status.WELLKNOWN
-        db.session.add(t)
-        i += 1
-        if i % batch_size == 0:
-            db.session.commit()
-
-    # Commit any remaining.
-    db.session.commit()
-
-
-def bulk_status_update(text: Text, terms_text_array, new_status):
-    """
-    Given a text and list of terms, update or create new terms
-    and set the status.
-    """
-    language = text.book.language
-    repo = Repository(db)
-    for term_text in terms_text_array:
-        t = repo.find_or_new(language.id, term_text)
-        t.status = new_status
-        repo.add(t)
-    repo.commit()
-
-
-def _save_new_status_0_terms(paragraphs):
-    "Add status 0 terms for new textitems in paragraph."
-    tis_with_new_terms = [
-        ti
-        for para in paragraphs
-        for sentence in para
-        for ti in sentence
-        if ti.is_word and ti.term.id is None and ti.term.status == 0
-    ]
-
-    for ti in tis_with_new_terms:
-        db.session.add(ti.term)
-    db.session.commit()
-
-
-def start_reading(dbbook, pagenum, db_session):
-    "Start reading a page in the book, getting paragraphs."
-
-    text = dbbook.text_at_page(pagenum)
-    text.load_sentences()
-
-    mark_stale(dbbook)
-    dbbook.current_tx_id = text.id
-    db_session.add(dbbook)
-    db_session.add(text)
-    db_session.commit()
-
-    lang = text.book.language
-    paragraphs = get_paragraphs(text.text, lang)
-    _save_new_status_0_terms(paragraphs)
-
-    return paragraphs
-
-
-def get_popup_data(termid):
-    "Get popup data, or None if popup shouldn't be shown."
-    term = Term.find(termid)
-
-    if term.status == Status.UNKNOWN:
-        return None
-
-    def has_popup_data(cterm):
-        return (
-            (cterm.translation or "").strip() != ""
-            or (cterm.romanization or "").strip() != ""
-            or cterm.get_current_image() is not None
-        )
-
-    if not has_popup_data(term) and len(term.parents) == 0:
-        return None
-
-    term_tags = [tt.text for tt in term.term_tags]
-
-    def make_array(t):
-        ret = {
-            "term": t.text,
-            "roman": t.romanization,
-            "trans": t.translation if t.translation else "-",
-            "tags": [tt.text for tt in t.term_tags],
+class Service:
+    "Service."
+
+    def __init__(self, session):
+        self.session = session
+
+    def set_unknowns_to_known(self, text: Text):
+        """
+        Given a text, create new Terms with status Well-Known
+        for any new Terms.
+        """
+        rs = RenderService(self.session)
+        paragraphs = rs.get_paragraphs(text.text, text.book.language)
+        self._save_new_status_0_terms(paragraphs)
+
+        unknowns = [
+            ti.term
+            for para in paragraphs
+            for sentence in para
+            for ti in sentence
+            if ti.is_word and ti.term.status == 0
+        ]
+
+        batch_size = 100
+        i = 0
+
+        for t in unknowns:
+            t.status = Status.WELLKNOWN
+            self.session.add(t)
+            i += 1
+            if i % batch_size == 0:
+                self.session.commit()
+
+        # Commit any remaining.
+        self.session.commit()
+
+    def bulk_status_update(self, text: Text, terms_text_array, new_status):
+        """
+        Given a text and list of terms, update or create new terms
+        and set the status.
+        """
+        language = text.book.language
+        repo = Repository(self.session)
+        for term_text in terms_text_array:
+            t = repo.find_or_new(language.id, term_text)
+            t.status = new_status
+            repo.add(t)
+        repo.commit()
+
+    def _save_new_status_0_terms(self, paragraphs):
+        "Add status 0 terms for new textitems in paragraph."
+        tis_with_new_terms = [
+            ti
+            for para in paragraphs
+            for sentence in para
+            for ti in sentence
+            if ti.is_word and ti.term.id is None and ti.term.status == 0
+        ]
+
+        for ti in tis_with_new_terms:
+            self.session.add(ti.term)
+        self.session.commit()
+
+    def start_reading(self, dbbook, pagenum):
+        "Start reading a page in the book, getting paragraphs."
+
+        text = dbbook.text_at_page(pagenum)
+        text.load_sentences()
+
+        svc = StatsService(self.session)
+        svc.mark_stale(dbbook)
+        dbbook.current_tx_id = text.id
+        self.session.add(dbbook)
+        self.session.add(text)
+        self.session.commit()
+
+        lang = text.book.language
+        rs = RenderService(self.session)
+        paragraphs = rs.get_paragraphs(text.text, lang)
+        self._save_new_status_0_terms(paragraphs)
+
+        return paragraphs
+
+    def get_popup_data(self, termid):
+        "Get popup data, or None if popup shouldn't be shown."
+        term = self.session.get(Term, termid)
+
+        if term.status == Status.UNKNOWN:
+            return None
+
+        def has_popup_data(cterm):
+            return (
+                (cterm.translation or "").strip() != ""
+                or (cterm.romanization or "").strip() != ""
+                or cterm.get_current_image() is not None
+            )
+
+        if not has_popup_data(term) and len(term.parents) == 0:
+            return None
+
+        term_tags = [tt.text for tt in term.term_tags]
+
+        def make_array(t):
+            ret = {
+                "term": t.text,
+                "roman": t.romanization,
+                "trans": t.translation if t.translation else "-",
+                "tags": [tt.text for tt in t.term_tags],
+            }
+            return ret
+
+        parent_terms = [p.text for p in term.parents]
+        parent_terms = ", ".join(parent_terms)
+
+        parents = term.parents
+        if len(parents) == 1 and parents[0].translation == term.translation:
+            parents = []
+        parent_data = [make_array(p) for p in parents]
+
+        def sort_components(components):
+            # Sort components by min position in string and length.
+            component_and_pos = []
+            for c in components:
+                c_indices = [
+                    loc[1] for loc in get_string_indexes([c.text_lc], term.text_lc)
+                ]
+
+                # Sometimes the components aren't found
+                # in the string, which makes no sense ...
+                # ref https://github.com/LuteOrg/lute-v3/issues/474
+                if len(c_indices) > 0:
+                    component_and_pos.append([c, min(c_indices)])
+
+            def compare(a, b):
+                # Lowest position (closest to front of string) sorts first.
+                if a[1] != b[1]:
+                    return -1 if (a[1] < b[1]) else 1
+                # Longest sorts first.
+                alen = len(a[0].text)
+                blen = len(b[0].text)
+                return -1 if (alen > blen) else 1
+
+            component_and_pos.sort(key=functools.cmp_to_key(compare))
+            return [c[0] for c in component_and_pos]
+
+        rs = RenderService(self.session)
+        components = [
+            c
+            for c in rs.find_all_Terms_in_string(term.text, term.language)
+            if c.id != term.id
+        ]
+        components = sort_components(components)
+
+        component_data = [make_array(c) for c in components]
+        component_data = [c for c in component_data if c["trans"] != "-"]
+
+        images = [term.get_current_image()] if term.get_current_image() else []
+        for p in term.parents:
+            if p.get_current_image():
+                images.append(p.get_current_image())
+        # DISABLED CODE: Don't include component images in the hover for now,
+        # it can get confusing!
+        # ref https://github.com/LuteOrg/lute-v3/issues/355
+        # for c in components:
+        #     if c.get_current_image():
+        #         images.append(c.get_current_image())
+
+        images = list(set(images))
+
+        return {
+            "term": term,
+            "flashmsg": term.get_flash_message(),
+            "term_tags": term_tags,
+            "term_images": images,
+            "parentdata": parent_data,
+            "parentterms": parent_terms,
+            "components": component_data,
         }
-        return ret
-
-    parent_terms = [p.text for p in term.parents]
-    parent_terms = ", ".join(parent_terms)
-
-    parents = term.parents
-    if len(parents) == 1 and parents[0].translation == term.translation:
-        parents = []
-    parent_data = [make_array(p) for p in parents]
-
-    def sort_components(components):
-        # Sort components by min position in string and length.
-        component_and_pos = []
-        for c in components:
-            c_indices = [
-                loc[1] for loc in get_string_indexes([c.text_lc], term.text_lc)
-            ]
-
-            # Sometimes the components aren't found
-            # in the string, which makes no sense ...
-            # ref https://github.com/LuteOrg/lute-v3/issues/474
-            if len(c_indices) > 0:
-                component_and_pos.append([c, min(c_indices)])
-
-        def compare(a, b):
-            # Lowest position (closest to front of string) sorts first.
-            if a[1] != b[1]:
-                return -1 if (a[1] < b[1]) else 1
-            # Longest sorts first.
-            alen = len(a[0].text)
-            blen = len(b[0].text)
-            return -1 if (alen > blen) else 1
-
-        component_and_pos.sort(key=functools.cmp_to_key(compare))
-        return [c[0] for c in component_and_pos]
-
-    components = [
-        c for c in find_all_Terms_in_string(term.text, term.language) if c.id != term.id
-    ]
-    components = sort_components(components)
-
-    component_data = [make_array(c) for c in components]
-    component_data = [c for c in component_data if c["trans"] != "-"]
-
-    images = [term.get_current_image()] if term.get_current_image() else []
-    for p in term.parents:
-        if p.get_current_image():
-            images.append(p.get_current_image())
-    # DISABLED CODE: Don't include component images in the hover for now,
-    # it can get confusing!
-    # ref https://github.com/LuteOrg/lute-v3/issues/355
-    # for c in components:
-    #     if c.get_current_image():
-    #         images.append(c.get_current_image())
-
-    images = list(set(images))
-
-    return {
-        "term": term,
-        "flashmsg": term.get_flash_message(),
-        "term_tags": term_tags,
-        "term_images": images,
-        "parentdata": parent_data,
-        "parentterms": parent_terms,
-        "components": component_data,
-    }
diff --git a/lute/settings/current.py b/lute/settings/current.py
new file mode 100644
index 00000000..8a1bd8e2
--- /dev/null
+++ b/lute/settings/current.py
@@ -0,0 +1,33 @@
+"""
+Current user settings stored in UserSettings.
+
+Storing a global dict to allow for db-less access, they're
+global settings, after all.
+
+They're written to at load (or when the settings change).
+"""
+
+from lute.models.setting import UserSetting
+
+# The current user settings, key/value dict.
+current_settings = {}
+
+
+def refresh_global_settings(session):
+    "Refresh all settings dictionary."
+    # Have to reload to not mess up any references
+    # (e.g. during testing).
+    current_settings.clear()
+
+    settings = session.query(UserSetting).all()
+    for s in settings:
+        current_settings[s.key] = s.value
+
+    # Convert some ints into bools.
+    boolkeys = [
+        "open_popup_in_new_tab",
+        "stop_audio_on_term_form_open",
+        "show_highlights",
+    ]
+    for k in boolkeys:
+        current_settings[k] = current_settings[k] == "1"
diff --git a/lute/settings/forms.py b/lute/settings/forms.py
new file mode 100644
index 00000000..9d7284fe
--- /dev/null
+++ b/lute/settings/forms.py
@@ -0,0 +1,80 @@
+"""
+Settings form.
+"""
+
+import os
+from flask_wtf import FlaskForm
+from wtforms import BooleanField, StringField, IntegerField, TextAreaField, SelectField
+from wtforms.validators import InputRequired, NumberRange
+from wtforms import ValidationError
+
+
+class UserSettingsForm(FlaskForm):
+    """
+    Settings.
+
+    Note the field names here must match the keys in the settings table.
+    """
+
+    backup_enabled = BooleanField("Backup Enabled")
+    backup_dir = StringField("Backup directory")
+    backup_auto = BooleanField("Run backups automatically (daily)")
+    backup_warn = BooleanField("Warn if backup hasn't run in a week")
+    backup_count = IntegerField(
+        "Retain backup count",
+        validators=[InputRequired(), NumberRange(min=1)],
+        render_kw={
+            "title": "Count of zipfiles to retain, oldest files are deleted first"
+        },
+    )
+
+    current_theme = SelectField("Theme")
+    custom_styles = TextAreaField("Custom styles")
+    show_highlights = BooleanField("Highlight terms by status")
+
+    open_popup_in_new_tab = BooleanField("Open popup in new tab")
+    stop_audio_on_term_form_open = BooleanField("Stop audio on term form open")
+    stats_calc_sample_size = IntegerField(
+        "Book stats page sample size",
+        validators=[InputRequired(), NumberRange(min=1, max=500)],
+        render_kw={"title": "Number of pages to use for book stats calculation."},
+    )
+
+    mecab_path = StringField("MECAB_PATH environment variable")
+    reading_choices = [
+        ("katakana", "Katakana"),
+        ("hiragana", "Hiragana"),
+        ("alphabet", "Romaji"),
+    ]
+    japanese_reading = SelectField("Pronunciation characters", choices=reading_choices)
+
+    def validate_backup_dir(self, field):
+        "Field must be set if enabled."
+        if self.backup_enabled.data is False:
+            return
+        v = field.data
+        if (v or "").strip() == "":
+            raise ValidationError("Backup directory required")
+
+        abspath = os.path.abspath(v)
+        if v != abspath:
+            msg = f'Backup dir must be absolute path.  Did you mean "{abspath}"?'
+            raise ValidationError(msg)
+        if not os.path.exists(v):
+            raise ValidationError(f'Directory "{v}" does not exist.')
+        if not os.path.isdir(v):
+            raise ValidationError(f'"{v}" is not a directory.')
+
+
+class UserShortcutsForm(FlaskForm):
+    """
+    Shortcuts form.
+
+    The route manages getting and storing the settings
+    from the db, as there's a variable number of settings,
+    and it's easier to just work with the data directly
+    rather than trying to create a variable number of fields.
+
+    I'm only using this form to get the validate_on_submit()!
+    There's likely a better way to do this.
+    """
diff --git a/lute/settings/routes.py b/lute/settings/routes.py
index 43d61432..e88254b5 100644
--- a/lute/settings/routes.py
+++ b/lute/settings/routes.py
@@ -2,7 +2,6 @@
 Settings routes.
 """
 
-import os
 from flask import (
     Blueprint,
     current_app,
@@ -12,74 +11,17 @@
     flash,
     jsonify,
 )
-from flask_wtf import FlaskForm
-from wtforms import BooleanField, StringField, IntegerField, TextAreaField, SelectField
-from wtforms.validators import InputRequired, NumberRange
-from wtforms import ValidationError
+from wtforms import BooleanField
 from lute.models.language import Language
 from lute.models.setting import UserSetting
-from lute.themes.service import list_themes
+from lute.models.repositories import UserSettingRepository
+from lute.themes.service import Service as ThemeService
+from lute.settings.forms import UserSettingsForm, UserShortcutsForm
+from lute.settings.current import refresh_global_settings
 from lute.db import db
 from lute.parse.mecab_parser import JapaneseParser
 
 
-class UserSettingsForm(FlaskForm):
-    """
-    Settings.
-
-    Note the field names here must match the keys in the settings table.
-    """
-
-    backup_enabled = BooleanField("Backup Enabled")
-    backup_dir = StringField("Backup directory")
-    backup_auto = BooleanField("Run backups automatically (daily)")
-    backup_warn = BooleanField("Warn if backup hasn't run in a week")
-    backup_count = IntegerField(
-        "Retain backup count",
-        validators=[InputRequired(), NumberRange(min=1)],
-        render_kw={
-            "title": "Count of zipfiles to retain, oldest files are deleted first"
-        },
-    )
-
-    current_theme = SelectField("Theme")
-    custom_styles = TextAreaField("Custom styles")
-    show_highlights = BooleanField("Highlight terms by status")
-
-    open_popup_in_new_tab = BooleanField("Open popup in new tab")
-    stop_audio_on_term_form_open = BooleanField("Stop audio on term form open")
-    stats_calc_sample_size = IntegerField(
-        "Book stats page sample size",
-        validators=[InputRequired(), NumberRange(min=1, max=500)],
-        render_kw={"title": "Number of pages to use for book stats calculation."},
-    )
-
-    mecab_path = StringField("MECAB_PATH environment variable")
-    reading_choices = [
-        ("katakana", "Katakana"),
-        ("hiragana", "Hiragana"),
-        ("alphabet", "Romaji"),
-    ]
-    japanese_reading = SelectField("Pronunciation characters", choices=reading_choices)
-
-    def validate_backup_dir(self, field):
-        "Field must be set if enabled."
-        if self.backup_enabled.data is False:
-            return
-        v = field.data
-        if (v or "").strip() == "":
-            raise ValidationError("Backup directory required")
-
-        abspath = os.path.abspath(v)
-        if v != abspath:
-            msg = f'Backup dir must be absolute path.  Did you mean "{abspath}"?'
-            raise ValidationError(msg)
-        if not os.path.exists(v):
-            raise ValidationError(f'Directory "{v}" does not exist.')
-        if not os.path.isdir(v):
-            raise ValidationError(f'"{v}" is not a directory.')
-
-
 bp = Blueprint("settings", __name__, url_prefix="/settings")
 
 
@@ -89,7 +31,8 @@ def edit_settings():
     form = UserSettingsForm()
 
     with current_app.app_context():
-        form.current_theme.choices = list_themes()
+        svc = ThemeService(db.session)
+        form.current_theme.choices = svc.list_themes()
 
     ac = current_app.env_config
     if ac.is_docker:
@@ -98,12 +41,14 @@ def edit_settings():
         # Backup dir gets mounted from host.
         form.backup_dir.render_kw = kw
 
+    repo = UserSettingRepository(db.session)
     if form.validate_on_submit():
         # Update the settings in the database
         for field in form:
             if field.id not in ("csrf_token", "submit"):
-                UserSetting.set_value(field.id, field.data)
+                repo.set_value(field.id, field.data)
         db.session.commit()
+        refresh_global_settings(db.session)
 
         flash("Settings updated", "success")
         return redirect("/")
@@ -111,7 +56,7 @@ def edit_settings():
     # Load current settings from the database
     for field in form:
         if field.id != "csrf_token":
-            field.data = UserSetting.get_value(field.id)
+            field.data = repo.get_value(field.id)
         if isinstance(field, BooleanField):
             # Hack: set boolean settings to ints, otherwise they're always checked.
             field.data = int(field.data or 0)
@@ -129,10 +74,11 @@ def test_parse():
 
     """
     mecab_path = request.args.get("mecab_path", None)
-    old_setting = UserSetting.get_value("mecab_path")
+    repo = UserSettingRepository(db.session)
+    old_setting = repo.get_value("mecab_path")
     result = {"failure": "tbd"}
     try:
-        UserSetting.set_value("mecab_path", mecab_path)
+        repo.set_value("mecab_path", mecab_path)
         # Parsing requires a language, even if it's a dummy.
         lang = Language()
         p = JapaneseParser()
@@ -145,7 +91,7 @@ def test_parse():
         message = f"{type(e).__name__}: { str(e) }"
         result = {"result": "failure", "message": message}
     finally:
-        UserSetting.set_value("mecab_path", old_setting)
+        repo.set_value("mecab_path", old_setting)
 
     return jsonify(result)
 
@@ -153,32 +99,20 @@ def test_parse():
 @bp.route("/set/<key>/<value>", methods=["POST"])
 def set_key_value(key, value):
     "Set a UserSetting key to value."
-    old_value = UserSetting.get_value(key)
+    repo = UserSettingRepository(db.session)
+    old_value = repo.get_value(key)
     try:
-        UserSetting.set_value(key, value)
+        repo.set_value(key, value)
         result = {"result": "success", "message": "OK"}
     except Exception as e:  # pylint: disable=broad-exception-caught
         message = f"{type(e).__name__}: { str(e) }"
-        UserSetting.set_value(key, old_value)
+        repo.set_value(key, old_value)
         result = {"result": "failure", "message": message}
     db.session.commit()
+    refresh_global_settings(db.session)
     return jsonify(result)
 
 
-class UserShortcutsForm(FlaskForm):
-    """
-    Shortcuts form.
-
-    The route manages getting and storing the settings
-    from the db, as there's a variable number of settings,
-    and it's easier to just work with the data directly
-    rather than trying to create a variable number of fields.
-
-    I'm only using this form to get the validate_on_submit()!
-    There's likely a better way to do this.
-    """
-
-
 def _get_categorized_hotkeys():
     """
     Return hotkey UserSetting keys and values,
@@ -236,14 +170,16 @@ def _get_categorized_hotkeys():
 @bp.route("/shortcuts", methods=["GET", "POST"])
 def edit_shortcuts():
     "Edit shortcuts."
+    repo = UserSettingRepository(db.session)
     form = UserShortcutsForm()
     if form.validate_on_submit():
         # print(request.form, flush=True)
         # Update the settings in the database
         for k, v in request.form.items():
             # print(f"{k} = {v}", flush=True)
-            UserSetting.set_value(k, v)
+            repo.set_value(k, v)
         db.session.commit()
+        refresh_global_settings(db.session)
         flash("Shortcuts updated", "success")
         return redirect("/")
 
diff --git a/lute/stats/routes.py b/lute/stats/routes.py
index d2ecfe8f..25f577d0 100644
--- a/lute/stats/routes.py
+++ b/lute/stats/routes.py
@@ -4,6 +4,7 @@
 
 from flask import Blueprint, render_template, jsonify
 from lute.stats.service import get_chart_data, get_table_data
+from lute.db import db
 
 bp = Blueprint("stats", __name__, url_prefix="/stats")
 
@@ -11,12 +12,12 @@
 @bp.route("/")
 def index():
     "Main page."
-    read_table_data = get_table_data()
+    read_table_data = get_table_data(db.session)
     return render_template("stats/index.html", read_table_data=read_table_data)
 
 
 @bp.route("/data")
 def get_data():
     "Ajax call."
-    chartdata = get_chart_data()
+    chartdata = get_chart_data(db.session)
     return jsonify(chartdata)
diff --git a/lute/stats/service.py b/lute/stats/service.py
index 9613796b..146475f7 100644
--- a/lute/stats/service.py
+++ b/lute/stats/service.py
@@ -4,10 +4,9 @@
 
 from datetime import datetime, timedelta
 from sqlalchemy import text
-from lute.db import db
 
 
-def _get_data_per_lang():
+def _get_data_per_lang(session):
     "Return dict of lang name to dict[date_yyyymmdd}: count"
     ret = {}
     sql = """
@@ -22,7 +21,7 @@ def _get_data_per_lang():
     ) raw
     group by lang, dt
     """
-    result = db.session.execute(text(sql)).all()
+    result = session.execute(text(sql)).all()
     for row in result:
         langname = row[0]
         if langname not in ret:
@@ -53,9 +52,9 @@ def _charting_data(readbydate):
     return data
 
 
-def get_chart_data():
+def get_chart_data(session):
     "Get data for chart for each language."
-    raw_data = _get_data_per_lang()
+    raw_data = _get_data_per_lang(session)
     chartdata = {}
     for k, v in raw_data.items():
         chartdata[k] = _charting_data(v)
@@ -90,9 +89,9 @@ def _in_range(i):
     }
 
 
-def get_table_data():
+def get_table_data(session):
     "Wordcounts by lang in time intervals."
-    raw_data = _get_data_per_lang()
+    raw_data = _get_data_per_lang(session)
 
     ret = []
     for langname, readbydate in raw_data.items():
diff --git a/lute/templates/term/_form.html b/lute/templates/term/_form.html
index 54da14be..da9cc588 100644
--- a/lute/templates/term/_form.html
+++ b/lute/templates/term/_form.html
@@ -23,7 +23,7 @@
     {{ form.hidden_tag() }}
     <div id="term">
       <div id="languageSel"
-        {% if term.language_id is none and term.language is none %}
+        {% if term.language_id is none %}
         {# show the language select box. #}
         {% else %}
         style="display:none;"
diff --git a/lute/term/datatables.py b/lute/term/datatables.py
index a07bfba6..36b82161 100644
--- a/lute/term/datatables.py
+++ b/lute/term/datatables.py
@@ -2,11 +2,10 @@
 Show terms in datatables.
 """
 
-from lute.db import db
 from lute.utils.data_tables import DataTablesSqliteQuery, supported_parser_type_criteria
 
 
-def get_data_tables_list(parameters):
+def get_data_tables_list(parameters, session):
     "Term json data for datatables."
 
     base_sql = """SELECT
@@ -93,5 +92,5 @@ def get_data_tables_list(parameters):
 
     # Phew.
     return DataTablesSqliteQuery.get_data(
-        base_sql + " WHERE " + " AND ".join(wheres), parameters, db.session.connection()
+        base_sql + " WHERE " + " AND ".join(wheres), parameters, session.connection()
     )
diff --git a/lute/term/forms.py b/lute/term/forms.py
index 7fe3ee88..c5f0e9d3 100644
--- a/lute/term/forms.py
+++ b/lute/term/forms.py
@@ -16,8 +16,8 @@
 from wtforms import ValidationError
 from wtforms.validators import DataRequired
 
-from lute.models.language import Language
 from lute.models.term import Term
+from lute.models.repositories import LanguageRepository, TermRepository
 
 
 class TermForm(FlaskForm):
@@ -63,7 +63,8 @@ class TermForm(FlaskForm):
     def __init__(self, *args, **kwargs):
         "Call the constructor of the superclass (FlaskForm)"
         super().__init__(*args, **kwargs)
-        term = kwargs.get("obj")
+        term = kwargs["obj"]
+        self.session = kwargs["session"]
 
         def _data(arr):
             "Get data in proper format for tagify."
@@ -102,7 +103,8 @@ def validate_text(self, field):  # pylint: disable=unused-argument
         if self.language_id.data in (None, 0):
             return
         langid = int(self.language_id.data)
-        lang = Language.find(langid)
+        language_repo = LanguageRepository(self.session)
+        lang = language_repo.find(langid)
         if lang is None:
             return
 
@@ -110,7 +112,8 @@ def validate_text(self, field):  # pylint: disable=unused-argument
         if orig_text in ("", None):
             # New term - throw if already exists.
             spec = Term(lang, self.text.data)
-            self.duplicated_term = Term.find_by_spec(spec)
+            term_repo = TermRepository(self.session)
+            self.duplicated_term = term_repo.find_by_spec(spec)
             if self.duplicated_term is not None:
                 raise ValidationError("Term already exists")
 
diff --git a/lute/term/model.py b/lute/term/model.py
index dc89064c..8c03e49b 100644
--- a/lute/term/model.py
+++ b/lute/term/model.py
@@ -9,7 +9,11 @@
 import sqlalchemy
 
 from lute.models.term import Term as DBTerm, TermTag
-from lute.models.language import Language
+from lute.models.repositories import (
+    LanguageRepository,
+    TermRepository,
+    TermTagRepository,
+)
 
 
 class Term:  # pylint: disable=too-many-instance-attributes
@@ -20,12 +24,6 @@ class Term:  # pylint: disable=too-many-instance-attributes
     def __init__(self):
         # The ID of the DBTerm.
         self.id = None
-        # A language object is required as the Term bus. object
-        # must downcase the text and the original_text to see
-        # if anything has changed.
-        self._language = None
-        # Ideally this wouldn't be needed, but the term form
-        # populates this field with the (primitive) language id.
         self.language_id = None
         # The text.
         self.text = None
@@ -46,19 +44,6 @@ def __repr__(self):
             f'<Term BO "{self.text}" lang_id={self.language_id} lang={self.language}>'
         )
 
-    @property
-    def language(self):
-        "Use or get the language."
-        if self._language is not None:
-            return self._language
-        return Language.find(self.language_id)
-
-    @language.setter
-    def language(self, lang):
-        if not isinstance(lang, Language):
-            raise ValueError("not a language")
-        self._language = lang
-
 
 class TermReference:
     "Where a Term has been used in books."
@@ -78,8 +63,8 @@ class Repository:
     Maps Term BO to and from lute.model.Term.
     """
 
-    def __init__(self, _db):
-        self.db = _db
+    def __init__(self, _session):
+        self.session = _session
 
         # Identity map for business lookup.
         # Note that the same term is stored
@@ -105,13 +90,29 @@ def _search_identity_map(self, langid, txt):
 
     def load(self, term_id):
         "Loads a Term business object for the DBTerm with the id."
-        dbt = DBTerm.find(term_id)
+        dbt = self.session.get(DBTerm, term_id)
         if dbt is None:
             raise ValueError(f"No term with id {term_id} found")
         term = self._build_business_term(dbt)
         self._add_to_identity_map(term)
         return term
 
+    def _search_spec_term(self, langid, text):
+        """
+        Make a term to get the correct text_lc to search for.
+        This ensures that the spec term is properly parsed
+        and downcased.
+        """
+        lang_repo = LanguageRepository(self.session)
+        lang = lang_repo.find(langid)
+        return DBTerm(lang, text)
+
+    def _find_by_spec(self, langid, text):
+        "Do a search using a spec term."
+        spec = self._search_spec_term(langid, text)
+        repo = TermRepository(self.session)
+        return repo.find_by_spec(spec)
+
     def find(self, langid, text):
         """
         Return a Term business object for the DBTerm with the langid and text.
@@ -121,8 +122,7 @@ def find(self, langid, text):
         if term is not None:
             return term
 
-        spec = self._search_spec_term(langid, text)
-        dbt = DBTerm.find_by_spec(spec)
+        dbt = self._find_by_spec(langid, text)
         if dbt is None:
             return None
         term = self._build_business_term(dbt)
@@ -168,7 +168,6 @@ def find_or_new(self, langid, text):
 
         spec = self._search_spec_term(langid, text)
         t = Term()
-        t.language = spec.language
         t.language_id = langid
         t.text = spec.text
         t.text_lc = spec.text_lc
@@ -227,11 +226,11 @@ def find_matches(self, langid, text, max_results=50):
         # print(params)
 
         alchsql = sqlalchemy.text(sql_query)
-        return self.db.session.execute(alchsql, params).fetchall()
+        return self.session.execute(alchsql, params).fetchall()
 
     def get_term_tags(self):
         "Get all available term tags, helper method."
-        tags = self.db.session.query(TermTag).all()
+        tags = self.session.query(TermTag).all()
         return [t.text for t in tags]
 
     def add(self, term):
@@ -241,7 +240,7 @@ def add(self, term):
         clients should not change it.
         """
         dbterm = self._build_db_term(term)
-        self.db.session.add(dbterm)
+        self.session.add(dbterm)
         return dbterm
 
     def delete(self, term):
@@ -250,30 +249,18 @@ def delete(self, term):
         """
         dbt = None
         if term.id is not None:
-            dbt = DBTerm.find(term.id)
+            dbt = self.session.get(DBTerm, term.id)
         else:
-            spec = self._search_spec_term(term.language_id, term.text)
-            dbt = DBTerm.find_by_spec(spec)
+            dbt = self._find_by_spec(term.language_id, term.text)
         if dbt is not None:
-            self.db.session.delete(dbt)
+            self.session.delete(dbt)
 
     def commit(self):
         """
         Commit everything, flush the map to force refetches.
         """
         self.identity_map = {}
-        self.db.session.commit()
-
-    def _search_spec_term(self, langid, text):
-        """
-        Make a term to get the correct text_lc to search for.
-
-        Creating a term does parsing and correct downcasing,
-        so term.language.id and term.text_lc match what the
-        db would contain.
-        """
-        lang = Language.find(langid)
-        return DBTerm(lang, text)
+        self.session.commit()
 
     def _build_db_term(self, term):
         "Convert a term business object to a DBTerm."
@@ -284,11 +271,12 @@ def _build_db_term(self, term):
         t = None
         if term.id is not None:
             # This is an existing term, so use it directly.
-            t = DBTerm.find(term.id)
+            t = self.session.get(DBTerm, term.id)
         else:
             # New term, or finding by text.
             spec = self._search_spec_term(term.language_id, term.text)
-            t = DBTerm.find_by_spec(spec) or DBTerm()
+            term_repo = TermRepository(self.session)
+            t = term_repo.find_by_spec(spec) or DBTerm()
             t.language = spec.language
 
         t.text = term.text
@@ -304,9 +292,10 @@ def _build_db_term(self, term):
         else:
             t.pop_flash_message()
 
+        tt_repo = TermTagRepository(self.session)
         termtags = []
         for s in list(set(term.term_tags)):
-            termtags.append(TermTag.find_or_create_by_text(s))
+            termtags.append(tt_repo.find_or_create_by_text(s))
         t.remove_all_term_tags()
         for tt in termtags:
             t.add_term_tag(tt)
@@ -334,9 +323,7 @@ def _build_db_term(self, term):
         return t
 
     def _find_or_create_parent(self, pt, language, term, termtags) -> DBTerm:
-        spec = self._search_spec_term(language.id, pt)
-        p = DBTerm.find_by_spec(spec)
-
+        p = self._find_by_spec(language.id, pt)
         new_or_unknown_parent = p is None or p.status == 0
         new_term = term.id is None
 
@@ -366,7 +353,6 @@ def _build_business_term(self, dbterm):
         "Create a Term bus. object from a lute.model.term.Term."
         term = Term()
         term.id = dbterm.id
-        term.language = dbterm.language
         term.language_id = dbterm.language.id
 
         text = dbterm.text
@@ -404,7 +390,8 @@ def find_references(self, term):
         Return references of term, children, and parents.
         """
         spec = self._search_spec_term(term.language_id, term.text)
-        searchterm = DBTerm.find_by_spec(spec)
+        term_repo = TermRepository(self.session)
+        searchterm = term_repo.find_by_spec(spec)
         if searchterm is None:
             searchterm = spec
 
@@ -460,7 +447,7 @@ def _get_references(self, term):
 
         pattern = f"%{chr(0x200B)}{term_lc}{chr(0x200B)}%"
         params = {"pattern": pattern}
-        result = self.db.session.execute(query, params)
+        result = self.session.execute(query, params)
         return self._build_term_references(term_lc, result)
 
     def _get_all_refs(self, terms):
diff --git a/lute/term/routes.py b/lute/term/routes.py
index 804ebecc..6430495d 100644
--- a/lute/term/routes.py
+++ b/lute/term/routes.py
@@ -14,8 +14,12 @@
     send_file,
 )
 from lute.models.language import Language
-from lute.models.term import Term as DBTerm, Status
-from lute.models.setting import UserSetting
+from lute.models.term import Status
+from lute.models.repositories import (
+    LanguageRepository,
+    TermRepository,
+    UserSettingRepository,
+)
 from lute.utils.data_tables import DataTablesFlaskParamParser
 from lute.term.datatables import get_data_tables_list
 from lute.term.model import Repository, Term
@@ -30,11 +34,12 @@
 @bp.route("/index/<search>", methods=["GET"])
 def index(search):
     "Index page."
-    DBTerm.delete_empty_images()
+    repo = TermRepository(db.session)
+    repo.delete_empty_images()
     languages = db.session.query(Language).order_by(Language.name).all()
     langopts = [(lang.id, lang.name) for lang in languages]
     langopts = [(0, "(all)")] + langopts
-    statuses = [s for s in Status.all() if s.id != Status.UNKNOWN]
+    statuses = [s for s in db.session.query(Status).all() if s.id != Status.UNKNOWN]
     return render_template(
         "term/index.html",
         initial_search=search,
@@ -64,7 +69,7 @@ def datatables_active_source():
     "Datatables data for terms."
     parameters = DataTablesFlaskParamParser.parse_params(request.form)
     _load_term_custom_filters(request.form, parameters)
-    data = get_data_tables_list(parameters)
+    data = get_data_tables_list(parameters, db.session)
     return jsonify(data)
 
 
@@ -75,7 +80,7 @@ def export_terms():
     _load_term_custom_filters(request.form, parameters)
     parameters["length"] = 1000000
     outfile = os.path.join(current_app.env_config.temppath, "export_terms.csv")
-    data = get_data_tables_list(parameters)
+    data = get_data_tables_list(parameters, db.session)
     term_data = data["data"]
 
     # Term data is an array of dicts, with the sql field name as dict
@@ -106,8 +111,13 @@ def export_terms():
 
 
 def handle_term_form(
-    term, repo, form_template_name, return_on_success, embedded_in_reading_frame=False
-):
+    term,
+    repo,
+    session,
+    form_template_name,
+    return_on_success,
+    embedded_in_reading_frame=False,
+):  # pylint: disable=too-many-arguments
     """
     Handle a form post.
 
@@ -115,16 +125,13 @@ def handle_term_form(
     lives in an iframe in the reading frames and returns a different
     template on success.
     """
-    # print(f"in handle_term_form with term.id = {term.id}", flush=True)
-    form = TermForm(obj=term)
-    # parents = [{"value": p} for p in term.parents]
-    # form.parentslist.data = json.dumps(parents)
+    form = TermForm(obj=term, session=session)
 
     # Flash messages get added on things like term imports.
     # The user opening the form is treated as an acknowledgement.
     term.flash_message = None
 
-    form.language_id.choices = lute.utils.formutils.language_choices()
+    form.language_id.choices = lute.utils.formutils.language_choices(session)
 
     if form.validate_on_submit():
         form.populate_obj(term)
@@ -136,8 +143,10 @@ def handle_term_form(
     # See DUPLICATE_TERM_CHECK comments in other files.
 
     hide_pronunciation = False
-    term_language = term._language  # pylint: disable=protected-access
-
+    language_repo = LanguageRepository(session)
+    term_language = language_repo.find(
+        term.language_id or -1
+    )  # -1 hack for no lang set.
     if term_language is not None:
         hide_pronunciation = not term_language.show_romanization
 
@@ -149,7 +158,8 @@ def handle_term_form(
     else:
         # The language select control is shown and this is a new term,
         # so use the default value.
-        current_language_id = int(UserSetting.get_value("current_language_id"))
+        us_repo = UserSettingRepository(db.session)
+        current_language_id = int(us_repo.get_value("current_language_id"))
         form.language_id.data = current_language_id
 
     return render_template(
@@ -169,7 +179,7 @@ def _handle_form(term, repo, redirect_to="/term/index"):
     Handle the form post, redirecting to specified url.
     """
     return handle_term_form(
-        term, repo, "/term/formframes.html", redirect(redirect_to, 302)
+        term, repo, db.session, "/term/formframes.html", redirect(redirect_to, 302)
     )
 
 
@@ -178,7 +188,7 @@ def edit(termid):
     """
     Edit a term.
     """
-    repo = Repository(db)
+    repo = Repository(db.session)
     term = repo.load(termid)
     if term.status == 0:
         term.status = 1
@@ -190,7 +200,7 @@ def edit_by_text(langid, text):
     """
     Edit a term.
     """
-    repo = Repository(db)
+    repo = Repository(db.session)
     term = repo.find_or_new(langid, text)
     if term.status == 0:
         term.status = 1
@@ -202,7 +212,7 @@ def new():
     """
     Create a term.
     """
-    repo = Repository(db)
+    repo = Repository(db.session)
     term = Term()
     return _handle_form(term, repo, "/term/new")
 
@@ -212,7 +222,7 @@ def search_by_text_in_language(text, langid):
     "JSON data for parent data."
     if text.strip() == "" or langid == 0:
         return []
-    repo = Repository(db)
+    repo = Repository(db.session)
     matches = repo.find_matches(langid, text)
 
     def _make_entry(t):
@@ -230,7 +240,7 @@ def _make_entry(t):
 @bp.route("/sentences/<int:langid>/<text>", methods=["GET"])
 def sentences(langid, text):
     "Get sentences for terms."
-    repo = Repository(db)
+    repo = Repository(db.session)
     # Use find_or_new(): if the user clicks on a parent tag
     # in the term form, and the parent does not exist yet, then
     # we're creating a new term.
@@ -262,7 +272,7 @@ def bulk_update_status():
       updates: [ { new_status: 1, termids: [ 42, ] }, ... }, ]
     }
     """
-    repo = Repository(db)
+    repo = Repository(db.session)
 
     data = request.get_json()
     updates = data.get("updates")
@@ -285,7 +295,7 @@ def bulk_set_parent():
     termids = data.get("wordids")
     parenttext = data.get("parenttext")
     parent = None
-    repo = Repository(db)
+    repo = Repository(db.session)
     for tid in termids:
         term = repo.load(int(tid))
         if parent is None:
@@ -304,7 +314,7 @@ def bulk_delete():
     "Delete terms."
     data = request.get_json()
     termids = data.get("wordids")
-    repo = Repository(db)
+    repo = Repository(db.session)
     for tid in termids:
         term = repo.load(int(tid))
         repo.delete(term)
@@ -317,7 +327,7 @@ def delete(termid):
     """
     Delete a term.
     """
-    repo = Repository(db)
+    repo = Repository(db.session)
     term = repo.load(termid)
     repo.delete(term)
     repo.commit()
diff --git a/lute/term_parent_map/routes.py b/lute/term_parent_map/routes.py
index 61a8b821..5a5a2d33 100644
--- a/lute/term_parent_map/routes.py
+++ b/lute/term_parent_map/routes.py
@@ -10,7 +10,7 @@
 from lute.db import db
 from lute.models.book import Book
 from lute.models.language import Language
-from lute.term_parent_map.service import export_unknown_terms
+from lute.term_parent_map.service import Service
 
 
 bp = Blueprint("term_parent_map", __name__, url_prefix="/term_parent_map")
@@ -42,5 +42,6 @@ def export_book(bookid):
     "Generate a file and return it."
     outfile = os.path.join(current_app.env_config.temppath, "export_book.txt")
     book = db.session.get(Book, bookid)
-    export_unknown_terms(book, outfile)
+    service = Service(db.session)
+    service.export_unknown_terms(book, outfile)
     return send_file(outfile, as_attachment=True, download_name="unknown_terms.txt")
diff --git a/lute/term_parent_map/service.py b/lute/term_parent_map/service.py
index e85897c7..25c82237 100644
--- a/lute/term_parent_map/service.py
+++ b/lute/term_parent_map/service.py
@@ -2,30 +2,38 @@
 Term parent mapping.
 """
 
-from lute.db import db
 from lute.models.term import Term
 
 
 ## Exports
 
 
-# TODO issue_336_export_unknown_book_terms: move this where needed.
-def export_unknown_terms(book, outfile):
-    "Export unknown terms in the book to outfile."
-    lang = book.language
-    unique_tokens = {
-        t for txt in book.texts for t in lang.get_parsed_tokens(txt.text) if t.is_word
-    }
-    unique_lcase_toks = {lang.get_lowercase(t.token) for t in unique_tokens}
-
-    lgid = lang.id
-    known_terms_lc = (
-        db.session.query(Term.text_lc)
-        .filter(Term.language_id == lgid, Term.token_count == 1)
-        .all()
-    )
-    known_terms_lc = [word[0] for word in known_terms_lc]
-
-    newtoks = [t for t in unique_lcase_toks if t not in known_terms_lc]
-    with open(outfile, "w", encoding="utf-8") as f:
-        f.write("\n".join(newtoks))
+class Service:
+    "Service."
+
+    def __init__(self, session):
+        self.session = session
+
+    # TODO issue_336_export_unknown_book_terms: move this where needed.
+    def export_unknown_terms(self, book, outfile):
+        "Export unknown terms in the book to outfile."
+        lang = book.language
+        unique_tokens = {
+            t
+            for txt in book.texts
+            for t in lang.get_parsed_tokens(txt.text)
+            if t.is_word
+        }
+        unique_lcase_toks = {lang.get_lowercase(t.token) for t in unique_tokens}
+
+        lgid = lang.id
+        known_terms_lc = (
+            self.session.query(Term.text_lc)
+            .filter(Term.language_id == lgid, Term.token_count == 1)
+            .all()
+        )
+        known_terms_lc = [word[0] for word in known_terms_lc]
+
+        newtoks = [t for t in unique_lcase_toks if t not in known_terms_lc]
+        with open(outfile, "w", encoding="utf-8") as f:
+            f.write("\n".join(newtoks))
diff --git a/lute/termimport/routes.py b/lute/termimport/routes.py
index e9a5a8bc..7cccd4fe 100644
--- a/lute/termimport/routes.py
+++ b/lute/termimport/routes.py
@@ -8,7 +8,8 @@
 from wtforms.validators import DataRequired
 from flask_wtf import FlaskForm
 from flask_wtf.file import FileField
-from lute.termimport.service import import_file, BadImportFileError
+from lute.termimport.service import Service, BadImportFileError
+from lute.db import db
 
 
 bp = Blueprint("termimport", __name__, url_prefix="/termimport")
@@ -26,7 +27,7 @@ class TermImportForm(FlaskForm):
 def term_import_index():
     "Import posted file."
     form = TermImportForm()
-
+    service = Service(db.session)
     if form.validate_on_submit():
         text_file = form.text_file.data
         if text_file:
@@ -35,7 +36,7 @@ def term_import_index():
             )
             text_file.save(temp_file_name)
             try:
-                stats = import_file(
+                stats = service.import_file(
                     temp_file_name,
                     form.create_terms.data,
                     form.update_terms.data,
diff --git a/lute/termimport/service.py b/lute/termimport/service.py
index a59a0540..3d7a3cba 100644
--- a/lute/termimport/service.py
+++ b/lute/termimport/service.py
@@ -4,9 +4,8 @@
 
 import csv
 
-from lute.db import db
 from lute.models.term import Status
-from lute.models.language import Language
+from lute.models.repositories import LanguageRepository
 from lute.term.model import Term, Repository
 
 
@@ -20,279 +19,279 @@ class BadImportFileError(Exception):
     """
 
 
-def import_file(filename, create_terms=True, update_terms=True, new_as_unknowns=False):
-    """
-    Validate and import file.
-
-    Throws BadImportFileError if file contains invalid data.
-    """
-    import_data = _load_import_file(filename)
-    _validate_data(import_data)
-    return _do_import(import_data, create_terms, update_terms, new_as_unknowns)
-
-
-def _load_import_file(filename, encoding="utf-8-sig"):
-    "Create array of hashes from file."
-    importdata = []
-    with open(filename, "r", encoding=encoding) as f:
-        reader = csv.DictReader(f)
-        if reader.fieldnames:  # Avoid empty file error
-            reader.fieldnames = [name.lower() for name in reader.fieldnames]
-
-        fieldnames = reader.fieldnames
-        if fieldnames is None:
+class Service:
+    "Service."
+
+    def __init__(self, session):
+        self.session = session
+
+    def import_file(
+        self, filename, create_terms=True, update_terms=True, new_as_unknowns=False
+    ):
+        """
+        Validate and import file.
+
+        Throws BadImportFileError if file contains invalid data.
+        """
+        import_data = self._load_import_file(filename)
+        self._validate_data(import_data)
+        return self._do_import(import_data, create_terms, update_terms, new_as_unknowns)
+
+    def _load_import_file(self, filename, encoding="utf-8-sig"):
+        "Create array of hashes from file."
+        importdata = []
+        with open(filename, "r", encoding=encoding) as f:
+            reader = csv.DictReader(f)
+            if reader.fieldnames:  # Avoid empty file error
+                reader.fieldnames = [name.lower() for name in reader.fieldnames]
+
+            fieldnames = reader.fieldnames
+            if fieldnames is None:
+                raise BadImportFileError("No terms in file")
+            self._validate_data_fields(fieldnames)
+
+            line_num = 0
+            for line in reader:
+                line_num += 1
+                if None in line.values():
+                    raise BadImportFileError(f"Missing values on line {line_num}")
+                if None in line.keys():
+                    raise BadImportFileError(f"Extra values on line {line_num}")
+                importdata.append(line)
+
+        if len(importdata) == 0:
             raise BadImportFileError("No terms in file")
-        _validate_data_fields(fieldnames)
-
-        line_num = 0
-        for line in reader:
-            line_num += 1
-            if None in line.values():
-                raise BadImportFileError(f"Missing values on line {line_num}")
-            if None in line.keys():
-                raise BadImportFileError(f"Extra values on line {line_num}")
-            importdata.append(line)
-
-    if len(importdata) == 0:
-        raise BadImportFileError("No terms in file")
-
-    return importdata
-
-
-def _validate_data_fields(field_list):
-    "Check the keys in the file."
-    required = ["language", "term"]
-    for k in required:
-        if k not in field_list:
-            raise BadImportFileError(f"Missing required field '{k}'")
-
-    allowed = required + [
-        "translation",
-        "parent",
-        "status",
-        "tags",
-        "pronunciation",
-        "link_status",
-    ]
-    ignored = ["added"]
-    for k in field_list:
-        if k not in allowed and k not in ignored:
-            raise BadImportFileError(f"Unknown field '{k}'")
-
-
-def _validate_data(import_data):
-    """
-    Check the data.
-    """
-    _validate_languages(import_data)
-    _validate_terms_exist(import_data)
-    _validate_statuses(import_data)
-    _validate_no_duplicate_terms(import_data)
-
-
-def _create_langs_dict(import_data):
-    "Create dictionary of language name to Language."
-    lang_dict = {}
-    langs = [hsh["language"].strip() for hsh in import_data]
-    for lang_name in list(set(langs)):
-        lang_dict[lang_name] = Language.find_by_name(lang_name)
-    return lang_dict
-
-
-def _get_status(s):
-    "Convert status to db value."
-    status_map = {
-        "": 1,
-        "1": 1,
-        "2": 2,
-        "3": 3,
-        "4": 4,
-        "5": 5,
-        "W": Status.WELLKNOWN,
-        "I": Status.IGNORED,
-    }
-    return status_map.get(s)
-
-
-def _validate_languages(import_data):
-    "Validate the languages."
-    lang_dict = _create_langs_dict(import_data)
-    for lang_name, lang in lang_dict.items():
-        if lang is None:
-            raise BadImportFileError(f"Unknown language '{lang_name}'")
-
-
-def _validate_statuses(import_data):
-    "All statuses must be valid."
-    statuses = [hsh["status"].strip() for hsh in import_data if "status" in hsh]
-    for s in set(statuses):
-        if _get_status(s) is None:
+
+        return importdata
+
+    def _validate_data_fields(self, field_list):
+        "Check the keys in the file."
+        required = ["language", "term"]
+        for k in required:
+            if k not in field_list:
+                raise BadImportFileError(f"Missing required field '{k}'")
+
+        allowed = required + [
+            "translation",
+            "parent",
+            "status",
+            "tags",
+            "pronunciation",
+            "link_status",
+        ]
+        ignored = ["added"]
+        for k in field_list:
+            if k not in allowed and k not in ignored:
+                raise BadImportFileError(f"Unknown field '{k}'")
+
+    def _validate_data(self, import_data):
+        """
+        Check the data.
+        """
+        self._validate_languages(import_data)
+        self._validate_terms_exist(import_data)
+        self._validate_statuses(import_data)
+        self._validate_no_duplicate_terms(import_data)
+
+    def _create_langs_dict(self, import_data):
+        "Create dictionary of language name to Language."
+        repo = LanguageRepository(self.session)
+        lang_dict = {}
+        langs = [hsh["language"].strip() for hsh in import_data]
+        for lang_name in list(set(langs)):
+            lang_dict[lang_name] = repo.find_by_name(lang_name)
+        return lang_dict
+
+    def _get_status(self, s):
+        "Convert status to db value."
+        status_map = {
+            "": 1,
+            "1": 1,
+            "2": 2,
+            "3": 3,
+            "4": 4,
+            "5": 5,
+            "W": Status.WELLKNOWN,
+            "I": Status.IGNORED,
+        }
+        return status_map.get(s)
+
+    def _validate_languages(self, import_data):
+        "Validate the languages."
+        lang_dict = self._create_langs_dict(import_data)
+        for lang_name, lang in lang_dict.items():
+            if lang is None:
+                raise BadImportFileError(f"Unknown language '{lang_name}'")
+
+    def _validate_statuses(self, import_data):
+        "All statuses must be valid."
+        statuses = [hsh["status"].strip() for hsh in import_data if "status" in hsh]
+        for s in set(statuses):
+            if self._get_status(s) is None:
+                raise BadImportFileError(
+                    "Status must be one of 1, 2, 3, 4, 5, I, W, or blank"
+                )
+
+    def _validate_terms_exist(self, import_data):
+        "All records must have a term."
+        blanks = [hsh for hsh in import_data if hsh["term"].strip() == ""]
+        if len(blanks) > 0:
+            raise BadImportFileError("Term is required")
+
+    def _validate_no_duplicate_terms(self, import_data):
+        """
+        Duplicate terms aren't allowed.
+
+        If file contained two duplicate terms, which is the "correct" one?
+        """
+
+        def make_lang_term_string(hsh):
+            t = hsh["term"].strip()
+            # Have to also clear unicode whitespace.
+            t = " ".join(t.split())
+            return f"{hsh['language']}: {t.lower()}"
+
+        lang_terms = [make_lang_term_string(hsh) for hsh in import_data]
+        term_counts = {}
+        for term in lang_terms:
+            term_counts[term] = term_counts.get(term, 0) + 1
+        duplicates = [term for term, count in term_counts.items() if count > 1]
+        if len(duplicates) != 0:
             raise BadImportFileError(
-                "Status must be one of 1, 2, 3, 4, 5, I, W, or blank"
+                f"Duplicate terms in import: {', '.join(duplicates)}"
             )
 
-
-def _validate_terms_exist(import_data):
-    "All records must have a term."
-    blanks = [hsh for hsh in import_data if hsh["term"].strip() == ""]
-    if len(blanks) > 0:
-        raise BadImportFileError("Term is required")
-
-
-def _validate_no_duplicate_terms(import_data):
-    """
-    Duplicate terms aren't allowed.
-
-    If file contained two duplicate terms, which is the "correct" one?
-    """
-
-    def make_lang_term_string(hsh):
-        t = hsh["term"].strip()
-        # Have to also clear unicode whitespace.
-        t = " ".join(t.split())
-        return f"{hsh['language']}: {t.lower()}"
-
-    lang_terms = [make_lang_term_string(hsh) for hsh in import_data]
-    term_counts = {}
-    for term in lang_terms:
-        term_counts[term] = term_counts.get(term, 0) + 1
-    duplicates = [term for term, count in term_counts.items() if count > 1]
-    if len(duplicates) != 0:
-        raise BadImportFileError(f"Duplicate terms in import: {', '.join(duplicates)}")
-
-
-def _import_term_skip_parents(repo, rec, lang, set_to_unknown=False):
-    "Add a single record to the repo."
-    t = Term()
-    t.language = lang
-    t.language_id = lang.id
-    t.text = rec["term"]
-    if "translation" in rec:
-        t.translation = rec["translation"]
-    if "status" in rec:
-        status = _get_status(rec["status"])
-        if status is not None:
-            t.status = int(status)
-    if set_to_unknown:
-        t.status = 0
-    if "pronunciation" in rec:
-        t.romanization = rec["pronunciation"]
-    if "tags" in rec:
-        tags = list(map(str.strip, rec["tags"].split(",")))
-        t.term_tags = [t for t in tags if t != ""]
-    repo.add(t)
-
-
-def _update_term_skip_parents(t, repo, rec):
-    "Update a term in the repo."
-    # Don't change the lang or text of the term
-    # t.language = lang
-    # t.language_id = lang.id
-    # t.text = rec["term"]
-    if "translation" in rec:
-        t.translation = rec["translation"]
-    if "status" in rec:
-        status = _get_status(rec["status"])
-        if status is not None:
-            t.status = int(status)
-    if "pronunciation" in rec:
-        t.romanization = rec["pronunciation"]
-    if "tags" in rec:
-        tags = list(map(str.strip, rec["tags"].split(",")))
-        t.term_tags = [t for t in tags if t != ""]
-
-    repo.add(t)
-
-
-def _set_term_parents(repo, rec, lang):
-    "Set the term parents."
-    t = repo.find(lang.id, rec["term"])
-    parents = list(map(str.strip, rec["parent"].split(",")))
-    t.parents = [p for p in parents if p != ""]
-    if "link_status" in rec and len(parents) == 1:
-        sync_status = rec["link_status"] or ""
-        t.sync_status = sync_status.strip().lower() == "y"
-    if len(t.parents) != 1:
-        t.sync_status = False
-
-    # If syncing to parent, and the term status was not explicitly set,
-    # then "inherit" the parent status.
-    if t.sync_status and len(t.parents) == 1 and "status" not in rec:
-        p = repo.find(lang.id, t.parents[0])
-        if p is not None:
-            t.status = p.status
-
-    repo.add(t)
-
-
-def _do_import(
-    import_data, create_terms=True, update_terms=True, new_as_unknowns=False
-):
-    """
-    Import records.
-
-    If create_terms is True, create new terms.
-    If update_terms is True, update existing terms.
-    If new_as_unknowns is True, new terms are given status 0.
-
-    The import is done in two passes:
-    1. import the basic terms, without setting their parents
-    2. update the terms with parents
-
-    The two passes are done because the import file may
-    contain a parent in its own row, and we want that to be
-    imported first to get its own specified data.
-    """
-    # pylint: disable=too-many-locals
-
-    repo = Repository(db)
-
-    skipped = 0
-
-    # Keep track of the created and updated terms: we only want to
-    # update these ones in pass #2.
-    created_terms = []
-    updated_terms = []
-
-    def term_string(lang, term):
-        return f"{lang.id}-{term}"
-
-    for batch in [import_data[i : i + 100] for i in range(0, len(import_data), 100)]:
-        langs_dict = _create_langs_dict(batch)
-        for hsh in batch:
-            lang = langs_dict[hsh["language"]]
-            t = repo.find(lang.id, hsh["term"])
-            ts = term_string(lang, hsh["term"])
-
-            if create_terms and t is None:
-                # Create a brand-new term.
-                _import_term_skip_parents(repo, hsh, lang, new_as_unknowns)
-                created_terms.append(ts)
-
-            elif update_terms and t is not None:
-                # Can only update existing terms.
-                _update_term_skip_parents(t, repo, hsh)
-                updated_terms.append(ts)
-
-            else:
-                skipped += 1
-
-        repo.commit()
-
-    pass_2 = [t for t in import_data if "parent" in t and t["parent"] != ""]
-    for batch in [pass_2[i : i + 100] for i in range(0, len(pass_2), 100)]:
-        langs_dict = _create_langs_dict(batch)
-        for hsh in batch:
-            lang = langs_dict[hsh["language"]]
-            ts = term_string(lang, hsh["term"])
-            if ts in created_terms or ts in updated_terms:
-                _set_term_parents(repo, hsh, lang)
-        repo.commit()
-
-    stats = {
-        "created": len(created_terms),
-        "updated": len(updated_terms),
-        "skipped": skipped,
-    }
-
-    return stats
+    def _import_term_skip_parents(self, repo, rec, lang, set_to_unknown=False):
+        "Add a single record to the repo."
+        t = Term()
+        t.language = lang
+        t.language_id = lang.id
+        t.text = rec["term"]
+        if "translation" in rec:
+            t.translation = rec["translation"]
+        if "status" in rec:
+            status = self._get_status(rec["status"])
+            if status is not None:
+                t.status = int(status)
+        if set_to_unknown:
+            t.status = 0
+        if "pronunciation" in rec:
+            t.romanization = rec["pronunciation"]
+        if "tags" in rec:
+            tags = list(map(str.strip, rec["tags"].split(",")))
+            t.term_tags = [t for t in tags if t != ""]
+        repo.add(t)
+
+    def _update_term_skip_parents(self, t, repo, rec):
+        "Update a term in the repo."
+        # Don't change the lang or text of the term
+        # t.language = lang
+        # t.language_id = lang.id
+        # t.text = rec["term"]
+        if "translation" in rec:
+            t.translation = rec["translation"]
+        if "status" in rec:
+            status = self._get_status(rec["status"])
+            if status is not None:
+                t.status = int(status)
+        if "pronunciation" in rec:
+            t.romanization = rec["pronunciation"]
+        if "tags" in rec:
+            tags = list(map(str.strip, rec["tags"].split(",")))
+            t.term_tags = [t for t in tags if t != ""]
+
+        repo.add(t)
+
+    def _set_term_parents(self, repo, rec, lang):
+        "Set the term parents."
+        t = repo.find(lang.id, rec["term"])
+        parents = list(map(str.strip, rec["parent"].split(",")))
+        t.parents = [p for p in parents if p != ""]
+        if "link_status" in rec and len(parents) == 1:
+            sync_status = rec["link_status"] or ""
+            t.sync_status = sync_status.strip().lower() == "y"
+        if len(t.parents) != 1:
+            t.sync_status = False
+
+        # If syncing to parent, and the term status was not explicitly set,
+        # then "inherit" the parent status.
+        if t.sync_status and len(t.parents) == 1 and "status" not in rec:
+            p = repo.find(lang.id, t.parents[0])
+            if p is not None:
+                t.status = p.status
+
+        repo.add(t)
+
+    def _do_import(
+        self, import_data, create_terms=True, update_terms=True, new_as_unknowns=False
+    ):
+        """
+        Import records.
+
+        If create_terms is True, create new terms.
+        If update_terms is True, update existing terms.
+        If new_as_unknowns is True, new terms are given status 0.
+
+        The import is done in two passes:
+        1. import the basic terms, without setting their parents
+        2. update the terms with parents
+
+        The two passes are done because the import file may
+        contain a parent in its own row, and we want that to be
+        imported first to get its own specified data.
+        """
+        # pylint: disable=too-many-locals
+
+        repo = Repository(self.session)
+
+        skipped = 0
+
+        # Keep track of the created and updated terms: we only want to
+        # update these ones in pass #2.
+        created_terms = []
+        updated_terms = []
+
+        def term_string(lang, term):
+            return f"{lang.id}-{term}"
+
+        for batch in [
+            import_data[i : i + 100] for i in range(0, len(import_data), 100)
+        ]:
+            langs_dict = self._create_langs_dict(batch)
+            for hsh in batch:
+                lang = langs_dict[hsh["language"]]
+                t = repo.find(lang.id, hsh["term"])
+                ts = term_string(lang, hsh["term"])
+
+                if create_terms and t is None:
+                    # Create a brand-new term.
+                    self._import_term_skip_parents(repo, hsh, lang, new_as_unknowns)
+                    created_terms.append(ts)
+
+                elif update_terms and t is not None:
+                    # Can only update existing terms.
+                    self._update_term_skip_parents(t, repo, hsh)
+                    updated_terms.append(ts)
+
+                else:
+                    skipped += 1
+
+            repo.commit()
+
+        pass_2 = [t for t in import_data if "parent" in t and t["parent"] != ""]
+        for batch in [pass_2[i : i + 100] for i in range(0, len(pass_2), 100)]:
+            langs_dict = self._create_langs_dict(batch)
+            for hsh in batch:
+                lang = langs_dict[hsh["language"]]
+                ts = term_string(lang, hsh["term"])
+                if ts in created_terms or ts in updated_terms:
+                    self._set_term_parents(repo, hsh, lang)
+            repo.commit()
+
+        stats = {
+            "created": len(created_terms),
+            "updated": len(updated_terms),
+            "skipped": skipped,
+        }
+
+        return stats
diff --git a/lute/termtag/datatables.py b/lute/termtag/datatables.py
index 9aba0b4f..353f20cc 100644
--- a/lute/termtag/datatables.py
+++ b/lute/termtag/datatables.py
@@ -2,11 +2,10 @@
 Show terms in datatables.
 """
 
-from lute.db import db
 from lute.utils.data_tables import DataTablesSqliteQuery
 
 
-def get_data_tables_list(parameters):
+def get_data_tables_list(parameters, session):
     "json data for datatables."
     base_sql = """SELECT
           TgID,
@@ -21,6 +20,5 @@ def get_data_tables_list(parameters):
             group by WtTgID
           ) src on src.WtTgID = TgID
     """
-    session = db.session
     connection = session.connection()
     return DataTablesSqliteQuery.get_data(base_sql, parameters, connection)
diff --git a/lute/termtag/routes.py b/lute/termtag/routes.py
index b1e10b22..6b8446d4 100644
--- a/lute/termtag/routes.py
+++ b/lute/termtag/routes.py
@@ -5,6 +5,7 @@
 from sqlalchemy import text
 from flask import Blueprint, request, jsonify, render_template, redirect
 from lute.models.term import TermTag
+from lute.models.repositories import TermTagRepository
 from lute.utils.data_tables import DataTablesFlaskParamParser
 from lute.termtag.datatables import get_data_tables_list
 from lute.db import db
@@ -24,7 +25,7 @@ def index(search):
 def datatables_active_source():
     "Datatables data for terms."
     parameters = DataTablesFlaskParamParser.parse_params(request.form)
-    data = get_data_tables_list(parameters)
+    data = get_data_tables_list(parameters, db.session)
     return jsonify(data)
 
 
@@ -48,7 +49,8 @@ def edit(termtagid):
     """
     Edit a termtag
     """
-    termtag = TermTag.find(termtagid)
+    repo = TermTagRepository(db.session)
+    termtag = repo.find(termtagid)
     return _handle_form(termtag, "termtag/edit.html")
 
 
@@ -66,7 +68,8 @@ def delete(termtagid):
     """
     Delete a termtag.
     """
-    termtag = TermTag.find(termtagid)
+    repo = TermTagRepository(db.session)
+    termtag = repo.find(termtagid)
     db.session.delete(termtag)
 
     # ANNOYING HACK.  Per GitHub issue 455, the records
diff --git a/lute/themes/routes.py b/lute/themes/routes.py
index dc131255..4cd86e13 100644
--- a/lute/themes/routes.py
+++ b/lute/themes/routes.py
@@ -2,8 +2,9 @@
 
 from flask import Blueprint, Response, jsonify
 
-from lute.themes.service import get_current_css, next_theme
-from lute.models.setting import UserSetting
+from lute.themes.service import Service
+from lute.models.repositories import UserSettingRepository
+from lute.settings.current import current_settings
 from lute.db import db
 
 bp = Blueprint("themes", __name__, url_prefix="/theme")
@@ -12,7 +13,8 @@
 @bp.route("/current", methods=["GET"])
 def current_theme():
     "Return current css."
-    response = Response(get_current_css(), 200)
+    service = Service(db.session)
+    response = Response(service.get_current_css(), 200)
     response.content_type = "text/css; charset=utf-8"
     return response
 
@@ -22,7 +24,8 @@ def custom_styles():
     """
     Return the custom settings for inclusion in the base.html.
     """
-    css = UserSetting.get_value("custom_styles")
+    repo = UserSettingRepository(db.session)
+    css = repo.get_value("custom_styles")
     response = Response(css, 200)
     response.content_type = "text/css; charset=utf-8"
     return response
@@ -31,14 +34,17 @@ def custom_styles():
 @bp.route("/next", methods=["POST"])
 def set_next_theme():
     "Go to next theme."
-    next_theme()
+    service = Service(db.session)
+    service.next_theme()
     return jsonify("ok")
 
 
 @bp.route("/toggle_highlight", methods=["POST"])
 def toggle_highlight():
     "Fix the highlight."
-    b = bool(int(UserSetting.get_value("show_highlights")))
-    UserSetting.set_value("show_highlights", not b)
+    new_setting = not current_settings["show_highlights"]
+    repo = UserSettingRepository(db.session)
+    repo.set_value("show_highlights", new_setting)
     db.session.commit()
+    current_settings["show_highlights"] = new_setting
     return jsonify("ok")
diff --git a/lute/themes/service.py b/lute/themes/service.py
index 8676aa85..47e4a2b8 100644
--- a/lute/themes/service.py
+++ b/lute/themes/service.py
@@ -1,87 +1,90 @@
 """
 Theming service.
 
-Themes are stored in the css folder.  Current theme is saved in
-UserSetting.
+Themes are stored in the css folder, current theme in UserSetting.
 """
 
 import os
 from glob import glob
 from flask import current_app
-from lute.models.setting import UserSetting
-from lute.db import db
+from lute.models.repositories import UserSettingRepository
 
 default_entry = ("-", "(default)")
 
 
-def _css_path():
-    """
-    Path to css in this folder.
-    """
-    thisdir = os.path.dirname(__file__)
-    theme_dir = os.path.join(thisdir, "css")
-    return os.path.abspath(theme_dir)
-
-
-def list_themes():
-    """
-    List of theme file names and user-readable name.
-    """
+class Service:
+    "Service."
+
+    def __init__(self, session):
+        self.session = session
+
+    def _css_path(self):
+        """
+        Path to css in this folder.
+        """
+        thisdir = os.path.dirname(__file__)
+        theme_dir = os.path.join(thisdir, "css")
+        return os.path.abspath(theme_dir)
+
+    def list_themes(self):
+        """
+        List of theme file names and user-readable name.
+        """
+
+        def _make_display_name(s):
+            ret = os.path.basename(s)
+            ret = ret.replace(".css", "").replace("_", " ")
+            return ret
+
+        g = glob(os.path.join(self._css_path(), "*.css"))
+        themes = [(os.path.basename(f), _make_display_name(f)) for f in g]
+        theme_basenames = [t[0] for t in themes]
+
+        g = glob(os.path.join(current_app.env_config.userthemespath, "*.css"))
+        additional_user_themes = [
+            (os.path.basename(f), _make_display_name(f))
+            for f in g
+            if os.path.basename(f) not in theme_basenames
+        ]
+
+        themes += additional_user_themes
+        sorted_themes = sorted(themes, key=lambda x: x[1])
+        return [default_entry] + sorted_themes
+
+    def get_current_css(self):
+        """
+        Return the current css pointed at by the current_theme user setting.
+        """
+        repo = UserSettingRepository(self.session)
+        current_theme = repo.get_value("current_theme")
+        if current_theme == default_entry[0]:
+            return ""
 
-    def _make_display_name(s):
-        ret = os.path.basename(s)
-        ret = ret.replace(".css", "").replace("_", " ")
+        def _get_theme_css_in_dir(d):
+            "Get css, or '' if no file."
+            fname = os.path.join(d, current_theme)
+            if not os.path.exists(fname):
+                return ""
+            with open(fname, "r", encoding="utf-8") as f:
+                return f.read()
+
+        ret = _get_theme_css_in_dir(self._css_path())
+        add = _get_theme_css_in_dir(current_app.env_config.userthemespath)
+        if add != "":
+            ret += f"\n\n/* Additional user css */\n\n{add}"
         return ret
 
-    g = glob(os.path.join(_css_path(), "*.css"))
-    themes = [(os.path.basename(f), _make_display_name(f)) for f in g]
-    theme_basenames = [t[0] for t in themes]
-
-    g = glob(os.path.join(current_app.env_config.userthemespath, "*.css"))
-    additional_user_themes = [
-        (os.path.basename(f), _make_display_name(f))
-        for f in g
-        if os.path.basename(f) not in theme_basenames
-    ]
-
-    themes += additional_user_themes
-    sorted_themes = sorted(themes, key=lambda x: x[1])
-    return [default_entry] + sorted_themes
-
-
-def get_current_css():
-    """
-    Return the current css pointed at by the current_theme user setting.
-    """
-    current_theme = UserSetting.get_value("current_theme")
-    if current_theme == default_entry[0]:
-        return ""
-
-    def _get_theme_css_in_dir(d):
-        "Get css, or '' if no file."
-        fname = os.path.join(d, current_theme)
-        if not os.path.exists(fname):
-            return ""
-        with open(fname, "r", encoding="utf-8") as f:
-            return f.read()
-
-    ret = _get_theme_css_in_dir(_css_path())
-    add = _get_theme_css_in_dir(current_app.env_config.userthemespath)
-    if add != "":
-        ret += f"\n\n/* Additional user css */\n\n{add}"
-    return ret
-
-
-def next_theme():
-    """
-    Move to the next theme in the list of themes.
-    """
-    current_theme = UserSetting.get_value("current_theme")
-    themes = [t[0] for t in list_themes()]
-    themes.append(default_entry[0])
-    for i in range(0, len(themes)):  # pylint: disable=consider-using-enumerate
-        if themes[i] == current_theme:
-            new_index = i + 1
-            break
-    UserSetting.set_value("current_theme", themes[new_index])
-    db.session.commit()
+    def next_theme(self):
+        """
+        Move to the next theme in the list of themes.
+        """
+        repo = UserSettingRepository(self.session)
+        current_theme = repo.get_value("current_theme")
+        themes = [t[0] for t in self.list_themes()]
+        themes.append(default_entry[0])
+        for i in range(0, len(themes)):  # pylint: disable=consider-using-enumerate
+            if themes[i] == current_theme:
+                new_index = i + 1
+                break
+        repo.set_value("current_theme", themes[new_index])
+        self.session.commit()
diff --git a/lute/utils/formutils.py b/lute/utils/formutils.py
index 3607eafc..92e1dcea 100644
--- a/lute/utils/formutils.py
+++ b/lute/utils/formutils.py
@@ -3,18 +3,17 @@
 """
 
 from lute.models.language import Language
-from lute.models.setting import UserSetting
-from lute.db import db
+from lute.models.repositories import UserSettingRepository
 
 
-def language_choices(dummy_entry_placeholder="-"):
+def language_choices(session, dummy_entry_placeholder="-"):
     """
     Return the list of languages for select boxes.
 
     If only one lang exists, only return that,
     otherwise add a '-' dummy entry at the top.
     """
-    langs = db.session.query(Language).order_by(Language.name).all()
+    langs = session.query(Language).order_by(Language.name).all()
     supported = [lang for lang in langs if lang.is_supported]
     lang_choices = [(s.id, s.name) for s in supported]
     # Add a dummy placeholder even if there are no languages.
@@ -23,19 +22,20 @@ def language_choices(dummy_entry_placeholder="-"):
     return lang_choices
 
 
-def valid_current_language_id():
+def valid_current_language_id(session):
     """
     Get the current language id from UserSetting, ensuring
     it's still valid.  If not, change it.
     """
-    current_language_id = UserSetting.get_value("current_language_id")
+    repo = UserSettingRepository(session)
+    current_language_id = repo.get_value("current_language_id")
     current_language_id = int(current_language_id)
 
-    valid_language_ids = [int(p[0]) for p in language_choices()]
+    valid_language_ids = [int(p[0]) for p in language_choices(session)]
     if current_language_id in valid_language_ids:
         return current_language_id
 
     current_language_id = valid_language_ids[0]
-    UserSetting.set_value("current_language_id", current_language_id)
-    db.session.commit()
+    repo.set_value("current_language_id", current_language_id)
+    session.commit()
     return current_language_id
diff --git a/tasks.py b/tasks.py
index 4675c75c..8a71ebfd 100644
--- a/tasks.py
+++ b/tasks.py
@@ -15,6 +15,8 @@
 import os
 import sys
 import subprocess
+import threading
+import time
 from datetime import datetime
 import requests
 from invoke import task, Collection
@@ -103,6 +105,65 @@ def _site_is_running(useport=None):
         return False
 
 
+def _wait_for_running_site(port):
+    "Wait until the site is running."
+    url = f"http://localhost:{port}"
+    is_running = False
+    attempt_count = 0
+    print(f"Wait until site is running at {url}.", flush=True)
+    while attempt_count < 10 and not is_running:
+        attempt_count += 1
+        try:
+            print(f"  Attempt {attempt_count}", flush=True)
+            requests.get(url, timeout=5)
+            is_running = True
+        except requests.exceptions.ConnectionError:
+            time.sleep(1)
+    if not is_running:
+        raise Exception("Site didn't start?")  # pylint: disable=broad-exception-raised
+
+
+def _run_browser_tests(c, port, run_test):
+    "Start server on port if necessary, and run tests."
+    tests_failed = False
+    if _site_is_running(port):
+        c.run(" ".join(run_test))
+    else:
+
+        def print_subproc_output(pipe, label):
+            """Prints output from a given pipe with a label."""
+            for line in iter(pipe.readline, b""):
+                print(f"[{label}] {line.decode().strip()}", flush=True)
+            pipe.close()
+
+        cmd = ["python", "-m", "tests.acceptance.start_acceptance_app", f"{port}"]
+        with subprocess.Popen(
+            cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
+        ) as app_process:
+            _wait_for_running_site(port)
+            stdout_thread = threading.Thread(
+                target=print_subproc_output, args=(app_process.stdout, "STDOUT")
+            )
+            stderr_thread = threading.Thread(
+                target=print_subproc_output, args=(app_process.stderr, "STDERR")
+            )
+            stdout_thread.start()
+            stderr_thread.start()
+            try:
+                subprocess.run(run_test, check=True)
+            except subprocess.CalledProcessError:
+                # This just means a test failed.  We don't need to see
+                # a stack trace, the assert failures are already displayed.
+                tests_failed = True
+            finally:
+                app_process.terminate()
+                stdout_thread.join()
+                stderr_thread.join()
+
+    if tests_failed:
+        raise RuntimeError("tests failed")
+
+
 @task(
     pre=[_ensure_test_db],
     help={
@@ -150,23 +211,7 @@ def accept(  # pylint: disable=too-many-arguments
     if verbose:
         run_test.append("-vv")
 
-    tests_failed = False
-    if _site_is_running(port):
-        c.run(" ".join(run_test))
-    else:
-        cmd = ["python", "-m", "tests.acceptance.start_acceptance_app", f"{port}"]
-        with subprocess.Popen(cmd) as app_process:
-            try:
-                subprocess.run(run_test, check=True)
-            except subprocess.CalledProcessError:
-                # This just means a test failed.  We don't need to see
-                # a stack trace, the assert failures are already displayed.
-                tests_failed = True
-            finally:
-                app_process.terminate()
-
-    if tests_failed:
-        raise RuntimeError("tests failed")
+    _run_browser_tests(c, 5001, run_test)
 
 
 @task(pre=[_ensure_test_db])
@@ -179,25 +224,7 @@ def playwright(c):
     If Lute's not running on specified port, start a server.
     """
     run_test = ["pytest", "tests/playwright/playwright.py", "-s"]
-
-    tests_failed = False
-    port = 5001
-    if _site_is_running(port):
-        c.run(" ".join(run_test))
-    else:
-        cmd = ["python", "-m", "tests.acceptance.start_acceptance_app", f"{port}"]
-        with subprocess.Popen(cmd) as app_process:
-            try:
-                subprocess.run(run_test, check=True)
-            except subprocess.CalledProcessError:
-                # This just means a test failed.  We don't need to see
-                # a stack trace, the assert failures are already displayed.
-                tests_failed = True
-            finally:
-                app_process.terminate()
-
-    if tests_failed:
-        raise RuntimeError("tests failed")
+    _run_browser_tests(c, 5001, run_test)
 
 
 @task(pre=[_ensure_test_db], help={"html": "open html report"})
@@ -217,7 +244,7 @@ def coverage(c, html=False):
         c.run(cmd)
 
 
-@task(post=[lint])
+@task
 def black(c):
     "black-format things."
     c.run("python -m black .")


Test patch:
diff --git a/tests/acceptance/start_acceptance_app.py b/tests/acceptance/start_acceptance_app.py
index 1a81853f..51aa1734 100644
--- a/tests/acceptance/start_acceptance_app.py
+++ b/tests/acceptance/start_acceptance_app.py
@@ -23,5 +23,5 @@
 app = create_app()
 
 port = int(sys.argv[1])
-print(f"running at localhost:{port}")
+print(f"running at localhost:{port}", flush=True)
 serve(app, host="0.0.0.0", port=port)
diff --git a/tests/conftest.py b/tests/conftest.py
index 9ab476a5..0b36e28e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,7 +8,7 @@
 
 from lute.config.app_config import AppConfig
 from lute.db import db
-from lute.language.service import get_language_def
+from lute.language.service import Service
 import lute.db.demo
 from lute.app_factory import create_app
 
@@ -88,7 +88,7 @@ def fixture_empty_db(app_context):
     """
     Wipe the db.
     """
-    lute.db.management.delete_all_data()
+    lute.db.management.delete_all_data(db.session)
 
 
 @pytest.fixture(name="client")
@@ -107,7 +107,8 @@ def _get_test_language(lang_name):
     lang = db.session.query(Language).filter(Language.name == lang_name).first()
     if lang is not None:
         return lang
-    lang = get_language_def(lang_name)["language"]
+    service = Service(db.session)
+    lang = service.get_language_def(lang_name)["language"]
     return lang
 
 
diff --git a/tests/features/test_rendering.py b/tests/features/test_rendering.py
index b4c1a2aa..54b93bb9 100644
--- a/tests/features/test_rendering.py
+++ b/tests/features/test_rendering.py
@@ -8,8 +8,8 @@
 from lute.db import db
 from lute.models.language import Language
 from lute.term.model import Repository
-from lute.read.render.service import get_paragraphs
-from lute.read.service import set_unknowns_to_known, bulk_status_update
+from lute.read.render.service import Service as RenderService
+from lute.read.service import Service
 
 from tests.utils import add_terms, make_text
 from tests.dbasserts import assert_sql_result
@@ -45,7 +45,7 @@ def given_terms(content):
 
 @given(parsers.parse('term "{content}" with status {status} and parent "{parenttext}"'))
 def given_term_with_status_and_parent(content, status, parenttext):
-    r = Repository(db)
+    r = Repository(db.session)
     t = r.find_or_new(language.id, content)
     t.status = int(status)
     t.parents.append(parenttext)
@@ -55,7 +55,7 @@ def given_term_with_status_and_parent(content, status, parenttext):
 
 @given(parsers.parse('term "{content}" with status {status}'))
 def given_term_with_status(content, status):
-    r = Repository(db)
+    r = Repository(db.session)
     t = r.find_or_new(language.id, content)
     t.status = int(status)
     r.add(t)
@@ -72,12 +72,14 @@ def given_text(content):
 
 @given("all unknowns are set to known")
 def set_to_known():
-    set_unknowns_to_known(text)
+    service = Service(db.session)
+    service.set_unknowns_to_known(text)
 
 
 @given(parsers.parse("bulk status {newstatus} update for terms:\n{terms}"))
 def update_status(newstatus, terms):
-    bulk_status_update(text, terms.split("\n"), int(newstatus))
+    service = Service(db.session)
+    service.bulk_status_update(text, terms.split("\n"), int(newstatus))
 
 
 def _assert_stringized_equals(stringizer, joiner, expected):
@@ -85,7 +87,8 @@ def _assert_stringized_equals(stringizer, joiner, expected):
     Get paragraphs and stringize all textitems,
     join and assert equals expected.
     """
-    paras = get_paragraphs(text.text, text.book.language)
+    rs = RenderService(db.session)
+    paras = rs.get_paragraphs(text.text, text.book.language)
     # print("TOKENS", flush=True)
     # print(paras, flush=True)
     ret = []
diff --git a/tests/features/test_term_import.py b/tests/features/test_term_import.py
index 69cd28c6..3cf37f4b 100644
--- a/tests/features/test_term_import.py
+++ b/tests/features/test_term_import.py
@@ -9,10 +9,11 @@
 
 from pytest_bdd import given, when, then, scenarios, parsers
 
+from lute.db import db
 from lute.models.language import Language
 from lute.models.term import Term
-
-from lute.termimport.service import import_file, BadImportFileError
+from lute.models.repositories import LanguageRepository, TermRepository
+from lute.termimport.service import Service, BadImportFileError
 
 from tests.dbasserts import assert_sql_result
 
@@ -52,7 +53,10 @@ def import_with_settings(create, update):
         tmp.write(content)
 
     global stats  # pylint: disable=global-statement
-    stats = import_file(path, create.lower() == "true", update.lower() == "true")
+    service = Service(db.session)
+    stats = service.import_file(
+        path, create.lower() == "true", update.lower() == "true"
+    )
     os.remove(path)
 
 
@@ -68,7 +72,8 @@ def import_with_settings_and_newunks(create, update, newunknowns):
         tmp.write(content)
 
     global stats  # pylint: disable=global-statement
-    stats = import_file(
+    service = Service(db.session)
+    stats = service.import_file(
         path,
         create.lower() == "true",
         update.lower() == "true",
@@ -95,7 +100,8 @@ def fail_with_message(message):
         # do stuff with temp file
         tmp.write(content)
     with pytest.raises(BadImportFileError, match=message):
-        import_file(path)
+        service = Service(db.session)
+        service.import_file(path)
     os.remove(path)
 
 
@@ -110,9 +116,11 @@ def then_words_table_contains_WoTextLC(text_lc_content):
 
 @then(parsers.parse('{language} term "{term}" should be:\n{expected}'))
 def then_term_tags(language, term, expected):
-    lang = Language.find_by_name(language)
+    repo = LanguageRepository(db.session)
+    lang = repo.find_by_name(language)
     spec = Term(lang, term)
-    t = Term.find_by_spec(spec)
+    term_repo = TermRepository(db.session)
+    t = term_repo.find_by_spec(spec)
     pstring = ", ".join([p.text for p in t.parents])
     if pstring == "":
         pstring = "-"
diff --git a/tests/orm/test_Book.py b/tests/orm/test_Book.py
index 34908f13..2fd6b267 100644
--- a/tests/orm/test_Book.py
+++ b/tests/orm/test_Book.py
@@ -4,8 +4,7 @@
 
 from datetime import datetime
 import pytest
-from lute.models.book import Book, BookTag, TextBookmark
-from lute.book.stats import BookStats
+from lute.models.book import Book, BookTag, TextBookmark, BookStats
 from lute.db import db
 from tests.dbasserts import assert_sql_result, assert_record_count_equals
 
diff --git a/tests/orm/test_Language.py b/tests/orm/test_Language.py
index 7f17132a..8ea7109e 100644
--- a/tests/orm/test_Language.py
+++ b/tests/orm/test_Language.py
@@ -4,6 +4,7 @@
 
 import json
 from lute.models.language import Language, LanguageDictionary
+from lute.models.repositories import LanguageRepository
 from lute.db import db
 from tests.dbasserts import assert_sql_result
 from tests.utils import make_text, add_terms
@@ -91,7 +92,7 @@ def test_language_dictionaries_smoke_test(empty_db):
 
 def test_delete_language_removes_book_and_terms(app_context, spanish):
     """
-    Test HACKY Language.delete() method to ensure deletes cascade.
+    Test HACKY LanguageRepository.delete() method to ensure deletes cascade.
     """
     add_terms(spanish, ["gato", "perro"])
     t = make_text("hola", "Hola amigo", spanish)
@@ -114,7 +115,8 @@ def test_delete_language_removes_book_and_terms(app_context, spanish):
     assert_sql_result(sqlbook, ["hola"], "initial book")
     assert_sql_result(sqldict, ["something?###"], "dict")
 
-    Language.delete(spanish)
+    repo = LanguageRepository(db.session)
+    repo.delete(spanish)
 
     assert_sql_result(sqlbook, [], "book deleted")
     assert_sql_result(sqlterms, [], "terms deleted")
diff --git a/tests/orm/test_Term.py b/tests/orm/test_Term.py
index 4912a150..29a00858 100644
--- a/tests/orm/test_Term.py
+++ b/tests/orm/test_Term.py
@@ -65,7 +65,7 @@ def test_term_parent_with_two_children(spanish):
     db.session.add(gato)
     db.session.commit()
 
-    parent_get = Term.find(parent.id)
+    parent_get = db.session.get(Term, parent.id)
     assert parent_get.text == "PARENT"
     assert len(parent_get.children) == 2
 
@@ -264,7 +264,7 @@ def test_changing_text_to_same_thing_does_not_throw(japanese):
     sql = f"select wotext, wotextlc from words where woid = {term.id}"
     assert_sql_result(sql, ["集めれ; 集めれ"], "have term")
 
-    t = Term.find(term.id)
+    t = db.session.get(Term, term.id)
     t.text = "集めれ"
     db.session.add(t)
     db.session.commit()
@@ -283,7 +283,7 @@ def test_changing_multiword_text_case_does_not_throw(english):
     sql = f"select wotext, wotextlc from words where woid = {term.id}"
     assert_sql_result(sql, ["a/ /CAT; a/ /cat"], "have term")
 
-    t = Term.find(term.id)
+    t = db.session.get(Term, term.id)
     t.text = term.text.lower()
     db.session.add(t)
     db.session.commit()
diff --git a/tests/orm/test_TermTag.py b/tests/orm/test_TermTag.py
index 913414fd..82c527e4 100644
--- a/tests/orm/test_TermTag.py
+++ b/tests/orm/test_TermTag.py
@@ -64,8 +64,7 @@ def test_deleting_termtag_removes_wordtags_table_record(empty_db, spanish):
     sqlassoc = "select * from wordtags"
     assert_record_count_equals(sqlassoc, 3, "word tag associations exist")
 
-    termtag = TermTag.find(tg.id)
-    # route_delete(tg.id)
+    termtag = db.session.get(TermTag, tg.id)
     db.session.delete(termtag)
     db.session.commit()
 
diff --git a/tests/playwright/playwright.py b/tests/playwright/playwright.py
index 65b0fbba..142891d0 100644
--- a/tests/playwright/playwright.py
+++ b/tests/playwright/playwright.py
@@ -49,6 +49,10 @@ def _print(s):
     context.set_default_timeout(30000)
     page = context.new_page()
 
+    # Hardcoded port will cause problems ...
+    _print("Initial load sanity check")
+    page.goto("http://localhost:5001/")
+
     _print("Reset db.")
     page.goto("http://localhost:5001/dev_api/load_demo")
 
diff --git a/tests/unit/backup/test_backup.py b/tests/unit/backup/test_backup.py
index 64f06d09..6a1d636c 100644
--- a/tests/unit/backup/test_backup.py
+++ b/tests/unit/backup/test_backup.py
@@ -7,15 +7,9 @@
 from unittest.mock import Mock, patch
 import pytest
 
-from lute.backup.service import (
-    create_backup,
-    BackupException,
-    should_run_auto_backup,
-    backup_warning,
-    DatabaseBackupFile,
-    list_backups,
-)
-from lute.models.setting import BackupSettings
+from lute.backup.service import Service, BackupException, DatabaseBackupFile
+from lute.models.repositories import UserSettingRepository
+from lute.db import db
 
 # pylint: disable=missing-function-docstring
 # Test method names are pretty descriptive already.
@@ -53,7 +47,8 @@ def cleanup_directory(directory):
 @pytest.fixture(name="backup_settings")
 def fixture_backup_settings(app_context, bkp_dir):
     # app_context is passed so that the db session is available.
-    ret = BackupSettings.get_backup_settings()
+    repo = UserSettingRepository(db.session)
+    ret = repo.get_backup_settings()
     ret.backup_dir = bkp_dir
     ret.backup_enabled = True
     yield ret
@@ -99,7 +94,8 @@ def test_backup_writes_file_to_output_dir(testconfig, bkp_dir, backup_settings):
     with open(os.path.join(img1, "file.txt"), "wb") as f:
         f.write(file_content)
 
-    create_backup(testconfig, backup_settings)
+    service = Service(db.session)
+    service.create_backup(testconfig, backup_settings)
     assert len(os.listdir(bkp_dir)) == 2  # db and directory
     assert len(os.listdir(os.path.join(bkp_dir, "userimages_backup", "1"))) == 1
     assert os.path.exists(os.path.join(bkp_dir, "userimages_backup", "1", "file.txt"))
@@ -107,21 +103,24 @@ def test_backup_writes_file_to_output_dir(testconfig, bkp_dir, backup_settings):
 
 def test_timestamp_added_to_db_name(testconfig, bkp_dir, backup_settings):
     assert os.listdir(bkp_dir) == [], "empty dirs at start"
-    create_backup(testconfig, backup_settings)
+    service = Service(db.session)
+    service.create_backup(testconfig, backup_settings)
     dbfile = [f for f in os.listdir(bkp_dir) if f.startswith("lute_backup_2")]
     assert len(dbfile) == 1, "db found"
 
 
 def test_backup_fails_if_missing_output_dir(testconfig, backup_settings):
     backup_settings.backup_dir = "some_missing_dir"
+    service = Service(db.session)
     with pytest.raises(BackupException, match="Missing directory some_missing_dir"):
-        create_backup(testconfig, backup_settings)
+        service.create_backup(testconfig, backup_settings)
 
 
 def test_user_can_configure_rolling_backup_count(testconfig, bkp_dir, backup_settings):
     backup_settings.backup_count = 2
+    service = Service(db.session)
     for i in range(1, 10):
-        create_backup(testconfig, backup_settings, suffix=f"0{i}")
+        service.create_backup(testconfig, backup_settings, suffix=f"0{i}")
     expected_files = [f"lute_backup_0{i}.db.gz" for i in range(8, 10)]
     db_files = [f for f in os.listdir(bkp_dir) if f.endswith(".gz")]
     assert db_files.sort() == expected_files.sort()
@@ -129,8 +128,11 @@ def test_user_can_configure_rolling_backup_count(testconfig, bkp_dir, backup_set
 
 def test_all_manual_backups_are_kept(testconfig, bkp_dir, backup_settings):
     backup_settings.backup_count = 2
+    service = Service(db.session)
     for i in range(1, 10):
-        create_backup(testconfig, backup_settings, suffix=f"0{i}", is_manual=True)
+        service.create_backup(
+            testconfig, backup_settings, suffix=f"0{i}", is_manual=True
+        )
     expected_files = [f"lute_backup_0{i}.db.gz" for i in range(1, 10)]
     db_files = [f for f in os.listdir(bkp_dir) if f.endswith(".gz")]
     assert db_files.sort() == expected_files.sort()
@@ -140,22 +142,27 @@ def test_last_import_setting_is_updated_on_successful_backup(
     testconfig, backup_settings
 ):
     assert backup_settings.last_backup_datetime is None, "no backup"
-    create_backup(testconfig, backup_settings)
-    updated = BackupSettings.get_backup_settings()
+    service = Service(db.session)
+    service.create_backup(testconfig, backup_settings)
+
+    repo = UserSettingRepository(db.session)
+    updated = repo.get_backup_settings()
     assert updated.last_backup_datetime is not None, "set"
 
 
 def test_should_not_run_autobackup_if_auto_is_no_or_false(backup_settings):
+    service = Service(db.session)
     backup_settings.backup_enabled = True
     backup_settings.backup_auto = False
-    assert should_run_auto_backup(backup_settings) is False
+    assert service.should_run_auto_backup(backup_settings) is False
 
 
 def test_autobackup_returns_true_if_never_backed_up(backup_settings):
     backup_settings.backup_enabled = True
     backup_settings.backup_auto = True
     backup_settings.last_backup_datetime = None
-    assert should_run_auto_backup(backup_settings) is True
+    service = Service(db.session)
+    assert service.should_run_auto_backup(backup_settings) is True
 
 
 def test_autobackup_returns_true_if_last_backed_up_over_one_day_ago(backup_settings):
@@ -164,11 +171,12 @@ def test_autobackup_returns_true_if_last_backed_up_over_one_day_ago(backup_setti
     curr_datetime = datetime.now()
     one_day_ago = curr_datetime.timestamp() - 24 * 60 * 60
 
+    service = Service(db.session)
     backup_settings.last_backup_datetime = one_day_ago - 10
-    assert should_run_auto_backup(backup_settings) is True
+    assert service.should_run_auto_backup(backup_settings) is True
 
     backup_settings.last_backup_datetime = one_day_ago + 10
-    assert should_run_auto_backup(backup_settings) is False
+    assert service.should_run_auto_backup(backup_settings) is False
 
 
 def test_warn_if_last_backup_never_happened_or_is_old(backup_settings):
@@ -177,16 +185,20 @@ def test_warn_if_last_backup_never_happened_or_is_old(backup_settings):
 
     backup_settings.backup_warn = True
     backup_settings.last_backup_datetime = None
-    assert backup_warning(backup_settings) == "Never backed up."
+    service = Service(db.session)
+    assert service.backup_warning(backup_settings) == "Never backed up."
 
     backup_settings.last_backup_datetime = one_week_ago + 10
-    assert backup_warning(backup_settings) == ""
+    assert service.backup_warning(backup_settings) == ""
 
     backup_settings.last_backup_datetime = one_week_ago - 10
-    assert backup_warning(backup_settings) == "Last backup was more than 1 week ago."
+    assert (
+        service.backup_warning(backup_settings)
+        == "Last backup was more than 1 week ago."
+    )
 
     backup_settings.backup_warn = False
-    assert backup_warning(backup_settings) == ""
+    assert service.backup_warning(backup_settings) == ""
 
 
 def test_database_backup_file_on_nonexistent_path(bkp_dir):
@@ -247,7 +259,8 @@ def test_database_backup_file_size_formatting(auto_backup_file):
 def test_list_backups_returns_both(
     bkp_dir, auto_backup_file, manual_backup_file
 ):  # pylint: disable=unused-argument
-    backups = list_backups(bkp_dir)
+    service = Service(db.session)
+    backups = service.list_backups(bkp_dir)
     assert len(backups) == 2
     assert all(isinstance(backup, DatabaseBackupFile) for backup in backups)
     assert any(not backup.is_manual for backup in backups)
@@ -257,7 +270,8 @@ def test_list_backups_returns_both(
 def test_backup_listing_sorts_by_modified(
     bkp_dir, auto_backup_file, manual_backup_file
 ):  # pylint: disable=unused-argument
-    backups = list_backups(bkp_dir)
+    service = Service(db.session)
+    backups = service.list_backups(bkp_dir)
     assert len(backups) == 2
 
     backups.sort()
diff --git a/tests/unit/book/test_Repository.py b/tests/unit/book/test_Repository.py
index d3ff5342..2c620ae6 100644
--- a/tests/unit/book/test_Repository.py
+++ b/tests/unit/book/test_Repository.py
@@ -14,7 +14,7 @@
 
 @pytest.fixture(name="repo")
 def fixture_repo():
-    return Repository(db)
+    return Repository(db.session)
 
 
 @pytest.fixture(name="new_book")
diff --git a/tests/unit/book/test_datatables.py b/tests/unit/book/test_datatables.py
index 35bad76d..e93d07fd 100644
--- a/tests/unit/book/test_datatables.py
+++ b/tests/unit/book/test_datatables.py
@@ -35,8 +35,8 @@ def test_smoke_book_datatables_query_runs(app_context, _dt_params):
     """
     Smoke test only, ensure query runs.
     """
-    load_demo_stories()
-    get_data_tables_list(_dt_params, False)
+    load_demo_stories(db.session)
+    get_data_tables_list(_dt_params, False, db.session)
     # print(d['data'])
     a = 1
     assert a == 1, "dummy check"
@@ -46,12 +46,12 @@ def test_book_query_only_returns_supported_language_books(app_context, _dt_param
     """
     Smoke test only, ensure query runs.
     """
-    load_demo_stories()
+    load_demo_stories(db.session)
     for lang in db.session.query(Language).all():
         lang.parser_type = "unknown"
         db.session.add(lang)
     db.session.commit()
-    d = get_data_tables_list(_dt_params, False)
+    d = get_data_tables_list(_dt_params, False, db.session)
     assert len(d["data"]) == 0, "no books should be active"
 
 
@@ -63,7 +63,7 @@ def test_book_data_says_completed_if_last_page_has_been_read(
     db.session.add(b)
     db.session.commit()
     _dt_params["search"] = {"value": "title", "regex": False}
-    d = get_data_tables_list(_dt_params, False)
+    d = get_data_tables_list(_dt_params, False, db.session)
     actual = d["data"][0]
     assert actual["BkID"] == b.id, "correct book"
     assert actual["IsCompleted"] == 0, "not completed"
@@ -71,7 +71,7 @@ def test_book_data_says_completed_if_last_page_has_been_read(
     t.read_date = datetime.now()
     db.session.add(t)
     db.session.commit()
-    d = get_data_tables_list(_dt_params, False)
+    d = get_data_tables_list(_dt_params, False, db.session)
     actual = d["data"][0]
     assert actual["BkID"] == b.id, "correct book"
     assert actual["IsCompleted"] == 1, "completed"
diff --git a/tests/unit/book/test_stats.py b/tests/unit/book/test_stats.py
index d9930f42..fe01bd89 100644
--- a/tests/unit/book/test_stats.py
+++ b/tests/unit/book/test_stats.py
@@ -7,12 +7,7 @@
 
 from lute.db import db
 from lute.term.model import Term, Repository
-from lute.book.stats import (
-    calc_status_distribution,
-    refresh_stats,
-    mark_stale,
-    get_stats,
-)
+from lute.book.stats import Service
 
 from tests.utils import make_text, make_book
 from tests.dbasserts import assert_record_count_equals, assert_sql_result
@@ -25,7 +20,7 @@ def add_term(lang, s, status):
     term.language_id = lang.id
     term.text = s
     term.status = status
-    repo = Repository(db)
+    repo = Repository(db.session)
     repo.add(term)
     repo.commit()
 
@@ -41,7 +36,8 @@ def scenario(language, fulltext, terms_and_statuses, expected):
     for ts in terms_and_statuses:
         add_term(language, ts[0], ts[1])
 
-    stats = calc_status_distribution(b)
+    svc = Service(db.session)
+    stats = svc.calc_status_distribution(b)
 
     assert stats == expected
 
@@ -112,9 +108,15 @@ def fixture_make_book(empty_db, spanish):
     return b
 
 
+@pytest.fixture(name="service")
+def fixture_service():
+    "svc."
+    return Service(db.session)
+
+
 def add_terms(lang, terms):
     "Create and add term."
-    repo = Repository(db)
+    repo = Repository(db.session)
     for s in terms:
         term = Term()
         term.language = lang
@@ -131,29 +133,29 @@ def assert_stats(expected, msg=""):
     assert_sql_result(sql, expected, msg)
 
 
-def test_cache_loads_when_prompted(_test_book):
+def test_cache_loads_when_prompted(service, _test_book):
     "Have to call refresh_stats() to load stats."
     assert_record_count_equals("bookstats", 0, "nothing loaded")
-    refresh_stats()
+    service.refresh_stats()
     assert_record_count_equals("bookstats", 1, "loaded")
 
 
-def test_stats_smoke_test(_test_book, spanish):
+def test_stats_smoke_test(service, _test_book, spanish):
     "Terms are rendered to count stats."
     add_terms(spanish, ["gato", "TENGO"])
-    refresh_stats()
+    service.refresh_stats()
     assert_stats(
         ["4; 2; 50; {'0': 2, '1': 2, '2': 0, '3': 0, '4': 0, '5': 0, '98': 0, '99': 0}"]
     )
 
 
-def test_get_stats_calculates_and_caches_stats(_test_book, spanish):
+def test_get_stats_calculates_and_caches_stats(service, _test_book, spanish):
     "Calculating stats is expensive, so store them on get."
     add_terms(spanish, ["gato", "TENGO"])
     assert_record_count_equals("bookstats", 0, "cache not loaded")
     assert_stats([], "No stats cached at start.")
 
-    stats = get_stats(_test_book)
+    stats = service.get_stats(_test_book)
     assert stats.BkID == _test_book.id
     assert stats.distinctterms == 4
     assert stats.distinctunknowns == 2
@@ -167,7 +169,7 @@ def test_get_stats_calculates_and_caches_stats(_test_book, spanish):
     assert_stats(
         ["4; 2; 50; {'0': 2, '1': 2, '2': 0, '3': 0, '4': 0, '5': 0, '98': 0, '99': 0}"]
     )
-    stats = get_stats(_test_book)
+    stats = service.get_stats(_test_book)
     assert stats.BkID == _test_book.id
     assert (
         stats.status_distribution
@@ -175,25 +177,25 @@ def test_get_stats_calculates_and_caches_stats(_test_book, spanish):
     )
 
 
-def test_stats_calculates_rendered_text(_test_book, spanish):
+def test_stats_calculates_rendered_text(service, _test_book, spanish):
     "Multiword term counted as one term."
     add_terms(spanish, ["tengo un"])
-    refresh_stats()
+    service.refresh_stats()
     assert_stats(
         ["3; 2; 67; {'0': 2, '1': 1, '2': 0, '3': 0, '4': 0, '5': 0, '98': 0, '99': 0}"]
     )
 
 
-def test_stats_only_update_books_marked_stale(_test_book, spanish):
+def test_stats_only_update_books_marked_stale(service, _test_book, spanish):
     "Have to mark book as stale, too expensive otherwise."
     add_terms(spanish, ["gato", "TENGO"])
-    refresh_stats()
+    service.refresh_stats()
     assert_stats(
         ["4; 2; 50; {'0': 2, '1': 2, '2': 0, '3': 0, '4': 0, '5': 0, '98': 0, '99': 0}"]
     )
 
     add_terms(spanish, ["hola"])
-    refresh_stats()
+    service.refresh_stats()
     assert_stats(
         [
             "4; 2; 50; {'0': 2, '1': 2, '2': 0, '3': 0, '4': 0, '5': 0, '98': 0, '99': 0}"
@@ -201,8 +203,8 @@ def test_stats_only_update_books_marked_stale(_test_book, spanish):
         "not updated",
     )
 
-    mark_stale(_test_book)
-    refresh_stats()
+    service.mark_stale(_test_book)
+    service.refresh_stats()
     assert_stats(
         [
             "4; 1; 25; {'0': 1, '1': 3, '2': 0, '3': 0, '4': 0, '5': 0, '98': 0, '99': 0}"
@@ -211,10 +213,10 @@ def test_stats_only_update_books_marked_stale(_test_book, spanish):
     )
 
 
-def test_stats_updated_if_field_empty(_test_book, spanish):
+def test_stats_updated_if_field_empty(service, _test_book, spanish):
     "Have to mark book as stale, too expensive otherwise."
     add_terms(spanish, ["gato", "TENGO"])
-    refresh_stats()
+    service.refresh_stats()
     assert_stats(
         ["4; 2; 50; {'0': 2, '1': 2, '2': 0, '3': 0, '4': 0, '5': 0, '98': 0, '99': 0}"]
     )
@@ -223,7 +225,7 @@ def test_stats_updated_if_field_empty(_test_book, spanish):
     db.session.commit()
 
     assert_stats(["4; 2; 50; None"], "Set to none")
-    refresh_stats()
+    service.refresh_stats()
     assert_stats(
         ["4; 2; 50; {'0': 2, '1': 2, '2': 0, '3': 0, '4': 0, '5': 0, '98': 0, '99': 0}"]
     )
diff --git a/tests/unit/cli/test_import_books.py b/tests/unit/cli/test_import_books.py
index 026314d0..0158f5cd 100644
--- a/tests/unit/cli/test_import_books.py
+++ b/tests/unit/cli/test_import_books.py
@@ -5,6 +5,7 @@
 from sqlalchemy import and_
 from lute.cli.import_books import import_books_from_csv
 from lute.models.book import Book
+from lute.models.repositories import BookRepository
 from lute.db import db
 
 
@@ -24,16 +25,18 @@ def test_smoke_test(app_context, tmp_path, english, german):
 
     common_tags = ["bar", "qux"]
 
+    repo = BookRepository(db.session)
+
     # Check that no changes are made if not committing.
     import_books_from_csv(csv_file, "English", common_tags, False)
-    assert Book.find_by_title("A Book", english.id) is None
-    assert Book.find_by_title("Another Book", english.id) is None
-    assert Book.find_by_title("A Book", german.id) is None
+    assert repo.find_by_title("A Book", english.id) is None
+    assert repo.find_by_title("Another Book", english.id) is None
+    assert repo.find_by_title("A Book", german.id) is None
 
     # Check that new books are added.
     import_books_from_csv(csv_file, "English", common_tags, True)
 
-    book = Book.find_by_title("A Book", english.id)
+    book = repo.find_by_title("A Book", english.id)
     assert book is not None
     assert book.title == "A Book"
     assert book.language_id == english.id
@@ -44,7 +47,7 @@ def test_smoke_test(app_context, tmp_path, english, german):
     assert book.texts[0].text == "Lorem ipsum, dolor sit amet."
     assert sorted([tag.text for tag in book.book_tags]) == ["bar", "baz", "foo", "qux"]
 
-    book = Book.find_by_title("Another Book", english.id)
+    book = repo.find_by_title("Another Book", english.id)
     assert book is not None
     assert book.title == "Another Book"
     assert book.language_id == english.id
@@ -55,7 +58,7 @@ def test_smoke_test(app_context, tmp_path, english, german):
     assert book.texts[0].text == "The quick brown fox jumps over the lazy dog."
     assert sorted([tag.text for tag in book.book_tags]) == ["bar", "qux"]
 
-    book = Book.find_by_title("A Book", german.id)
+    book = repo.find_by_title("A Book", german.id)
     assert book is not None
     assert book.title == "A Book"
     assert book.language_id == german.id
diff --git a/tests/unit/cli/test_language_term_export.py b/tests/unit/cli/test_language_term_export.py
index 18af3ae0..d52fac20 100644
--- a/tests/unit/cli/test_language_term_export.py
+++ b/tests/unit/cli/test_language_term_export.py
@@ -3,6 +3,7 @@
 from lute.cli.language_term_export import generate_language_file, generate_book_file
 
 from lute.models.term import Term, TermTag
+from lute.models.repositories import TermRepository
 from lute.models.book import Book
 from lute.db import db
 from tests.dbasserts import assert_sql_result
@@ -56,7 +57,8 @@ def test_single_book_export(app_context, empty_db, tmp_path, english):
     def _find(term_string):
         "Find term with the text."
         spec = Term(english, term_string)
-        ret = Term.find_by_spec(spec)
+        repo = TermRepository(db.session)
+        ret = repo.find_by_spec(spec)
         assert ret is not None, f"Have {term_string}"
         return ret
 
diff --git a/tests/unit/db/test_demo.py b/tests/unit/db/test_demo.py
index 45749823..f511fdce 100644
--- a/tests/unit/db/test_demo.py
+++ b/tests/unit/db/test_demo.py
@@ -18,44 +18,44 @@
 
 def test_new_db_is_demo(app_context):
     "New db created from the baseline has the demo flag set."
-    assert contains_demo_data() is True, "new db contains demo."
+    assert contains_demo_data(db.session) is True, "new db contains demo."
 
 
 def test_removing_flag_means_not_demo(app_context):
     "Unsetting the flag means the db is not a demo."
-    remove_flag()
-    assert contains_demo_data() is False, "not a demo."
+    remove_flag(db.session)
+    assert contains_demo_data(db.session) is False, "not a demo."
 
 
 def test_wiping_db_clears_flag(app_context):
     "No longer a demo if the demo is wiped out!"
-    delete_demo_data()
-    assert contains_demo_data() is False, "not a demo."
+    delete_demo_data(db.session)
+    assert contains_demo_data(db.session) is False, "not a demo."
 
 
 def test_wipe_db_only_works_if_flag_is_set(app_context):
     "Can only wipe a demo db!"
-    remove_flag()
+    remove_flag(db.session)
     with pytest.raises(Exception):
-        delete_demo_data()
+        delete_demo_data(db.session)
 
 
 def test_tutorial_id_returned_if_present(app_context):
     "Sanity check."
-    assert tutorial_book_id() > 0, "have tutorial"
+    assert tutorial_book_id(db.session) > 0, "have tutorial"
 
     sql = 'update books set bktitle = "xxTutorial" where bktitle = "Tutorial"'
     db.session.execute(text(sql))
     db.session.commit()
-    assert tutorial_book_id() is None, "no tutorial"
+    assert tutorial_book_id(db.session) is None, "no tutorial"
 
     sql = 'update books set bktitle = "Tutorial" where bktitle = "xxTutorial"'
     db.session.execute(text(sql))
     db.session.commit()
-    assert tutorial_book_id() > 0, "have tutorial again"
+    assert tutorial_book_id(db.session) > 0, "have tutorial again"
 
-    delete_demo_data()
-    assert tutorial_book_id() is None, "no tutorial"
+    delete_demo_data(db.session)
+    assert tutorial_book_id(db.session) is None, "no tutorial"
 
 
 # Loading.
@@ -69,12 +69,12 @@ def test_load_demo_loads_language_yaml_files(app_context):
     This test is also used from "inv db.reset" in tasks.py
     (see .pytest.ini).
     """
-    delete_demo_data()
-    assert contains_demo_data() is False, "not a demo."
+    delete_demo_data(db.session)
+    assert contains_demo_data(db.session) is False, "not a demo."
     assert_record_count_equals("languages", 0, "wiped out")
 
-    load_demo_data()
-    assert contains_demo_data() is True, "demo loaded"
+    load_demo_data(db.session)
+    assert contains_demo_data(db.session) is True, "demo loaded"
     checks = [
         "select * from languages where LgName = 'English'",
         "select * from books where BkTitle = 'Tutorial'",
diff --git a/tests/unit/db/test_management.py b/tests/unit/db/test_management.py
index d9966bb9..514b7834 100644
--- a/tests/unit/db/test_management.py
+++ b/tests/unit/db/test_management.py
@@ -5,9 +5,11 @@
 """
 
 import pytest
+from sqlalchemy import text
 from lute.db import db
-from lute.models.setting import UserSetting, BackupSettings
-from lute.db.management import delete_all_data
+from lute.models.setting import UserSetting
+from lute.models.repositories import UserSettingRepository
+from lute.db.management import delete_all_data, add_default_user_settings
 from tests.dbasserts import assert_record_count_equals
 
 
@@ -20,7 +22,7 @@ def test_wiping_db_clears_out_all_tables(app_context):
     """
     old_user_settings = db.session.query(UserSetting).all()
 
-    delete_all_data()
+    delete_all_data(db.session)
     tables = [
         "books",
         "bookstats",
@@ -47,7 +49,42 @@ def test_wiping_db_clears_out_all_tables(app_context):
 
 def test_can_get_backup_settings_when_db_is_wiped(app_context):
     "The backupsettings struct assumes certain things about the data."
-    delete_all_data()
-    bs = BackupSettings.get_backup_settings()
+    delete_all_data(db.session)
+    repo = UserSettingRepository(db.session)
+    bs = repo.get_backup_settings()
     assert bs.backup_enabled, "backup is back to being enabled"
     assert bs.backup_dir is not None, "default restored"
+
+
+@pytest.fixture(name="us_repo")
+def fixture_usersetting_repo(app_context):
+    "Repo"
+    r = UserSettingRepository(db.session)
+    return r
+
+
+def test_user_settings_loaded_with_defaults(us_repo):
+    "Called on load."
+    db.session.execute(text("delete from settings"))
+    db.session.commit()
+    assert us_repo.key_exists("backup_dir") is False, "key removed"
+    add_default_user_settings(db.session, "blah")
+    assert us_repo.key_exists("backup_dir") is True, "key created"
+
+    # Check defaults
+    b = us_repo.get_backup_settings()
+    assert b.backup_enabled is True
+    assert b.backup_dir is not None
+    assert b.backup_auto is True
+    assert b.backup_warn is True
+    assert b.backup_count == 5
+
+
+def test_user_settings_load_leaves_existing_values(us_repo):
+    "Called on load."
+    us_repo.set_value("backup_count", 17)
+    db.session.commit()
+    assert us_repo.get_value("backup_count") == "17"
+    add_default_user_settings(db.session, "blah")
+    b = us_repo.get_backup_settings()
+    assert b.backup_count == 17, "still 17"
diff --git a/tests/unit/language/test_service.py b/tests/unit/language/test_service.py
index 7899e6f7..f89844b2 100644
--- a/tests/unit/language/test_service.py
+++ b/tests/unit/language/test_service.py
@@ -3,12 +3,14 @@
 """
 
 # from lute.db import db
-from lute.language import service
+from lute.language.service import Service
+from lute.db import db
 from tests.dbasserts import assert_sql_result
 
 
 def test_get_all_lang_defs(app_context):
     "Can get all predefined languages."
+    service = Service(db.session)
     defs = service.get_supported_defs()
     engs = [d for d in defs if d["language"].name == "English"]
     assert len(engs) == 1, "have english"
@@ -23,6 +25,7 @@ def test_get_language_def():
     """
     Smoke test, can load a new language from yaml definition.
     """
+    service = Service(db.session)
     lang = service.get_language_def("English")["language"]
 
     assert lang.name == "English"
@@ -50,6 +53,7 @@ def test_load_def_loads_lang_and_stories(empty_db):
     assert_sql_result(lang_sql, [], "no langs")
     assert_sql_result(story_sql, [], "nothing loaded")
 
+    service = Service(db.session)
     lang_id = service.load_language_def("English")
     assert lang_id > 0, "ID returned, used for filtering"
     assert_sql_result(lang_sql, ["English"], "eng loaded")
@@ -63,6 +67,7 @@ def test_load_all_defs_loads_lang_and_stories(empty_db):
     assert_sql_result(lang_sql, [], "no langs")
     assert_sql_result(story_sql, [], "nothing loaded")
 
+    service = Service(db.session)
     defs = service.get_supported_defs()
     langnames = [d["language"].name for d in defs]
     for n in langnames:
diff --git a/tests/unit/models/test_Language.py b/tests/unit/models/test_Language.py
index 683e59cc..95dbfc26 100644
--- a/tests/unit/models/test_Language.py
+++ b/tests/unit/models/test_Language.py
@@ -4,7 +4,9 @@
 Low value but ensure that the db mapping is correct.
 """
 
+from lute.db import db
 from lute.models.language import Language
+from lute.models.repositories import LanguageRepository
 from tests.dbasserts import assert_sql_result
 
 
@@ -40,13 +42,14 @@ def test_can_find_lang_by_name(app_context):
     """
     Returns lang if found, or None
     """
-    e = Language.find_by_name("English")
+    repo = LanguageRepository(db.session)
+    e = repo.find_by_name("English")
     assert e.name == "English", "case match"
 
-    e_lc = Language.find_by_name("english")
+    e_lc = repo.find_by_name("english")
     assert e_lc.name == "English", "case-insensitive"
 
-    nf = Language.find_by_name("notfound")
+    nf = repo.find_by_name("notfound")
     assert nf is None, "not found"
 
 
@@ -61,7 +64,8 @@ def test_language_word_char_regex_returns_python_compatible_regex(app_context):
 
     u0600-u06FFuFE70-uFEFC  (where u = backslash-u)
     """
-    a = Language.find_by_name("Arabic")
+    repo = LanguageRepository(db.session)
+    a = repo.find_by_name("Arabic")
     assert a.word_characters == r"\u0600-\u06FF\uFE70-\uFEFC"
 
 
@@ -71,7 +75,8 @@ def test_lang_to_dict_from_dict_returns_same_thing(app_context):
     A dictionary is used as the intermediary form, so the
     same language should return the same data.
     """
-    e = Language.find_by_name("English")
+    repo = LanguageRepository(db.session)
+    e = repo.find_by_name("English")
     e_dict = e.to_dict()
     e_from_dict = Language.from_dict(e_dict)
     e_back_to_dict = e_from_dict.to_dict()
diff --git a/tests/unit/models/test_Setting.py b/tests/unit/models/test_Setting.py
index 3a2f8389..dbea294e 100644
--- a/tests/unit/models/test_Setting.py
+++ b/tests/unit/models/test_Setting.py
@@ -2,66 +2,77 @@
 Settings test.
 """
 
-import os
 from unittest.mock import patch
 import pytest
-from sqlalchemy import text
 from lute.db import db
-from lute.models.setting import (
-    UserSetting,
+from lute.models.repositories import (
+    UserSettingRepository,
+    SystemSettingRepository,
     MissingUserSettingKeyException,
-    SystemSetting,
-    BackupSettings,
 )
 from tests.dbasserts import assert_sql_result
 
 
-def test_user_and_system_settings_do_not_intersect(app_context):
+@pytest.fixture(name="us_repo")
+def fixture_usersetting_repo(app_context):
+    "Repo"
+    r = UserSettingRepository(db.session)
+    return r
+
+
+@pytest.fixture(name="ss_repo")
+def fixture_systemsetting_repo(app_context):
+    "Repo"
+    r = SystemSettingRepository(db.session)
+    return r
+
+
+def test_user_and_system_settings_do_not_intersect(us_repo, ss_repo):
     "A UserSetting is not available as a system setting."
-    UserSetting.set_value("backup_count", 42)
+    us_repo.set_value("backup_count", 42)
     db.session.commit()
     sql = "select StValue, StKeyType from settings where StKey = 'backup_count'"
     assert_sql_result(sql, ["42; user"], "loaded")
-    u = UserSetting.get_value("backup_count")
+    u = us_repo.get_value("backup_count")
     assert u == "42", "found user setting"
-    assert SystemSetting.get_value("backup_count") is None, "not in system settings"
+    assert ss_repo.get_value("backup_count") is None, "not in system settings"
 
 
-def test_save_and_retrieve_user_setting(app_context):
+def test_save_and_retrieve_user_setting(us_repo):
     "Smoke tests."
-    UserSetting.set_value("backup_count", 42)
+    us_repo.set_value("backup_count", 42)
     sql = "select StValue from settings where StKey = 'backup_count'"
     assert_sql_result(sql, ["5"], "still default")
 
     db.session.commit()
     assert_sql_result(sql, ["42"], "now set")
 
-    v = UserSetting.get_value("backup_count")
+    v = us_repo.get_value("backup_count")
     assert v == "42", "is string"
 
 
-def test_missing_value_value_is_nullapp_context(app_context):
+def test_missing_value_value_is_null(ss_repo):
     "Missing key = None."
-    assert SystemSetting.get_value("missing") is None, "missing key"
+    assert ss_repo.get_value("missing") is None, "missing key"
 
 
-def test_smoke_last_backup(app_context):
+def test_smoke_last_backup(us_repo):
     "Check syntax only."
-    v = SystemSetting.get_last_backup_datetime()
+    v = us_repo.get_last_backup_datetime()
     assert v is None, "not set"
 
-    SystemSetting.set_last_backup_datetime(42)
-    v = SystemSetting.get_last_backup_datetime()
+    us_repo.set_last_backup_datetime(42)
+    v = us_repo.get_last_backup_datetime()
     assert v == 42, "set _and_ saved"
 
 
-def test_get_backup_settings(app_context):
+def test_get_backup_settings(us_repo):
     "Smoke test."
-    UserSetting.set_value("backup_dir", "blah")
-    UserSetting.set_value("backup_count", 12)
-    UserSetting.set_value("backup_warn", 0)
+    us_repo.set_value("backup_dir", "blah")
+    us_repo.set_value("backup_count", 12)
+    us_repo.set_value("backup_warn", 0)
     db.session.commit()
-    b = BackupSettings.get_backup_settings()
+    b = us_repo.get_backup_settings()
     assert b.backup_dir == "blah"
     assert b.backup_auto is True  # initial defaults
     assert b.backup_warn is False  # set to 0 above
@@ -69,49 +80,49 @@ def test_get_backup_settings(app_context):
     assert b.last_backup_datetime is None
 
 
-def test_time_since_last_backup_future(app_context):
+def test_time_since_last_backup_future(us_repo):
     """
     Check formatting when last backup is reported to be in the future.
 
     current time = 600, backup time = 900
     """
-    b = BackupSettings.get_backup_settings()
+    b = us_repo.get_backup_settings()
     with patch("time.time", return_value=600):
         b.last_backup_datetime = 900
         assert b.time_since_last_backup is None
 
 
-def test_time_since_last_backup_none(app_context):
+def test_time_since_last_backup_none(us_repo):
     """
     Check formatting when last backup is reported to be None.
 
     current time = 600, backup time = None
     """
-    b = BackupSettings.get_backup_settings()
+    b = us_repo.get_backup_settings()
     with patch("time.time", return_value=600):
         b.last_backup_datetime = None
         assert b.time_since_last_backup is None
 
 
-def test_time_since_last_backup_right_now(app_context):
+def test_time_since_last_backup_right_now(us_repo):
     """
     Check formatting when last backup is reported to be the same as current time.
 
     current time = 600, backup time = 600
     """
-    b = BackupSettings.get_backup_settings()
+    b = us_repo.get_backup_settings()
     with patch("time.time", return_value=600):
         b.last_backup_datetime = 600
         assert b.time_since_last_backup == "0 seconds ago"
 
 
-def test_time_since_last_backup_in_past(app_context):
+def test_time_since_last_backup_in_past(us_repo):
     """
     Check formatting when last backup is reported to be in the past.
 
     current time = 62899200, backup time = various
     """
-    b = BackupSettings.get_backup_settings()
+    b = us_repo.get_backup_settings()
     now = 62899200
     with patch("time.time", return_value=now):
         b.last_backup_datetime = now - 45
@@ -136,46 +147,9 @@ def test_time_since_last_backup_in_past(app_context):
         assert b.time_since_last_backup == "75 weeks ago"
 
 
-def test_user_settings_loaded_with_defaults(app_context):
-    "Called on load."
-    db.session.execute(text("delete from settings"))
-    db.session.commit()
-    assert UserSetting.key_exists("backup_dir") is False, "key removed"
-    UserSetting.load()
-    assert UserSetting.key_exists("backup_dir") is True, "key created"
-
-    # Check defaults
-    b = BackupSettings.get_backup_settings()
-    assert b.backup_enabled is True
-    assert b.backup_dir is not None
-    assert b.backup_auto is True
-    assert b.backup_warn is True
-    assert b.backup_count == 5
-
-
-def test_user_settings_load_leaves_existing_values(app_context):
-    "Called on load."
-    UserSetting.set_value("backup_count", 17)
-    db.session.commit()
-    assert UserSetting.get_value("backup_count") == "17"
-    UserSetting.load()
-    b = BackupSettings.get_backup_settings()
-    assert b.backup_count == 17, "still 17"
-
-
-def test_get_or_set_user_setting_unknown_key_throws(app_context):
+def test_get_or_set_user_setting_unknown_key_throws(us_repo):
     "Safety, ensure no typo for user settings."
     with pytest.raises(MissingUserSettingKeyException):
-        UserSetting.get_value("bad_key")
+        us_repo.get_value("bad_key")
     with pytest.raises(MissingUserSettingKeyException):
-        UserSetting.set_value("bad_key", 17)
-
-
-def test_setting_mecab_path_sets_env_var(app_context):
-    "Natto-py needs an env var."
-    UserSetting.set_value("mecab_path", "blah")
-    assert os.environ["MECAB_PATH"] == "blah", "was set"
-    UserSetting.set_value("mecab_path", None)
-    assert os.environ.get("MECAB_PATH", "X") == "X", "not set"
-    UserSetting.set_value("mecab_path", "")
-    assert os.environ.get("MECAB_PATH", "Y") == "Y", "not set"
+        us_repo.set_value("bad_key", 17)
diff --git a/tests/unit/models/test_Term.py b/tests/unit/models/test_Term.py
index d4ca7b16..48a3a62f 100644
--- a/tests/unit/models/test_Term.py
+++ b/tests/unit/models/test_Term.py
@@ -6,6 +6,7 @@
 import pytest
 from sqlalchemy import text
 from lute.models.term import Term, TermImage
+from lute.models.repositories import TermRepository
 from lute.db import db
 from tests.dbasserts import assert_record_count_equals, assert_sql_result
 
@@ -79,15 +80,16 @@ def test_find_by_spec(app_context, spanish, english):
     db.session.commit()
 
     spec = Term(spanish, "GATO")
-    found = Term.find_by_spec(spec)
+    repo = TermRepository(db.session)
+    found = repo.find_by_spec(spec)
     assert found.id == t.id, "term found by matching spec"
 
     spec = Term(english, "GATO")
-    found = Term.find_by_spec(spec)
+    found = repo.find_by_spec(spec)
     assert found is None, "not found in different language"
 
     spec = Term(spanish, "gatito")
-    found = Term.find_by_spec(spec)
+    found = repo.find_by_spec(spec)
     assert found is None, "not found with different text"
 
 
@@ -222,7 +224,8 @@ def test_delete_empty_image_records(app_context, spanish):
 
     assert_sql_result("select wisource from wordimages", ["", "   ", "3.png"], "images")
 
-    Term.delete_empty_images()
+    repo = TermRepository(db.session)
+    repo.delete_empty_images()
     assert_sql_result("select wisource from wordimages", ["3.png"], "cleaned images")
 
 
diff --git a/tests/unit/models/test_TermTag.py b/tests/unit/models/test_TermTag.py
index f187e009..5899f5f4 100644
--- a/tests/unit/models/test_TermTag.py
+++ b/tests/unit/models/test_TermTag.py
@@ -5,6 +5,7 @@
 import pytest
 
 from lute.models.term import TermTag
+from lute.models.repositories import TermTagRepository
 from lute.db import db
 from tests.dbasserts import assert_sql_result
 
@@ -34,19 +35,22 @@ def test_new_dup_tag_text_fails(_hola_tag, app_context):
 
 def test_find_by_text(_hola_tag, app_context):
     "Find by text returns match."
-    retrieved = TermTag.find_by_text("Hola")
+    repo = TermTagRepository(db.session)
+    retrieved = repo.find_by_text("Hola")
     assert retrieved is not None
     assert retrieved.text == "Hola"
 
 
 def test_find_by_text_returns_null_if_not_exact_match(_hola_tag, app_context):
     "Find returns null if no match."
-    assert TermTag.find_by_text("unknown") is None
-    assert TermTag.find_by_text("hola") is None
+    repo = TermTagRepository(db.session)
+    assert repo.find_by_text("unknown") is None
+    assert repo.find_by_text("hola") is None
 
 
 def test_find_or_create_by_text_returns_new_if_no_match(_hola_tag, app_context):
     "Return new."
-    assert TermTag.find_by_text("unknown") is None
-    t = TermTag.find_or_create_by_text("unknown")
+    repo = TermTagRepository(db.session)
+    assert repo.find_by_text("unknown") is None
+    t = repo.find_or_create_by_text("unknown")
     assert t.text == "unknown", "new tag created"
diff --git a/tests/unit/parse/test_JapaneseParser.py b/tests/unit/parse/test_JapaneseParser.py
index b00dd49d..fd61854c 100644
--- a/tests/unit/parse/test_JapaneseParser.py
+++ b/tests/unit/parse/test_JapaneseParser.py
@@ -4,8 +4,7 @@
 
 from lute.parse.mecab_parser import JapaneseParser
 from lute.models.term import Term
-from lute.models.setting import UserSetting
-from lute.db import db
+from lute.settings.current import current_settings
 from lute.parse.base import ParsedToken
 
 
@@ -105,6 +104,5 @@ def test_reading_setting(app_context):
     }
     p = JapaneseParser()
     for k, v in cases.items():
-        UserSetting.set_value("japanese_reading", k)
-        db.session.commit()
+        current_settings["japanese_reading"] = k
         assert p.get_reading("強い") == v, k
diff --git a/tests/unit/read/render/test_service.py b/tests/unit/read/render/test_service.py
index 1e56d497..4682500a 100644
--- a/tests/unit/read/render/test_service.py
+++ b/tests/unit/read/render/test_service.py
@@ -3,7 +3,7 @@
 """
 
 from lute.parse.base import ParsedToken
-from lute.read.render.service import find_all_Terms_in_string, get_paragraphs
+from lute.read.render.service import Service
 from lute.db import db
 from lute.models.term import Term
 
@@ -17,7 +17,8 @@ def _run_scenario(language, content, expected_found, msg=""):
     find_all method returns the expected_found terms that
     exist in the content string.
     """
-    found_terms = find_all_Terms_in_string(content, language)
+    service = Service(db.session)
+    found_terms = service.find_all_Terms_in_string(content, language)
     assert len(found_terms) == len(expected_found), "found count, " + msg
     zws = "\u200B"  # zero-width space
     found_terms = [t.text.replace(zws, "") for t in found_terms]
@@ -101,7 +102,8 @@ def test_smoke_get_paragraphs(spanish, app_context):
     assert_sql_result(sql, ["perro", "tengo/ /un", "un/ /gato"], "initial")
 
     ParsedToken.reset_counters()
-    paras = get_paragraphs(t.text, t.book.language)
+    service = Service(db.session)
+    paras = service.get_paragraphs(t.text, t.book.language)
     assert len(paras) == 2
 
     def stringize(t):
diff --git a/tests/unit/read/test_service.py b/tests/unit/read/test_service.py
index 3520e7a6..80fed581 100644
--- a/tests/unit/read/test_service.py
+++ b/tests/unit/read/test_service.py
@@ -4,7 +4,7 @@
 
 from lute.models.term import Term
 from lute.book.model import Book, Repository
-from lute.read.service import set_unknowns_to_known, start_reading
+from lute.read.service import Service
 from lute.db import db
 
 from tests.dbasserts import assert_record_count_equals, assert_sql_result
@@ -20,14 +20,15 @@ def test_set_unknowns_to_known(english, app_context):
     b.title = "blah"
     b.language_id = english.id
     b.text = "Dog CAT dog cat."
-    r = Repository(db)
+    r = Repository(db.session)
     dbbook = r.add(b)
     r.commit()
 
     sql = "select WoTextLC, WoStatus from words order by WoText"
     assert_sql_result(sql, ["dog; 1"], "before start")
 
-    start_reading(dbbook, 1, db.session)
+    service = Service(db.session)
+    service.start_reading(dbbook, 1)
     assert_sql_result(sql, ["cat; 0", "dog; 1"], "after start")
 
     tx = dbbook.texts[0]
@@ -35,7 +36,8 @@ def test_set_unknowns_to_known(english, app_context):
     db.session.add(tx)
     db.session.commit()
 
-    set_unknowns_to_known(tx)
+    service = Service(db.session)
+    service.set_unknowns_to_known(tx)
     assert_sql_result(sql, ["cat; 99", "dog; 1", "extra; 99"], "after set")
 
 
@@ -45,12 +47,13 @@ def test_smoke_start_reading(english, app_context):
     b.title = "blah"
     b.language_id = english.id
     b.text = "Here is some content.  Here is more."
-    r = Repository(db)
+    r = Repository(db.session)
     dbbook = r.add(b)
     r.commit()
 
     assert_record_count_equals("select * from sentences", 0, "before start")
-    start_reading(dbbook, 1, db.session)
+    service = Service(db.session)
+    service.start_reading(dbbook, 1)
     assert_record_count_equals("select * from sentences", 2, "after start")
 
 
@@ -64,14 +67,15 @@ def test_start_reading_creates_Terms_for_unknown_words(english, app_context):
     b.title = "blah"
     b.language_id = english.id
     b.text = "Dog CAT dog cat."
-    r = Repository(db)
+    r = Repository(db.session)
     dbbook = r.add(b)
     r.commit()
 
     sql = "select WoTextLC from words order by WoText"
     assert_sql_result(sql, ["dog"], "before start")
 
-    paragraphs = start_reading(dbbook, 1, db.session)
+    service = Service(db.session)
+    paragraphs = service.start_reading(dbbook, 1)
     textitems = [
         ti
         for para in paragraphs
diff --git a/tests/unit/read/test_service_popup_data.py b/tests/unit/read/test_service_popup_data.py
index 4a4c8447..3f86348f 100644
--- a/tests/unit/read/test_service_popup_data.py
+++ b/tests/unit/read/test_service_popup_data.py
@@ -2,73 +2,84 @@
 Term popup data tests.
 """
 
+import pytest
 from lute.models.term import Term, Status
-from lute.read.service import get_popup_data
+from lute.read.service import Service
 from lute.db import db
 
 
-def test_popup_data_is_none_if_no_data(spanish, app_context):
+@pytest.fixture(name="service")
+def fixture_svc(app_context):
+    "Service"
+    return Service(db.session)
+
+
+def test_popup_data_is_none_if_no_data(spanish, app_context, service):
     "Return None if no popup."
     t = Term(spanish, "gato")
     db.session.add(t)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d is None, "No data, no popup"
 
     t.translation = "hello"
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d is not None, "Have data, popup"
 
 
-def test_popup_shown_if_parent_exists_even_if_no_other_data(spanish, app_context):
+def test_popup_shown_if_parent_exists_even_if_no_other_data(
+    spanish, app_context, service
+):
     "I always want to know if a parent has been set."
     t = Term(spanish, "gato")
     db.session.add(t)
     db.session.commit()
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d is None, "No data, no popup"
 
     p = Term(spanish, "perro")
     t.parents.append(p)
     db.session.add(t)
     db.session.commit()
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d is not None, "Has parent, popup, even if no other data."
 
 
-def test_popup_data_is_none_for_unknown(spanish, app_context):
+def test_popup_data_is_none_for_unknown(spanish, app_context, service):
     "Return None if no-popup statuses."
     t = Term(spanish, "gato")
     db.session.add(t)
     db.session.commit()
     t.translation = "hello"
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d is not None, "Have data, popup"
 
     t.status = Status.UNKNOWN
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d is None, "No popup for UNKNOWN words"
 
     for s in [1, Status.IGNORED]:
         t.status = s
-        d = get_popup_data(t.id)
+        d = service.get_popup_data(t.id)
         assert d is not None, f"Have data for status {s}"
 
 
-def test_term_with_no_parents(spanish, app_context):
+def test_term_with_no_parents(spanish, app_context, service):
     "Keep the lights on test, smoke only."
     t = Term(spanish, "gato")
     t.translation = "cat"
     db.session.add(t)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d["parentdata"] == [], "no parents"
     assert d["parentterms"] == "", "no parents"
 
 
-def test_single_parent_data_not_added_if_same_translation(spanish, app_context):
+def test_single_parent_data_not_added_if_same_translation(
+    spanish, app_context, service
+):
     "Extra data added for display."
     t = Term(spanish, "gato")
     t.translation = "cat"
@@ -78,12 +89,14 @@ def test_single_parent_data_not_added_if_same_translation(spanish, app_context):
     db.session.add(t)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d["parentdata"] == [], "no extra parent data"
     assert d["parentterms"] == "perro", "one parent"
 
 
-def test_parent_data_added_if_parent_translation_is_different(spanish, app_context):
+def test_parent_data_added_if_parent_translation_is_different(
+    spanish, app_context, service
+):
     "Extra data added for display."
     t = Term(spanish, "gato")
     t.translation = "cat"
@@ -93,7 +106,7 @@ def test_parent_data_added_if_parent_translation_is_different(spanish, app_conte
     db.session.add(t)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     expected_p_data = {
         "term": "perro",
         "roman": None,
@@ -104,7 +117,7 @@ def test_parent_data_added_if_parent_translation_is_different(spanish, app_conte
     assert d["parentterms"] == "perro", "one parent"
 
 
-def test_parent_data_always_added_if_multiple_parents(spanish, app_context):
+def test_parent_data_always_added_if_multiple_parents(spanish, app_context, service):
     "Extra data added for display."
     t = Term(spanish, "gato")
     t.translation = "cat"
@@ -117,7 +130,7 @@ def test_parent_data_always_added_if_multiple_parents(spanish, app_context):
     db.session.add(t)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     expected_p1_data = {"term": "perro", "roman": None, "trans": "kitty", "tags": []}
     expected_p2_data = {"term": "hombre", "roman": None, "trans": "kitteh", "tags": []}
     expected = [expected_p1_data, expected_p2_data]
@@ -148,18 +161,18 @@ def _c_to_string(c):
     assert actual == expected, msg
 
 
-def test_single_term_not_included_in_own_components(spanish, app_context):
+def test_single_term_not_included_in_own_components(spanish, app_context, service):
     "Keep the lights on test, smoke only."
     t = Term(spanish, "gato")
     t.translation = "cat"
     db.session.add(t)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d["components"] == [], "no components"
 
 
-def test_component_without_translation_not_returned(spanish, app_context):
+def test_component_without_translation_not_returned(spanish, app_context, service):
     "Component word is returned."
     t = Term(spanish, "un gato")
     t.translation = "a cat"
@@ -167,11 +180,11 @@ def test_component_without_translation_not_returned(spanish, app_context):
     make_terms([("gato", "")], spanish)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert d["components"] == [], "no data"
 
 
-def test_component_word_with_translation_returned(spanish, app_context):
+def test_component_word_with_translation_returned(spanish, app_context, service):
     "Component word is returned."
     t = Term(spanish, "un gato")
     t.translation = "a cat"
@@ -179,11 +192,11 @@ def test_component_word_with_translation_returned(spanish, app_context):
     make_terms([("gato", "cat")], spanish)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert_components(d, ["gato; cat"], "one component")
 
 
-def test_nested_multiword_components(spanish, app_context):
+def test_nested_multiword_components(spanish, app_context, service):
     "Complete components are returned."
     t = Term(spanish, "un gato gordo")
     t.translation = "a fat cat"
@@ -191,11 +204,13 @@ def test_nested_multiword_components(spanish, app_context):
     make_terms([("gato", "cat"), ("gat", "x"), ("un gato", "a cat")], spanish)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert_components(d, ["un gato; a cat", "gato; cat"], "components")
 
 
-def test_multiword_components_returned_in_order_of_appearance(spanish, app_context):
+def test_multiword_components_returned_in_order_of_appearance(
+    spanish, app_context, service
+):
     "Complete components are returned."
     t = Term(spanish, "un gato gordo")
     t.translation = "a fat cat"
@@ -211,13 +226,13 @@ def test_multiword_components_returned_in_order_of_appearance(spanish, app_conte
     )
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert_components(
         d, ["un gato; a cat", "gato gordo; fat", "gato; cat"], "components"
     )
 
 
-def test_components_only_returned_once(spanish, app_context):
+def test_components_only_returned_once(spanish, app_context, service):
     "Component not returned multiple times if present multiple times."
     t = Term(spanish, "un gato gordo gato")
     t.translation = "a cat fat cat"
@@ -225,5 +240,5 @@ def test_components_only_returned_once(spanish, app_context):
     make_terms([("gato", "cat")], spanish)
     db.session.commit()
 
-    d = get_popup_data(t.id)
+    d = service.get_popup_data(t.id)
     assert_components(d, ["gato; cat"], "components")
diff --git a/tests/unit/settings/__init__.py b/tests/unit/settings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/settings/test_current.py b/tests/unit/settings/test_current.py
new file mode 100644
index 00000000..b1840a6d
--- /dev/null
+++ b/tests/unit/settings/test_current.py
@@ -0,0 +1,14 @@
+"""
+Current settings tests.
+"""
+
+from lute.db import db
+from lute.settings.current import refresh_global_settings, current_settings
+
+
+def test_refresh_refreshes_current_settings(app_context):
+    "Current settigns are loaded."
+    if "backup_dir" in current_settings:
+        del current_settings["backup_dir"]
+    refresh_global_settings(db.session)
+    assert "backup_dir" in current_settings, "loaded"
diff --git a/tests/unit/stats/test_service.py b/tests/unit/stats/test_service.py
index ae0bf1c5..c306689f 100644
--- a/tests/unit/stats/test_service.py
+++ b/tests/unit/stats/test_service.py
@@ -48,7 +48,7 @@ def test_get_chart_data(spanish, english, app_context):
             {"readdate": today.strftime("%Y-%m-%d"), "wordcount": 2, "runningTotal": 2},
         ],
     }
-    assert get_chart_data() == expected
+    assert get_chart_data(db.session) == expected
 
 
 def test_get_table_data(spanish, english, app_context):
@@ -71,11 +71,11 @@ def test_get_table_data(spanish, english, app_context):
             "counts": {"day": 4, "week": 7, "month": 7, "year": 7, "total": 7},
         },
     ]
-    actual = get_table_data()
+    actual = get_table_data(db.session)
     assert actual == expected
 
 
 def test_get_data_works_when_nothing_read(app_context):
     "Nothing read should still be ok, empty chart."
-    assert not get_chart_data(), "nothing present"
-    assert not get_table_data(), "nothing"
+    assert not get_chart_data(db.session), "nothing present"
+    assert not get_table_data(db.session), "nothing"
diff --git a/tests/unit/term/test_Repository.py b/tests/unit/term/test_Repository.py
index 0e0ca5ec..11bc6fb7 100644
--- a/tests/unit/term/test_Repository.py
+++ b/tests/unit/term/test_Repository.py
@@ -17,7 +17,7 @@
 
 @pytest.fixture(name="repo")
 def fixture_repo():
-    return Repository(db)
+    return Repository(db.session)
 
 
 @pytest.fixture(name="hello_term")
@@ -550,7 +550,7 @@ def test_load(empty_db, english, repo):
 
     term = repo.load(t.id)
     assert term.id == t.id
-    assert term.language.id == t.language.id, "lang object set"
+    assert term.language_id == t.language.id
     assert term.language_id == english.id
     assert term.text == "Hello"
     assert term.original_text == "Hello"
@@ -618,7 +618,7 @@ def test_find_or_new_existing_word(spanish, repo):
     t = repo.find_or_new(spanish.id, "bebida")
     assert t.id > 0, "exists"
     assert t.text == "BEBIDA"
-    assert t.language.id == spanish.id, "lang object set"
+    assert t.language_id == spanish.id, "lang id set"
 
 
 def test_find_or_new_non_existing(spanish, repo):
@@ -626,7 +626,7 @@ def test_find_or_new_non_existing(spanish, repo):
     t = repo.find_or_new(spanish.id, "TENGO")
     assert t.id is None
     assert t.text == "TENGO"
-    assert t.language.id == spanish.id, "lang object set"
+    assert t.language_id == spanish.id, "lang id set"
 
 
 def test_find_or_new_existing_multi_word(spanish, repo):
diff --git a/tests/unit/term/test_TermForm.py b/tests/unit/term/test_TermForm.py
index 98ce6648..b07a5c1d 100644
--- a/tests/unit/term/test_TermForm.py
+++ b/tests/unit/term/test_TermForm.py
@@ -11,14 +11,19 @@
 from lute.term.forms import TermForm
 
 
+def _make_form(t, app, english):
+    "Make form with args."
+    with app.test_request_context():
+        f = TermForm(obj=t, session=db.session)
+        f.language_id.choices = [(english.id, "english")]
+        return f
+
+
 def test_validate(app, app_context, english):
     "A new term is valid."
-    repo = Repository(db)
+    repo = Repository(db.session)
     t = repo.find_or_new(english.id, "CAT")
-    with app.test_request_context():
-        f = TermForm(obj=t)
-    f.language_id.choices = [(english.id, "english")]
-
+    f = _make_form(t, app, english)
     assert f.validate() is True, "no change = valid"
 
 
@@ -28,12 +33,10 @@ def test_text_change_not_valid(app, app_context, english):
     db.session.add(dbt)
     db.session.commit()
 
-    repo = Repository(db)
+    repo = Repository(db.session)
     t = repo.find_or_new(english.id, "CAT")
     t.text = "dog"
-    with app.test_request_context():
-        f = TermForm(obj=t)
-    f.language_id.choices = [(english.id, "english")]
+    f = _make_form(t, app, english)
 
     is_valid = f.validate()
     assert is_valid is False, "text change = not valid"
@@ -49,9 +52,7 @@ def test_duplicate_text_not_valid(app, app_context, english):
     t = Term()
     t.language_id = english.id
     t.text = "cat"
-    with app.test_request_context():
-        f = TermForm(obj=t)
-    f.language_id.choices = [(english.id, "english")]
+    f = _make_form(t, app, english)
 
     is_valid = f.validate()
     assert is_valid is False, "dup term not valid"
@@ -64,12 +65,10 @@ def test_update_existing_term_is_valid(app, app_context, english):
     db.session.add(dbt)
     db.session.commit()
 
-    repo = Repository(db)
+    repo = Repository(db.session)
     t = repo.find_or_new(english.id, "cat")
     t.text = "cat"
-    with app.test_request_context():
-        f = TermForm(obj=t)
-    f.language_id.choices = [(english.id, "english")]
+    f = _make_form(t, app, english)
 
     is_valid = f.validate()
     assert is_valid is True, "updating existing term is ok"
diff --git a/tests/unit/term/test_Term_status_follow.py b/tests/unit/term/test_Term_status_follow.py
index e9c39de9..cdf95e33 100644
--- a/tests/unit/term/test_Term_status_follow.py
+++ b/tests/unit/term/test_Term_status_follow.py
@@ -341,7 +341,7 @@ def test_deleting_parent_deactivates_sync_status(term_family, app_context):
 
 def test_adding_extra_parents_unsets_sync_status(term_family, app_context):
     "Can't follow multiple people."
-    c1 = DBTerm.find(term_family.c1.id)
+    c1 = db.session.get(DBTerm, term_family.c1.id)
     assert c1.sync_status is True, "following C"
 
     c1.add_parent(term_family.B)
@@ -350,7 +350,7 @@ def test_adding_extra_parents_unsets_sync_status(term_family, app_context):
 
 def test_changing_parent_keeps_sync_status(term_family, app_context):
     "Can't follow multiple people."
-    c1 = DBTerm.find(term_family.c1.id)
+    c1 = db.session.get(DBTerm, term_family.c1.id)
     assert c1.sync_status is True, "following C"
 
     c1.remove_all_parents()
diff --git a/tests/unit/term/test_datatables.py b/tests/unit/term/test_datatables.py
index 9836fd4b..ea8b07d5 100644
--- a/tests/unit/term/test_datatables.py
+++ b/tests/unit/term/test_datatables.py
@@ -4,6 +4,7 @@
 
 import pytest
 from lute.term.datatables import get_data_tables_list
+from lute.db import db
 
 
 @pytest.fixture(name="_dt_params")
@@ -38,7 +39,7 @@ def test_smoke_term_datatables_query_runs(app_context, _dt_params):
     """
     Smoke test only, ensure query runs.
     """
-    get_data_tables_list(_dt_params)
+    get_data_tables_list(_dt_params, db.session)
     # print(d['data'])
     a = 1
     assert a == 1, "dummy check"
@@ -53,4 +54,4 @@ def test_smoke_query_with_filter_params_runs(app_context, _dt_params):
     _dt_params["filtStatusMin"] = "2"
     _dt_params["filtStatusMax"] = "4"
     _dt_params["filtIncludeIgnored"] = "true"
-    get_data_tables_list(_dt_params)
+    get_data_tables_list(_dt_params, db.session)
diff --git a/tests/unit/term_parent_map/test_service.py b/tests/unit/term_parent_map/test_service.py
index 9b292307..0c55ab1e 100644
--- a/tests/unit/term_parent_map/test_service.py
+++ b/tests/unit/term_parent_map/test_service.py
@@ -5,7 +5,8 @@
 import os
 import tempfile
 import pytest
-from lute.term_parent_map.service import export_unknown_terms
+from lute.db import db
+from lute.term_parent_map.service import Service
 from tests.utils import add_terms, make_book
 
 
@@ -37,7 +38,8 @@ def assert_file_content(fname, expected):
 
 def test_smoke_book_file_created(app_context, _book, output_tempfile):
     "Smoke test only."
-    export_unknown_terms(_book, output_tempfile)
+    service = Service(db.session)
+    service.export_unknown_terms(_book, output_tempfile)
     expected = [
         "hola",
         "gato",
@@ -59,7 +61,8 @@ def test_known_multiword_terms_can_hide_unknown_terms(
     "See what terms would show up as blue 'unknown' for a book."
     spanish_terms = ["gato", "lista", "tiene una", "listo"]
     add_terms(spanish, spanish_terms)
-    export_unknown_terms(_book, output_tempfile)
+    service = Service(db.session)
+    service.export_unknown_terms(_book, output_tempfile)
     expected = ["hola", "tengo", "un", "no", "ella", "tiene", "una", "bebida"]
     assert_file_content(output_tempfile, expected)
 
diff --git a/tests/unit/termtag/test_datatables.py b/tests/unit/termtag/test_datatables.py
index 521e1f03..03ec9d6f 100644
--- a/tests/unit/termtag/test_datatables.py
+++ b/tests/unit/termtag/test_datatables.py
@@ -3,6 +3,7 @@
 """
 
 from lute.termtag.datatables import get_data_tables_list
+from lute.db import db
 
 
 def test_smoke_datatables_query_runs(app_context):
@@ -22,7 +23,7 @@ def test_smoke_datatables_query_runs(app_context):
         "search": {"value": "", "regex": False},
     }
 
-    d = get_data_tables_list(params)
+    d = get_data_tables_list(params, db.session)
     print(d)
     a = 1
     assert a == 1, "dummy check"
diff --git a/tests/unit/textbookmark/test_datatables.py b/tests/unit/textbookmark/test_datatables.py
index 0e1655c4..c4d9650b 100644
--- a/tests/unit/textbookmark/test_datatables.py
+++ b/tests/unit/textbookmark/test_datatables.py
@@ -4,6 +4,7 @@
 
 import pytest
 from lute.bookmarks.datatables import get_data_tables_list
+from lute.db import db
 
 
 @pytest.fixture(name="_dt_params")
@@ -28,5 +29,5 @@ def test_smoke_term_datatables_query_runs(app_context, _dt_params):
     """
     Smoke test only, ensure query runs.
     """
-    data = get_data_tables_list(_dt_params, 1)
+    data = get_data_tables_list(_dt_params, 1, db.session)
     assert data is not None
diff --git a/tests/unit/themes/test_service.py b/tests/unit/themes/test_service.py
index 6b5016c5..f1a83a80 100644
--- a/tests/unit/themes/test_service.py
+++ b/tests/unit/themes/test_service.py
@@ -3,13 +3,14 @@
 """
 
 import os
-import lute.themes.service as svc
+from lute.themes.service import Service
 from lute.db import db
-from lute.models.setting import UserSetting
+from lute.models.repositories import UserSettingRepository
 
 
 def test_list_themes(app_context):
     "Smoke test only."
+    svc = Service(db.session)
     lst = svc.list_themes()
     assert len(lst) > 0, "have themes"
     assert lst[0][0] == "-", "No theme"
@@ -20,21 +21,27 @@ def test_list_themes(app_context):
 
 def test_default_theme_is_blank_css(app_context):
     "UserSetting starts off with blank css."
-    assert UserSetting.get_value("current_theme") == "-"
+    repo = UserSettingRepository(db.session)
+    assert repo.get_value("current_theme") == "-"
+    svc = Service(db.session)
     assert svc.get_current_css() == "", "Default = empty string."
 
 
 def test_bad_setting_returns_blank_css(app_context):
     "Just in case."
-    UserSetting.set_value("current_theme", "_missing_file.css")
+    repo = UserSettingRepository(db.session)
+    repo.set_value("current_theme", "_missing_file.css")
     db.session.commit()
+    svc = Service(db.session)
     assert svc.get_current_css() == "", "Missing = empty string."
 
 
 def test_setting_a_theme_returns_its_css(app_context):
     "User choice is used."
-    UserSetting.set_value("current_theme", "Apple_Books.css")
+    repo = UserSettingRepository(db.session)
+    repo.set_value("current_theme", "Apple_Books.css")
     db.session.commit()
+    svc = Service(db.session)
     assert "Georgia" in svc.get_current_css(), "font specified"
 
 
@@ -43,10 +50,12 @@ def test_next_theme_cycles_themes(app_context):
     Users should be able to move the 'next' theme quickly
     while reading, via a hotkey.
     """
+    svc = Service(db.session)
     lst = svc.list_themes()
-    assert UserSetting.get_value("current_theme") == lst[0][0]
+    repo = UserSettingRepository(db.session)
+    assert repo.get_value("current_theme") == lst[0][0]
     svc.next_theme()
-    assert UserSetting.get_value("current_theme") == lst[1][0]
+    assert repo.get_value("current_theme") == lst[1][0]
     for _ in range(0, len(lst) + 10):  # pylint: disable=consider-using-enumerate
         svc.next_theme()
         svc.next_theme()
@@ -71,10 +80,12 @@ def test_custom_theme_in_theme_dir_is_available(app, app_context):
     with open(themefile, "w", encoding="utf-8") as f:
         f.write(mytheme_content)
 
+    svc = Service(db.session)
     lst = svc.list_themes()
     assert ("my_theme.css", "my theme") in lst, "Have my theme"
 
-    UserSetting.set_value("current_theme", "my_theme.css")
+    repo = UserSettingRepository(db.session)
+    repo.set_value("current_theme", "my_theme.css")
     db.session.commit()
     assert mytheme_content in svc.get_current_css(), "my theme used"
 
@@ -84,9 +95,11 @@ def test_custom_theme_in_theme_dir_appends_to_existing_theme(app, app_context):
     theme_dir = app.env_config.userthemespath
     _delete_custom_theme_files(theme_dir)
 
+    svc = Service(db.session)
     lst = svc.list_themes()
     assert ("Apple_Books.css", "Apple Books") in lst
-    UserSetting.set_value("current_theme", "Apple_Books.css")
+    repo = UserSettingRepository(db.session)
+    repo.set_value("current_theme", "Apple_Books.css")
     db.session.commit()
     old_content = svc.get_current_css()
 
@@ -98,7 +111,7 @@ def test_custom_theme_in_theme_dir_appends_to_existing_theme(app, app_context):
     lst = svc.list_themes()
     assert ("Apple_Books.css", "Apple Books") in lst, "Have my theme"
 
-    UserSetting.set_value("current_theme", "Apple_Books.css")
+    repo.set_value("current_theme", "Apple_Books.css")
     db.session.commit()
 
     new_css = old_content + "\n\n/* Additional user css */\n\n" + mytheme_content
diff --git a/tests/unit/utils/test_formutils.py b/tests/unit/utils/test_formutils.py
index 4ca28193..1785d841 100644
--- a/tests/unit/utils/test_formutils.py
+++ b/tests/unit/utils/test_formutils.py
@@ -2,13 +2,14 @@
 DataTables sqlite tests.
 """
 
+from lute.db import db
 from lute.utils.formutils import language_choices, valid_current_language_id
-from lute.models.setting import UserSetting
+from lute.models.repositories import UserSettingRepository
 
 
 def test_language_choices(app_context):
     "Gets all languages."
-    choices = language_choices()
+    choices = language_choices(db.session)
     assert choices[0][1] == "-", "- at the top"
     langnames = [c[1] for c in choices]
     assert "Spanish" in langnames, "have Spanish"
@@ -16,8 +17,9 @@ def test_language_choices(app_context):
 
 def test_valid_current_language_id(app_context):
     "Sanity check only."
-    UserSetting.set_value("current_language_id", 9999)
-    assert int(UserSetting.get_value("current_language_id")) == 9999, "pre-check"
-    curr_lang_id = int(valid_current_language_id())
+    repo = UserSettingRepository(db.session)
+    repo.set_value("current_language_id", 9999)
+    assert int(repo.get_value("current_language_id")) == 9999, "pre-check"
+    curr_lang_id = int(valid_current_language_id(db.session))
     assert curr_lang_id == 0, "set back to 0"
-    assert int(UserSetting.get_value("current_language_id")) == 0, "re-set to 0"
+    assert int(repo.get_value("current_language_id")) == 0, "re-set to 0"
diff --git a/tests/utils.py b/tests/utils.py
index 78dabe8a..9174d867 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -4,7 +4,7 @@
 
 from lute.models.term import Term
 from lute.models.book import Book
-from lute.read.render.service import get_paragraphs
+from lute.read.render.service import Service
 from lute.db import db
 
 
@@ -46,7 +46,8 @@ def stringize(ti):
 
     usestringize = overridestringize or stringize
     ret = []
-    paras = get_paragraphs(text.text, text.book.language)
+    service = Service(db.session)
+    paras = service.get_paragraphs(text.text, text.book.language)
     for p in paras:
         tis = [t for s in p for t in s]
         ss = [usestringize(ti) for ti in tis]