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: Blueprints for Modular Routing and API Organization

Capítulo 3

Estimated reading time: 11 minutes

+ Exercise

Blueprints are Flask’s unit of modular routing: a blueprint groups related endpoints, handlers, and hooks so you can organize an API by domain (health, auth, items) instead of keeping all routes in one file. A blueprint is not an app; it becomes active only when registered on the Flask application. This separation makes it easier to scale a small service without turning routing into a monolith.

Why blueprints matter for small services

  • Domain boundaries: each domain owns its routes, validation, errors, and helpers.
  • Consistent API organization: predictable URL prefixes and versioning.
  • Safer refactors: moving a domain rarely touches other domains.
  • Composable hooks: attach request hooks and error handlers per domain.

Organizing endpoints by domain

A practical approach is to create one blueprint per domain. For example:

  • health: liveness/readiness checks
  • auth: login/logout/refresh
  • items: CRUD endpoints for a resource

Each blueprint should expose a single bp object and keep internal imports local to the domain to reduce coupling.

Blueprint registration, URL prefixes, and versioning patterns

Blueprint registration happens in the application assembly step (often in a dedicated register_blueprints(app) function). You can apply a URL prefix at registration time to keep routes clean inside the blueprint.

Pattern A: Version prefix at registration time (recommended)

Keep blueprint routes version-agnostic and apply versioning when registering:

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/blueprints.py (example module responsible for registration)  from app.health.routes import bp as health_bp from app.auth.routes import bp as auth_bp from app.items.routes import bp as items_bp  def register_blueprints(app):     app.register_blueprint(health_bp, url_prefix="/api/v1")     app.register_blueprint(auth_bp, url_prefix="/api/v1")     app.register_blueprint(items_bp, url_prefix="/api/v1")

Inside each blueprint, routes can be defined as "/health", "/auth/login", "/items", etc. This keeps the domain code stable if you later add /api/v2.

Pattern B: Versioned blueprint per API version

If v2 diverges significantly, you can create separate blueprints (or packages) per version:

# app/blueprints.py from app.v1.items.routes import bp as items_v1_bp from app.v2.items.routes import bp as items_v2_bp  def register_blueprints(app):     app.register_blueprint(items_v1_bp, url_prefix="/api/v1")     app.register_blueprint(items_v2_bp, url_prefix="/api/v2")

This pattern is heavier but can be clearer when behavior changes substantially.

Pattern C: URL prefix per domain

You can also prefix by domain during registration (useful if you want the blueprint to own only its subpaths):

app.register_blueprint(items_bp, url_prefix="/api/v1/items")

Then the blueprint can define routes like "/", "/<item_id>". This makes the blueprint more portable (it can be mounted elsewhere), but be consistent across domains.

A structured approach to building a blueprint

A blueprint becomes easier to maintain when you separate routing (HTTP concerns) from business logic (services) and shared utilities (responses, errors). A common structure per domain looks like this:

app/ items/   __init__.py   routes.py         # blueprint + route declarations   controllers.py    # request parsing, calling services, shaping responses   services.py       # business logic, data access orchestration   schemas.py        # validation/serialization helpers (optional)   errors.py         # domain-specific exceptions (optional)   hooks.py          # before_request/after_request for this blueprint (optional) shared/   responses.py      # consistent response helpers   errors.py         # shared exception classes + error handler registration   auth.py           # shared auth helpers (optional)   logging.py        # request id, structured logging helpers (optional)

Step 1: Create the blueprint and routes module

routes.py should be thin: define the blueprint, declare routes, and delegate to controller functions.

# app/items/routes.py from flask import Blueprint from app.items.controllers import list_items, create_item, get_item  bp = Blueprint("items", __name__)  @bp.get("/items") def items_list():     return list_items()  @bp.post("/items") def items_create():     return create_item()  @bp.get("/items/<item_id>") def items_get(item_id):     return get_item(item_id)

Note: avoid importing the Flask app here. Blueprints should not depend on the app instance.

Step 2: Add controller functions (HTTP boundary)

Controllers handle HTTP specifics: reading JSON, query params, headers, and mapping service results to consistent responses. Keep business rules out of controllers.

