featured image

Your Flask App Is a Monolith Until You Learn the Factory: How `create_app`, Blueprints, and Jinja2 Inheritance Turn a Script Into a Scalable Project

A deep dive into the architectural patterns that transform a single-file Flask script into a maintainable, scalable web application — covering the application factory, Blueprint organization, and Jinja2 template inheritance.

Published

Mon Feb 23 2026

Technologies Used

Flask Jinja2
Advanced 41 minutes

Every introductory Flask tutorial starts the same way:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello():
    return 'Hello, World!'

if __name__ == '__main__':
    app.run()

This works fine for a 20-line demo. It falls apart the moment you add a second feature. The app object is a module-level global — every file that imports it creates a hard dependency on that single module, and circular imports become inevitable once your routes need your configuration and your configuration needs your app. There’s no way to run the same code with different settings in development versus testing versus production. Templates pile up in a flat directory. Routes, models, and utilities accumulate in one file.

STORM DAT is a Flask application with document analysis, screen recording, file upload/download, and AI transcription. Building it as a single-file script would be unmaintainable after the second week. Instead, it uses three patterns that solve these problems permanently: the application factory, Blueprint-based route organization, and Jinja2 template inheritance.

Before You Begin: Python Packages, HTTP Basics, and HTML Templating

  • Python packages: the difference between a module and a package, how relative imports work
  • Flask basics: routes, request, render_template, flash, url_for
  • HTML structure: <head>, <body>, <nav>, CSS stylesheet links
  • Template concepts: the idea of a layout that child pages extend

Environment:

  • Python 3.12, Flask 3.1.0, Flask-CORS 6.0.1, Jinja2 3.1.6, Gunicorn 23.0.0
pip install flask==3.1.0 flask-cors==6.0.1 gunicorn==23.0.0

Nested Layers That Build a Complete Application

Think of STORM DAT’s architecture as a Russian nesting doll. Each layer wraps the one inside it, adding structure without breaking what’s already there.

flowchart TD
    A["run.py\n(Entry Point)"] -->|"Calls create_app(config)"| B["src/__init__.py\n(Application Factory)"]
    B -->|"Registers"| C["src/routes.py\n(Blueprint)"]
    B -->|"Attaches"| D["src/utils/security_headers.py\n(Middleware)"]
    B -->|"Loads"| E["src/config/config.py\n(Configuration)"]
    B -->|"Initializes"| F["Whisper Model\n(Singleton)"]
    C -->|"Calls render_template"| G["templates/modern_base.html\n(Base Layout)"]
    G -->|"Inherited by"| H["pages/modern_home.html"]
    G -->|"Inherited by"| I["pages/modern_word_upload.html"]
    G -->|"Inherited by"| J["pages/modern_word_result.html"]
    G -->|"Inherited by"| K["pages/modern_record.html"]

    style A fill:#e8f5e9,stroke:#333
    style B fill:#fff3e0,stroke:#333
    style G fill:#e3f2fd,stroke:#333

The outermost layer is run.py — the file you actually execute. It picks a configuration and calls the factory. The middle layer is create_app() — the factory function that constructs, configures, and returns a Flask instance. The innermost layer is the template system — a base HTML layout that defines the shell (navigation, flash messages, CSS), with child templates that fill in only the content that changes per page.

Layer by Layer: From Entry Point to Rendered Page

The Entry Point: run.py

The entry point is deliberately minimal. Its only job is to select a configuration and call the factory:

import argparse
from src import create_app
from src.config.config import DevelopmentConfig, TestingConfig, ProductionConfig

# Module-level app for Gunicorn: "gunicorn run:app"
app = create_app(config_class=ProductionConfig)

def main(config_class):
    app = create_app(config_class=config_class)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description="Run Flask app")
    parser.add_argument('--dev', action='store_true')
    parser.add_argument('--test', action='store_true')
    parser.add_argument('--production', action='store_true')
    args = parser.parse_args()

    configClass = DevelopmentConfig
    if args.production:
        configClass = ProductionConfig
    elif args.test:
        configClass = TestingConfig

    main(configClass)
    app.run()

Two things to notice. The module-level app = create_app(ProductionConfig) exists for Gunicorn. When the Dockerfile runs gunicorn run:app, Gunicorn imports run.py as a module and looks for an attribute named app. It never executes the if __name__ == '__main__' block — that block only runs during direct python run.py execution. This dual-mode design lets the same file serve both development and production with zero changes.

The configurations are plain Python dicts:

DevelopmentConfig = {
    "DEBUG": True,
    "Testing": False,
    "VERIFY_SSL": False
}

TestingConfig = {
    "DEBUG": False,
    "TESTING": True,
    "VERIFY_SSL": True
}

ProductionConfig = {
    "DEBUG": False,
    "Testing": False,
    "VERIFY_SSL": True
}

Flask’s app.config.from_object() accepts any object with key-value attributes, including dicts. No class hierarchies, no BaseConfig with inheritance. A dictionary is the right level of complexity for three environment profiles with three keys each.

