Skip to main content
友田 陽大
Flask in production
Python
Flask
Blueprint
アーキテクチャ設計
本番運用
バックエンド

Flask Large-App Structure: Extending Without Circular Imports Using the Application Factory (create_app) and Blueprints

A practical guide for designing a large Flask 3.1-series app structure at production quality. Explained with real code faithful to the official documentation: the breakdown of the global app and circular imports, the create_app application factory, the bare extension → init_app of extensions.py, Blueprints' url_prefix / endpoint naming / nesting / templates and static files / error handlers / CLI, per-environment Config with from_object + from_prefixed_env, and the src/ layout and the YAGNI discipline of splitting.

Published
Reading time
22 min read
Author
友田 陽大
Share

Introduction: a Flask app breaks not from "lack of features" but from "lack of structure"

When a Flask project sinks into technical debt, the cause is almost never a lack of Flask features. It's a lack of structure. An app that started by writing app = Flask(__name__) at the top of one file, while adding routes, models, and forms, one day stops launching with ImportError: cannot import name 'app' from partially initialized module — anyone who has touched Flask has seen this scene at least once.

This breakdown isn't coincidence but a structural inevitability. The moment you place a global app at the center, views, models, and extensions are all forced to "import app," and that app side also imports the views — dependencies draw a circle, and the whole app hangs on the most fragile premise of import order.

This article is a deep-dive guide for resolving this breakdown faithfully to the official documentation of the Flask 3.1 series (the current stable version, 3.1.3 at the time of writing) — the application factory (create_app) and Blueprints, and the init_app pattern of extensions.py that connects the two — and assembling it at production quality. This is a spoke that thoroughly develops §1 (app structure) and §3 (the factory) of the Flask production operation guide from the angles of avoiding circular imports and the pitfalls of Blueprints. For the overall picture, first refer to the pillar.

The author has designed and implemented the backend of a B2B SaaS that won the Minister of Economy, Trade and Industry Award in Python / Flask / SQLAlchemy / PostgreSQL, and operated it in production on API Gateway → ALB → ECS (Fargate). As endpoints grew and authentication, billing, and the admin panel grew as separate concerns, only the discipline of the factory and Blueprints shown here kept the codebase in "an easy-to-change state (ETC)." This article articulates that real-combat design judgment in light of the official spec.

💡 The version covered in this article: it assumes the Flask 3.1 series (the latest stable version 3.1.3). Flask 3.1 requires Python 3.9 or later. Note that Flask.__version__ was removed in Flask 3.0, so when you want to get the version, use importlib.metadata.version("flask") (described later). The code in this article is based on the patterns of the official documentation.


1. Why you go beyond a single file: the breakdown of the global app and circular imports

Before talking about design, let me accurately understand what breaks. Flask's minimal form was this.

# hello.py
from flask import Flask

app = Flask(__name__)


@app.route("/")
def index():
    return "Hello, World!"

This is enough for learning. The problem occurs the instant features grow and you carve views and models into separate files. Let me reproduce the typical "way it breaks."

# models.py — モデルが app を欲しがる
from app import app   # ← app.py を import

db = ...  # app.config を読んで初期化したい


# app.py — app がビュー(モデルを使う)を欲しがる
from flask import Flask

app = Flask(__name__)

import models   # ← models.py を import(models は app を import する)

Launch app.py and at the import models line, models.py runs from app import app. But at this point app.py is still mid-execution of the import models line, and the module's initialization hasn't completed. As a result, the name app isn't bound yet, and it crashes with ImportError: cannot import name 'app' from partially initialized module 'app' (a circular import).

This breakdown has 3 independent symptoms.

SymptomWhat happensRoot cause
Can't swap settings in testsapp is settled at import time, so there's no opening to switch to a test DB or test settingsapp is immediately generated at the module top
Can't have multiple configurations at onceCan't make apps with different settings for production, staging, and test in parallelapp is a singleton within the module
Induces circular importsViews/models import app, and the app side also imports themThe dependency direction has become bidirectional

