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.pyWhy Each Piece Exists
| Path | Responsibility | Notes |
|---|---|---|
app/__init__.py | Application factory (create_app) and blueprint registration | Keeps app creation centralized and testable |
app/config.py | Configuration objects and environment loading | No secrets; reads from environment variables |
app/extensions.py | Instantiate 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 logic | Pure functions/classes where possible; easy to unit test |
app/models/ | Data models | SQLAlchemy models or other persistence representations |
app/schemas/ | Serialization/validation | Marshmallow/Pydantic schemas or manual validation |
app/common/ | Shared utilities (errors, constants) | Keep small; avoid dumping ground |
instance/ | Local machine config overrides | Typically excluded from git; Flask supports instance config |
tests/ | Unit/integration tests | Use app factory to create isolated test app |
migrations/ | DB migrations | Only if you use a relational DB + Alembic |
wsgi.py | Production entry point | Gunicorn/uwsgi imports app from here |
Conventions That Prevent Entropy
Naming and Module Responsibilities
- Blueprint modules:
app/api/routes.pyor feature-based folders likeapp/api/health/routes.pyas 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_healthKeep 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 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/migrationsCreate 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__.py2) 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.db3) 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 ProductionConfigConvention: 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 app6) 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)), 2007) 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_codeIf 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.pyThen request:
GET http://localhost:5000/api/healthTesting 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 bodyGrowth 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.pyThis 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.