Free Ebook cover Flask Essentials: Practical Backend Patterns for Small Services

Flask Essentials: Practical Backend Patterns for Small Services

New course

14 pages

Flask Essentials: Extensions and Dependency Management Without Tight Coupling

Capítulo 7

Estimated reading time: 8 minutes

+ Exercise

Why extensions can create tight coupling (and how to avoid it)

Flask extensions are convenient because they provide ready-made integrations (ORMs, migrations, authentication, caching). The coupling risk appears when you treat an extension object as a global singleton that is imported everywhere and used directly. This makes tests harder, complicates multiple app instances (e.g., CLI tools, background workers), and turns refactors into “find-and-replace across the codebase.”

The goal is to keep extensions app-bound (initialized per application instance) and keep their usage localized (only a few modules know about the extension API). The most common pattern is: create the extension object once (unbound), then bind it inside the app factory via init_app.

The init_app pattern in practice

Step 1: Create unbound extension objects

Create a small module that only defines extension instances. These instances are created without an app, so importing them does not require an application context.

# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager

# Unbound instances (no app passed here)
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()

This module is intentionally “dumb”: no configuration reads, no imports of your models, no blueprint registration. It should be safe to import from anywhere.

Step 2: Initialize extensions inside the app factory

In your app factory, bind each extension to the created app instance. This is where configuration is applied.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

# app/__init__.py
from flask import Flask
from .extensions import db, migrate, login_manager

def create_app(config_object="app.config.ProdConfig"):
    app = Flask(__name__)
    app.config.from_object(config_object)

    # Bind extensions to this app instance
    db.init_app(app)
    migrate.init_app(app, db)
    login_manager.init_app(app)

    # Optional: configure extension behavior
    login_manager.login_view = "auth.login"  # if you have a UI flow

    return app

Key idea: the extension objects exist at import time, but they are not usable until init_app runs for a specific app instance.

Configuration-driven initialization (no hard-coded behavior)

Extensions often support configuration keys. Prefer reading behavior from config rather than hard-coding it across modules. This keeps behavior consistent across environments and reduces “magic constants.”

Example: SQLAlchemy and engine options

# app/config.py
class BaseConfig:
    SQLALCHEMY_DATABASE_URI = "postgresql+psycopg://..."
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_ENGINE_OPTIONS = {
        "pool_pre_ping": True,
        "pool_size": 5,
        "max_overflow": 10,
    }

Then the extension reads these values automatically when initialized. Your application code should not need to know about pooling details.

Example: Login settings

Some extensions don’t automatically read all behavior from config. You can still keep it config-driven by applying config values during init_app:

# app/__init__.py
from .extensions import login_manager

def create_app(config_object):
    app = Flask(__name__)
    app.config.from_object(config_object)

    login_manager.init_app(app)
    login_manager.session_protection = app.config.get("LOGIN_SESSION_PROTECTION", "strong")

    return app

Lazy access and current_app: using extensions without global state

Even with unbound extension instances, you can still accidentally create global coupling by importing and using them everywhere. A better approach is to keep direct extension usage in a small number of modules (data access layer, auth layer) and expose narrow functions to the rest of the code.

When you need app-specific configuration or resources, use lazy access via current_app (requires an application context, which you have during requests and CLI commands that push context).

Example: a small database session helper

# app/db/session.py
from flask import current_app
from ..extensions import db

def commit_or_rollback():
    try:
        db.session.commit()
    except Exception:
        current_app.logger.exception("DB commit failed; rolling back")
        db.session.rollback()
        raise

Only this module needs to know about db.session. Other modules call commit_or_rollback() instead of touching the session directly.

Example: reading config lazily

# app/security/tokens.py
from flask import current_app

def token_ttl_seconds() -> int:
    return int(current_app.config.get("TOKEN_TTL_SECONDS", 3600))

This avoids importing a config module directly and keeps behavior app-instance-specific.

Wrapper modules: keep extension setup and usage contained

A practical pattern is to create a wrapper module per concern (database, auth, migrations, caching). The wrapper module can (1) initialize the extension and (2) expose a small API that the rest of the app uses.

Database wrapper: initialize + narrow API

# app/db/__init__.py
from ..extensions import db, migrate

def init_db(app):
    db.init_app(app)
    migrate.init_app(app, db)
# app/__init__.py
from .db import init_db

def create_app(config_object):
    app = Flask(__name__)
    app.config.from_object(config_object)

    init_db(app)
    return app

Now the app factory doesn’t need to know which migration tool you use; it just calls init_db.

Auth wrapper: centralize LoginManager configuration

# app/auth/extension.py
from flask_login import LoginManager

login_manager = LoginManager()

def init_auth(app):
    login_manager.init_app(app)
    login_manager.login_message = app.config.get("LOGIN_MESSAGE", "Please log in")

    @login_manager.user_loader
    def load_user(user_id: str):
        # Import locally to avoid import cycles
        from .service import get_user_by_id
        return get_user_by_id(user_id)