The Application Factory: src/__init__.py

This is the architectural heart. The create_app() function constructs a Flask instance and attaches every component the application needs:

def create_app(config_class=None):
    app = Flask(__name__)
    CORS(app)

    if config_class:
        app.config.from_object(config_class)

Why a function instead of a global? Because a function can be called multiple times with different arguments. create_app(DevelopmentConfig) produces an app with DEBUG=True and SSL verification disabled. create_app(TestingConfig) produces an app with TESTING=True for pytest. create_app(ProductionConfig) produces the production version. In a test suite, you call create_app(TestingConfig) in a fixture and get a fresh app instance for every test — no global state leaking between tests.

If the app object were a module-level global (as in the “Hello World” pattern), tests would have to patch global state. A test that modifies app.config['DEBUG'] would affect every subsequent test. The factory pattern eliminates this entire class of test pollution.

The factory wires four subsystems:

    # 1. Register routes
    app.register_blueprint(routes.main)

    # 2. Attach security middleware
    add_security_headers(app)

    # 3. Configure secret key
    secret_key = os.environ.get('FLASK_SECRET_KEY')
    if secret_key:
        app.secret_key = secret_key
    else:
        app.secret_key = secrets.token_hex(32)

    # 4. Load the Whisper ML model once at startup
    try:
        import whisper
        app.whisper_model = whisper.load_model("medium")
    except Exception as e:
        app.whisper_model = None

    return app

Each subsystem is a separate module — the factory imports and attaches them, but they know nothing about each other. The Blueprint doesn’t import the security headers module. The security headers module doesn’t import the routes. This is loose coupling: each component can be understood, tested, and modified independently.

The Blueprint: src/routes.py

A Blueprint groups related routes into a portable unit that can be registered on any app. STORM DAT uses a single Blueprint:

from flask import Blueprint, render_template, request, flash, jsonify, current_app

main = Blueprint('main', __name__)

@main.route('/')
def index():
    return render_template('pages/modern_home.html')

@main.route("/storm/word", methods=["GET"])
def upload_word():
    return render_template('pages/modern_word_upload.html')

@main.route("/storm/word/results", methods=["POST"])
def analyze_word():
    # ... analysis logic ...
    return render_template('pages/modern_word_result.html',
                           findings=findings,
                           output_html=html_content,
                           output_filenames=output_filenames)

Blueprint('main', __name__) instead of @app.route means the main Blueprint is created without needing a reference to app. This eliminates the circular import problem — routes.py never imports app from __init__.py. Instead, __init__.py imports routes and calls app.register_blueprint(routes.main).

Inside route handlers, current_app is a proxy that points to whatever Flask app is currently handling the request. This is how Blueprint routes access the app’s configuration and attached objects (like current_app.whisper_model) without importing the app instance directly. current_app is a Werkzeug LocalProxy backed by a context-local stack — when Flask receives a request, it pushes an application context onto a thread-local stack, and current_app always resolves to the top of that stack.

The Python Package Structure

The directory layout enforces three rules by convention:

src/
├── __init__.py           ← Application factory (create_app)
├── routes.py             ← Blueprint with all endpoints
├── config/
│   ├── __init__.py
│   └── config.py         ← Configuration dicts
├── utils/
│   ├── __init__.py
│   ├── security.py       ← Filename sanitization, HTML escaping
│   ├── validators.py     ← File extension/size validation
│   └── security_headers.py
├── word_analysis/
│   └── word_analysis.py  ← WordAnalyzer class
├── parse_files/
│   └── parse_files.py    ← Parser class
├── output_table/
│   └── output_table.py   ← WriteExcel class
├── static/
│   ├── modern-styles.css
│   ├── uploads/
│   └── outputs/
└── templates/
    ├── modern_base.html
    └── pages/
        ├── modern_home.html
        ├── modern_word_upload.html
        ├── modern_word_result.html
        └── modern_record.html

One domain per package. A developer looking for “where acronyms are checked” goes straight to word_analysis/, not to routes.py. Imports flow downward: routes.py imports from domain packages, domain packages don’t import from routes.py, and nothing imports the factory. Templates mirror routes: the route /storm/word renders pages/modern_word_upload.html.

Flask resolves template paths relative to the templates/ directory inside the package where Flask(__name__) is called. Since that’s src/__init__.py, Flask looks in src/templates/. If you create a second templates/ directory at the project root, Flask won’t find it — and the error message (“Template not found”) won’t tell you why.

Jinja2 Template Inheritance: One Layout, Four Pages

The base template defines the HTML shell — everything that appears on every page:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="{{ url_for('static', filename='modern-styles.css') }}">
    <title>{% block title %}STORM DAT{% endblock title %}</title>
    {% block extra_css %}{% endblock extra_css %}