# app/items/controllers.py from flask import request from app.items.services import ItemsService from app.shared.responses import ok, created from app.shared.errors import BadRequest  def list_items():     limit = request.args.get("limit", default=50, type=int)     if limit < 1 or limit > 200:         raise BadRequest("limit must be between 1 and 200")     items = ItemsService.list_items(limit=limit)     return ok({"items": items})  def create_item():     payload = request.get_json(silent=True) or {}     name = payload.get("name")     if not name:         raise BadRequest("name is required")     item = ItemsService.create_item(name=name)     return created({"item": item})  def get_item(item_id):     item = ItemsService.get_item(item_id=item_id)     return ok({"item": item})

Raising exceptions (instead of returning ad-hoc error responses) keeps error formatting centralized.

Step 3: Implement a service layer (business logic)

Services coordinate business rules and data access. They should not import Flask request objects. This makes them testable without HTTP context.

# app/items/services.py from app.shared.errors import NotFound  class ItemsService:     _items = {}  # placeholder in-memory store for demonstration      @classmethod     def list_items(cls, limit=50):         return list(cls._items.values())[:limit]      @classmethod     def create_item(cls, name):         item_id = str(len(cls._items) + 1)         item = {"id": item_id, "name": name}         cls._items[item_id] = item         return item      @classmethod     def get_item(cls, item_id):         item = cls._items.get(item_id)         if not item:             raise NotFound(f"item {item_id} not found")         return item

In a real service, the service layer would call repositories/clients (database, cache, external APIs). The key is that Flask stays at the edges.

Step 4: Shared utilities for consistent response shapes

Define a consistent response envelope across the API. This reduces client-side branching and makes errors predictable. A common pattern is:

  • Success: {"ok": true, "data": ...}
  • Error: {"ok": false, "error": {"code": "...", "message": "...", "details": ...}}
# app/shared/responses.py from flask import jsonify  def ok(data=None, meta=None, status=200):     payload = {"ok": True, "data": data or {}}     if meta is not None:         payload["meta"] = meta     return jsonify(payload), status  def created(data=None):     return ok(data=data, status=201)  def no_content():     return "", 204

Keep response helpers small and stable; controllers should not manually call jsonify everywhere.

Where to place shared error handlers

Error handlers should be registered once (globally) so every blueprint benefits from consistent formatting. You can still add blueprint-specific handlers when a domain needs special mapping.

Shared exception types

# app/shared/errors.py class ApiError(Exception):     status_code = 400     code = "bad_request"      def __init__(self, message, *, status_code=None, code=None, details=None):         super().__init__(message)         if status_code is not None:             self.status_code = status_code         if code is not None:             self.code = code         self.details = details  class BadRequest(ApiError):     status_code = 400     code = "bad_request"  class Unauthorized(ApiError):     status_code = 401     code = "unauthorized"  class NotFound(ApiError):     status_code = 404     code = "not_found"

Registering global error handlers

Centralize formatting in one place. The handler converts exceptions into the standard error envelope.

# app/shared/error_handlers.py from flask import jsonify from app.shared.errors import ApiError  def register_error_handlers(app):     @app.errorhandler(ApiError)     def handle_api_error(err):         payload = {             "ok": False,             "error": {                 "code": err.code,                 "message": str(err),                 "details": err.details,             },         }         return jsonify(payload), err.status_code      @app.errorhandler(404)     def handle_404(_err):         payload = {"ok": False, "error": {"code": "not_found", "message": "route not found"}}         return jsonify(payload), 404      @app.errorhandler(500)     def handle_500(_err):         payload = {"ok": False, "error": {"code": "internal", "message": "internal server error"}}         return jsonify(payload), 500

Controllers and services can now raise ApiError subclasses, and the API stays consistent.

Blueprint-specific error handlers (when needed)

If a domain has a special exception that should map to a particular code, register it on the blueprint:

# app/items/routes.py (add below bp creation) from app.items.errors import ItemConflict  @bp.errorhandler(ItemConflict) def handle_item_conflict(err):     return {"ok": False, "error": {"code": "item_conflict", "message": str(err)}}, 409

Use this sparingly; too many domain-specific formats defeat the goal of consistency.

Middleware-like hooks: before_request and after_request

Flask provides request hooks that behave like lightweight middleware. You can attach them globally on the app or locally on a blueprint.

Blueprint-level hooks (domain-scoped)

Use blueprint hooks for concerns that apply only to that domain, such as requiring auth for all /items endpoints.