Notes:

  • The user_loader is registered during initialization, keeping auth wiring in one place.
  • The import inside load_user is local to avoid circular imports between models/services and auth setup.

Keeping extension usage localized: patterns that scale

Prefer “service functions” over direct extension calls

Instead of calling db.session or login_user throughout your codebase, create a small set of service functions. This reduces the surface area you must change if you replace an extension later.

# app/users/service.py
from ..extensions import db
from .models import User

def create_user(email: str) -> User:
    user = User(email=email)
    db.session.add(user)
    db.session.commit()
    return user

If you later move from Flask-SQLAlchemy to another data layer, you update the service layer rather than every route.

Don’t import the app instance into modules

A common anti-pattern is importing app from a global module (e.g., from app import app) and then using app.config or app.logger. This breaks multi-app scenarios and makes tests brittle. Use current_app inside request/CLI contexts, or pass what you need as parameters.

Use local imports to break cycles (sparingly)

Extensions often sit at the center of your dependency graph. If you see circular imports (models import auth, auth imports models), move wiring into wrapper modules and use local imports inside functions that run after initialization.

Example: putting it together with SQLAlchemy, Migrate, and Login

The following layout shows a clean separation: extension instances, initialization wrappers, and usage modules.

app/
  __init__.py
  extensions.py
  db/
    __init__.py
    session.py
  auth/
    extension.py
    service.py
  users/
    models.py
    service.py
# app/__init__.py
from flask import Flask
from .db import init_db
from .auth.extension import init_auth

def create_app(config_object):
    app = Flask(__name__)
    app.config.from_object(config_object)

    init_db(app)
    init_auth(app)

    return app

Only the app factory coordinates initialization. Only wrapper modules know the details of each extension. Feature modules (like users) depend on narrow service APIs.

Guidelines for selecting extensions (avoid lock-in)

  • Prefer widely adopted, actively maintained extensions: check release cadence, issue responsiveness, and compatibility with your Flask version.
  • Evaluate the “surface area”: extensions that require you to sprinkle decorators or globals everywhere are harder to replace. Favor ones you can wrap behind a small internal API.
  • Check configuration model: good extensions rely on app.config and support init_app cleanly.
  • Look for testability: can you run it against SQLite/in-memory? Can you disable network calls? Does it provide hooks for mocking?
  • Be cautious with extensions that monkey-patch Flask internals: they can complicate upgrades.

Dependency management: pinning, upgrades, and minimal blast radius

Pin direct dependencies intentionally

Pin versions for the packages you directly depend on (Flask, extensions, database drivers). This makes builds reproducible and prevents surprise breakages from upstream releases.

PracticeWhy it helps
Pin direct deps (e.g., Flask==3.0.2)Reproducible deploys; predictable behavior
Allow patch updates for transitive deps (via lockfile)Security fixes without manual micromanagement
Upgrade on a scheduleAvoids “big bang” upgrades after years

Use a lockfile workflow

Whether you use pip-tools, Poetry, or another tool, the principle is the same: keep a human-edited list of top-level requirements and generate a fully pinned lock for deployments/CI.

# requirements.in (top-level, human-edited)
Flask==3.0.2
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
Flask-Login==0.6.3
psycopg==3.1.18

# requirements.txt (generated lock with transitive pins)
# ... fully pinned versions ...

Keep the “extension surface area” small to ease refactors

Even with perfect pinning, you will eventually replace or remove an extension. Reduce future work by:

  • Centralizing initialization in one place (app factory calling wrapper init_* functions).
  • Centralizing usage in service modules (data access, auth, caching) rather than routes and models everywhere.
  • Avoiding extension-specific types in public interfaces (e.g., don’t return SQLAlchemy query objects from service functions; return domain objects or plain data).
  • Keeping configuration keys documented in one config module so you can audit behavior quickly.

Quick checklist: extension integration without tight coupling

  • Create unbound extension instances in a dedicated module (no side effects).
  • Bind them in the app factory via init_app (or wrapper init_* functions).
  • Read behavior from app.config; avoid hard-coded values scattered across modules.
  • Use current_app for lazy access to config/logging inside request/CLI contexts.
  • Expose a narrow internal API (service functions) so most modules never touch the extension directly.
  • Pin direct dependencies and use a lockfile to control transitive versions.

Now answer the exercise about the content:

Which approach best reduces tight coupling when integrating Flask extensions in a small service?

You are right! Congratulations, now go to the next page

You missed! Try again.

Keeping extensions unbound until init_app runs per app instance avoids global singletons, supports multiple app instances, and improves testability. Localizing extension usage behind service/wrapper modules reduces refactor blast radius.

Next chapter

Flask Essentials: Database Access with SQLAlchemy Models and Sessions

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.