⚠️ Anti-pattern: keep adding features with the global app "because it works for now," and when a circular import appears, stopgap it by pushing the import statement into a function (a lazy import). This only hides the debt; the fact that dependencies draw a circle doesn't disappear. The application factory in the next section structurally cuts off this circle by making the dependency direction one-way "from top to bottom."


2. The application factory: deep dive into create_app

The solution is the application factory. Stop generating app at the module top, and assemble it inside a function and return it. The caller gains complete control over "when, with which settings, and which parts loaded" the app is made.

2.1 The canonical form of the official tutorial

This is the canonical form of the factory the official Flask tutorial (Flaskr) shows. Every element has meaning, so let me read it line by line.

# src/myapp/__init__.py
import os

from flask import Flask


def create_app(test_config: dict | None = None) -> Flask:
    # ① instance_relative_config=True で、設定をリポジトリ外の instance/ から読む
    app = Flask(__name__, instance_relative_config=True)

    # ② コード内の安全な既定値(本番では必ず上書きする)
    app.config.from_mapping(
        SECRET_KEY="dev",
        DATABASE=os.path.join(app.instance_path, "myapp.sqlite"),
    )

    if test_config is None:
        # ③ 本番/開発:instance/config.py があれば上書き(無ければ黙ってスキップ)
        app.config.from_pyfile("config.py", silent=True)
    else:
        # ④ テスト:呼び出し側が渡した設定で上書き
        app.config.from_mapping(test_config)

    # ⑤ instance フォルダは Flask が自動生成しない。明示的に作る
    os.makedirs(app.instance_path, exist_ok=True)

    @app.route("/health")
    def health():
        return {"status": "ok"}

    return app
  • instance_relative_config=True: switches the resolution base of config files to the instance folder (app.instance_path, by default <project>/instance/) rather than inside the package. This lets you place settings containing secrets outside the repository (§6.2).
  • from_mapping(...): place here only the safe default values you can write directly in code. SECRET_KEY="dev" is a development dummy, on the premise that it's always overridden in production.
  • ③④ the test_config branch: this branch is the heart of the factory. If the argument is None (normal startup), read production settings from instance/config.py, and if an argument is passed (test), override with those settings. Tests can inject settings "without placing a real file."
  • os.makedirs(app.instance_path, exist_ok=True): Flask doesn't auto-generate the instance folder. If you place SQLite or uploads there, you need to explicitly create it in the factory. The official tutorial also includes this one line.

For startup, the flask CLI auto-detects the factory.

flask --app myapp run --debug

2.2 Auto-detection of the factory and passing arguments

flask --app, if the target module has a function named create_app or make_app, automatically calls it as the factory. When you want to pass arguments, you can write inside the parentheses as a Python literal.

# 引数なしのファクトリを自動検出
flask --app myapp run --debug

# ファクトリに文字列リテラル "dev" を渡す(括弧内は Python リテラルとして解釈)
flask --app 'hello:create_app("dev")' run

This format is the same in production Gunicorn. You can point directly at the factory like gunicorn 'myapp:create_app()'. The production-deploy details of Gunicorn's worker count, ProxyFix, and Dockerization are consolidated in the production deployment guide.

2.3 Why the factory solves testability and multiple environments "at once"

The essential value of the factory is in "delaying" and "parameterizing" app generation.

  • Testability: like create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:"}), you can generate an app with clean settings per test. Re-create the app in a pytest fixture and contamination of state between tests doesn't occur either (for testing details, see the testing practical guide).
  • Multiple environments: just call the same create_app differently — production with environment-variable settings, test with the test_config argument. You don't need to branch code per environment.

💡 The factory is not "a future extension" but "a current requirement": regarding the application factory as something "to introduce once scale grows" is wrong. Testability is a requirement needed from the start, and the factory is the minimal structure that meets it. Even in a small app, put in just the separation of create_app and extensions.py from the start — this is the starting point that doesn't generate debt (it doesn't contradict §7's YAGNI discipline. What should be split is Blueprints, and introducing the factory is a separate matter).


