Featured image of post Desain Model Django untuk API

Desain Model Django untuk API

Memahami cara mendesain model Django yang siap untuk API, supaya data yang dihasilkan lebih konsisten, mudah digunakan, dan siap dikonsumsi oleh frontend.

Desain Model Django untuk API

Gambaran Umum

Sebelumnya kita sudah membuat model Book dengan pendekatan yang natural dari perspektif database. Pada development API, kita perlu sedikit merubah mindset: setiap model yang kita desain, pada akhirnya akan di-serialize menjadi JSON dan dikonsumsi / digunakan oleh frontend, mobile app, atau third-party service. Ini akan merubah keputusan kita mengenai field yang digunakan, cara penamaannya, dan metadata apa yang perlu ada.

Kali ini, kita akan membuat app baru bernama articles untuk dapat memahaminya. Apps tentang artikel ini cocok karena mencakup banyak konsep penting: ownership, timestamps, status workflow, dan slug untuk URL.


Membuat App articles

1
2
3
cd my-drf-project
source .venv/bin/activate
python manage.py startapp articles

Lalu register di config/settings.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
INSTALLED_APPS = [
    # ... Django built-ins ...

    # Third-party
    'rest_framework',

    # Your apps
    'books',
    'articles',
]

User Relationship (ForeignKey)

Relasi paling umum di API adalah: “data ini milik siapa?” Dalam kasus kita: Article memiliki author, dan author adalah User.

Django sudah menyediakan model User atau (django.contrib.auth.models.User). Kita tidak perlu membuatnya lagi, dan hanya perlu mereferensi model ini.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.conf import settings
from django.db import models

class Article(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='articles',
    )
    title = models.CharField(max_length=200)

Ada 3 hal yang perlu diperhatikan:

Kenapa menggunakan settings.AUTH_USER_MODEL dibandingkan import User secara langsung?

Kita mungkin pernah melihat kode seperti ini pada tutorial:

1
2
3
4
from django.contrib.auth.models import User

class Article(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE)

Ini dapat berfungsi, tetapi cara tersebut meng-hardcode ke model User. Kalau nanti kita mau merubah menggunakan model custom untuk user,, setiap ForeignKey yang diimport dari User akan menjadi bermasalah. settings.AUTH_USER_MODEL adalah string reference (yang secara default adalah "auth.User") yang ditentukan / diproses oleh Django pada saat runtime. Menggunakan settings.AUTH_USER_MODEL adalah cara yang paling direkomendasikan.

Penting diingat: Selalu gunakan settings.AUTH_USER_MODEL untuk ForeignKey/OneToOne ke User. Jangan langsung import model User secara langsung di models.py.

Apa artinya on_delete=models.CASCADE?

Ini menjawab Apa yang terjadi pada artikel jika akun user author dihapus?

OptionBehaviourKapan Digunakan
CASCADEArtikel ikut dihapusArtikel tidak masuk akal jika tidak ada author
PROTECTTidak dapat dihapus — menimbulkan errorData perlu selalu ada (contoh: invoices)
SET_NULLSet author jadi NULL (perlu null=True)Simpan artikel, tapi ditandai “author sudah dihapus”
SET_DEFAULTSet ke value defaultReassign ke akun “deleted user”
DO_NOTHINGTidak terjadi apa-apa (berbahaya — dapat membuat DB bermasalah)Hampir tidak pernah

Untuk artikel, CASCADE adalah pilihan yang tepat: jika akun user-nya dihapus, artikel-artikelnya juga mengikuti author-nya. Untuk sesuatu yang bersifat seperti Order di sistem e-commerce, kita akan menggunakan PROTECT karena kita tidak mau hapus order history secara tidak sengaja.

related_name='articles mendefinisikan reverse relationship, yaitu cara kita akses artikel-artikel seorang author dari sisi User

1
2
user = User.objects.get(pk=1)
user.articles.all()  # return semua object Article milik user ini

Tanpa related_name, Django auto-generate: article_set. Itu dapat digunakan tapi kodenya kurang enak dibaca dan nantinya akan menjadi lebih parah di serializer. Saat kita mau serialize User dan mau include artikel-artikelnya, "articles" itu yang API consumer ekspektasikan, bukan “article_set”.

