Skip to content

Skeleton FastAPI: Kontrak Lapis, Bukan Sekadar Folder Kosong

Adityo Guni Waluyo

Empat folder berisi init.py kosong ternyata memegang kontrak lapisan, unit of work per request, dan titik integrasi multi-tenant yang menjaga skeleton tetap sehat.

Ringkasan

Penulis bikin empat folder kosong buat skeleton backend, kelihatannya remeh tapi tiap lapisan punya kontrak satu kalimat biar logika bisnis nggak nyampur di route. Commit database ditaruh di tepi request pake dependency yield, jadi gagal di tengah otomatis rollback. Multi-tenancy diatur lewat JWT, bcrypt dipagari 72 byte, plus NoopSource siap disambung nanti.

Malam bootstrap DemandScope saya habiskan untuk membuat empat folder: app/domain, app/application, app/infrastructure, app/api. Isinya? __init__.py kosong semua. Dari luar, ini kelihatan seperti pekerjaan paling percuma semalam. Padahal justru itu bagian termahalnya: kontrak yang dijanjikan tiap folder.

Dugaan yang meleset

Dugaan pertama saya: ini cuma template folder, tinggal jalankan uvicorn dan kode mengalir. Yang saya lewatkan: tanpa kalimat kontrak per lapisan, logika bisnis merembes ke route, query bermunculan di tempat yang salah, dan aturan auth nyebar ke mana-mana. Jadi setiap lapisan di sini diikat satu kalimat. Domain menyimpan bentuk data dan kontrak: entity frozen dataclass plus SignalSource, interface abstrak sumber sinyal. Application menampung use case, contoh konkretnya AuthService. Infrastructure mengurus detail teknis: ORM, repository, bcrypt, JWT, sampai NoopSource sebagai placeholder sumber sinyal. Api murni penerjemah HTTP dengan validasi Pydantic di gerbangnya. Skema database tidak ikut campur di keempatnya; itu urusan Alembic yang punya skrip migrasi tersendiri [16].

Unit of work per request

Sisi yang membuat kontrak ini berguna ada di dependency injection-nya FastAPI, yang memang dirancang untuk berbagi logika, koneksi database, dan aturan auth [9]. Pola yang saya pakai: dependency dengan yield. Fungsi get_db di app/core/di.py membuka session, route berjalan, dan commit hanya terjadi kalau route sukses sampai akhir.

def get_db() -> Iterator[Session]:
    db = SessionLocal()
    try:
        yield db
        db.commit()
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()

Exception apa pun yang dilempar saat menggunakan dependency sampai ke blok try di dalamnya [10], dan di situ rollback dieksekusi; Session.rollback membatalkan transaksi yang sedang berjalan [15]. Efek praktisnya terasa di AuthService.register: tenant dan user owner dibuat dalam satu transaksi yang sama. Gagal di tengah jalan, tidak ada tenant yatim tanpa owner yang tertinggal.

Kenapa tidak commit di tiap repository saja? Karena batas transaksi harus jadi milik satu tempat. Kalau tiap repository commit sendiri, dua tabel berbeda dalam satu use case tidak mungkin atomik. Dipindahkannya commit ke tepi request membuat use case bebas menyusun beberapa perubahan sekaligus tanpa mikir kapan data balik ke database.

Titik-titik kecil multi-tenant

Multi-tenancy di skeleton ini masuk lewat titik-titik kecil, bukan framework besar. Pertama, identitas tenant ikut di JWT: claims sub, tenant_id, role, dienkode HS256 sesuai penggunaan dasar PyJWT [11]. Setiap request berikutnya langsung tahu tenant-nya tanpa query tambahan.

Kedua, batas bcrypt dipagari di gerbang. Bcrypt hanya menangani password sampai 72 byte, dan sejak bcrypt 5.0 hashpw melempar ValueError untuk password yang lebih panjang; versi lama diam-diam memotongnya [13]. Pyca sendiri menyebut pustakanya acceptable dengan menunjuk argon2id dan scrypt sebagai alternatif [12], dan saya catat itu sebagai evaluasi ke depan, bukan keputusan malam ini. Di RegisterRequest batas itu ditulis eksplisit:

class RegisterRequest(BaseModel):
    company_name: str = Field(min_length=1, max_length=200)
    email: str = Field(min_length=3, max_length=320, pattern=_EMAIL_PATTERN)
    # bcrypt beroperasi maksimal 72 byte; pagari di boundary.
    password: str = Field(min_length=8, max_length=72)

Pelanggaran batas menghasilkan 422 yang bisa dibaca klien, bukan ValueError yang muncul terlambat di dalam fungsi hash. Kesalahan lain juga punya kode: email sudah terdaftar dijawab 409, kredensial salah dijawab 401. Slug tenant yang bentrok otomatis dapat sufiks uuid supaya pendaftaran kedua tidak meledak.

Ketiga, ingest seam: SignalSource berupa ABC plus NoopSource yang mengembalikan list kosong. Besok kalau sumber sinyal sungguhan ada, yang berubah tinggal satu binding di DI, bukan rewrite aplikasi.

Filter per tenant di query adalah langkah pertama yang wajar. Kalau suatu saat butuh pemaksaan di level database, PostgreSQL punya Row-Level Security: default-nya tabel tanpa policy sehingga semua baris tersedia bagi yang punya akses [14], artinya policy per tenant memang harus dibuat sadar, bukan disangka datang bawaan. Skeleton ini bukan arsitektur heksagonal kanonik dan tidak berpretensi jadi; yang menjaga dia tetap sehat adalah kontrak satu kalimat per lapisan. Folder-folder kosong tadi akhirnya jadi tempat semua keputusan itu tinggal.

Sumber

  1. [9] Dependencies (FastAPI)
  2. [10] Dependencies with yield (FastAPI)
  3. [11] PyJWT usage examples
  4. [12] pyca/bcrypt
  5. [13] bcrypt README: maximum password length
  6. [14] PostgreSQL row security policies
  7. [15] SQLAlchemy Session basics
  8. [16] Alembic tutorial

Artikel terkait