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: Setting Up a Maintainable Project Structure

Capítulo 1

Estimated reading time: 8 minutes

+ Exercise

Target Service: A Small Backend with Clear Boundaries

This chapter assumes you are building a small Flask backend service (an API or internal service) that should remain easy to change as it grows. “Maintainable” here means: you can add endpoints without creating circular imports, you can test business logic without HTTP, configuration is predictable across environments, and responsibilities are obvious from the folder structure.

A useful boundary for a small service is: HTTP layer (routes/controllers) is thin, domain logic (services) is testable without Flask, data layer (models/repositories) is isolated, and serialization/validation (schemas) is explicit.

Design Goals

  • Single entry point for app creation (application factory).
  • Environment-based configuration (no secrets in code).
  • Separation of concerns: routes vs services vs models vs schemas.
  • Test-friendly: create app with test config, run unit tests without a running server.
  • Scaffold that scales: add modules/blueprints without reorganizing everything later.

Repository Layout: A Clean, Conventional Scaffold

Below is a practical layout that works well for small services and remains stable as you add features. It uses an app/ package for code, an instance/ directory for local overrides, and dedicated folders for tests and migrations.

your-service/  (repository root)├─ app/│  ├─ __init__.py│  ├─ config.py│  ├─ extensions.py│  ├─ api/│  │  ├─ __init__.py│  │  └─ routes.py│  ├─ services/│  │  ├─ __init__.py│  │  └─ health_service.py│  ├─ models/│  │  ├─ __init__.py│  │  └─ (future SQLAlchemy models)│  ├─ schemas/│  │  ├─ __init__.py│  │  └─ health_schema.py│  └─ common/│     ├─ __init__.py│     └─ errors.py├─ instance/│  └─ config.py  (optional local overrides; not committed)├─ migrations/  (if using Flask-Migrate/Alembic)├─ tests/│  ├─ __init__.py│  ├─ conftest.py│  └─ test_health.py├─ .env.example├─ .gitignore├─ pyproject.toml  (or requirements.txt)└─ wsgi.py

Why Each Piece Exists

PathResponsibilityNotes
app/__init__.pyApplication factory (create_app) and blueprint registrationKeeps app creation centralized and testable
app/config.pyConfiguration objects and environment loadingNo secrets; reads from environment variables
app/extensions.pyInstantiate extensions (db, migrate, etc.)Avoids circular imports by initializing in one place
app/api/HTTP layer (blueprints, routes)Routes should call services; minimal logic
app/services/Business logicPure functions/classes where possible; easy to unit test
app/models/Data modelsSQLAlchemy models or other persistence representations
app/schemas/Serialization/validationMarshmallow/Pydantic schemas or manual validation
app/common/Shared utilities (errors, constants)Keep small; avoid dumping ground
instance/Local machine config overridesTypically excluded from git; Flask supports instance config
tests/Unit/integration testsUse app factory to create isolated test app
migrations/DB migrationsOnly if you use a relational DB + Alembic
wsgi.pyProduction entry pointGunicorn/uwsgi imports app from here

Conventions That Prevent Entropy

Naming and Module Responsibilities

  • Blueprint modules: app/api/routes.py or feature-based folders like app/api/health/routes.py as you grow.
  • Services: verbs or domain nouns, e.g. user_service.py, billing_service.py. Services should not import Flask request globals (request, g) unless explicitly designed as HTTP-aware.
  • Models: singular nouns, e.g. user.py, invoice.py.
  • Schemas: match model/domain names, e.g. user_schema.py.
  • Config classes: BaseConfig, DevelopmentConfig, TestingConfig, ProductionConfig.

Imports: Prefer Absolute Imports Within the Package

Inside the app package, use absolute imports from app to make refactors easier and reduce ambiguity.

# goodfrom app.services.health_service import get_health# avoid (can be okay, but becomes fragile during refactors)from ..services.health_service import get_health

Keep the HTTP Layer Thin

Routes should translate HTTP to domain calls: parse inputs, call a service, serialize output, handle errors. Avoid embedding business rules in route functions.

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

Step-by-Step: Scaffold the Project

1) Create the Folder Structure

mkdir -p your-service/app/{api,services,models,schemas,common}mkdir -p your-service/{tests,instance}mkdir -p your-service/migrations

Create empty __init__.py files so Python treats directories as packages:

touch your-service/app/__init__.pytouch your-service/app/api/__init__.pytouch your-service/app/services/__init__.pytouch your-service/app/models/__init__.pytouch your-service/app/schemas/__init__.pytouch your-service/app/common/__init__.pytouch your-service/tests/__init__.py

2) Add Environment Variable Template

Provide an example file to document required settings. Do not commit real secrets.

# .env.exampleFLASK_ENV=developmentSECRET_KEY=change-meDATABASE_URL=sqlite:///local.db

3) Implement Configuration (Environment-Based, No Hardcoded Secrets)

This example uses environment variables directly (works in containers and most deployments). If you use a local .env loader, keep it as a development convenience, not a production dependency.