</head>
<body>
    <nav class="nav-bar">
        <div class="nav-content">
            <a href="{{ url_for('main.index') }}" class="nav-brand">STORM DAT</a>
            <div class="nav-links">
                <a href="{{ url_for('main.upload_word') }}" class="nav-link">Acronym Sweep</a>
                <a href="{{ url_for('main.record') }}" class="nav-link">Record</a>
                <a href="{{ url_for('main.cleanup_static_files') }}" class="nav-link">Cleanup</a>
            </div>
        </div>
    </nav>

    {% with messages = get_flashed_messages(with_categories=true) %}
        {% if messages %}
            {% for category, message in messages %}
                <div class="alert alert-{{ category }}">{{ message }}</div>
            {% endfor %}
        {% endif %}
    {% endwith %}

    {% block body %}{% endblock body %}

    {% block extra_js %}{% endblock extra_js %}
</body>
</html>

Four blocks define the extension points: title, extra_css, body, and extra_js. A child template uses {% extends %} and overrides only what it needs:

{% extends 'modern_base.html' %}
{% block title %}STORM Document Analysis Tool - Home{% endblock title %}

{% block body %}
<div class="hero">
    <h1 class="hero-title">STORM Document Analysis Tool</h1>
    <p class="hero-subtitle">Automated document analysis with security marking validation...</p>
    <div class="button-group">
        <a href="{{ url_for('main.upload_word') }}" class="btn btn-primary btn-lg">Acronym Sweep</a>
        <a href="{{ url_for('main.record') }}" class="btn btn-secondary btn-lg">Record Desktop</a>
    </div>
</div>
{% endblock body %}

The home page overrides title and body but leaves extra_css and extra_js empty — no extra styles or scripts load for this page. Navigation, flash messages, and the main stylesheet are inherited.

The recording page overrides all four blocks because it needs 300+ lines of page-specific CSS for the recorder UI and 300+ lines of JavaScript for MediaRecorder API and audio processing. Both pages share the same navigation bar and flash message system without any code duplication.

url_for and Why Hardcoded URLs Are Technical Debt

Every link in STORM DAT’s templates uses url_for() instead of hardcoded paths:

<a href="{{ url_for('main.index') }}">Home</a>
<a href="{{ url_for('main.upload_word') }}">Acronym Sweep</a>
<link rel="stylesheet" href="{{ url_for('static', filename='modern-styles.css') }}">

url_for('main.upload_word') generates /storm/word by looking up the upload_word function registered on the main Blueprint. If you rename the URL from /storm/word to /analysis/upload, every template link updates automatically. If you hardcode <a href="/storm/word">, you’re hunting through all your templates for a find-and-replace every time a URL changes.

url_for('static', filename='modern-styles.css') handles environment differences too. Behind a CDN the path might be https://cdn.example.com/static/modern-styles.css?v=abc123. The template doesn’t care — url_for resolves the correct path for the current environment.

Flash Messages as Shared Infrastructure

Because the flash message renderer lives in the base template, every page inherits it. A route handler can call flash('No file provided', 'error') and return any template — the message displays automatically regardless of which child template renders.

The Jinja2 implementation shows a few useful patterns:

{% with messages = get_flashed_messages(with_categories=true) %}
    {% if messages %}
        {% for category, message in messages %}
            <div class="alert alert-{{ category if category in
                 ['error', 'success', 'warning', 'info'] else 'info' }}">
                {{ message }}
            </div>
        {% endfor %}
    {% endif %}
{% endwith %}

{% with %} creates a scoped variable — messages only exists within the block, no namespace pollution. The inline conditional sanitizes the CSS class name: if a route flashes an unrecognized category, the template defaults to alert-info instead of generating a broken class like alert-debug.

Where the Factory Pattern Breaks

Double-import problem. run.py has app = create_app(ProductionConfig) at module scope. If a test file writes from run import app, it gets a production-configured app. The correct pattern for tests is calling the factory directly:

from src import create_app
from src.config.config import TestingConfig

def test_home_page():
    app = create_app(TestingConfig)
    client = app.test_client()
    response = client.get('/')
    assert response.status_code == 200

Never import app from run.py in tests.

Circular import prevention. The factory pattern works because imports flow in one direction: run.py → src/__init__.py → src/routes.py → domain packages. If routes.py tried to import create_app from src/__init__.py, Python would detect the cycle and raise an ImportError. The Blueprint pattern breaks the cycle — routes.py creates a Blueprint object instead of importing app.

Template shadowing. If two Blueprints register templates with the same path, Flask uses the first one found in search order. STORM DAT avoids this by using a single Blueprint and a single templates/ directory. In a multi-Blueprint app, each Blueprint should set template_folder to its own directory and prefix template names to prevent collisions.

Flash messages and double-renders. Flash messages are stored in the session cookie. If a route calls flash() and returns a rendered template (not a redirect), the flash is consumed immediately — but if the user refreshes, it won’t reappear. STORM DAT’s analyze_word route renders the template directly after flashing, which is correct behavior for form submissions that shouldn’t be retried on refresh.

The investment in this structure is small — a few __init__.py files, one factory function, one base template. But the payoff compounds with every feature you add. Adding the fifth endpoint or the third developer stops feeling like archaeology.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!