Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions modules/module-02/REFLECTION.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Module 2 — Reflection

**Team name**: _______________
**Branch**: `module-02/<team-name>`
**Team name**: BUI_Nhat
**Branch**: `module-02/BUI_Nhat`
**Submitted**: before Module 3 lesson

---
Expand All @@ -19,6 +19,12 @@ You built a service with distinct layers: models, schemas, repository, service,
Think about what happens six months later when someone new joins the team, or when you need to swap SQLite for PostgreSQL. What does the layered structure protect you from?

> *Your answer:*
Keeping everything in one file may work for a very small project, but it becomes difficult to maintain as the system grows. The layered structure separates responsibilities so each part of the service has a clear role.
For example, the repository layer handles database queries, while the service layer contains business logic. This makes the code easier to understand for new developers because they immediately know where to look when changing a feature or fixing a bug.
The structure also makes future changes safer. If we later switch from SQLite to PostgreSQL, most changes would stay inside the database and repository layers without affecting the API routes or schemas. Without separation, changing one part of the system could accidentally break unrelated features.

---


---

Expand All @@ -31,6 +37,10 @@ Each service owns its data exclusively — no other service is allowed to touch
Give a concrete scenario, not a general principle.

> *Your answer:*
The "Game" entity should only be owned by the game-service.
For example, imagine the recommendation-service could directly write into the "games" table. A bug in the recommendation logic could accidentally modify or delete game information while generating recommendations.
This would create inconsistent data across the platform. Users might suddenly see incorrect game titles, genres, or missing entries. By forcing all changes to go through the game-service API, the service keeps control over validation and business rules.


---

Expand All @@ -43,6 +53,9 @@ You now have models, schemas, a repository, a service, and routes — five layer
And at what point does the complexity start to pay off? Where is the tipping point?

> *Your answer:*
The main cost of this structure is extra complexity and boilerplate. For a small CRUD service, creating models, schemas, repositories, services, and routes can feel repetitive and slower than writing everything in one file.
Debugging can also require jumping through multiple layers before finding the source of a problem.
However, the structure starts paying off once the project grows beyond a few endpoints or multiple developers start working on it. At that point, clean separation makes the codebase easier to scale, test, and maintain without turning into a monolith full of tightly coupled logic.

---

Expand Down
2 changes: 1 addition & 1 deletion modules/module-02/exercise.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Module 2 — FastAPI Service Design

**Duration**: 2h in class
**Branch to submit**: `module-02/<team-name>`
**Branch to submit**: `module-02/BUI_Nhat`

---

Expand Down
26 changes: 26 additions & 0 deletions services/game-service/app/database.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
import os

DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./games.db")

engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False}
)

SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)

class Base(DeclarativeBase):
pass

def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Infrastructure layer — database connection.
#
# Replicate the same structure as user-service/app/database.py.
Expand Down
7 changes: 7 additions & 0 deletions services/game-service/app/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
from fastapi import FastAPI

from app.routes import router

app = FastAPI(title="game-service")

app.include_router(router)
# Entry point — FastAPI application.
#
# Create the FastAPI app instance and register the router from app.routes.
Expand Down
25 changes: 25 additions & 0 deletions services/game-service/app/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
from sqlalchemy import Column, String, Integer, DateTime
from datetime import datetime, timezone
import uuid

from app.database import Base

class Game(Base):
__tablename__ = "games"

id = Column(
String,
primary_key=True,
default=lambda: str(uuid.uuid4())
)

title = Column(String, nullable=False)
genre = Column(String, nullable=False)
platform = Column(String, nullable=False)
release_year = Column(Integer, nullable=True)
cover_url = Column(String, nullable=True)

created_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc)
)
# Infrastructure layer — ORM model.
#
# Define the `games` table. This is the only file that knows about columns.
Expand Down
60 changes: 60 additions & 0 deletions services/game-service/app/repository.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,63 @@
from sqlalchemy.orm import Session

from app.models import Game
from app.schemas import GameCreate

def create_game(db: Session, data: GameCreate) -> Game:
game = Game(
title=data.title,
genre=data.genre,
platform=data.platform,
release_year=data.release_year,
cover_url=data.cover_url,
)

db.add(game)
db.commit()
db.refresh(game)

return game

def get_game(db: Session, game_id: str) -> Game | None:
return db.query(Game).filter(Game.id == game_id).first()

def list_games(
db: Session,
limit: int = 20,
offset: int = 0
) -> tuple[list[Game], int]:

total = db.query(Game).count()

games = (
db.query(Game)
.offset(offset)
.limit(limit)
.all()
)