3. The extensions.py pattern: bare extensions → bind with init_app

The key by which the factory cuts off circular imports is in how you handle extension objects. Generate an extension like Flask-SQLAlchemy or Flask-Migrate together with app, and you're back in the circular-import trap. The solution the official docs show is clear — generate the extension in "a bare state unbound to any app," and bind it only by calling init_app(app) inside the factory.

3.1 The structure of two-stage initialization

# src/myapp/extensions.py — どのアプリにも束縛されていない「裸」の拡張
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

db = SQLAlchemy()      # この時点では app を知らない
migrate = Migrate()    # この時点では app を知らない
# src/myapp/__init__.py(抜粋)
from flask import Flask

from .extensions import db, migrate


def create_app(test_config: dict | None = None) -> Flask:
    app = Flask(__name__, instance_relative_config=True)
    # ...設定読み込み(§2 / §6)...

    # ここで初めて app に束縛する(二段階初期化の第二段)
    db.init_app(app)
    migrate.init_app(app, db)

    # Blueprint の登録は import を「関数の中」で行う(§4)
    from .blueprints.api import bp as api_bp
    app.register_blueprint(api_bp, url_prefix="/api")

    return app

The point is that db = SQLAlchemy() is generated in extensions.py independent of the app. A model definition imports db with from myapp.extensions import db, but this db knows nothing of app, so it doesn't need to import app. The dependency direction becomes one-way, models → extensions, and the circle disappears.

3.2 Why this structurally resolves circular imports

Looking at the dependency graph, the power of two-stage initialization becomes clear.

