API Development with Python: FastAPI, Pydantic, dan Service Boundaries
Part 026 — API Development with Python: FastAPI, Pydantic, dan Service Boundaries
Membahas pengembangan API Python dengan FastAPI dan Pydantic: HTTP semantics, schemas, validation boundary, dependency injection, service layer, error mapping, OpenAPI, testing, dan production-ready API design.
Part 026 — API Development with Python: FastAPI, Pydantic, dan Service Boundaries
1. Tujuan Part Ini
Banyak Python production system berbentuk API service.
FastAPI populer karena menggabungkan:
- Python type hints;
- Pydantic validation;
- Starlette/ASGI runtime;
- dependency injection;
- automatic OpenAPI docs;
- sync/async route support;
- ergonomi tinggi.
Tetapi API yang baik bukan sekadar route function.
API yang baik punya boundary jelas:
HTTP Request
-> Pydantic Request Schema
-> Application Service
-> Domain Model
-> Repository / Infrastructure
-> Pydantic Response Schema
-> HTTP Response
Kesalahan umum:
- domain model menjadi Pydantic model tanpa batas;
- route handler berisi business logic;
- HTTPException dilempar dari domain layer;
- database session bocor ke domain;
- async route memanggil blocking dependency tanpa sadar;
- error mapping tidak konsisten;
- response schema tidak stabil;
- status code salah;
- validation dianggap sama dengan authorization;
- OpenAPI dianggap dokumentasi cukup tanpa semantic design;
- test hanya happy path;
- dependency injection terlalu magic;
- service boundary tidak ada.
Part ini membahas API development dengan fokus software engineering.
Target setelah part ini:
- Memahami HTTP/API boundary.
- Memahami FastAPI app dan route.
- Memahami Pydantic model sebagai boundary schema.
- Membedakan schema model dan domain model.
- Memetakan request ke service command.
- Memetakan domain result ke response.
- Memakai dependency injection dengan
Depends. - Mendesain error mapping.
- Memahami sync/async route decision.
- Menulis API tests.
- Mendesain
case-trackerAPI version awal. - Menghindari framework leakage ke domain.
2. HTTP Mental Model
API HTTP punya konsep dasar:
| Concept | Contoh |
|---|---|
| Method | GET, POST, PATCH, DELETE |
| Path | /cases/{case_id} |
| Query | ?status=DRAFT |
| Header | Authorization, Content-Type |
| Body | JSON request payload |
| Status code | 200, 201, 400, 404, 409, 500 |
| Response body | JSON response |
| Idempotency | Aman diulang atau tidak |
| Cacheability | Bisa cache atau tidak |
Common method semantics:
| Method | Use |
|---|---|
GET | Read |
POST | Create/action non-idempotent |
PUT | Replace full resource |
PATCH | Partial update/transition |
DELETE | Delete/remove |
Status code examples:
| Code | Meaning |
|---|---|
| 200 | OK |
| 201 | Created |
| 204 | No Content |
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 409 | Conflict |
| 422 | Validation error often used by FastAPI/Pydantic |
| 500 | Internal Server Error |
3. FastAPI Minimal App
from fastapi import FastAPI
app = FastAPI(title="Case Tracker API")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
Run with ASGI server such as uvicorn:
uvicorn case_tracker_api.main:app --reload
FastAPI automatically exposes interactive docs by default:
/docs
/redoc
/openapi.json
Docs are useful, but they do not replace API design discipline.
4. Project Structure
For API evolution:
src/
case_tracker/
domain.py
service.py
repository.py
storage.py
case_tracker_api/
__init__.py
main.py
schemas.py
dependencies.py
error_handlers.py
routes/
__init__.py
cases.py
tests/
test_api_cases.py
Why separate case_tracker_api?
- domain/application package remains framework-agnostic;
- API boundary can import service/domain;
- domain does not import FastAPI;
- future CLI and API can share service layer.
Import direction:
Forbidden:
domain -> fastapi
service -> fastapi
repository -> fastapi
5. Pydantic as Boundary Schema
Pydantic models validate external data.
from pydantic import BaseModel, Field
class CreateCaseRequest(BaseModel):
title: str = Field(min_length=1, max_length=200)
Response:
class CaseResponse(BaseModel):
id: str
title: str
status: str
notes: list[str]
Route:
@app.post("/cases", response_model=CaseResponse, status_code=201)
def create_case(request: CreateCaseRequest) -> CaseResponse:
...
Pydantic validates request body and serializes response.
Important:
Pydantic model is API boundary schema, not automatically your domain model.
6. Domain Model vs API Schema
Domain:
@dataclass
class Case:
id: CaseId
title: str
status: CaseStatus
notes: list[str]
API request:
class CreateCaseRequest(BaseModel):
title: str
API response:
class CaseResponse(BaseModel):
id: str
title: str
status: str
notes: list[str]
Mapping:
def case_to_response(case: Case) -> CaseResponse:
return CaseResponse(
id=case.id.value,
title=case.title,
status=case.status.value,
notes=list(case.notes),
)
Why mapping is good:
- API schema can evolve separately;
- domain can use value objects/enums;
- response can hide internal fields;
- request can have API-specific validation;
- framework does not leak into domain.
7. Pydantic Validation Is Boundary Validation
Pydantic validates shape/type constraints.
Example:
class TransitionCaseRequest(BaseModel):
target_status: str
But domain still validates business rule:
case.transition_to(CaseStatus(request.target_status))
Invalid enum string might be boundary validation if schema uses enum.
Better:
from enum import Enum
class CaseStatusSchema(str, Enum):
DRAFT = "DRAFT"
SUBMITTED = "SUBMITTED"
UNDER_REVIEW = "UNDER_REVIEW"
ESCALATED = "ESCALATED"
CLOSED = "CLOSED"
class TransitionCaseRequest(BaseModel):
target_status: CaseStatusSchema
Then map:
target_status = CaseStatus(request.target_status.value)
Pydantic validation does not replace domain invariant.
8. Pydantic v2 Essentials
Pydantic v2 commonly uses:
BaseModel;Field;model_validate;model_dump;ConfigDict;- validators.
Example:
class CaseResponse(BaseModel):
id: str
title: str
status: str
data = {"id": "CASE-001", "title": "Late reporting", "status": "DRAFT"}
model = CaseResponse.model_validate(data)
payload = model.model_dump()
model_validate validates input into model.
model_dump converts model to dict.
Do not assume static type hints alone validate runtime data; Pydantic performs runtime validation for model creation.
9. Request Command Object
Route should not contain business logic.
Request schema:
class CreateCaseRequest(BaseModel):
title: str = Field(min_length=1, max_length=200)
Application command:
@dataclass(frozen=True)
class CreateCaseCommand:
title: str
Route mapping:
command = CreateCaseCommand(title=request.title)
case = service.create_case(command)
For small apps, passing title directly is okay. Command object becomes useful when use case input grows:
- actor;
- priority;
- source;
- idempotency key;
- metadata;
- request id.
10. Service Boundary
Service should be framework-agnostic.
class CaseService:
def __init__(self, repository: CaseRepository) -> None:
self._repository = repository
def create_case(self, title: str) -> Case:
cases = self._repository.list()
case = create_case(title)
cases.append(case)
self._repository.save_all(cases)
return case
def get_case(self, case_id: CaseId) -> Case:
cases = self._repository.list()
return get_case_from_list(cases, case_id)
No Request, no HTTPException, no Depends.
This service can be used by:
- CLI;
- FastAPI;
- worker;
- tests;
- future gRPC/message handler.
11. FastAPI Dependency Injection
FastAPI provides dependency injection via Depends.
Repository dependency:
from pathlib import Path
from fastapi import Depends
def get_repository() -> CaseRepository:
return JsonCaseRepository(Path("cases.json"))
def get_case_service(
repository: CaseRepository = Depends(get_repository),
) -> CaseService:
return CaseService(repository)
Route:
@app.post("/cases", response_model=CaseResponse, status_code=201)
def create_case_endpoint(
request: CreateCaseRequest,
service: CaseService = Depends(get_case_service),
) -> CaseResponse:
case = service.create_case(request.title)
return case_to_response(case)
Dependency functions are boundary wiring.
12. Dependency Scope and Lifecycle
For simple JSON repository, dependency creation is cheap.
For database sessions, lifecycle matters:
def get_session():
with Session(engine) as session:
yield session
FastAPI supports dependencies with yield for cleanup.
Concept:
Do not create expensive global mutable dependencies casually. Be explicit about lifecycle.
13. Configuration Dependency
Avoid hardcoded path:
def get_repository() -> CaseRepository:
return JsonCaseRepository(Path("cases.json"))
Better:
@dataclass(frozen=True)
class ApiConfig:
store_path: Path
def get_config() -> ApiConfig:
return ApiConfig(store_path=Path(os.environ.get("CASE_TRACKER_STORE", "cases.json")))
def get_repository(config: ApiConfig = Depends(get_config)) -> CaseRepository:
return JsonCaseRepository(config.store_path)
For production, config loading should be centralized and tested.
14. Error Mapping
Domain/application exceptions must be mapped to HTTP.
Service may raise:
CaseNotFoundError;InvalidCaseTransitionError;CaseStoreCorruptedError;ValueErrorfor invalid domain value.
API layer maps:
| Exception | HTTP |
|---|---|
CaseNotFoundError | 404 |
InvalidCaseTransitionError | 409 |
| Invalid request schema | 422 by FastAPI |
| Invalid domain input | 400 |
| Store unavailable/corrupt | 500 or 503 depending semantics |
| Unexpected exception | 500 |
Simple route local handling:
from fastapi import HTTPException
try:
case = service.get_case(CaseId(case_id))
except CaseNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
Better centralized exception handlers for consistency.
15. Exception Handlers
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(CaseNotFoundError)
async def case_not_found_handler(request: Request, error: CaseNotFoundError) -> JSONResponse:
return JSONResponse(
status_code=404,
content={
"error": "case_not_found",
"message": str(error),
},
)
For invalid transition:
@app.exception_handler(InvalidCaseTransitionError)
async def invalid_transition_handler(
request: Request,
error: InvalidCaseTransitionError,
) -> JSONResponse:
return JSONResponse(
status_code=409,
content={
"error": "invalid_case_transition",
"message": str(error),
"from_status": error.from_status.value,
"to_status": error.to_status.value,
},
)
Benefits:
- consistent errors;
- routes stay clean;
- mapping centralized.
16. Error Response Schema
Define consistent error shape:
class ErrorResponse(BaseModel):
error: str
message: str
details: dict[str, str] = {}
Example:
{
"error": "case_not_found",
"message": "Case not found: CASE-001",
"details": {}
}
Consistent errors help clients.
Avoid leaking internal stack traces in response.
Logs can include internal diagnostics.
17. Route Design for Case Tracker
Resource endpoints:
GET /health
POST /cases
GET /cases
GET /cases/{case_id}
POST /cases/{case_id}/notes
PATCH /cases/{case_id}/status
Why PATCH /cases/{case_id}/status?
Status transition is partial update of case state.
Alternative:
POST /cases/{case_id}/transitions
This can be better if transition is a domain action with audit/reason.
For learning:
PATCH /cases/{case_id}/status
is simple.
18. Schemas
class CreateCaseRequest(BaseModel):
title: str = Field(min_length=1, max_length=200)
class AddNoteRequest(BaseModel):
note: str = Field(min_length=1, max_length=2000)
class TransitionCaseRequest(BaseModel):
target_status: CaseStatusSchema
class CaseResponse(BaseModel):
id: str
title: str
status: CaseStatusSchema
notes: list[str]
If response uses enum schema:
status=CaseStatusSchema(case.status.value)
19. Case Mapper
def case_to_response(case: Case) -> CaseResponse:
return CaseResponse(
id=case.id.value,
title=case.title,
status=CaseStatusSchema(case.status.value),
notes=list(case.notes),
)
List response:
class CaseListResponse(BaseModel):
cases: list[CaseResponse]
Why envelope list?
{
"cases": [...]
}
Benefits:
- metadata later;
- pagination fields;
- total count;
- links.
20. Pagination
Never assume list endpoint can return everything forever.
Query parameters:
@app.get("/cases", response_model=CaseListResponse)
def list_cases(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
service: CaseService = Depends(get_case_service),
) -> CaseListResponse:
cases = service.list_cases()
page = cases[offset : offset + limit]
return CaseListResponse(cases=[case_to_response(case) for case in page])
For JSON file, slicing in memory is okay. For database, pagination should happen in query.
Response could include:
class CaseListResponse(BaseModel):
cases: list[CaseResponse]
limit: int
offset: int
total: int
21. Filtering
Query:
@app.get("/cases")
def list_cases(status: CaseStatusSchema | None = None):
...
Map:
domain_status = CaseStatus(status.value) if status is not None else None
Service:
def list_cases(self, status: CaseStatus | None = None) -> list[Case]:
cases = self._repository.list()
if status is None:
return cases
return [case for case in cases if case.status is status]
Keep filtering rule in service/repository, not duplicated in route.
22. Status Codes
Create:
@app.post("/cases", status_code=201)
Get:
@app.get("/cases/{case_id}", status_code=200)
No content:
@app.delete("/cases/{case_id}", status_code=204)
Invalid transition conflict:
409 Conflict
Why 409? The request may be syntactically valid, but conflicts with current resource state.
Validation failure from Pydantic often returns 422. Some APIs prefer 400; know your API standard.
23. Sync vs Async Routes
FastAPI supports both:
@app.get("/sync")
def sync_route():
...
@app.get("/async")
async def async_route():
...
Use sync route when dependencies are blocking sync:
- file I/O with standard library;
- sync database driver;
- CPU-light service;
- legacy sync code.
Use async route when you await async dependencies:
- async HTTP client;
- async database driver;
- async message broker.
Do not write async def and then call blocking heavy code directly.
If you need to call blocking code from async route, consider threadpool/offload, but design carefully.
For current JSON file case-tracker, sync routes are fine.
24. ASGI Mental Model
FastAPI runs on ASGI server.
Conceptual stack:
ASGI enables async interface between server and app.
You do not need to deeply implement ASGI early, but understand the server/application boundary.
25. OpenAPI
FastAPI generates OpenAPI schema from routes, type hints, Pydantic models, and metadata.
Benefits:
- interactive docs;
- client generation;
- API discoverability;
- contract review;
- integration with tooling.
But generated OpenAPI is only as good as:
- schema names;
- descriptions;
- response models;
- status codes;
- error documentation;
- route design.
Add metadata:
app = FastAPI(
title="Case Tracker API",
version="0.1.0",
description="API for managing regulatory case lifecycle.",
)
26. Tags and Descriptions
@app.post(
"/cases",
response_model=CaseResponse,
status_code=201,
tags=["cases"],
summary="Create a case",
)
def create_case_endpoint(...):
...
Use route metadata to improve docs.
Avoid generated docs that are technically present but semantically poor.
27. Request IDs and Logging
API should log request context.
Middleware can add request id.
Simple conceptual middleware:
from uuid import uuid4
from fastapi import Request
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid4()))
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
For production, integrate with logging context. Keep sensitive data out of logs.
28. Authentication and Authorization Preview
Authentication:
Who are you?
Authorization:
Are you allowed to do this?
Do not confuse with validation.
Example future:
def get_current_user(...) -> User:
...
Route:
def create_case_endpoint(
request: CreateCaseRequest,
current_user: User = Depends(get_current_user),
service: CaseService = Depends(get_case_service),
):
...
Authorization belongs in service/policy layer, not just route decorator, if it affects domain/application behavior.
Detailed security is covered later.
29. Testing API with TestClient
FastAPI provides testing via TestClient.
from fastapi.testclient import TestClient
from case_tracker_api.main import app
client = TestClient(app)
def test_health() -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
Create case:
def test_create_case() -> None:
response = client.post("/cases", json={"title": "Late reporting"})
assert response.status_code == 201
body = response.json()
assert body["title"] == "Late reporting"
assert body["status"] == "DRAFT"
For isolated tests, override dependencies.
30. Dependency Overrides in Tests
FastAPI supports dependency overrides.
def get_test_service() -> CaseService:
return CaseService(FakeCaseRepository())
app.dependency_overrides[get_case_service] = get_test_service
Test:
def test_create_case_with_fake_repository() -> None:
app.dependency_overrides[get_case_service] = lambda: CaseService(FakeCaseRepository())
try:
client = TestClient(app)
response = client.post("/cases", json={"title": "Late reporting"})
assert response.status_code == 201
finally:
app.dependency_overrides.clear()
Better: create app factory.
31. App Factory
Instead of global app only:
def create_app() -> FastAPI:
app = FastAPI(title="Case Tracker API")
register_routes(app)
register_exception_handlers(app)
return app
app = create_app()
For tests:
def create_test_app(repository: CaseRepository) -> FastAPI:
app = create_app()
app.dependency_overrides[get_repository] = lambda: repository
return app
App factory improves testability and configuration.
32. API Tests to Write
Minimum:
- health returns ok;
- create case returns 201;
- create case rejects empty title;
- list cases returns envelope;
- get existing case returns 200;
- get missing case returns 404;
- transition valid returns updated status;
- transition invalid returns 409;
- add note returns updated case;
- invalid status returns validation error;
- pagination params validated;
- dependency override uses fake repository.
33. Avoid Testing FastAPI Internals
Do not test that Pydantic itself validates string length in detail. Test your boundary contract.
Good:
def test_create_case_rejects_empty_title() -> None:
response = client.post("/cases", json={"title": ""})
assert response.status_code in {400, 422}
Better if API standard fixed:
assert response.status_code == 422
Do not assert entire validation response unless client depends on it.
34. Route Handler Thinness
Bad:
@app.post("/cases/{case_id}/status")
def transition(...):
cases = load_cases(Path("cases.json"))
for case in cases:
...
save_cases(...)
Good:
@app.patch("/cases/{case_id}/status")
def transition_case_endpoint(
case_id: str,
request: TransitionCaseRequest,
service: CaseService = Depends(get_case_service),
) -> CaseResponse:
case = service.transition_case(
CaseId(case_id),
CaseStatus(request.target_status.value),
)
return case_to_response(case)
Route does:
- parse boundary input;
- call service;
- map response.
No business loop.
35. API Versioning
Start simple:
/api/v1/cases
or unversioned during internal learning:
/cases
Versioning matters when external clients depend on API.
Strategies:
- path version:
/v1/cases; - header version;
- media type version;
- no explicit version but compatibility policy.
For public API, define versioning early.
For internal learning, do not overcomplicate.
36. Idempotency
Create endpoint POST /cases is usually non-idempotent: repeated request creates multiple cases.
If clients retry due to timeout, duplicates can happen.
Add idempotency key later:
Idempotency-Key: abc123
Service stores key/result.
For transition:
PATCH /cases/{id}/status
Can be idempotent if setting same status is defined as no-op, or non-idempotent if transition event must be unique. Decide.
In regulatory systems, idempotency and audit semantics matter.
37. Validation vs Business Rule vs Authorization
Example request:
{
"target_status": "CLOSED"
}
Validation:
- is field present?
- is it allowed enum value?
Business rule:
- can case transition from current status to CLOSED?
Authorization:
- can this user close this case?
These are different layers.
Do not collapse all into “validation”.
38. API Boundary Security Basics
Even before full security part:
- validate input;
- limit body size at server/proxy;
- avoid logging sensitive body;
- avoid exposing stack traces;
- use HTTPS in deployment;
- set correct CORS policy;
- do not trust client-provided actor without auth;
- map errors safely;
- keep dependencies updated;
- avoid debug mode in production.
39. Deployment Preview
FastAPI app is ASGI. Common production stack:
Uvicorn/Gunicorn/Uvicorn workers behind reverse proxy
For learning:
uvicorn case_tracker_api.main:app --reload
For production:
- no
--reload; - config via environment;
- structured logs;
- health endpoint;
- graceful shutdown;
- process manager/container;
- dependency timeouts;
- database connection lifecycle;
- metrics/tracing.
Detailed operations later.
40. Case Tracker API v1 Sketch
schemas.py:
from enum import Enum
from pydantic import BaseModel, Field
class CaseStatusSchema(str, Enum):
DRAFT = "DRAFT"
SUBMITTED = "SUBMITTED"
UNDER_REVIEW = "UNDER_REVIEW"
ESCALATED = "ESCALATED"
CLOSED = "CLOSED"
class CreateCaseRequest(BaseModel):
title: str = Field(min_length=1, max_length=200)
class TransitionCaseRequest(BaseModel):
target_status: CaseStatusSchema
class AddNoteRequest(BaseModel):
note: str = Field(min_length=1, max_length=2000)
class CaseResponse(BaseModel):
id: str
title: str
status: CaseStatusSchema
notes: list[str]
class CaseListResponse(BaseModel):
cases: list[CaseResponse]
limit: int
offset: int
total: int
41. Case Tracker API Routes Sketch
from fastapi import APIRouter, Depends, Query
router = APIRouter(prefix="/cases", tags=["cases"])
@router.post("", response_model=CaseResponse, status_code=201)
def create_case_endpoint(
request: CreateCaseRequest,
service: CaseService = Depends(get_case_service),
) -> CaseResponse:
case = service.create_case(request.title)
return case_to_response(case)
@router.get("", response_model=CaseListResponse)
def list_cases_endpoint(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
service: CaseService = Depends(get_case_service),
) -> CaseListResponse:
cases = service.list_cases()
page = cases[offset : offset + limit]
return CaseListResponse(
cases=[case_to_response(case) for case in page],
limit=limit,
offset=offset,
total=len(cases),
)
42. Case Tracker API Error Handler Sketch
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(CaseNotFoundError)
async def case_not_found_handler(request: Request, error: CaseNotFoundError) -> JSONResponse:
return JSONResponse(
status_code=404,
content={"error": "case_not_found", "message": str(error), "details": {}},
)
@app.exception_handler(InvalidCaseTransitionError)
async def invalid_transition_handler(
request: Request,
error: InvalidCaseTransitionError,
) -> JSONResponse:
return JSONResponse(
status_code=409,
content={
"error": "invalid_case_transition",
"message": str(error),
"details": {
"from_status": error.from_status.value,
"to_status": error.to_status.value,
},
},
)
43. Case Tracker API Test Sketch
from fastapi.testclient import TestClient
def test_create_case_returns_created_case() -> None:
repository = FakeCaseRepository()
app = create_app()
app.dependency_overrides[get_repository] = lambda: repository
client = TestClient(app)
response = client.post("/cases", json={"title": "Late reporting"})
assert response.status_code == 201
body = response.json()
assert body["title"] == "Late reporting"
assert body["status"] == "DRAFT"
Clear overrides after test if using shared app. App factory avoids shared override leakage.
44. API Design Smell Checklist
Watch for:
- Route handler contains business logic.
- Domain imports FastAPI.
- Service raises
HTTPException. - Pydantic model used as mutable domain entity everywhere.
- No response model.
- No error schema.
- Inconsistent status codes.
- No pagination on list endpoint.
- Blocking I/O inside async route.
- Unbounded request body assumptions.
- Logs include full request body.
- Dependency override leaks between tests.
- OpenAPI docs generated but semantically poor.
- Validation, business rule, and authorization mixed.
- Tests only happy path.
45. Practice: Build Minimal API
Create package:
src/case_tracker_api/
__init__.py
main.py
schemas.py
dependencies.py
routes/
__init__.py
cases.py
Implement:
GET /health;POST /cases;GET /cases;- fake repository for tests.
Run:
uvicorn case_tracker_api.main:app --reload
Open docs.
46. Practice: Add Error Handlers
Add handlers for:
CaseNotFoundError;InvalidCaseTransitionError.
Test:
- missing case returns 404;
- invalid transition returns 409;
- response has
errorandmessage.
47. Practice: Add Pagination
Add query params:
limit: int = Query(default=100, ge=1, le=1000)
offset: int = Query(default=0, ge=0)
Test:
- default values;
- limit works;
- offset works;
- invalid negative offset returns validation error.
48. Practice: Dependency Override
Use fake repository in tests.
Questions:
- Why not use real JSON file for every API test?
- Which tests should use real repository?
- How to avoid override leakage?
- Why app factory helps?
- What does this imply for production config?
49. Practice: Prevent Framework Leakage
Search:
grep -R "fastapi" src/case_tracker
Expected:
no results
Only case_tracker_api should import FastAPI.
If domain/service imports FastAPI, refactor.
50. Self-Check
Jawab tanpa melihat materi:
- Apa peran FastAPI?
- Apa peran Pydantic?
- Apa itu ASGI?
- Kenapa schema model berbeda dari domain model?
- Apa itu service boundary?
- Apa fungsi
Depends? - Kenapa route handler harus tipis?
- Kenapa domain tidak boleh raise
HTTPException? - Bagaimana memetakan
CaseNotFoundError? - Status code apa untuk created resource?
- Status code apa untuk missing case?
- Status code apa untuk invalid state transition?
- Apa beda validation, business rule, authorization?
- Kapan route harus sync?
- Kapan route harus async?
- Kenapa list endpoint perlu pagination?
- Apa manfaat response envelope?
- Apa fungsi OpenAPI?
- Bagaimana dependency override untuk test?
- Apa API smell paling berbahaya?
51. Definition of Done Part 026
Kamu selesai part ini jika bisa:
- Membuat FastAPI app minimal.
- Membuat Pydantic request/response schema.
- Memetakan domain object ke response schema.
- Menjaga domain/service bebas FastAPI.
- Membuat service dependency dengan
Depends. - Membuat repository dependency.
- Membuat route
POST /cases. - Membuat route
GET /cases. - Membuat route
GET /cases/{id}. - Membuat route transition status.
- Menambahkan exception handlers.
- Menambahkan pagination.
- Menulis API tests dengan TestClient.
- Menggunakan dependency overrides.
- Menjelaskan sync vs async route decision.
52. Ringkasan
FastAPI memberi API boundary yang produktif, tetapi desain tetap tanggung jawab engineer.
Inti part ini:
- HTTP punya semantics method, status code, path, body, headers;
- FastAPI route handler harus tipis;
- Pydantic schema adalah boundary model;
- domain model tetap framework-agnostic;
- service layer berisi use case orchestration;
- dependency injection wiring dilakukan di API boundary;
- error mapping harus konsisten;
- OpenAPI berguna jika schema/status/metadata didesain baik;
- sync route cocok untuk sync/blocking dependencies;
- async route cocok jika dependency benar-benar async;
- pagination harus dipikirkan sejak list endpoint;
- validation, business rule, dan authorization adalah layer berbeda;
- tests harus memakai dependency overrides dan app factory agar isolated;
- framework tidak boleh bocor ke domain.
Part berikutnya akan membahas data access: SQLAlchemy, transactions, repository, dan unit of work.
53. Referensi
- FastAPI Documentation — FastAPI.
- FastAPI Documentation — Dependencies.
- FastAPI Documentation — Path Operation Configuration.
- FastAPI Documentation — Testing.
- Pydantic Documentation — Models.
- Pydantic Documentation — BaseModel.
- Python Documentation —
enum. - Python Documentation —
dataclasses.
You just completed lesson 26 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.