Penting Diingat: Selalu set related_name secara eksplisit. Beri nama menggunakan bentuk plural dari model-nya, itu yang menjadi ekspektasi dari API consumer.


Field yang Masuk Akal untuk API Consumer

Saat membuat design model untuk API, selalu tanyakan ke diri sendiri: “Apa yang client perlukan untuk menampilkan dan berinteraksi dengan resource ini?”

Ini model Article model yang sudah dikembangkan:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Article(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='articles',
    )
    title = models.CharField(max_length=200)
    body = models.TextField()
    excerpt = models.CharField(max_length=300, blank=True)

    def __str__(self):
        return self.title

Sekarang mari kita bahas pilihan fieldnya:

CharField vs TextField

FieldTipe kolom DBConstraint
CharFieldVARCHAR(n)Butuh max_length, disimpan dengan alokasi batasan length yang sudah ditentukan
TextFieldTEXTTidak perlu ``max_length`, disimpan dengan batasan length variable

Gunakan CharField untuk yang secara umum ada batasan length (judul, nama, kode). Pakai TextField untuk data yang lebih freeform (isi article, komentar, deskripsi).

Kenapa ini penting untuk API: Serializer kita akan validasi berdasarkan constraint ini. Kalau client kirim judul 500 karakter dan max_length=200, DRF otomatis return 400 Bad Request. Dari definisi model kita mendapatkan validasi input secara otomatis.

blank=True vs null=True — Konsep Django yang Paling Sering Bikin Bingung

Kedua ini adalah dua hal yang berbeda:

ParameterScopeArtinya
blank=TrueValidasi form/serializer“Field ini boleh disubmit sebagai string kosong”
null=TrueLevel database“Kolom ini bisa menyimpan NULL”

Untuk string field (CharField, TextField), convention untuk Django adalah:

  • Hanya gunakan blank=True kalau field-nya optional
  • JANGAN tambahkan null=True untuk string field

Kenapa begitu? Karena nanti kita punya dua kemungkinan nilai “kosong” di database: NULL dan "" (empty string). Ini bikin ambigu waktu melakukan query. Konvensi Django itu pakai "" untuk “tidak ada value” di string field, dan NULL untuk field yang non-string.

1
2
3
4
5
6
7
8
# Benar — optional string field
excerpt = models.CharField(max_length=300, blank=True)

# Benar — optional date field
published_at = models.DateTimeField(null=True, blank=True)

# Salah — jangan pakai null=True di string field
excerpt = models.CharField(max_length=300, null=True, blank=True)  # jangan lakukan ini

Untuk non-string field (tanggal, integer, ForeignKey), kita gunakan null=True, blank=True jika kedua field-nya optional, karena tipe-tipe data ini tidak berguna menyimpan string kosong.


Timestamp dan Audit Field

Hampir setiap resource API perlu dua field: kapan dibuat dan kapan terakhir dimodifikasi. Field ini dibutuhkan untuk hampir semua case, sehingga sebaiknya kita anggap sebagai mandatory.

1
2
3
4
5
class Article(models.Model):
    # ... field lainnya ...

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

Cara kerja auto_now_add dan auto_now

ParameterKapan Nilainya di setBisa di-override manual?
auto_now_add=TrueSekali — pas object pertama kali dibuatTidak (diabaikan saat save())
auto_now=TrueSetiap kali save() dipanggilTidak (selalu di-overwrite)

Artinya:

  • created_at di-set sekali dan tidak pernah berubah
  • updated_at otomatis mengikuti modifikasi terakhir

Kenapa ini penting untuk API: Client menggunakan timestamp ini untuk caching, sorting, dan menampilkan data. Aplikasi mobile bisa jadi menampilkan “posted 3 hours ago” atau frontend mungkin melakukan sortir article berdasarkan update terbaru. Tanpa field-field ini, client tidak memili cara untuk mengetahui kapan data berubah.

Pattern Abstract Base Model

Karena created_at dan updated_at diperlukan di hampir setiap model, terus mengulangi hal yang sama akan mubazir. Pattern standar dari Django-nya adalah abstract base model:

1
2
3
4
5
6
class TimestampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

abstract = True artinya Django tidak akan membuat tabel database untuk TimestampedModel. Dia ada hanya untuk menjadi parent class yang digunakan sebagai inheritance. Model apapun yang inherit dari sini otomatis dapat kedua timestamp field:

1
2
3
4
class Article(TimestampedModel):
    # created_at dan updated_at di-inherit dan tidak perlu didefinisikan lagi
    title = models.CharField(max_length=200)
    body = models.TextField()

Dimana kita akan meletakkan abstract model-nya? Untuk project belajar seperti ini, kita bisa taruh di atas file articles/models.py. Di project sebenarnya dengan banyak app yang menggunakannya, kita akan meletakkannya di app common atau core dan didefinisikan di sana.


Choice Field untuk Data Status/State

Kebanyakan API resource punya semacam notion of state (konsep tentang kondisi/status suatu resource pada saat tertentu). Maksudnya, hampir semua resource di API itu punya semacam “kondisi” yang menggambarkan di tahap mana dia sekarang dalam lifecycle-nya.

Dalam konteks artikel, notion of statenya bisa berupa draft, published, atau archived. Task bisa pending, in progress, atau done. Order bisa placed, shipped, atau delivered.

Django menangani ini dengan choices:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Article(TimestampedModel):
    class Status(models.TextChoices):
        DRAFT = 'draft', 'Draft'
        PUBLISHED = 'published', 'Published'
        ARCHIVED = 'archived', 'Archived'

    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='articles',
    )
    title = models.CharField(max_length=200)
    body = models.TextField()
    excerpt = models.CharField(max_length=300, blank=True)
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

Penjelasan TextChoices

TextChoices adalah class enum khusus yang diperkenalkan di Django 3.0. Setiap member punya dua bagian:

1
2
3
DRAFT = 'draft', 'Draft'
#        ^^^^^    ^^^^^
#        value    label yang human-readable
  • Value ('draft') adalah yang disimpan di database dan yang muncul di JSON API kita
  • Label ('Draft') adalah yang muncul di Django admin dan form

Kenapa pakai TextChoices dan bukan tuple?

Style lama seperti ini:

1
2
3
4
5
STATUS_CHOICES = [
    ('draft', 'Draft'),
    ('published', 'Published'),
    ('archived', 'Archived'),
]

TextChoices lebih baik karena:

  1. Kita mendapatkan constant: Article.Status.DRAFT adalah named reference. Tidak ada lagi string yang rawan typo berserakan di codebase.
  2. Kita mendapatkan type safety: article.status = Article.Status.PUBLISHED itu self-documenting.
  3. Bisa di-iterasi: Article.Status.choices return list of tuple secara otomatis.
  4. Merupakan nested class: Membuat choices tetap scoped ke model-nya, tidak mengambang di module level.

Tampilannya di API

Nanti saat kita build serializer, response GET akan include:

1
2
3
4
5
{
    "id": 1,
    "title": "My First Article",
    "status": "draft"
}

Value ('draft') tersimpan itu yang diterima API consumer kita. Label ('Draft') itu untuk human interface. DRF juga secara otomatis memvalidasi value yang masuk. Jika client kirim "status": "banana", mereka akan mendapatkan response 400 Bad Request beserta daftar pilihan yang valid.

Querying menggunakan choices

1
2
3
Article.objects.filter(status=Article.Status.PUBLISHED)

Article.objects.exclude(status=Article.Status.ARCHIVED)

Tidak ada magic string. Kalau ninta kita rename value dari status, IDE kita bisa menemukan semua reference-nya.

IntegerChoices — alternatif numerik

Untuk kasus di mana value database-nya harus integer (priority level, ranking):

1
2
3
4
5
6
7
8
9
class Priority(models.IntegerChoices):
    LOW = 1, 'Low'
    MEDIUM = 2, 'Medium'
    HIGH = 3, 'High'

priority = models.IntegerField(
    choices=Priority.choices,
    default=Priority.MEDIUM,
)

Gunakan TextChoices kalau value-nya bermanfaat untuk dibaca di database atau API ("draft" masuk akal). Gunakan IntegerChoices kalau kita butuh numeric ordering atau value-nya memang angka.


Slug Field untuk Clean URL

Slug itu versi URL-friendly dari sebuah string. Daripada:

1
/articles/42/

Kita dapat:

1
/articles/my-first-django-article/

Ini penting untuk API karena:

  • SEO: Search engine lebih suka URL yang readable
  • Usability: User bisa baca dan share URL-nya
  • Stability: Slug bisa tetap stabil meskipun judul berubah (kalau kita pilih untuk tidak auto-update)

Menambahkan SlugField

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from django.utils.text import slugify

class Article(TimestampedModel):
    class Status(models.TextChoices):
        DRAFT = 'draft', 'Draft'
        PUBLISHED = 'published', 'Published'
        ARCHIVED = 'archived', 'Archived'

    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='articles',
    )
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    body = models.TextField()
    excerpt = models.CharField(max_length=300, blank=True)
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

    def __str__(self):
        return self.title

Apa sebenarnya SlugField

SlugField hanyalah CharField dengan built-in validator yang cuma memperbolehkan huruf, angka, hyphen, dan underscore. Di balik layar:

1
2
# SlugField pada dasarnya:
models.CharField(max_length=50, validators=[validate_slug])

max_length-nya secara default nilainya 50. Karena title kita perbolehkan 200 karakter, maka kita set slug-nya juga sama.

Kenapa unique=True di slug?

Jika kita mau menggunakan slug sebagai URL lookup (/articles/my-first-article/), slug-nya harus unique. Tanpa unique=True, dua article berjudul “My First Article” akan sama-sama mendapatkan slug my-first-article, dan URL routing kita tidak tahu yang mana yang harus di-return.

Override save() — auto-generate slug

1
2
3
4
def save(self, *args, **kwargs):
    if not self.slug:
        self.slug = slugify(self.title)
    super().save(*args, **kwargs)

slugify() convert "My First Article!" menjadi "my-first-article". Perlindungan if not self.slug berfungsi:

  • Saat creation: Jika tidak ada slug yang di-provide, generate dari title
  • Saat update: Jika slug-nya udah ada, maka diabaikan.

Hal tersebut dibuat sedemikian rupa. Begitu article sudah di-published dan orang-orang udah bookmark URL-nya, kita tidak mau ganti slug hanya karena seseorang edit judulnya. Broken link lebih buruk daripada slug yang sedikit outdated.

Handle slug collision

save() sederhana di atas punya kelemahan: jika dua article punya judul yang sama, yang kedua akan gagal dengan database IntegrityError (karena unique=True). Solusi yang lebih baik itu adalah dengan menambahkan sufiks/akhiran:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.utils.text import slugify

def save(self, *args, **kwargs):
    if not self.slug:
        base_slug = slugify(self.title)
        slug = base_slug
        counter = 1
        while Article.objects.filter(slug=slug).exists():
            slug = f"{base_slug}-{counter}"
            counter += 1
        self.slug = slug
    super().save(*args, **kwargs)

Ini akan menghasilkan: my-first-article, my-first-article-1, my-first-article-2, dst. Saat ini kita tidak butuh kerumitan ini untuk project belajar, tapi sebaiknya kita mengerti pola-nya.


Model Lengkap

Ini articles/models.py final yang semuanya sudah digabungkan:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from django.conf import settings
from django.db import models
from django.utils.text import slugify


class TimestampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class Article(TimestampedModel):
    class Status(models.TextChoices):
        DRAFT = 'draft', 'Draft'
        PUBLISHED = 'published', 'Published'
        ARCHIVED = 'archived', 'Archived'

    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='articles',
    )
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    body = models.TextField()
    excerpt = models.CharField(max_length=300, blank=True)
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
    )

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

    def __str__(self):
        return self.title

Perhatikan bahwa created_at dan updated_at tidak dituliskan di dalam Article. Mereka datang dari TimestampedModel melalui inheritance.


Model Meta Class (Preview)

Kita bahas ini lebih dalam nanti, tapi ini begini gambarannya karena relevan dengan bagaimana model kita akan berperilaku di API:

1
2
3
4
5
6
7
class Article(TimestampedModel):
    # ... fields ...

    class Meta:
        ordering = ['-created_at']
        verbose_name = 'article'
        verbose_name_plural = 'articles'
OptionEfek
ordering = ['-created_at']Default sort order — Terbaru duluan. Setiap queryset return article dalam urutan ini kecuali di-override. Prefix - artinya descending.
verbose_nameBagaimana model-nya muncul di Django admin dan error message. Django auto-generate ini dari nama class, tapi jika dibuat secara eksplisit lebih baik.
verbose_name_pluralBentuk plural. Tanpa ini, Django akan menambahkan “s” secara apa adanya (Contoh yang salah “Category” → “Categorys”).

Kenapa ordering penting untuk API: Tanpa default ordering, GET /articles/ return baris-baris dalam urutan database di-insert, itu arbitrary dan tidak stabil. Client memiliki ekspektasi urutan yang konsisten dan predictable. Mengatur orderini di model berarti setiap queryset — termasuk yang di-generate oleh generic view DRF, akan return data dalam urutan yang benar tanpa konfigurasi tambahan.


Praktik dan Latihan

Ini yang perlu kita lakukan sekarang:

Buat app articles dan register

1
python manage.py startapp articles

Tambahkan 'articles' ke INSTALLED_APPS di config/settings.py.

Tulis model yang lengkap di articles/models.py

Copy model lengkap dari Part 8 di atas. Baca setiap baris dan pastikan pahami kenapa setiap keputusan dibuat.

Buat dan apply migration

1
2
python manage.py makemigrations articles
python manage.py migrate

Coba di Django shell

1
python manage.py shell
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from django.contrib.auth.models import User
from articles.models import Article

# Buat test user (kalau belum ada)
user = User.objects.create_user('testauthor', password='testpass123')

# Buat article — perhatikan bagaimana slug auto-generate
article = Article.objects.create(
    author=user,
    title='My First Django Article!',
    body='This is the body of my article.',
    excerpt='A short preview.',
)

print(article.slug)        # 'my-first-django-article'
print(article.status)      # 'draft' (default-nya)
print(article.created_at)  # timestamp yang auto-set
print(article.updated_at)  # timestamp yang auto-set

# Test the reverse relationship
print(user.articles.all())  # <QuerySet [<Article: My First Django Article!>]>

# Test choice field
article.status = Article.Status.PUBLISHED
article.save()
print(article.status)       # 'published'
print(article.updated_at)   # timestamp updated-nya sudah berubah

Test constraint-nya

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Coba buat article tanpa title — apa yang terjadi?
Article.objects.create(author=user, title='', body='test')

# Coba buat slug duplikat
Article.objects.create(author=user, title='My First Django Article!', body='test')
# This should fail with IntegrityError because the slug already exists

# Coba status yang invalid
article.status = 'banana'
article.save()  # Ini berhasil disave. Django tidak enforce choices di level DB.
# Enforcement-nya terjadi di level serializer/form — penting untuk dipahami.

Poin terakhir diatas penting: choices itu validation constraint, bukan database constraint. Database menyimpan string apapun yang kita berikan. Enforcement-nya terjadi di DRF serializer dan Django form. Ini kenapa serializer validation itu perlu.


Rangkuman Model

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
settings.AUTH_USER_MODEL    selalu gunakan untuk ForeignKey ke User, jangan pernah import User langsung
on_delete=CASCADE           tentukan apa yang terjadi ke children kalau parent-nya dihapus
related_name='articles'     namai reverse relationship untuk code dan API yang readable
blank=True                  validasi: "field ini boleh kosong"
null=True                   database: "kolom ini boleh NULL" (hindari di string field)
auto_now_add=True           di-set sekali pas creation, tidak pernah berubah
auto_now=True               di-set setiap kali .save() dipanggil
TextChoices                 class mirip enum untuk field status/state
SlugField + unique=True     identifier URL-friendly, harus unique untuk lookup
abstract = True             tidak ada tabel database, ada hanya untuk inheritance

Setiap field yang kita tambahkan ke model itu adalah janji ke API consumer kita. Bayangkan models.py kita sebagai sebuah kontrak: dia mendefinisikan data apa yang ada, apa yang wajib ada, apa yang optional, dan value apa yang valid. Semakin akurat kontrak tersebut, semakin ringan juga kerja dari serializer, view, dan frontend code kita.

Dibawah Lisensi CC BY-NC-SA 4.0
Dibangun dengan Hugo
Tema Stack dirancang oleh Jimmy