Python Flask API Project Folder Structure
title: Contents
style: nestedList # TOC style (nestedList|inlineFirstLevel)
minLevel: 1 # Include headings from the specified level
maxLevel: 4 # Include headings up to the specified level
includeLinks: true # Make headings clickable
debugInConsole: false # Print debug info in Obsidian console
Overview
<root>
├───config/
│ ├───config.testing.ini
│ ├───config.production.ini
│ └───config.development.ini
│
├───docs/
├───src/
│ └──app/
│ ├───controllers/
│ ├───infra/
│ ├───middlewares/
│ ├───models/
│ ├───routes/
│ ├───schemas/
│ ├───__init__.py
│ ├───__main__.py
│ ├───exts.py
│ └───utils.py
│
├───tests/
│ └───api/
│ ├───__init__.py
│ └───test_user.py
│ ├───__init__.py
│ ├───conftest.py
│ └───factory.py
│
│───.gitignore
│───.gitattributes
│───.editorconfig
│───Dockerfile
│───compose.yml
│───CHANGELOG.md
│───README.md
│───alembic.ini
│───poetry.lock
│───pyproject.toml
│───requirements.txt
│───ruff.toml
Application Entrypoint
from flask import Flask
from src.app import middlewares, routes
from src.app.infra.config import load_config
from src.app.infra.log import configure_logging
from src.app.infra.database.session import create_session_maker
def main () -> Flask:
config = load_config()
configure_logging(config.app_config)
session_maker = create_session_maker(config.db_config.full_url)
app = Flask( __name__ )
middlewares.register(app, session_maker)
routes.register(app)
return app
def run ():
app = main()
app.run( "localhost" , 5000 )
if __name__ == "__main__" :
run()
The Flask application is not in the module scope, but it needs to be passed all the necessary modules and configurations. At first, the infra
is setup (config
, logging
, etc.) and the SQLAlchemy session_maker
is instantiated.
So for this configuration:
[ application ]
debug = true
major_version = 0
minor_version = 1
patch_version = 0
[ database ]
host = l ocalhost
port = 5432
database = a pp
user = a pp_admin
password = a pp_admin
echo = true
results in the following models in src/app/infra/config/models.py
:
# src/app/infra/config/models.py
from dataclasses import dataclass
@dataclass
class AppConfig :
debug: bool
major_version: int
minor_version: int
patch_version: int
@dataclass
class DatabaseConfig :
host: str
port: int
database: str
user: str
password: str
echo: bool
# default values
rdbms: str = "postgresql"
connector: str = "psycopg"
@ property
def full_url (self) -> str :
return " {} + {} :// {} : {} @ {} : {} / {} " .format(
self .rdbms, self .connector,
self .user, self .password,
self .host, self .port, self .database
)
@dataclass
class Config :
app_config: AppConfig
db_config: DatabaseConfig
as well as the following parser in src/app/infra/config/parser.py
:
# src/app/infra/config/parsers.py
import configparser
import os
from src.app.infra.config.models import (
AppConfig,
Config,
DatabaseConfig,
)
DEFAULT_CONFIG_PATH : str = "./config/local.ini"
def load_config (path: str | None = None ) -> Config:
if path is None :
path = os.getenv( "CONFIG_PATH" , DEFAULT_CONFIG_PATH )
parser = configparser.ConfigParser()
parser.read(path)
application_data, database_data = parser[ "application" ], parser[ "database" ]
application_config = AppConfig(
debug = application_data.getboolean( "debug" ),
major_version = application_data.getint( "major_version" ),
minor_version = application_data.getint( "minor_version" ),
patch_version = application_data.getint( "patch_version" ),
)
database_config = DatabaseConfig(
host = database_data.get( "host" ),
port = database_data.getint( "port" ),
database = database_data.get( "database" ),
user = database_data.get( "user" ),
password = database_data.get( "password" ),
echo = database_data.getboolean( "echo" ),
)
return Config(application_config, database_config)
To configure logging in src/app/infra/log/main.py
:
# src/app/infra/log/main.py
import logging
from src.app.infra.config.models import AppConfig
from src.app.infra.log.formatters import MainConsoleFormatter
DEFAULT_LOGGING_LEVEL : int = logging. INFO
def configure_logging (config: AppConfig) -> None :
logging_level: int = logging. DEBUG if config.debug else DEFAULT_LOGGING_LEVEL
console_handler = logging.StreamHandler()
console_handler.setLevel(logging_level)
console_handler.setFormatter(MainConsoleFormatter())
logging.basicConfig( handlers = [console_handler], level = logging_level)
with the following formatter in src/app/infra/log/formatters.py
:
# src/app/infra/log/formatters.py
import logging
class MainConsoleFormatter ( logging . Formatter ):
GREY = " \x1b [38;20m"
GREEN = " \x1b [32;20m"
YELLOW = " \x1b [33;20m"
RED = " \x1b [31;20m"
RESET = " \x1b [0m"
FORMAT = " %(asctime)s - %(levelname)s - %(message)s "
FORMATS = {
logging. DEBUG : GREY + FORMAT + RESET ,
logging. INFO : GREEN + FORMAT + RESET ,
logging. WARNING : YELLOW + FORMAT + RESET ,
logging. ERROR : RED + FORMAT + RESET ,
logging. CRITICAL : RED + FORMAT + RESET ,
}
def format (self, record):
log_fmt = self . FORMATS .get(record.levelno)
formatter = logging.Formatter(log_fmt)
return formatter.format(record)
And, finally we have database package that holds migrations and alembic config with the session_maker
 factory:
# src/app/infra/database/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm.session import sessionmaker
def create_session_maker (database_url: str ) -> sessionmaker:
engine = create_engine(
database_url,
echo = True ,
pool_size = 15 ,
max_overflow = 15 ,
connect_args = {
"connect_timeout" : 5 ,
},
)
return sessionmaker(engine, autoflush = False , expire_on_commit = False )
So, this part of the application will be almost similar for any design pattern. Let’s move to the one of the most popular and easy one.
Model View Controller (MVC)
MVC Â is a well-known software design pattern. Its main advantage is its simplicity; it is intuitive even for beginners. We are going to create a simple template so MVC will meet all our needs for small size projects.
If we look at MVC implementation in other languages, we will see that there are other layers besides model, view and controller. For example, Spring MVC (Java) offers us following project layout.
As you can see, there are some layers besides MVC. There:
View is our presentation for the client.
Controller is our endpoint that calls the necessary services and returns view to client.
Service is a layer, that holds all the business logic/rules.
DAO (Data Access Object) is a persistence layer, that holds all interactions with the database through the models.
From the following diagram, we can conclude that MVC is a 3-layered architecture, which can also have more than three layers (persistence, services, etc.).
As for Flask, we can also divide our application into 3 layers:
Models — SQLAlchemy ORM models.
View — our route that will call the necessary Controller.
Controller is our business logic holder, that will manipulate the models and return the proper result.
But we gonna simplify Spring’s vision of MVC and do not separate a Services and DAO layers.
Model Example
# src/app/models/user.py
from sqlalchemy.orm import Mapped, mapped_column
from .base import CreatedUpdatedAtMixin
class User ( CreatedUpdatedAtMixin ):
__tablename__ = "users"
id : Mapped[ int ] = mapped_column( primary_key = True )
username: Mapped[ str | None ] = mapped_column( unique = True )
first_name: Mapped[ str ]
last_name: Mapped[ str ]
with base model configuration and mixin:
# src/app/models/base.py
import datetime
from sqlalchemy import func, MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, registry
convention = {
"ix" : "ix_ %(column_0_label)s " , # INDEX
"uq" : "uq_ %(table_name)s _ %(column_0_N_name)s " , # UNIQUE
"ck" : "ck_ %(table_name)s _ %(constraint_name)s " , # CHECK
"fk" : "fk_ %(table_name)s _ %(column_0_N_name)s _ %(referred_table_name)s " , # FOREIGN KEY
"pk" : "pk_ %(table_name)s " , # PRIMARY KEY
}
mapper_registry = registry( metadata = MetaData( naming_convention = convention))
class BaseModel ( DeclarativeBase ):
registry = mapper_registry
metadata = mapper_registry.metadata
class CreatedUpdatedAtMixin ( BaseModel ):
"""
A model mixin that adds `created_at` and `updated_at` timestamp fields
"""
__abstract__ = True
created_at: Mapped[datetime.datetime] = mapped_column(
nullable = False ,
server_default = func.now()
)
updated_at: Mapped[datetime.datetime] = mapped_column(
nullable = False ,
server_default = func.now(),
onupdate = func.now(),
)
Views Example
# src/app/routes/user.py
from flask import Blueprint, g
from src.app.controllers.users import UserController
user_blueprint = Blueprint( "user" , __name__ , url_prefix = "/users" )
@user_blueprint.route ( "/" )
def list_users ():
session = g.session
users_list = UserController(session).list_users()
return [user.model_dump( mode = "json" ) for user in users_list]
To avoid global variables, we should register blueprints like that (in package __init__.py
 module)
# src/app/routes/__init__.py
from flask import Flask
from .user import user_blueprint
def register (app: Flask) -> None :
app.register_blueprint(user_blueprint)
Controller Example:
# src/app/controllers/user.py
from pydantic import TypeAdapter
from sqlalchemy import select
from src.app.models.user import User as UserModel
from src.app.schemas.user import User as UserSchema
from .base import Controller
class UserController (Controller[UserModel]):
# some operations with the user
def list_users (self) -> list[UserSchema]:
stmt = select(UserModel)
result = self .session.scalars(stmt.order_by(UserModel.id)).fetchall()
return TypeAdapter(list[UserSchema]).validate_python(result)
with such generic Controller (just to reduce code duplication):
# src/app/controllers/base.py
from typing import Generic, TypeVar
from sqlalchemy.orm.session import Session
from src.app.models.base import BaseModel
Model = TypeVar( "Model" , bound = BaseModel)
class Controller (Generic[Model]):
def __init__ (self, session: Session):
self .session = session
self .model: type[Model] = type (Model)
# some base operations
# Note: controller needs to also provide de(serialization),
# or it can be imported from the separate layer
def get (self, pk: int ) -> Model:
return self .session.get( self .model, pk)