# app/config.pyimport osclass BaseConfig:    SECRET_KEY = os.environ.get("SECRET_KEY", "")    JSON_SORT_KEYS = Falseclass DevelopmentConfig(BaseConfig):    DEBUG = Trueclass TestingConfig(BaseConfig):    TESTING = Trueclass ProductionConfig(BaseConfig):    DEBUG = Falsedef get_config():    env = os.environ.get("FLASK_ENV", "production").lower()    if env == "development":        return DevelopmentConfig    if env == "testing":        return TestingConfig    return ProductionConfig

Convention: if SECRET_KEY is missing, you can either fail fast (recommended for production) or allow empty in development. A common pattern is to validate in create_app when env is production.

4) Centralize Extensions (Even If You Don’t Use Them Yet)

Keeping extensions in one module prevents circular imports later. You can start with none and add as needed.

# app/extensions.py# Example placeholders for future extensions.# from flask_sqlalchemy import SQLAlchemy# from flask_migrate import Migrate# db = SQLAlchemy()# migrate = Migrate()

5) Create the Application Factory

The factory pattern allows multiple app instances with different configs (development vs testing). It also makes tests straightforward.

# app/__init__.pyfrom flask import Flaskfrom app.config import get_configdef create_app(config_object=None):    app = Flask(__name__, instance_relative_config=True)    # Load config from environment-based config class    if config_object is None:        config_object = get_config()    app.config.from_object(config_object)    # Optional: allow instance/config.py to override local settings    # (keep instance/config.py out of version control)    app.config.from_pyfile("config.py", silent=True)    # Fail fast for missing secrets in production-like environments    if not app.config.get("SECRET_KEY") and not app.config.get("DEBUG") and not app.config.get("TESTING"):        raise RuntimeError("SECRET_KEY must be set in production")    # Register blueprints    from app.api.routes import api_bp    app.register_blueprint(api_bp, url_prefix="/api")    return app

6) Add a Minimal API Blueprint (Routes Layer)

Blueprints keep endpoints grouped and make it easy to add new modules without growing a single app.py file.

# app/api/routes.pyfrom flask import Blueprint, jsonifyfrom app.services.health_service import get_healthfrom app.schemas.health_schema import serialize_healthapi_bp = Blueprint("api", __name__)@api_bp.get("/health")def health():    data = get_health()    return jsonify(serialize_health(data)), 200

7) Add a Service (Business Logic Layer)

This service is intentionally simple: it returns a dictionary that can be tested without Flask.

# app/services/health_service.pyfrom datetime import datetime, timezonedef get_health():    return {        "status": "ok",        "time": datetime.now(timezone.utc).isoformat()    }

8) Add a Schema (Serialization Layer)

Even if you don’t use a schema library yet, a small explicit serializer helps establish the boundary: routes don’t decide output shape; schemas do.

# app/schemas/health_schema.pydef serialize_health(data: dict) -> dict:    # Whitelist fields to avoid leaking internal keys later    return {        "status": data.get("status"),        "time": data.get("time")    }

9) Add a Shared Error Pattern (Optional but Useful Early)

Centralizing error types prevents ad-hoc abort() usage scattered across services.

# app/common/errors.pyclass AppError(Exception):    def __init__(self, message: str, status_code: int = 400):        super().__init__(message)        self.message = message        self.status_code = status_code

If you later add error handlers, register them in create_app so all blueprints share the same behavior.

Minimal Runnable Service Entry Point

Keep a small wsgi.py at the repository root for production servers and local runs. This avoids importing from a module named app.py (which often conflicts with the package name app).

# wsgi.pyfrom app import create_appapp = create_app()if __name__ == "__main__":    app.run(host="0.0.0.0", port=5000)

Run It Locally (Environment-Based Settings)

Set environment variables in your shell (or via your process manager). Example:

export FLASK_ENV=developmentexport SECRET_KEY="dev-only-secret"python wsgi.py

Then request:

GET http://localhost:5000/api/health

Testing Scaffold: Verify Boundaries Early

Even for a minimal service, add a test that uses the app factory. This ensures your structure supports isolated app creation and keeps route logic thin.

# tests/conftest.pyimport osimport pytestfrom app import create_appfrom app.config import TestingConfig@pytest.fixture()def app():    os.environ["FLASK_ENV"] = "testing"    os.environ["SECRET_KEY"] = "test-secret"    app = create_app(TestingConfig)    yield app@pytest.fixture()def client(app):    return app.test_client()
# tests/test_health.pydef test_health(client):    resp = client.get("/api/health")    assert resp.status_code == 200    body = resp.get_json()    assert body["status"] == "ok"    assert "time" in body

Growth Path: How This Structure Expands Without Rewrites

As you add features, prefer feature folders while keeping the same layers. For example:

app/  api/    users/      __init__.py      routes.py  services/    user_service.py  models/    user.py  schemas/    user_schema.py

This keeps responsibilities stable: routes remain HTTP-only, services remain business logic, models remain persistence, schemas remain IO shape. The result is a small backend with clear boundaries and a maintainable path for growth.

Now answer the exercise about the content:

Which choice best reflects a maintainable Flask service structure that keeps boundaries clear and tests simple?

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

You missed! Try again.

A maintainable structure separates concerns: routes handle HTTP, services contain testable domain logic, models isolate persistence, and schemas define input/output shape. This supports predictable configuration and easier testing without a running server.

Next chapter

Flask Essentials: Application Factory Pattern and Configuration Management

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