ApproachDependency directionResult
Global app + db = SQLAlchemy(app)models → app → modelsCircular (can't launch)
extensions.py + db.init_app(app)models → extensions, __init__ → extensions, __init__ → models (in-function import)One-way (sound)

By extensions.py becoming "the bottom-most layer that depends on neither the app, the models, nor the views," all modules can safely import extensions. This is also a naive implementation of dependency inversion — depending on an abstraction (the bare extension object) rather than a concrete (a specific app).

3.3 One extension object can be reused across multiple apps

This design has one more important consequence beyond avoiding circular imports. A single extension object db can be bound to multiple apps through init_app. The production app and the test app can use the same db object, each with a different DB setting. This is why the factory (§2) and the extension pattern mesh — the factory makes multiple apps, and binds the same extension to each app with init_app.

💡 current_app completes this design: make the app in the factory and a situation arises where "you can't import app from the module top" (because it doesn't exist at import time). For an extension or view to access the current app's settings, use the current_app proxy instead of dragging app around. current_app.config["..."] resolves dynamically to "the app currently being processed," so it's fully consistent with the factory configuration. The mechanism of current_app / g / the application context is dug into in the context thorough explanation. For the design of the persistence layer (SQLAlchemy 2.0's typed ORM), also read the SQLAlchemy 2.0 practical guide.


4. Blueprint thorough explanation: the unit of splitting and the pitfalls of the official spec

Once the application factory has solved "how to make the app," next is "how to split features." That's the Blueprint. A Blueprint is "a registerable feature unit bundling routes, templates, static files, error handlers, and CLI commands." The Blueprint API was added in Flask 0.7.

4.1 Definition and registration: name is not the URL but the prefix of the endpoint

Let me crush the most important misconception first. A Blueprint's name (the first argument) doesn't decide the URL. It prefixes the endpoint (the name used in url_for).

# src/myapp/blueprints/simple_page.py
from flask import Blueprint, render_template
from werkzeug.exceptions import abort

# 第1引数 = name(endpoint の前綴り)、第2引数 = import_name
simple_page = Blueprint("simple_page", __name__, template_folder="templates")


@simple_page.route("/<page>")
def show(page):
    return render_template(f"pages/{page}.html")
# create_app 内で登録
from .blueprints.simple_page import simple_page

app.register_blueprint(simple_page)
# URL を前綴りしたいなら url_prefix を登録時に指定する
app.register_blueprint(simple_page, url_prefix="/pages")

The crux here is how url_for changes.

SpecificationendpointGenerated URL (url_prefix="/pages")
url_for("simple_page.show", page="about")simple_page.show/pages/about

The name (simple_page) is prefixed to the endpoint with a . separator, and the URL is prefixed with url_prefix — these two are different things. Change name and the URL doesn't change, and change url_prefix and the endpoint doesn't change. Without understanding this separation, you'll endlessly get stuck emitting a BuildError with url_for.

⚠️ Anti-pattern: writing a URL path in name, like Blueprint("/admin", __name__). name is the endpoint's namespace, not the URL. A name containing a slash is invalid. If you want to attach a URL, use app.register_blueprint(admin, url_prefix="/admin"). Similarly, omit the Blueprint name like url_for("show") and resolution fails when the view is in a different Blueprint. Within the same Blueprint, you can reference relatively with url_for(".show") (a leading dot).

4.2 Nesting Blueprints

Blueprints can be nested. Register a child to a parent Blueprint and register the parent to the app, and the child inherits both the parent's name and url_prefix.

parent = Blueprint("parent", __name__, url_prefix="/parent")
child = Blueprint("child", __name__, url_prefix="/child")


@child.route("/create")
def create():
    return "created"


parent.register_blueprint(child)   # 親に子を登録
app.register_blueprint(parent)     # アプリに親を登録

As a result, the endpoint becomes parent.child.create and the URL becomes /parent/child/create.

url_for("parent.child.create")   # → "/parent/child/create"

Note that both name and url_prefix are concatenated. Nesting with a subdomain using subdomain= works similarly too. Nesting straightforwardly expresses a hierarchy like "the admin panel has user management and billing management," but make it too deep and the endpoint name becomes huge. Use 2 levels of nesting as a guideline, and beyond that, it's realistic to consider splitting into a separate app (microservice).

4.3 Blueprint-local templates and static files: a treasure trove of pitfalls

A Blueprint can have its own templates and static files, but the official-spec pitfalls are concentrated here.

Templates: with Blueprint("x", __name__, template_folder="templates"), you can add a Blueprint-specific template folder. But you need to be careful about priority.

  • The Blueprint's template folder is added at a lower priority than the app's template folder. That is, if there's a template with the same relative path, the app side can override the Blueprint side (a design leaving room for customization).
  • If multiple Blueprints provide a template with the same relative path, the first-registered Blueprint wins.

⚠️ The template-collision trap: if multiple Blueprints each have a templates/index.html, the index.html of a later-registered Blueprint is never resolved (because the first-registered wins). To avoid this, namespace each Blueprint with a subfolder — like templates/admin/index.html, templates/auth/index.html. Namespacing the template's relative path with the Blueprint name is the only sure way to prevent collisions.

Static files: here is even more of a trap. With Blueprint("admin", __name__, static_folder="static"), you can have a static folder, but a Blueprint without a url_prefix can't serve its own static folder.

  • The Blueprint's static files are served at url_prefix + /static, and the endpoint becomes admin.static.
  • In a Blueprint without url_prefix, the app's /static wins, and the Blueprint's static folder isn't even searched as a fallback.
# url_prefix があれば静的フォルダが配信される
admin = Blueprint("admin", __name__, static_folder="static")
app.register_blueprint(admin, url_prefix="/admin")
# → /admin/static/... で配信、endpoint は "admin.static"
# → url_for("admin.static", filename="style.css") → "/admin/static/style.css"

In practice, rather than scattering static files per Blueprint, consolidating them into the app's single /static and bundling with a build tool (Vite, etc.) is simpler to operate. Blueprint-local static is best limited to special cases like "distributing a fully independent feature unit plugin-style."

4.4 Blueprint error handlers and the "can't catch 404" exception

A Blueprint can register an error handler with @bp.errorhandler(404), but the most easily misunderstood pitfall is here.

@simple_page.errorhandler(404)
def page_not_found(e):
    # これは「無効な URL アクセス」では呼ばれない!
    return render_template("pages/404.html"), 404

A Blueprint's errorhandler(404) is called only when abort(404) or raise occurs inside that Blueprint's own view function. It's not called for access to a nonexistent URL (a routing failure). The reason is clear — a Blueprint doesn't "own" the URL space. Flask can't judge which Blueprint a request for /nonexistent should belong to, so an invalid-URL 404 can only be caught by an app-level handler.

When you want to register a handler effective for the whole app from a Blueprint, use app_errorhandler.

# 自分の Blueprint のビュー内の abort(403) だけを捕捉
@simple_page.errorhandler(403)
def handle_403(e):
    return render_template("pages/403.html"), 403


# アプリ全体の 404(無効 URL を含む)を、この Blueprint から登録する
@simple_page.app_errorhandler(404)
def handle_app_404(e):
    return render_template("404.html"), 404
DecoratorScopeCatches invalid-URL 404?
@bp.errorhandler(404)Only abort/raise inside that Blueprint's own views❌ No
@bp.app_errorhandler(404)The whole app (including invalid URLs)✅ Yes
@app.errorhandler(404)The whole app✅ Yes

The error-handler resolution order, bulk handling of HTTPException, and the details of JSON error design in an API are split into the error-handling / observability guide.

4.5 Blueprint CLI commands

A Blueprint can define flask CLI commands tied to that Blueprint with @bp.cli.command(...). By default, they go under a group named after the Blueprint.

# blueprints/admin/__init__.py
import click
from flask import Blueprint

admin = Blueprint("admin", __name__)


@admin.cli.command("create")
@click.argument("name")
def create(name):
    """管理ユーザーを作成する。"""
    click.echo(f"created admin: {name}")
# 既定では Blueprint 名(admin)のグループ配下に入る
flask admin create alice

The group name can be changed. With Blueprint("admin", __name__, cli_group="other") it goes under a different-named group, and with cli_group=None it's promoted to the top level as flask create alice. Giving independent operational command groups like db migrate per Blueprint keeps the CLI organized by feature and easier to operate.


Let me land the design elements so far — the factory, extensions.py, Blueprints, Config — into one directory structure. This is the "pattern" of production Flask. Adopt the src/ layout (placing the package under src/) and at test time it's imported as "an installed package," preventing the accident of "carelessly importing a file in the current directory."

myapp/
├── pyproject.toml
├── src/
│   └── myapp/
│       ├── __init__.py          # create_app(アプリケーションファクトリ)§2
│       ├── config.py            # 環境別 Config クラス(Base/Production/Test)§6
│       ├── extensions.py        # db = SQLAlchemy() など「裸」の拡張 §3
│       ├── errors.py            # app_errorhandler でアプリ全体のエラー整形 §4.4
│       ├── models/
│       │   ├── __init__.py
│       │   ├── user.py          # from myapp.extensions import db
│       │   └── invoice.py
│       └── blueprints/
│           ├── __init__.py
│           ├── auth/            # 認証 Blueprint(login/logout/signup)
│           │   ├── __init__.py  # auth = Blueprint("auth", __name__)
│           │   ├── routes.py
│           │   └── templates/auth/   # 名前空間化したテンプレート §4.3
│           └── api/             # API Blueprint(REST エンドポイント)
│               ├── __init__.py  # api = Blueprint("api", __name__)
│               ├── routes.py
│               └── schemas.py   # marshmallow スキーマ(境界バリデーション)
├── instance/                    # .gitignore(秘密・SQLite・アップロード)§6.2
│   └── config.py
├── migrations/                  # Flask-Migrate(Alembic)
└── tests/
    ├── conftest.py              # app / client / runner fixtures
    └── test_*.py

Let me make explicit the dependency direction in this structure — all one-way, "from top to bottom."

__init__.py (create_app)
    ├──→ config.py        (Config クラスを読む)
    ├──→ extensions.py    (db, migrate を init_app)
    ├──→ blueprints/*     (register_blueprint)
    └──→ errors.py        (app_errorhandler を登録)

blueprints/api/routes.py
    ├──→ extensions.py    (db を使う)
    ├──→ models/*         (モデルを使う)
    └──→ blueprints/api/schemas.py

models/*  ──→ extensions.py   (db.Model を継承するためだけに db を import)

As long as you keep this shape — extensions.py at the bottom layer, and no one imports __init__.py (create_app) — circular imports structurally don't occur. The "layer separation of Router → Schema (the boundary) → Model" in the API Blueprint's schemas.py is detailed in designing a production REST API with marshmallow × Flask × SQLAlchemy. This article's Blueprint handles "where," and that article handles "what inside."

💡 Make models/ and blueprints/ packages (a directory + __init__.py): starting with a single models.py file is fine, but once models grow, promote it to a models/ package and re-export each model in models/__init__.py. This keeps the import path from myapp.models import User stable while freely changing the internal file split. The stability of the import path directly connects to refactoring freedom.


6. Per-environment Config: a two-stage from_object + from_prefixed_env

Combined with the factory's test_config branch (§2.1), prepare a mechanism to switch settings per environment. There's one principle you must not waver on in production — don't write secrets (SECRET_KEY, DB passwords, API keys) in code. This is the core of 12-factor and the minimum line of security.

6.1 The inheritance hierarchy of Config classes

app.config.from_object(...) loads only uppercase attributes as settings. Using this, express environment differences with Base → Production / Test inheritance.

# src/myapp/config.py
class BaseConfig:
    """全環境共通の安全な既定値。"""
    JSON_SORT_KEYS = False
    SESSION_COOKIE_HTTPONLY = True       # 既定 True だが意図を明示
    SESSION_COOKIE_SAMESITE = "Lax"
    SQLALCHEMY_TRACK_MODIFICATIONS = False


class ProductionConfig(BaseConfig):
    SESSION_COOKIE_SECURE = True         # HTTPS 限定 Cookie


class TestConfig(BaseConfig):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
# create_app 内(§2 の設定読み込み部分を置き換える)
def create_app(test_config: dict | None = None) -> Flask:
    app = Flask(__name__, instance_relative_config=True)

    # ① Config クラスで安全な既定値を与える
    app.config.from_object("myapp.config.ProductionConfig")
    # ② 環境変数で本番値を上書きする(FLASK_ 前綴り)
    app.config.from_prefixed_env()

    if test_config is not None:
        app.config.from_mapping(test_config)   # テストは最後に上書き

    db.init_app(app)
    # ...Blueprint 登録...
    return app

⚠️ The from_object trap: a class reference is "not instantiated": from_object("myapp.config.ProductionConfig") reads the class's uppercase attributes as-is and does not instantiate the class. That is, a setting value computed with @property isn't loaded (it's ignored as a property object). If you need a dynamically computed setting, pass an instance rather than a class (from_object(ProductionConfig())), or flow it in from an environment variable with from_prefixed_env().

6.2 from_prefixed_env and the instance folder

from_prefixed_env() is a method added in Flask 3.0, optimal for the container / 12-factor era. It automatically loads environment variables starting with FLASK_, and the value is typed with json.loads.

# 本番の環境変数(コンテナの Secrets から注入)
export FLASK_SECRET_KEY='...'                       # python -c 'import secrets; print(secrets.token_hex())'
export FLASK_SQLALCHEMY_DATABASE_URI='postgresql+psycopg://...'
export FLASK_MAX_CONTENT_LENGTH=10485760            # json.loads されて int になる
export FLASK_SQLALCHEMY_ENGINE_OPTIONS__pool_size=10  # __ でネストしたキーに代入

FLASK_MAX_CONTENT_LENGTH=10485760 is loaded not as a string but as int 10485760, and you can also assign to nested dictionary keys with __ (a double underscore). Flask resolves the typical worry of "environment variables are all strings and hard to handle."

There are other loading interfaces for settings. Besides the from_object / from_prefixed_env / from_pyfile used in this article, there are from_file(path, load=tomllib.load, text=False) to read JSON/TOML, and from_envvar to read a path from a single environment variable. The overall picture of settings management is summarized in §4 of the Flask production operation guide.

The instance folder (instance/, added in Flask 0.8) is "a settings location not committed to the repository" that becomes the resolution base of from_pyfile("config.py") when instance_relative_config=True (§2.1). Always put instance/ in .gitignore. For the deep dive into security settings around Cookies, CSRF, and SECRET_KEY, see the security implementation guide.

6.3 Getting Flask's version

You sometimes want to output the framework's version in settings or a health check. Flask.__version__ was removed in Flask 3.0. Instead, use the standard library's importlib.metadata.

from importlib.metadata import version

flask_version = version("flask")   # "3.1.3" のような文字列

7. When to split: the "extract on the third" YAGNI discipline

I've explained Blueprints and the src layout up to here, but the most important is the judgment of "when to split." Design skill is not the power to increase structure but the power to discern the moment structure should be increased.

You don't need to split finely into blueprints/auth/, blueprints/api/, and blueprints/admin/ from the start. This is typical too-early abstraction, an act of prepaying cognitive load for requirements that don't exist yet. The discipline the author has held to in production is simple.

StageStructureJudgment
1st featureWrite routes directly in create_appNo Blueprint needed yet
2nd featureBundle into one Blueprint"Want to split," but still hold
3rd featureExtract into Blueprints per concernSplit for the first time here

This "extract on the third" is the same philosophy as DRY's "twice is coincidence, three times is a pattern." Two similar routes might be a coincidental match, but with three lined up you can have conviction that "this is an independent concern." By delaying the split until you have conviction, you avoid the risk of splitting at the wrong boundary (ending up re-splitting later).

💡 Decide the unit of splitting by "the reason to change (SRP)": split Blueprints not by "URL prefix" but by "what changes together when something changes." Things that change together when the auth logic changes go in auth/, and things that change together when the API schema changes go in api/. Gather code that changes for the same reason into one Blueprint, and separate code that changes for different reasons — this is the application of the single responsibility principle at the Blueprint granularity. The URL is a result, not the criterion for splitting.

But there's an exception. Put in just the separation of create_app and extensions.py from the start, even with one feature (§2.3). This is a separation to meet "the current requirement of testability," not "for a future extension." YAGNI applies to the subdivision of Blueprints, not to the introduction of the factory. Not mistaking this distinction is the key to avoiding both under-design (can't test) and over-design (split too much).


Summary: eliminate circular imports structurally with "design"

The breakdown of a large Flask app comes not from a lack of Flask features but from a lack of structure. Let me re-list this article's discipline.

  1. Throw away the global app. A module-top app = Flask(__name__) is the root of the three big breakdowns of untestable, single-configuration, and circular imports.
  2. Make the application factory (create_app(test_config=None)) the backbone. With instance_relative_config=True and the test_config branch, solve testability and multiple environments at once.
  3. Generate extensions bare in extensions.py and bind them with init_app(app). Placing extensions at the bottom layer makes dependencies one-way and eliminates circular imports structurally. One extension is reusable across multiple apps.
  4. Split features with Blueprints. Don't step on these official-spec pitfalls: name is the endpoint prefix, not the URL; an invalid-URL 404 can't be caught by a Blueprint's errorhandler and needs app_errorhandler; a Blueprint without url_prefix can't serve its static folder; for templates, the first-registered wins.
  5. Per-environment Config is a two-stage from_object (defaults) → from_prefixed_env (environment override). Be careful that from_object doesn't instantiate the class, and drive secrets out of code with the instance folder and environment variables.
  6. Split with the "extract on the third" YAGNI discipline. You may delay the subdivision of Blueprints, but put in just the separation of create_app and extensions.py from the start.

These aren't things "to introduce once scale grows" but a design that pays off from the first few hundred lines. Flask provides "only the core" and entrusts the structure to you. The minimal discipline that takes responsibility for that freedom is the application factory and Blueprints. For the map of the overall picture and the topics (context, testing, deployment, security, observability), go back to the Flask production operation guide for the overview.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading