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 checksauth: login/logout/refreshitems: 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 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 itemIn 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 "", 204Keep 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), 500Controllers 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)}}, 409Use 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 responseAvoiding 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 inIf 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 NoneItems 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
| Concern | Where it goes | Goal |
|---|---|---|
| Route declarations | domain/routes.py | Thin routing, no business logic |
| HTTP parsing + response shaping | domain/controllers.py | Keep Flask-specific code at the boundary |
| Business logic | domain/services.py | Testable logic, no request objects |
| Response envelope helpers | shared/responses.py | Consistent success responses |
| Exception types | shared/errors.py | Standardized error signaling |
| Error handlers | shared/error_handlers.py | Standardized error formatting |
| Domain hooks | domain/hooks.py | Domain-scoped “middleware” |
| Blueprint registration | central registration module | Single place for prefixes/versioning |