return games, total

def search_games(
db: Session,
q: str,
limit: int = 20,
offset: int = 0
) -> tuple[list[Game], int]:

query = db.query(Game).filter(
Game.title.ilike(f"%{q}%")
)

total = query.count()

games = (
query.offset(offset)
.limit(limit)
.all()
)

return games, total
# Infrastructure layer — raw database queries.
#
# Implement these four functions. Each takes `db: Session` as its first argument.
Expand Down
72 changes: 72 additions & 0 deletions services/game-service/app/routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.database import get_db
from app import service, schemas

router = APIRouter(
prefix="/v1/games",
tags=["games"]
)

@router.post(
"/",
response_model=schemas.GameOut,
status_code=201
)
def create_game(
data: schemas.GameCreate,
db: Session = Depends(get_db)
):
return service.add_game(db, data)

@router.get(
"/",
response_model=schemas.GameList
)
def list_games(
limit: int = 20,
offset: int = 0,
db: Session = Depends(get_db)
):
return service.fetch_all_games(
db,
limit=limit,
offset=offset,
)

# IMPORTANT:
# search route must come BEFORE /{game_id}
@router.get(
"/search",
response_model=schemas.GameList
)
def search_games(
q: str,
limit: int = 20,
offset: int = 0,
db: Session = Depends(get_db)
):
return service.find_games(
db,
q=q,
limit=limit,
offset=offset,
)

@router.get(
"/{game_id}",
response_model=schemas.GameOut
)
def get_game(
game_id: str,
db: Session = Depends(get_db)
):
try:
return service.fetch_game(db, game_id)

except ValueError as e:
raise HTTPException(
status_code=404,
detail=str(e)
)
# Interface layer — HTTP endpoints.
#
# Define a router with prefix="/v1/games" and implement these endpoints:
Expand Down
26 changes: 26 additions & 0 deletions services/game-service/app/schemas.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
from pydantic import BaseModel
from datetime import datetime

class GameCreate(BaseModel):
title: str
genre: str
platform: str
release_year: int | None = None
cover_url: str | None = None

class GameOut(BaseModel):
id: str
title: str
genre: str
platform: str
release_year: int | None
cover_url: str | None
created_at: datetime

model_config = {"from_attributes": True}

class GameList(BaseModel):
items: list[GameOut]
total: int
limit: int
offset: int
# Application layer — Pydantic DTOs.
#
# Define the shapes of data coming IN and going OUT of the API.
Expand Down
66 changes: 66 additions & 0 deletions services/game-service/app/service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,69 @@
from sqlalchemy.orm import Session

from app import repository
from app.schemas import (
GameCreate,
GameOut,
GameList,
)

def add_game(db: Session, data: GameCreate) -> GameOut:
game = repository.create_game(db, data)
return GameOut.model_validate(game)

def fetch_game(db: Session, game_id: str) -> GameOut:
game = repository.get_game(db, game_id)

if game is None:
raise ValueError(f"Game {game_id} not found")

return GameOut.model_validate(game)

def fetch_all_games(
db: Session,
limit: int = 20,
offset: int = 0
) -> GameList:

games, total = repository.list_games(
db,
limit=limit,
offset=offset,
)

return GameList(
items=[
GameOut.model_validate(game)
for game in games
],
total=total,
limit=limit,
offset=offset,
)

def find_games(
db: Session,
q: str,
limit: int = 20,
offset: int = 0
) -> GameList:

games, total = repository.search_games(
db,
q=q,
limit=limit,
offset=offset,
)

return GameList(
items=[
GameOut.model_validate(game)
for game in games
],
total=total,
limit=limit,
offset=offset,
)
# Application layer — business logic.
#
# Calls repository functions and returns Pydantic schemas (not raw ORM objects).
Expand Down
6 changes: 6 additions & 0 deletions services/game-service/create_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from app.database import engine
from app.models import Base

Base.metadata.create_all(bind=engine)

print("Database tables created")
Binary file added services/game-service/games.db
Binary file not shown.
4 changes: 4 additions & 0 deletions services/game-service/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ uvicorn[standard]
sqlalchemy
alembic
pydantic
python-dotenv
aiosqlite
pytest
httpx
pydantic-settings
python-dotenv
aiosqlite
Expand Down
11 changes: 11 additions & 0 deletions services/game-service/tests/test_games.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)

def test_list_games():
response = client.get("/v1/games")

assert response.status_code == 200
assert "items" in response.json()
# Module 2 exercise — write your pytest tests here.
#
# Use FastAPI's TestClient to test your endpoints without a running server.
Expand Down
Loading