# app/items/hooks.py from flask import g, request from app.shared.errors import Unauthorized  def require_bearer_token():     auth = request.headers.get("Authorization", "")     if not auth.startswith("Bearer "):         raise Unauthorized("missing bearer token")     g.token = auth.removeprefix("Bearer ").strip()
# app/items/routes.py from flask import Blueprint from app.items.hooks import require_bearer_token  bp = Blueprint("items", __name__)  @bp.before_request def _items_before_request():     require_bearer_token()

This keeps auth enforcement close to the domain without sprinkling checks into each controller.

App-level hooks (cross-cutting)

Use app-level hooks for cross-cutting concerns like request IDs, timing, or response headers. Keep them in shared modules and register once.

# app/shared/hooks.py import time from flask import g, request  def register_request_hooks(app):     @app.before_request     def start_timer():         g._start = time.time()      @app.after_request     def add_timing_header(response):         start = getattr(g, "_start", None)         if start is not None:             response.headers["X-Response-Time-ms"] = str(int((time.time() - start) * 1000))         return response

Avoiding circular imports with blueprints

Circular imports usually happen when modules import each other at import time (module top-level). Blueprints can amplify this if routes import services, services import app, and app imports routes.

Rules of thumb

  • Never import the Flask app instance inside domain modules. Domain code should be app-agnostic.
  • Keep imports one-directional: routes -> controllers -> services -> lower-level clients/repositories.
  • Shared utilities should not import domain modules. Shared should be “downstream” only.
  • Prefer local imports for optional dependencies (e.g., importing a heavy client only inside a function) when it breaks cycles.

Example of a circular import and the fix

Problem: items/services.py imports current_app or imports from a module that imports routes.

# problematic: app/items/services.py from app.items.routes import bp  # causes cycle (routes imports controllers imports services)  def do_something():     ...

Fix: remove route imports from services; if you need configuration, pass it in from the controller or use current_app carefully without importing app modules that import routes.

# better: app/items/services.py def do_something(config_value):     ...  # controller passes config_value in

If you must access Flask context, import from Flask directly (not from your app modules):

from flask import current_app  def do_something():     value = current_app.config.get("SOME_SETTING")     ...

This avoids importing your own app package modules that may import routes.

Putting it together: health, auth, items blueprints

Below is a compact example showing three blueprints with consistent response shapes and shared error handling.

Health blueprint

# app/health/routes.py from flask import Blueprint from app.shared.responses import ok  bp = Blueprint("health", __name__)  @bp.get("/health") def health():     return ok({"status": "up"})

Auth blueprint (controller + service)

# app/auth/routes.py from flask import Blueprint from app.auth.controllers import login  bp = Blueprint("auth", __name__)  @bp.post("/auth/login") def auth_login():     return login()
# app/auth/controllers.py from flask import request from app.auth.services import AuthService from app.shared.responses import ok from app.shared.errors import BadRequest, Unauthorized  def login():     payload = request.get_json(silent=True) or {}     username = payload.get("username")     password = payload.get("password")     if not username or not password:         raise BadRequest("username and password are required")     token = AuthService.authenticate(username, password)     if not token:         raise Unauthorized("invalid credentials")     return ok({"token": token})
# app/auth/services.py class AuthService:     @staticmethod     def authenticate(username, password):         # placeholder logic         if username == "demo" and password == "demo":             return "demo-token"         return None

Items blueprint (protected by blueprint hook)

# app/items/routes.py from flask import Blueprint from app.items.controllers import list_items from app.items.hooks import require_bearer_token  bp = Blueprint("items", __name__)  @bp.before_request def _before():     require_bearer_token()  @bp.get("/items") def items_list():     return list_items()

Checklist for a maintainable blueprint

ConcernWhere it goesGoal
Route declarationsdomain/routes.pyThin routing, no business logic
HTTP parsing + response shapingdomain/controllers.pyKeep Flask-specific code at the boundary
Business logicdomain/services.pyTestable logic, no request objects
Response envelope helpersshared/responses.pyConsistent success responses
Exception typesshared/errors.pyStandardized error signaling
Error handlersshared/error_handlers.pyStandardized error formatting
Domain hooksdomain/hooks.pyDomain-scoped “middleware”
Blueprint registrationcentral registration moduleSingle place for prefixes/versioning

Now answer the exercise about the content:

When organizing a small Flask API with blueprints, what is the recommended way to handle API versioning while keeping domain code stable?

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

You missed! Try again.

Keeping blueprint routes version-agnostic and applying url_prefix at registration time lets you add new versions (like /api/v2) without changing domain route code.

Next chapter

Flask Essentials: Request Lifecycle, Context, and Response Design

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