Clean architecture in Python REST API’s: Quick start

DISCLAIMERS

Disclaimer #1

This presentation is not a One way fits all set for rules, neither is a theory 'class' about clean arquitecture

is a group of quick/simple approaches to get some benefits of clean architecture

Pragmatism, trade offs

Disclaimer #2

Oriented to microservices

Tradeoffs: some Clean Architecture parts might represent more cost than benefit

Clean Architecture

Clean architecture chatacteristics

  • Independent of frameworks
  • Testable.
  • Independent of the UI.
  • Independent of the database.
  • Independent of any external agency.

Clean architecture

What the ...?

Let's start simple

Cutest furry little API endpoint

@app.route('/application/<int:application_id>')
def get_application(application_id):
    application = db_session().query(Application).get(application_id)
    return jsonify(ApplicationSchema(many=False).dump(application).data)

Does it work?

It does :)

Does it apply clean architecture?

No

Is that a bad thing?

We are fine for now :)

Do we need to apply clean architecture?

Probably not

But...

The API start growing

@app.route('/application/<int:application_id>')
def get_application(application_id):
    application = db_session().query(Application).get(application_id)
    return jsonify(ApplicationSchema(many=False).dump(application).data)


@app.route('/application')
def get_user_applications():
    user_id = request.args.get('user_id')
    application = db_session().query(Application).fliter_by(user_id=user_id)
    return jsonify(ApplicationSchema(many=True).dump(application).data)


@app.route('/application/<int:application_id>/process')
def process_application(application_id):
    session = db_session()
    application = session.query(Application).get(application_id)
    if application.requested_amount > 50000:
        application.status = 'DECLINE'
        notify_declined_application()
    else
        application.status = 'APPROVE'
        notify_approved_application()
    session.commit()
    return jsonify()


@app.route('/application/<int:application_id>/decline')
def approve_application(application_id):
    session = db_session()
    application = session.query(Application).get(application_id)
    application.status = 'DECLINE'
    notify_declined_application()
    session.commit()
    return jsonify()

Lets take another look

@app.route('/application/<int:application_id>')
def get_application(application_id):
    application = db_session().query(Application).get(application_id)
    return jsonify(ApplicationSchema(many=False).dump(application).data)


@app.route('/application')
def get_user_applications():
    user_id = request.args.get('user_id')
    application = db_session().query(Application).fliter_by(user_id=user_id)
    return jsonify(ApplicationSchema(many=True).dump(application).data)


@app.route('/application/<int:application_id>/process')
def process_application(application_id):
    session = db_session()
    application = session.query(Application).get(application_id)
    if application.requested_amount > 50000:
        application.status = 'DECLINE'
        notify_declined_application()
    else
        application.status = 'APPROVE'
        notify_approved_application()
    session.commit()
    return jsonify()


@app.route('/application/<int:application_id>/decline')
def approve_application(application_id):
    session = db_session()
    application = session.query(Application).get(application_id)
    application.status = 'DECLINE'
    notify_declined_application()
    session.commit()
    return jsonify()

Jumm, this smells fishy

@app.route('/application/<int:application_id>')
def get_application(application_id):
    application = db_session().query(Application).get(application_id)
    return jsonify(ApplicationSchema(many=False).dump(application).data)


@app.route('/application')
def get_user_applications():
    user_id = request.args.get('user_id')
    application = db_session().query(Application).fliter_by(user_id=user_id)
    return jsonify(ApplicationSchema(many=True).dump(application).data)


@app.route('/application/<int:application_id>/process')
def process_application(application_id):
    session = db_session()
    application = session.query(Application).get(application_id)
    if application.requested_amount > 50000:
        application.status = 'DECLINE'
        notify_declined_application()
    else
        application.status = 'APPROVE'
        notify_approved_application()
    session.commit()
    return jsonify()


@app.route('/application/<int:application_id>/decline')
def approve_application(application_id):
    session = db_session()
    application = session.query(Application).get(application_id)
    application.status = 'DECLINE'
    notify_declined_application()
    session.commit()
    return jsonify()

*** 1

*** 2

*** 3

*** 4

***1, ***3, ***4

are exactly the same queries

***1, ***2, ***3, ***4 they are all queries to the same table

@app.route('/application/<int:application_id>')
def get_application(application_id):
    application = db_session().query(Application).get(application_id)
    return jsonify(ApplicationSchema(many=False).dump(application).data)


@app.route('/application')
def get_user_applications():
    user_id = request.args.get('user_id')
    application = db_session().query(Application).fliter_by(user_id=user_id)
    return jsonify(ApplicationSchema(many=True).dump(application).data)


@app.route('/application/<int:application_id>/process')
def process_application(application_id):
    session = db_session()
    application = session.query(Application).get(application_id)
    if application.requested_amount > 50000:
        application.status = 'DECLINE'
        notify_declined_application()
    else
        application.status = 'APPROVE'
        notify_approved_application()
    session.commit()
    return jsonify()


@app.route('/application/<int:application_id>/decline')
def approve_application(application_id):
    session = db_session()
    application = session.query(Application).get(application_id)
    application.status = 'DECLINE'
    notify_declined_application()
    session.commit()
    return jsonify()

*** 1

*** 2

*** 3

*** 4

There must be a better way !!

Raymond Hettinger @raymondh Python core developer.

Freelance programmer/consultant/trainer.

Lets move data access to a common place

# application_manager.py

def __get_application_by_id(session, application_id):
    return session.query(Application).get(application_id)


def get_application(application_id):
    return _get_application_by_id(db_session(), application_id)


def retrieve_user_applications(user_id):
    return db_session().query(Application).fliter_by(user_id=user_id)


def approve_application(application_id):
    session = db_session()
    with session.begin():
        application = __get_application_by_id(session, application_id)
        application.status = 'APPROVE'


def decline_application(application_id):
    session = db_session()
    with session.begin():
        application = __get_application_by_id(session, application_id)
        application.status = 'DECLINE'


Now our API endpoints have nothing to do with database sessions

# api/application.py

@app.route('/application/<int:application_id>')
def get_application(application_id):
    application = get_application(application_id)
    return jsonify(ApplicationSchema(many=False).dump(application).data)


@app.route('/application')
def get_user_applications():
    user_id = request.args.get('user_id')
    application = get_user_applications(user_id)
    return jsonify(ApplicationSchema(many=True).dump(application).data)


@app.route('/application/<int:application_id>/process')
def process_application(application_id):
    application = get_application(application_id)
    if application.requested_amount > 50000:
        decline_application(application_id)
        notify_declined_application()
    else:
        application.status = 'APPROVE'
        notify_approved_application()
    return jsonify()


@app.route('/application/<int:application_id>/decline')
def approve_application(application_id):
    approve_application(application_id)
    notify_declined_application()
    return jsonify()

*** 1

*** 2

*** 3

*** 4

Project structure

Improvements list

Dedicated place to interact with database

Principle involved

Single responsibility principle

The App keeps growing

We need to decline an application via command line

Ideas?

We already have an API endpoint, may be we can call it?

/application/<int:application_id>/decline

NO!!!!

Lets use a  script for it

Hold on

@app.route('/application/<int:application_id>/decline')
def decline_application(application_id):
    decline_application(application_id)
    notify_declined_application()
    return jsonify()

How can my current logic be used by a script?

It can't

@app.route('/application/<int:application_id>/decline')
def declined_application(application_id):
    declined_application(application_id)
    notify_declined_application()
    return jsonify()

The http protocol access is coupled to the business logic

*** 1

*** 2

There must be a better way !!

Raymond Hettinger @raymondh Python core developer.

Freelance programmer/consultant/trainer.

Lets take the business logic appart

# use_cases/application/decline_application_use_case.py

from database.managers.application_manager import decline_application

class DeclineApplicationUseCase:
  
    def __notify_declined_application():
        # add logic to notify declined application
        pass


    def decline(self, application_id):
        decline_application(application_id)
        self.__notify_declined_application()

Adapters

# api/application.py

@app.route('/application/<int:application_id>/decline')
def decline_application(application_id):
    DeclineApplicationUseCase.decline(application_id)
    return jsonify()

The endpoint is a REST API adapter that calls the Decline Application use case

# script/decline_application.py

import argparse

from use_case.application_use_cases import decline_application_use_case

parser = argparse.ArgumentParser(description='Decline given application.')
parser.add_argument('application_id', metavar='a', type=int,
                    help='id of the application to be declined')


def main():
    args = parser.parse_args()
    DeclineApplicationUseCase.decline(args.application_id)

if __name__ == "__main__":
    main()


The script is a command line adapter that calls the Decline Application use case

Adapters

Improvements list

Dedicated place to interact with database

Dedicated place for business logic

Dedicated place for API access

Dedicated place for Scripts

Decoupled business logic from http access

Project structure

Principle involved

Single responsibility principle

Again :)

Jobs

Crossing Boundaries

About parameters and returns....

# database/managers/application_manager.py


session_factory = sessionmaker(bind=engine)
db_session = scoped_session(session_factory)


def __get_application_by_id(session, application_id):
    return session.query(Application).get(application_id)


def get_application(application_id):
    return __get_application_by_id(db_session(), application_id)


def get_user_applications(user_id):
    return db_session().query(Application).fliter_by(user_id=user_id)

*** 2

*** 3

*** 1

*** 2 and ***3 return type is an Application Model

This is not good :(

Why?

Other modules that use data would "know" that an Application Model exist :(

# use_cases/application/application_use_case.py


def process_application_use_case(application_id):
    application = get_application(application_id)
    if application.requested_amount > 50000:
        decline_application(application_id)
        __notify_declined_application()
    else:
        approve_application(application_id)
        __notify_approved_application()

*** 2

This use case knows application is a model instance with requested_amount attribute

The data from/to modules should be simple data structures

  • Don't pass ORM entities or database rows.
  • Simple data structures: Dict, list, string, int, float, boolean etc...

So they are agnostic to the implementation details/technologies of other modules

No Application Models or any Other Model should be returned

# database/managers/application_manager.py


session_factory = sessionmaker(bind=engine)
db_session = scoped_session(session_factory)


def __get_application_by_id(session, application_id):
    return session.query(Application).get(application_id)


def get_application(application_id):
    return __get_application_by_id(db_session(), application_id)


def get_user_applications(user_id):
    return db_session().query(Application).fliter_by(user_id=user_id)

***2

***3

# database/managers/application_manager.py


from database.models import Application, engine
from database.schemas import ApplicationSchema

from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import sessionmaker

session_factory = sessionmaker(bind=engine)
db_session = scoped_session(session_factory)


def __get_application_by_id(session, application_id):
    return session.query(Application).get(application_id)


def get_application(application_id):
    session = db_session()
    application = __get_application_by_id(session, application_id)
    return ApplicationSchema(many=False).dump(application).data


def get_user_applications(user_id):
    application = db_session().query(Application).fliter_by(user_id=user_id)
    return ApplicationSchema(many=True).dump(application).data

***2

***3

Schemas to the rescue

from marshmallow_enum import EnumField
from marshmallow_sqlalchemy import ModelSchema


from database.models import Application, ApplicationStatus


class ApplicationSchema(ModelSchema):
    status = EnumField(ApplicationStatus)

    class Meta:
        model = Application

Now the use case would handle a dict instead of an Application model

# use_cases/application/application_use_case.py


def process_application_use_case(application_id):
    application = get_application(application_id)
    if application['requested_amount'] > 50000:
        decline_application(application_id)
        __notify_declined_application()
    else:
        approve_application(application_id)
        __notify_approved_application()

Project structure

Jobs

The App is growing even more

Applications by  companies is needed

@app.route('/application')
def get_applications():
    applications = []
    
    # Filter application by users
    if 'user_id' in request.args:
        user_id = request.args.get('user_id')
        applications = GetUserApplicationsUseCase().get_applications(user_id)
    
    # Filter applications by company
    elif 'company_id' in request.args:
        company_id = request.args.get('company_id')
        applications = GetCompanyApplicationsUseCase().get_applications(company_id)
        
    return jsonify(applications)

This works :)

But

A user usually have 10's or 100's of applications

While a company might have 10k's - 100k's - 1M's of applications

That means some fun with the db:

Indexes
Sharding

Data replication in other tables

Sometimes, that is not enough or too much work

Sometimes, the fastest solution is to handle a different data source for that specific query

For example mongo or redis.

# database/managers/application_sql_manager.py

from database.models import Application, engine
from database.schemas import ApplicationSchema

from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import sessionmaker


class ApplicationSQLManager:

    def __init__(self):
        session_factory = sessionmaker(bind=engine)
        db_session = scoped_session(session_factory)
        self.session = db_session()

    ...
    ...

    def get_company_applications(self, company_id):
        application = self.session.query(Application).fliter_by(company_id=company_id)
        return ApplicationSchema(many=True).dump(application).data

    ...
    ...
# database/managers/application_document_manager.py

from pymongo import MongoClient


class ApplicationDocumentManager:

    def __init__(self):
        db_url = 'mongodb://mydbuser:mydbpassword@ds053148.mongolab.com:53148/heroku_app18934798'
        self.collection = MongoClient(db_url)['my_database']['application']

    def get_application(self, application_id):
        raise NotImplementedError

    def get_user_applications(self, user_id):
        raise NotImplementedError

    def get_company_applications(self, company_id):
        fields_object = {'_id': 1, 'company_id': 1, 'created_at': 1, 'user_id': 1}
        return list(self.collection.find({'company_id': company_id}, fields_object))

    def approve_application(self, application_id):
        raise NotImplementedError

    def decline_application(self, application_id):
        raise NotImplementedError
# database/repositories/application_repository.py

from database.managers.application_sql_manager import ApplicationSQLManager


class ApplicationRepository:

	def __init__(self, default_manager=ApplicationSQLManager):
		self.manager = default_manager()

	def get_application_by_company(self, company_id):
		return self.manager.get_company_applications(company_id)
# database imports
from database.managers.application_sql_manager import ApplicationSQLManager
from database.managers.application_document_manager import ApplicationDocumentManager
from database.repositories.application_repository import ApplicationRepository

## import GetApplicationsUseCase and GetCompanyApplicationsUseCase
# ...
# ...

@app.route('/application')
def get_applications():
    applications = []
    
    # Filter application by users
    if 'user_id' in request.args:
        user_id = request.args.get('user_id')
        applications = GetUserApplicationsUseCase(ApplicationRepository(ApplicationSQLManager()).get_applications(user_id)
    
    # Filter applications by company
    elif 'company_id' in request.args:
        company_id = request.args.get('company_id')
        applications = GetCompanyApplicationsUseCase(ApplicationRepository(ApplicationDocumentManager()).get_applications(company_id)
        
    return jsonify(applications)

Applied technique:

Dependency injection

One object supplies the dependencies of another object

So, instead of a class calling/importing its dependencies...

... the class dependencies are sent as parameters

# database imports
from database.managers.application_sql_manager import ApplicationSQLManager
from database.managers.application_document_manager import ApplicationDocumentManager
from database.repositories.application_repository import ApplicationRepository

## import GetUserApplicationsUseCase and  GetCompanyApplicationsUseCase
# ...
# ...

@app.route('/application')
def get_applications():
    applications = []
    
    # Filter application by users
    if 'user_id' in request.args:
        user_id = request.args.get('user_id')
        applications = GetUserApplicationsUseCase(ApplicationRepository(ApplicationSQLManager()).get_applications(user_id)
    
    # Filter applications by company
    elif 'company_id' in request.args:
        company_id = request.args.get('company_id')
        company_application_use_case = GetCompanyApplicationsUseCase(ApplicationRepository(ApplicationDocumentManager())
        applications = company_application_use_case.get_applications(company_id)                                                      
                                                                     
                                                                     
    return jsonify(applications)

*** 1

*** 2

***1 ApplicationDocumentManager is sent "injected" to ApplicationRepository

***2 ApplicationRepository is sent "injected" to GetCompanyApplicationsUseCase

***3 GetCompanyApplicationsUseCase object is being created

TODO: Inversion of Control for GetCompanyApplicationsUseCase

*** 3

*** 4

***4 Applications are retrieved

TODO: Inversion of Control for GetCompanyApplicationsUseCase?

Ok quick pick :P

# database imports
from database.managers.application_sql_manager import ApplicationSQLManager
from database.managers.application_document_manager import ApplicationDocumentManager
from database.repositories.application_repository import ApplicationRepository

# Use cases imports
# ...
# ...

import dependency_injector.containers as containers
import dependency_injector.providers as providers


class Managers(containers.DeclarativeContainer):
    """IoC container of Managers """

    applicationDocumentManager = providers.Factory(ApplicationDocumentManager)


class Repositories(containers.DeclarativeContainer):
    """IoC container of Repositories """

    applicationDocumentRepository = providers.Factory(ApplicationRepository, default_manager=Managers.applicationDocumentManager)


class UseCases(containers.DeclarativeContainer):
    """IoC container of Adapters """
    companyApplicationUseCase = providers.Factory(GetCompanyApplicationsUseCase, applicatiomn_repository=Repositories.applicationDocumentRepository)


@app.route('/application')
def get_applications():
    applications = []

    ...

    # Filter applications by company
    elif 'company_id' in request.args:
        company_id = request.args.get('company_id')
        company_application_use_case = UseCases.companyApplicationUseCase()
        applications = company_application_use_case.get_list_by_company(company_id)
    return jsonify(applications)

*** 1

***No more those "nested" parameters

application_adapter = CompanyApplicationsAdapter(ApplicationRepository(ApplicationDocumentManager()))

Use Case


class GetCompanyApplicationsUseCase:

    def __init__(self, application_repository):
        self.repository = application_repository

    def get_company_applications(self, company_id):
        return self.repository.get_company_applications(company_id)

It doesn't not import any repository or manager, and yet, is used in execution time.

Dependency rule compliance

 no code in the inner circle can directly reference a piece of code from the outer circle

Adapters don't import endpoints

Use cases don't import Adapters

Project structure

Anything else?

Or course, we are just starting....

What is left?

Presenters

Controllers

Gateways

Interactors: Flow of control

Entities

Boundary Anatomy

Component Cohesion and Coupling

Main

Tests

More...

But with the already explained cases, your new born API could be in a good shape for a while

Final thoughts

Should  you apply all Clean architecture 'techniques' to your project?

Of course not, each project has it's own problems.

Overarchitecture could be very hamful and delay stuff

For start ups: Do not try yo anticipate a scalability problem when you dong even have a single client/user

Once you get users, you would gradually apply some techniques to some parts of the code: do not refactor everything overnight

"The squeaky wheel gets the oil"

A good software architect should fully understand the business, market, project, team, deadlines, etc before working on 'latest', 'trendy' and 'cool' techniques

So, what should I do?

You should understand the techniques and the problems they solve

Then apply them once you see you project is facing or could face the problem *soon.

Resources

SOCIAL

Jhon Jairo Roa Acuña

Thanks

Clean architecture in Python REST API’s: QuickStart

By Jhon Jairo Roa

Clean architecture in Python REST API’s: QuickStart

  • 4,555