Understanding Flaskβs Architecture and Default Flexibility
Flask is intentionally minimalist β it provides a simple core with routing, request handling, and templating, leaving most architectural decisions to the developer. By default, a Flask application can be written in a single file, often called app.py, containing routes, models, configuration, and even database initialisation. This works beautifully for tiny prototypes, microservices, or tutorials, but it quickly becomes a maintenance burden as the application grows.
Flask does not enforce a specific project structure or design pattern. Itβs up to you to organise code into logical layers, manage dependencies, and ensure the application remains testable and scalable. The frameworkβs extension ecosystem (Flask-SQLAlchemy, Flask-Migrate, Flask-Login, etc.) can also influence your architecture, but the core decisions remain yours. This freedom is both a strength and a responsibility β understanding established patterns will help you build robust, production-ready Flask applications.
Why Architecture Matters in Flask Applications
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Without a deliberate architecture, a Flask project often suffers from the following issues:
- Tight coupling: Route handlers mix business logic, database queries, and presentation code, making it hard to change any single piece.
- Poor testability: Logic intertwined with Flaskβs request context and database sessions is difficult to isolate and test independently.
- Unclear boundaries: As features multiply, developers struggle to locate responsibilities β βWhere should I put email sending code?β or βWhich module handles payment calculations?β
- Duplicate code: Without shared services or utilities, similar logic gets copied across routes, increasing bug surface.
- Difficult scaling: Adding new developers or breaking the application into microservices becomes chaotic.
A well-defined architecture brings clarity, separation of concerns, and a codebase that evolves gracefully. It enables you to:
- Write unit tests for business rules independently of HTTP and databases.
- Swap components (e.g., change from SQLAlchemy to an external API client) without rewriting route handlers.
- Reuse logic across HTTP endpoints, CLI commands, background tasks, or even other applications.
- Onboard new team members faster because the codebase follows predictable conventions.
Core Design Patterns for Flask
1. The Application Factory Pattern
Instead of creating a global Flask instance at module level, you wrap its creation inside a function β typically create_app(). This pattern lets you create multiple application instances (e.g., for testing, different configurations) and defer the registration of extensions, blueprints, and configurations.
Benefits:
- No implicit side effects when importing the application package.
- Configuration can be loaded dynamically based on environment variables.
- Testing becomes simpler: you can create a fresh app instance with in-memory databases.
Example skeleton:
# myapp/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# Create extension instances without binding to an app yet
db = SQLAlchemy()
def create_app(config_name='development'):
app = Flask(__name__)
# Load configuration
app.config.from_object(f'config.{config_name}Config')
# Initialise extensions with the app
db.init_app(app)
# Register blueprints
from .routes.main import main_bp
app.register_blueprint(main_bp)
return app
This allows you to run the application with:
# run.py
from myapp import create_app
app = create_app('production')
app.run()
2. Blueprints for Modularity
Blueprints let you split routes, error handlers, and static files into self-contained modules. Think of them as mini-applications that can be mounted at different URL prefixes. They drastically improve organisation and allow independent development of features.
A typical blueprint structure:
# myapp/routes/auth.py
from flask import Blueprint, request, jsonify
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/login', methods=['POST'])
def login():
# authentication logic
return jsonify({'status': 'ok'})
Registration happens inside the factory:
def create_app():
app = Flask(__name__)
# ...
from .routes.auth import auth_bp
app.register_blueprint(auth_bp)
return app
Blueprints also support their own templates and static folders, making large applications manageable.
3. MVC (Model-View-Controller) Separation
While Flask doesnβt enforce MVC, adopting it conceptually helps separate concerns:
- Model: Data structures, ORM models (e.g., SQLAlchemy classes), and validation rules.
- View: Templates (Jinja2) or JSON serialisation logic. Keep view logic out of route handlers.
- Controller: Route handlers that receive HTTP input, call services/models, and return a response. They should be thin β no business rules.
Example of a thin controller:
@order_bp.route('/orders', methods=['POST'])
def create_order():
data = request.get_json()
# Delegate to a service
order = order_service.place_order(data['customer_id'], data['items'])
return jsonify(order.to_dict()), 201
4. Service Layer / Use Case Pattern
Move business logic and orchestration into a dedicated service layer. Routes become simple adapters between HTTP and your domain. Services are plain Python classes or functions that donβt depend on Flaskβs request context, making them fully testable.
Example service:
# myapp/services/order_service.py
from myapp.models import Order, OrderItem
from myapp.repositories.order_repo import OrderRepository
class OrderService:
def __init__(self, repo: OrderRepository):
self.repo = repo
def place_order(self, customer_id: int, items: list) -> Order:
# Validate, calculate totals, apply discounts, etc.
# Business logic lives here
if not items:
raise ValueError("Order must contain at least one item")
order = Order(customer_id=customer_id)
for item in items:
order.items.append(OrderItem(product_id=item['product_id'], quantity=item['quantity']))
self.repo.save(order)
return order
The route uses the service:
@order_bp.route('/orders', methods=['POST'])
def create_order():
data = request.get_json()
repo = OrderRepository(db.session)
service = OrderService(repo)
try:
order = service.place_order(data['customer_id'], data['items'])
return jsonify(order.to_dict()), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
5. Repository Pattern for Data Access
Encapsulate database queries behind a repository interface. This decouples your services from ORM specifics and allows you to mock the repository during tests or switch data sources. Even if you stay with SQLAlchemy, repositories centralise query logic and avoid scattered session usage.
Example repository:
# myapp/repositories/order_repo.py
from myapp.models import Order
class OrderRepository:
def __init__(self, session):
self.session = session
def get_by_id(self, order_id: int) -> Order | None:
return self.session.query(Order).filter_by(id=order_id).first()
def save(self, order: Order) -> None:
self.session.add(order)
self.session.commit()
During testing, you can provide a mock repository that doesnβt touch the database at all.
Recommended Project Structure
Combining the patterns above, a production-ready Flask project might look like this:
myapp/
βββ app/
β βββ __init__.py # Application factory
β βββ config.py # Configuration classes
β βββ models/
β β βββ __init__.py
β β βββ user.py
β β βββ order.py
β β βββ base.py # shared base model
β βββ repositories/
β β βββ __init__.py
β β βββ user_repo.py
β β βββ order_repo.py
β βββ services/
β β βββ __init__.py
β β βββ auth_service.py
β β βββ order_service.py
β βββ routes/
β β βββ __init__.py
β β βββ main.py
β β βββ auth.py
β β βββ orders.py
β βββ templates/
β β βββ base.html
β β βββ ...
β βββ static/
β β βββ css/
β β βββ js/
β βββ utils/
β βββ __init__.py
β βββ validators.py
βββ tests/
β βββ conftest.py # Fixtures for app, db, etc.
β βββ test_auth_service.py
β βββ test_orders.py
βββ migrations/ # Flask-Migrate / Alembic
βββ requirements.txt
βββ run.py # Entry point
βββ wsgi.py # For production servers
This structure clearly separates layers: models, repositories, services, and routes. The application factory ties them together. Blueprints keep route files focused. Utilities and validators prevent duplication. Tests mirror the source layout for easy navigation.
Putting It All Together: A Practical Example
Letβs build a small βUser and Productβ feature following the architecture described. Weβll implement a service that retrieves user data and their orders, demonstrating layers working together.
Model definition:
# app/models/user.py
from app.models.base import db, BaseModel
class User(BaseModel):
__tablename__ = 'users'
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
orders = db.relationship('Order', backref='user', lazy='dynamic')
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email
}
Repository:
# app/repositories/user_repo.py
from app.models.user import User
class UserRepository:
def __init__(self, session):
self.session = session
def find_by_username(self, username: str) -> User | None:
return self.session.query(User).filter_by(username=username).first()
def find_with_orders(self, user_id: int) -> User | None:
from app.models.order import Order
return self.session.query(User).filter_by(id=user_id).join(User.orders).first()
Service:
# app/services/user_service.py
class UserService:
def __init__(self, user_repo):
self.user_repo = user_repo
def get_user_profile(self, username: str) -> dict | None:
user = self.user_repo.find_by_username(username)
if not user:
return None
return user.to_dict()
def get_user_with_orders(self, user_id: int) -> dict | None:
user = self.user_repo.find_with_orders(user_id)
if not user:
return None
profile = user.to_dict()
profile['orders'] = [order.to_dict() for order in user.orders]
return profile
Route (Blueprints):
# app/routes/user_routes.py
from flask import Blueprint, jsonify, request
from app.repositories.user_repo import UserRepository
from app.services.user_service import UserService
from app import db
user_bp = Blueprint('user', __name__, url_prefix='/users')
@user_bp.route('//profile')
def profile(username):
repo = UserRepository(db.session)
service = UserService(repo)
profile_data = service.get_user_profile(username)
if profile_data is None:
return jsonify({'error': 'User not found'}), 404
return jsonify(profile_data)
Application factory tying it together:
# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from app.config import DevelopmentConfig
db = SQLAlchemy()
def create_app(config=DevelopmentConfig):
app = Flask(__name__)
app.config.from_object(config)
db.init_app(app)
with app.app_context():
from app.routes.user_routes import user_bp
app.register_blueprint(user_bp)
# create tables for demo (in real projects use migrations)
db.create_all()
return app
Now any route handler is just a thin adapter; business logic lives in services, data access in repositories. Tests can mock the repository and test services directly, without Flask or database connections.
Best Practices and Common Pitfalls
- Keep routes thin: Resist the urge to put validation, calculation, or multiple database calls directly in view functions. Delegate to services.
- Use the application factory from day one: Even in small projects, it prevents configuration headaches and makes testing trivial. Itβs easy to set up and avoids the singleton app object.
- Blueprints are not just for large apps: Use them to group logically related routes (auth, API v1, admin) early. It clarifies intent and prevents monolithic route files.
- Dependency injection over hard-coded imports: Services and repositories can be instantiated with dependencies passed in (like the session). This keeps them reusable and testable. Consider using a lightweight DI container or simply construct them in a factory function.
- Donβt abuse Flaskβs global
gobject for business data: Itβs fine for request-scoped utilities (e.g., current user), but donβt store complex business state there. - Separate configuration by environment: Use classes like
DevelopmentConfig,TestingConfig,ProductionConfigand load them via environment variable in the factory. - Migrations over
db.create_all(): In production, always use Flask-Migrate / Alembic. The factory should not automatically create tables. - Write tests at multiple levels: Unit tests for services, integration tests for repositories with a test database, and functional tests for routes using Flaskβs test client.
- Avoid circular imports: Use the factory pattern and import models, blueprints inside functions or after app context to break cycles. The structure above naturally avoids them.
Common pitfalls include:
- Putting everything in one
app.pythat grows to thousands of lines β refactor early. - Mixing ORM session management across layers β always pass the session from the route level down, or use a scoped session tied to the request context.
- Creating a βGod serviceβ that handles everything β instead, split services per aggregate root (UserService, OrderService).
- Forgetting that Flask extensions often expect a single app instance β always initialise them inside the factory with
init_app().
Conclusion
Flaskβs minimalism gives you complete control over your applicationβs architecture. By embracing proven patterns β application factory, blueprints, service layer, and repository β you transform a simple script into a maintainable, testable, and scalable system. This structure decouples HTTP from business logic, isolates data access, and organises code by feature and responsibility, not by file type alone. Start with the factory, separate concerns early, and invest in a clean project layout. The result is a Flask codebase that remains a joy to work with as it grows, and that adapts easily to changing requirements and team size.