← Back to DevBytes

Flask Architecture: Design Patterns and Project Structure

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:

A well-defined architecture brings clarity, separation of concerns, and a codebase that evolves gracefully. It enables you to:

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:

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:

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

Common pitfalls include:

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.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles