FastAPI Backend Architecture: Model-Controller-Service

FastAPI Backend Architecture: Model-Controller-Service

Building an app has its challenges, but making the app scale well as the code and traffic increases in volume is a different beast altogether. You not only have to consider the features to be implemented, but also how you can make your services performant, bug-free, and maintainable as your product evolves over time.

As your feature backlog gets larger and your technical debts grow in size, you will suddenly find yourself stuck in a labyrinth of code that no one knows how to maintain without eventually breaking something in production.

Why use architecture patterns for your backend code?

This is where architectural patterns come into play. They help you to scale your codebase even as its complexities grow ever faster. Organizing your software into loosely coupled, highly cohesive parts that’s easier to maintain.

An overview of the Model-Controller-Service Architecture

The Model-Controller-Service is a software architectural pattern used on server-side applications to improve software maintainability and scalability. In this architecture, your backend application should be separated into three distinct parts that communicate with one another: namely the model, controller, and service layers. Each layer has its own purpose, and the layers work with one another to produce working software.

Diagram for MCS architecture

Say that we’re creating a backend application used to convert audio files (e.g. your class recordings) into text output. This process is called “transcription”. First the user will perform an action on the client application, such as pressing a button, to send a HTTP Request to be received by the controller. The Controller would then define the business logic that needs to be executed by calling methods from the Service. The Service will perform the actions to fulfill a business need, such as storing the audio file to a cloud storage service, calling the Machine Learning API to transform audio into pieces of text, and instructing the model to store data into the database. The model will directly interact with the database to perform operations such as Reading or Writing data.

A. Model Layer

Definition

The model layer is the central data structure of the application. It acts as the blueprints of the objects used within our program. These objects mainly refer to database table schemas, where we set predefined rules of how items on our database should look like, what fields should be populated, etc.

The model is also responsible for interacting with the database to fulfill any business logic within our application. This could mean providing interfaces for other layers to get or insert data to the database. In modern applications, developers may use an ORM (Object Relational Mapper) to simplify the interaction between the model layer and the database.

Implementation

Say that we need to store resulting Transcription data from our case earlier in our database. We can define how a Transcription object should be by specifiying a transcription.py file inside of the src/models/ folder. We’ll use SQLAlchemy as the ORM for this example.

src/models/transcription.py

from src.utils.db import Base
from src.models.base import DBBase
from src.models.users import User
from sqlalchemy import (
    Column,
    UUID,
    String,
    Float,
    Boolean,
    ForeignKey,
)

class Transcription(Base, DBBase):
  __tablename__ = "transcription"

  ## Foreign Relationships
  # Foreign key to User
  owner_id = Column(UUID, ForeignKey(User.id), nullable=False)

  # NOTE in case User gets a `transcriptions` field
  # owner = relationship("User", back_populates="transcriptions")

  title = Column(String(255), nullable=False, unique=False)

  tags = Column(ARRAY(String), nullable=True, unique=False)

  chunks = relationship("TranscriptionChunk", back_populates="transcription_parent")

  duration = Column(Float(precision=1), default=0, nullable=False)

In the above code snippet, we define a Transcription model inheriting the model Base and DBBase. Here, Base is just a factory function that constructs a base class. DBBase is a custom base model that includes generic fields such as “id”, “created_at”, and “updated_at”, and “is_deleted.” We defined the Transcription model to have a foreign key called owner_id, title, tags, chunks, and duration. The chunks field refers to TranscriptionChunk, a small portion of Transcription (e.g. 5 words long) that combine to make the whole Transcription object. We define the chunks following the TranscriptionChunk model below:

class TranscriptionChunk(Base, DBBase):
  __tablename__ = "transcription_chunk"

  # Extra fields
  duration = Column(Float(precision=1), default=0, nullable=False)

  is_edited = Column(Boolean, default=False)

  ## Foreign Relationships
  # Foreign key to Transcription
  transcription_id = Column(UUID, ForeignKey(Transcription.id), nullable=False)

  # One to many relationship with Transcription
  transcription_parent = relationship("Transcription", back_populates="chunks")

B. Controller Layer

Definition

The Controller layer acts as the brain of the program. It is responsible to coordinate the business operations to be performed for the user. Controllers in FastAPI are also known as routers. They are Python functions with Path Operation Decorators to route them to specific endpoints within our app.

@app.get("/")
def root():

  return "Hello World!"

This function will return “Hello World” each time a user sends a GET HTTP Request to the “/” (root, e.g. google.com) path. You might have seen this decorator pattern in other web frameworks such as Java Spring Boot.

Implementation

In our Audio Transcription application earlier, the controller would be placed inside a folder called controllers/. The controller functions would be imported from our main.py file where our FastAPI() server object lives.

First we define our main.py to be:

src/main.py

from fastapi import FastAPI
from src.controllers import transcription

# Create FastAPI server
app = FastAPI()

# Include routing to access domain-specific controllers
app.include_router(transcription.transcription_router)

...

And next, we’ll define our transcription_controller in this file:

src/controllers/transcription.py

import http

from fastapi import APIRouter
from botocore.exceptions import ClientError

from src.utils.settings import AWS_BUCKET_NAME
from src.schemas.base import GenericResponseModel
from src.schemas.transcription import (
  TranscribeAudioRequestSchema,
  PollTranscriptionRequestSchema
)
from src.services.transcription import TranscriptionService

transcription_router = APIRouter(
  prefix="/v1/transcription",
  tags=[TranscriptionRouterTags.transcribe]
)

# Define the path operation decorator
@transcription_router.post(
  "/create", 
  status_code=http.HTTPStatus.CREATED, 
  response_model=GenericResponseModel
)

# Define an async function to create the transcribed audio with help from Service layer
async def transcribe_audio(req: TranscribeAudioRequestSchema):
  transcribe_client = AWSTranscribeClient().get_client()

  service = TranscriptionService()

  filename, file_format = req.s3_filename.split(".")
  job_name = req.job_name
  language_code = req.language_code

  file_uri = service.generate_file_uri(
    bucket_name=AWS_BUCKET_NAME, 
    filename=filename, 
    extension=file_format
  )

  try:
    response = await service.transcribe_file(
      transcribe_client=transcribe_client,
      job_name=job_name,
      file_uri=file_uri,
      file_format=file_format,
      language_code=language_code
    ) 

    return GenericResponseModel(
      status_code=http.HTTPStatus.CREATED,
      message="Successfully created audio transcription",
      error="",
      data=response,
    )
  except TimeoutError:
    return GenericResponseModel(
      ...
    )
  except ClientError:
    return GenericResponseModel(
      ...
    )

...

The function above is a more complex example of controllers in a FastAPI Backend Application. It is decorated with transcription_router.post(“/create”, …) to define it as the POST endpoint of the /v1/transcription/create URL. Whenever the client sends a POST request to this URL, they will invoke the function to perform the necessary logic with the help of Service, and return the appropriate values back to the client.

In this example we see that there are a few imported modules such as TranscribeAudioRequestSchema, GenericResponseModel, and TranscriptionService. They are used to define controller inputs, outputs, and the operations used respectively. For now just know that we’ll probably need to be importing parts of our program and stitch them together in the controller layer.

C. Service Layer

Definition

Last but not least, we have the Service layer which acts as the executor of business logic for our application. The Service layer is the middle actor between the API (Controller) and the Data (Model) layer. It may be used in tasks such as performing calculations, calling external APIs, et cetera.

Implementation

In our Audio Transcription example above, we saw that the Controller function will call methods from the Service such as:

service.transcribe_file(...)

Here we will look at how those methods are implemented on the Service layer:

src/services/transcription.py

from fastapi import FastAPI
from src.controllers import transcription

# Create Transcription Service
class TranscriptionService:
  async def transcribe_file(self, transcribe_client: any, 
                       job_name: str, file_uri: str,
                       file_format: str, language_code = "id-ID"):
    try:
      transcribe_client.start_transcription_job(
        TranscriptionJobName=job_name,
        Media={
          "MediaFileUri": file_uri
        },
        MediaFormat=file_format,
        LanguageCode=language_code,
      )

      job_result = await self.poll_transcription_job(
        transcribe_client=transcribe_client, 
        job_name=job_name
      )

      # Store to database
      await self.store_transcription(item=job_result)
      ...

      return job_result

      # return res
    except TimeoutError:
      return TimeoutError("Timeout when polling the transcription results")
    except ClientError:
        return ClientError("Transcription Job failed.", operation_name="start_transcription_job")

  def poll_transcription_job(...):
    ...

We see that the transcribe_file method performs much of the logic needed to finish a specific task, namely to transcribe an audio file using AWS Machine Learning services. They are dependent on other TranscriptionService methods such as poll_transcription_job() and store_transcription() method defined within the class. Service methods such as store_transcription() may interact with the Model layer by performing ORM operations:

def store_transcription(session: Session, payload: StoreTranscriptionSchema):
  tsc = Transcription(**payload.model_dump())

  session.add(tsc)
  session.commit()
  ...

By decoupling the Service logic from Model and Controller, we can develop parts of the application without worrying that something breaking elsewhere. Implementing optimizations become simpler because your application is separated into small, well-defined chunks that you can modify without having to spend hours finding the connection with other components.

Summary

We’ve seen how to implement the Model-Controller-Service pattern in a FastAPI backend application. As our product evolves in complexity, navigating, maintaining, and scaling the codebase becomes a challenge in its own. To make our work as developers effective, we need to structure software in a loosely coupled way by using the Model-Controller-Service pattern.

References

  1. https://stackoverflow.com/questions/15175339/sqlalchemy-what-is-declarative-base

  2. https://konstantinmb.medium.com/from-request-to-database-understanding-the-three-layer-architecture-in-api-development-1c44c973c7af

  3. https://www.freecodecamp.org/news/the-model-view-controller-pattern-mvc-architecture-and-frameworks-explained/