กลับไปที่บทความ
Database Architecture PostgreSQL Data Engineering

Database Diagrams: คู่มือปฏิบัติสำหรับออกแบบและจัดทำเอกสาร Schema

พลากร วรมงคล
27 เมษายน 2568 15 นาที

“คู่มือครบวงจรเกี่ยวกับ Database Diagrams — ครอบคลุม ERD notation (Chen, Crow's Foot, UML) การวาดด้วย Mermaid และ PlantUML ตัวอย่าง Schema จริง และ Best Practices สำหรับจัดทำเอกสาร Database Design”

Database Diagrams: คู่มือปฏิบัติสำหรับออกแบบและจัดทำเอกสาร Schema

Database diagram หนึ่งภาพมีค่ากว่าเอกสารประกอบเป็นพันคำ เมื่อวิศวกรใหม่เข้าร่วมทีม diagram ของ schema ที่วาดไว้อย่างดีสื่อสาร data model ได้เร็วกว่า wiki page ใดๆ เมื่อออกแบบ feature ใหม่ ERD คร่าวๆ บน whiteboard ช่วยค้นพบความไม่สอดคล้องก่อนที่จะเขียน SQL แม้แต่บรรทัดเดียว และเมื่อเกิด incident ตี 3 diagram ที่แสดง foreign key relationships อย่างชัดเจนคือความแตกต่างระหว่างการแก้ไขที่รวดเร็วกับการเดาสุ่มนานหนึ่งชั่วโมง

คู่มือนี้ครอบคลุมทุกอย่างตั้งแต่ระบบ notation พื้นฐานไปจนถึง tooling เชิงปฏิบัติ พร้อม diagram จริงที่ใช้ Mermaid และ PlantUML ซึ่งสามารถนำไปใส่ในเอกสารของคุณได้ทันที

ทำไม Database Diagrams ถึงสำคัญ

Database diagrams ทำหน้าที่สำคัญสามประการในขั้นตอนการทำงานของทีม software

การสื่อสาร: diagram ทำให้สมมติฐานที่ซ่อนอยู่กลายเป็นสิ่งที่มองเห็นได้ order จำเป็นต้องมี user เสมอหรือไม่ หรือสามารถ checkout แบบ guest ได้? product สามารถอยู่ใน category มากกว่าหนึ่งได้ไหม? คำถามเหล่านี้มองเห็นได้ทันทีใน diagram และมักถูกมองข้ามในเอกสารแบบร้อยแก้ว

การ Onboarding: สมาชิกทีมใหม่ใช้เวลาหลายวันหรือหลายสัปดาห์สร้าง mental model ของ data layer diagram ที่ครอบคลุมเพียงภาพเดียวย่นย่อเวลานั้นลงอย่างมาก วิศวกรที่เข้าใจ data model เขียน query ได้ดีกว่า ออกแบบ API ได้ดีกว่า และทำให้เกิด data-consistency bug น้อยกว่า

Design Reviews: การตรวจสอบ schema diagram ระหว่างการวางแผนช่วยค้นพบปัญหา — foreign key ที่ขาดหาย relationship ที่กำกวม ปัญหา normalization — ก่อนที่มันจะถูกฝังใน migration ที่ยากต่อการยกเลิก

ประเภทของ Database Diagrams

Entity-Relationship Diagrams (ERDs)

ERD คือประเภทที่พบบ่อยที่สุดของ database diagram ซึ่ง model entities (ตาราง) attribute (คอลัมน์) และ relationship ระหว่างกัน ERD มีอยู่สองระดับ:

  • Conceptual ERD: เฉพาะ entities และ relationships เท่านั้น ไม่มีประเภทคอลัมน์ ไม่มี key ใช้สำหรับการหารือในระดับสูงกับผู้มีส่วนได้ส่วนเสียที่ไม่ใช่ด้านเทคนิค
  • Logical ERD: เพิ่มชื่อ attribute และชนิดข้อมูล primary key และ foreign key แต่ยังคงเป็น database-agnostic

Physical Database Diagrams

Physical diagram คือ logical ERD ที่แปลงมาสำหรับ database engine เฉพาะ มันแสดงประเภทคอลัมน์ที่แน่นอน (VARCHAR(255), BIGINT, TIMESTAMPTZ) indexes constraints (NOT NULL, UNIQUE, CHECK) และ feature เฉพาะของ engine เช่น partitioning หรือ tablespaces นี่คือสิ่งที่คุณสร้างจาก tool อย่าง DBeaver หรือ pgAdmin โดย reverse-engineering จาก database ที่ใช้งานจริง

Logical vs Physical Models

ด้านLogical ModelPhysical Model
ผู้ชมArchitects, analystsDBAs, backend engineers
ประเภทข้อมูลGeneric (String, Integer)Engine-specific (TEXT, INT8)
KeysConceptual PK/FKIndex definitions จริง
PortabilityDatabase-agnosticEngine-specific
Use caseDesign, communicationImplementation, migration

Standard Notations

Chen Notation

Peter Chen แนะนำ ERD notation ดั้งเดิมในปี 1976 ใน Chen notation:

  • สี่เหลี่ยม แทน entities (เช่น USER, ORDER)
  • รูปเพชร แทน relationships (เช่น PLACES)
  • วงรี แทน attributes (เช่น email, created_at)
  • สี่เหลี่ยมคู่ แทน weak entities (entities ที่ไม่สามารถมีอยู่ได้โดยไม่มี parent)
  • รูปเพชรคู่ แทน identifying relationships

Chen notation เหมาะสำหรับงานวิชาการและ conceptual modeling ระดับสูง ในทางปฏิบัติมันจะเทอะทะสำหรับ schema ที่มีมากกว่า 10–15 entities เนื่องจากรูปร่างเพชรและเส้นที่เชื่อมต่อสร้างความวุ่นวายทางสายตา

Crow’s Foot / IE Notation

Crow’s Foot (หรือเรียกว่า Information Engineering notation) คือมาตรฐานที่โดดเด่นในเครื่องมือ database มืออาชีพ มันวางสัญลักษณ์ cardinality ของ relationship ตรงบนเส้นที่เชื่อมต่อโดยตรง:

  • || — หนึ่งอย่างแน่นอน
  • |< — หนึ่งหรือมากกว่า (the “crow’s foot”)
  • o| — ศูนย์หรือหนึ่ง
  • o< — ศูนย์หรือมากกว่า

Crow’s Foot diagrams ดูสะอาดตากว่า Chen notation สำหรับ schema ขนาดใหญ่ ทุก tool ที่คุณจะพบในทางปฏิบัติ — DBeaver, pgAdmin, DataGrip, dbdiagram.io — ใช้ Crow’s Foot เป็นค่าเริ่มต้น

UML Class Diagrams สำหรับ Databases

UML class diagrams ยืมมาจาก object-oriented design แต่ทำงานได้ดีสำหรับ database modeling ตารางกลายเป็น classes คอลัมน์กลายเป็น attributes และ relationships แสดงด้วย UML multiplicity notation (1, *, 0..1, 1..*) ถ้าทีมของคุณใช้ UML สำหรับ architecture diagrams อยู่แล้ว การใช้มันสำหรับ data layer ทำให้ทุกอย่างสอดคล้องกัน

IDEF1X Notation

IDEF1X (Integration Definition for Information Modeling) คือ notation เชิงทางการที่ใช้ในระบบภาครัฐและอวกาศ มันแยกแยะระหว่าง identifying relationships (ที่ primary key ของ child entity รวม primary key ของ parent) และ non-identifying relationships คุณจะพบ IDEF1X น้อยมากนอกบริบทองค์กรหรืออุตสาหกรรมที่ถูกควบคุม

การวาด ERD ด้วย Mermaid

Mermaid syntax แบบ erDiagram render ได้โดยตรงใน GitHub Markdown, GitLab, Notion และ platform เอกสารสมัยใหม่ส่วนใหญ่ มันใช้ Crow’s Foot notation โดยอัตโนมัติ

Basic Relationships: One-to-Many

erDiagram
    USER {
        bigint id PK
        varchar email UK
        varchar name
        timestamp created_at
    }
    ORDER {
        bigint id PK
        bigint user_id FK
        varchar status
        decimal total_amount
        timestamp created_at
    }
    ADDRESS {
        bigint id PK
        bigint user_id FK
        varchar street
        varchar city
        varchar country
    }

    USER ||--o{ ORDER : "places"
    USER ||--o{ ADDRESS : "has"

syntax ||--o{ อ่านว่า: entity ด้านซ้ายมีหนึ่ง instance ที่สัมพันธ์กับศูนย์หรือมากกว่า instance ทางขวา — one-to-many แบบคลาสสิก

Many-to-Many กับ Junction Table

Many-to-many relationships จำเป็นต้องมีตารางกลาง (junction table) ใน relational database เสมอ ERD ควรแสดงสิ่งนี้อย่างชัดเจน:

erDiagram
    PRODUCT {
        bigint id PK
        varchar name
        text description
        decimal price
        bigint category_id FK
    }
    TAG {
        bigint id PK
        varchar name UK
        varchar slug UK
    }
    PRODUCT_TAG {
        bigint product_id FK
        bigint tag_id FK
    }
    CATEGORY {
        bigint id PK
        varchar name UK
        bigint parent_id FK
    }

    PRODUCT }o--o{ TAG : "tagged with"
    PRODUCT_TAG }|--|| PRODUCT : "belongs to"
    PRODUCT_TAG }|--|| TAG : "references"
    PRODUCT }o--|| CATEGORY : "belongs to"
    CATEGORY ||--o{ CATEGORY : "parent of"

Complete E-Commerce Schema

diagram นี้แสดง e-commerce data model ระดับ production พร้อม users, orders, products และ order line items ที่เชื่อมต่อกัน:

erDiagram
    USER {
        bigint id PK
        varchar email UK
        varchar name
        varchar password_hash
        boolean is_active
        timestamp created_at
        timestamp updated_at
    }
    ADDRESS {
        bigint id PK
        bigint user_id FK
        varchar street
        varchar city
        varchar state
        varchar country
        varchar postal_code
        boolean is_default
    }
    PRODUCT {
        bigint id PK
        varchar sku UK
        varchar name
        text description
        decimal price
        int stock_quantity
        bigint category_id FK
        timestamp created_at
    }
    CATEGORY {
        bigint id PK
        varchar name UK
        bigint parent_id FK
    }
    ORDER {
        bigint id PK
        bigint user_id FK
        bigint shipping_address_id FK
        varchar status
        decimal subtotal
        decimal tax
        decimal total
        timestamp created_at
        timestamp updated_at
    }
    ORDER_ITEM {
        bigint id PK
        bigint order_id FK
        bigint product_id FK
        int quantity
        decimal unit_price
        decimal line_total
    }
    PAYMENT {
        bigint id PK
        bigint order_id FK
        varchar provider
        varchar status
        decimal amount
        varchar transaction_ref
        timestamp paid_at
    }

    USER ||--o{ ADDRESS : "has"
    USER ||--o{ ORDER : "places"
    CATEGORY ||--o{ PRODUCT : "contains"
    CATEGORY ||--o{ CATEGORY : "parent of"
    ORDER }o--|| ADDRESS : "ships to"
    ORDER ||--o{ ORDER_ITEM : "contains"
    ORDER ||--o{ PAYMENT : "paid via"
    PRODUCT ||--o{ ORDER_ITEM : "included in"

การวาดด้วย PlantUML

syntax !define ของ PlantUML สร้าง diagram คุณภาพสิ่งพิมพ์และ integrate กับ Confluence, IntelliJ IDEA และ VS Code สำหรับ database schema ใช้ keyword entity

Blog Platform Schema

@startuml blog-schema
!define TABLE(name,desc) class name as "desc" << (T,#FFAAAA) >>
!define PK(x) <b><color:#b8861b>x</color></b>
!define FK(x) <color:#aaaaaa>x</color>
hide methods
hide stereotypes

TABLE(users, "users") {
  PK(id) BIGSERIAL
  --
  email VARCHAR(255) UNIQUE NOT NULL
  username VARCHAR(100) UNIQUE NOT NULL
  password_hash VARCHAR(255) NOT NULL
  display_name VARCHAR(200)
  bio TEXT
  avatar_url TEXT
  is_active BOOLEAN DEFAULT true
  created_at TIMESTAMPTZ NOT NULL
  updated_at TIMESTAMPTZ NOT NULL
}

TABLE(posts, "posts") {
  PK(id) BIGSERIAL
  --
  FK(author_id) BIGINT NOT NULL
  title VARCHAR(500) NOT NULL
  slug VARCHAR(500) UNIQUE NOT NULL
  excerpt TEXT
  content TEXT NOT NULL
  status VARCHAR(20) DEFAULT 'draft'
  published_at TIMESTAMPTZ
  view_count INT DEFAULT 0
  created_at TIMESTAMPTZ NOT NULL
  updated_at TIMESTAMPTZ NOT NULL
}

TABLE(comments, "comments") {
  PK(id) BIGSERIAL
  --
  FK(post_id) BIGINT NOT NULL
  FK(author_id) BIGINT NOT NULL
  FK(parent_id) BIGINT
  content TEXT NOT NULL
  is_approved BOOLEAN DEFAULT false
  created_at TIMESTAMPTZ NOT NULL
}

TABLE(tags, "tags") {
  PK(id) BIGSERIAL
  --
  name VARCHAR(100) UNIQUE NOT NULL
  slug VARCHAR(100) UNIQUE NOT NULL
  description TEXT
}

TABLE(post_tags, "post_tags") {
  FK(post_id) BIGINT NOT NULL
  FK(tag_id) BIGINT NOT NULL
  --
  created_at TIMESTAMPTZ NOT NULL
}

users "1" --> "0..*" posts : author_id
users "1" --> "0..*" comments : author_id
posts "1" --> "0..*" comments : post_id
comments "0..1" --> "0..*" comments : parent_id
posts "1" --> "0..*" post_tags : post_id
tags "1" --> "0..*" post_tags : tag_id

@enduml

E-Commerce Physical Schema พร้อม Indexes

@startuml ecommerce-physical
!define TABLE(name,desc) class name as "desc" << (T,#AADDFF) >>
!define PK(x) <b><color:#b8861b>x</color></b>
!define FK(x) <color:#888888>x</color>
!define IDX(x) <color:#336699><i>x</i></color>
hide methods
hide stereotypes

TABLE(users, "users") {
  PK(id) BIGSERIAL PRIMARY KEY
  --
  email VARCHAR(255) UNIQUE NOT NULL
  password_hash VARCHAR(255) NOT NULL
  created_at TIMESTAMPTZ DEFAULT now()
  ==indexes==
  IDX(idx_users_email) ON (email)
}

TABLE(orders, "orders") {
  PK(id) BIGSERIAL PRIMARY KEY
  --
  FK(user_id) BIGINT NOT NULL
  status VARCHAR(30) NOT NULL DEFAULT 'pending'
  total NUMERIC(12,2) NOT NULL
  created_at TIMESTAMPTZ DEFAULT now()
  ==indexes==
  IDX(idx_orders_user_id) ON (user_id)
  IDX(idx_orders_status) ON (status)
  IDX(idx_orders_created_at) ON (created_at DESC)
}

TABLE(products, "products") {
  PK(id) BIGSERIAL PRIMARY KEY
  --
  FK(category_id) BIGINT
  sku VARCHAR(100) UNIQUE NOT NULL
  name VARCHAR(500) NOT NULL
  price NUMERIC(10,2) NOT NULL
  stock_qty INT NOT NULL DEFAULT 0
  ==indexes==
  IDX(idx_products_sku) ON (sku)
  IDX(idx_products_category) ON (category_id)
}

TABLE(order_items, "order_items") {
  PK(id) BIGSERIAL PRIMARY KEY
  --
  FK(order_id) BIGINT NOT NULL
  FK(product_id) BIGINT NOT NULL
  quantity INT NOT NULL
  unit_price NUMERIC(10,2) NOT NULL
  line_total NUMERIC(12,2) GENERATED ALWAYS AS (quantity * unit_price) STORED
  ==indexes==
  IDX(idx_oi_order_id) ON (order_id)
  IDX(idx_oi_product_id) ON (product_id)
}

users "1" --> "0..*" orders : user_id
orders "1" --> "1..*" order_items : order_id
products "1" --> "0..*" order_items : product_id

@enduml

ตัวอย่างจริง: Blog Platform Schema

มาออกแบบ blog platform ที่สมบูรณ์ตั้งแต่ต้น ความต้องการนั้นตรงไปตรงมา:

  • Users เขียน posts; posts มี slug สำหรับ URL ที่เป็นมิตรกับ SEO
  • Posts สามารถ comment ได้; comments เป็นแบบ threaded (parent/child)
  • Posts ถูก tag; tag นำมาใช้ซ้ำได้ข้าม posts
  • timestamps ทั้งหมดใช้ประเภทที่รับรู้ timezone

ERD ใน Mermaid

erDiagram
    USERS {
        bigint id PK
        varchar email UK
        varchar username UK
        varchar password_hash
        varchar display_name
        text bio
        text avatar_url
        boolean is_active
        timestamptz created_at
        timestamptz updated_at
    }
    POSTS {
        bigint id PK
        bigint author_id FK
        varchar title
        varchar slug UK
        text excerpt
        text content
        varchar status
        timestamptz published_at
        int view_count
        timestamptz created_at
        timestamptz updated_at
    }
    COMMENTS {
        bigint id PK
        bigint post_id FK
        bigint author_id FK
        bigint parent_id FK
        text content
        boolean is_approved
        timestamptz created_at
    }
    TAGS {
        bigint id PK
        varchar name UK
        varchar slug UK
        text description
    }
    POST_TAGS {
        bigint post_id FK
        bigint tag_id FK
        timestamptz created_at
    }

    USERS ||--o{ POSTS : "writes"
    USERS ||--o{ COMMENTS : "authors"
    POSTS ||--o{ COMMENTS : "receives"
    COMMENTS ||--o{ COMMENTS : "parent of"
    POSTS ||--o{ POST_TAGS : "has"
    TAGS ||--o{ POST_TAGS : "used in"

SQL DDL ในสาม Dialects

-- PostgreSQL
CREATE TABLE users (
  id          BIGSERIAL PRIMARY KEY,
  email       VARCHAR(255) NOT NULL,
  username    VARCHAR(100) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  display_name  VARCHAR(200),
  bio         TEXT,
  avatar_url  TEXT,
  is_active   BOOLEAN NOT NULL DEFAULT true,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uq_users_email    UNIQUE (email),
  CONSTRAINT uq_users_username UNIQUE (username)
);

CREATE TABLE posts (
  id           BIGSERIAL PRIMARY KEY,
  author_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title        VARCHAR(500) NOT NULL,
  slug         VARCHAR(500) NOT NULL,
  excerpt      TEXT,
  content      TEXT NOT NULL,
  status       VARCHAR(20) NOT NULL DEFAULT 'draft'
                 CHECK (status IN ('draft','published','archived')),
  published_at TIMESTAMPTZ,
  view_count   INT NOT NULL DEFAULT 0,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uq_posts_slug UNIQUE (slug)
);

CREATE TABLE comments (
  id          BIGSERIAL PRIMARY KEY,
  post_id     BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  author_id   BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  parent_id   BIGINT REFERENCES comments(id) ON DELETE CASCADE,
  content     TEXT NOT NULL,
  is_approved BOOLEAN NOT NULL DEFAULT false,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE tags (
  id          BIGSERIAL PRIMARY KEY,
  name        VARCHAR(100) NOT NULL,
  slug        VARCHAR(100) NOT NULL,
  description TEXT,
  CONSTRAINT uq_tags_name UNIQUE (name),
  CONSTRAINT uq_tags_slug UNIQUE (slug)
);

CREATE TABLE post_tags (
  post_id    BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  tag_id     BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (post_id, tag_id)
);

-- Indexes
CREATE INDEX idx_posts_author_id    ON posts(author_id);
CREATE INDEX idx_posts_status       ON posts(status);
CREATE INDEX idx_posts_published_at ON posts(published_at DESC)
  WHERE status = 'published';
CREATE INDEX idx_comments_post_id   ON comments(post_id);
CREATE INDEX idx_comments_parent_id ON comments(parent_id);
-- MySQL
CREATE TABLE users (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  email         VARCHAR(255) NOT NULL,
  username      VARCHAR(100) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  display_name  VARCHAR(200),
  bio           TEXT,
  avatar_url    TEXT,
  is_active     TINYINT(1) NOT NULL DEFAULT 1,
  created_at    DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  updated_at    DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
                  ON UPDATE CURRENT_TIMESTAMP(6),
  UNIQUE KEY uq_users_email    (email),
  UNIQUE KEY uq_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE posts (
  id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  author_id    BIGINT UNSIGNED NOT NULL,
  title        VARCHAR(500) NOT NULL,
  slug         VARCHAR(500) NOT NULL,
  excerpt      TEXT,
  content      LONGTEXT NOT NULL,
  status       ENUM('draft','published','archived') NOT NULL DEFAULT 'draft',
  published_at DATETIME(6),
  view_count   INT UNSIGNED NOT NULL DEFAULT 0,
  created_at   DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  updated_at   DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
                 ON UPDATE CURRENT_TIMESTAMP(6),
  UNIQUE KEY uq_posts_slug (slug),
  KEY idx_posts_author_id    (author_id),
  KEY idx_posts_status       (status),
  KEY idx_posts_published_at (published_at),
  CONSTRAINT fk_posts_author FOREIGN KEY (author_id)
    REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE comments (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  post_id     BIGINT UNSIGNED NOT NULL,
  author_id   BIGINT UNSIGNED NOT NULL,
  parent_id   BIGINT UNSIGNED,
  content     TEXT NOT NULL,
  is_approved TINYINT(1) NOT NULL DEFAULT 0,
  created_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  KEY idx_comments_post_id   (post_id),
  KEY idx_comments_parent_id (parent_id),
  CONSTRAINT fk_comments_post   FOREIGN KEY (post_id)   REFERENCES posts(id)    ON DELETE CASCADE,
  CONSTRAINT fk_comments_author FOREIGN KEY (author_id) REFERENCES users(id)    ON DELETE CASCADE,
  CONSTRAINT fk_comments_parent FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE tags (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name        VARCHAR(100) NOT NULL,
  slug        VARCHAR(100) NOT NULL,
  description TEXT,
  UNIQUE KEY uq_tags_name (name),
  UNIQUE KEY uq_tags_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE post_tags (
  post_id    BIGINT UNSIGNED NOT NULL,
  tag_id     BIGINT UNSIGNED NOT NULL,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (post_id, tag_id),
  CONSTRAINT fk_pt_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
  CONSTRAINT fk_pt_tag  FOREIGN KEY (tag_id)  REFERENCES tags(id)  ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- SQLite
CREATE TABLE users (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  email         TEXT    NOT NULL UNIQUE,
  username      TEXT    NOT NULL UNIQUE,
  password_hash TEXT    NOT NULL,
  display_name  TEXT,
  bio           TEXT,
  avatar_url    TEXT,
  is_active     INTEGER NOT NULL DEFAULT 1,
  created_at    TEXT    NOT NULL DEFAULT (datetime('now','utc')),
  updated_at    TEXT    NOT NULL DEFAULT (datetime('now','utc'))
);

CREATE TABLE posts (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  author_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title        TEXT    NOT NULL,
  slug         TEXT    NOT NULL UNIQUE,
  excerpt      TEXT,
  content      TEXT    NOT NULL,
  status       TEXT    NOT NULL DEFAULT 'draft'
                 CHECK (status IN ('draft','published','archived')),
  published_at TEXT,
  view_count   INTEGER NOT NULL DEFAULT 0,
  created_at   TEXT    NOT NULL DEFAULT (datetime('now','utc')),
  updated_at   TEXT    NOT NULL DEFAULT (datetime('now','utc'))
);

CREATE TABLE comments (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  post_id     INTEGER NOT NULL REFERENCES posts(id)    ON DELETE CASCADE,
  author_id   INTEGER NOT NULL REFERENCES users(id)    ON DELETE CASCADE,
  parent_id   INTEGER          REFERENCES comments(id) ON DELETE CASCADE,
  content     TEXT    NOT NULL,
  is_approved INTEGER NOT NULL DEFAULT 0,
  created_at  TEXT    NOT NULL DEFAULT (datetime('now','utc'))
);

CREATE TABLE tags (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  name        TEXT NOT NULL UNIQUE,
  slug        TEXT NOT NULL UNIQUE,
  description TEXT
);

CREATE TABLE post_tags (
  post_id    INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  tag_id     INTEGER NOT NULL REFERENCES tags(id)  ON DELETE CASCADE,
  created_at TEXT    NOT NULL DEFAULT (datetime('now','utc')),
  PRIMARY KEY (post_id, tag_id)
);

CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_status    ON posts(status);
CREATE INDEX idx_comments_post   ON comments(post_id);

ORM Model Definitions

blog schema เดิมแสดงเป็น ORM models สำหรับ stack ที่พบบ่อยที่สุดสี่แบบ:

// Prisma (TypeScript)
// schema.prisma

model User {
  id           BigInt    @id @default(autoincrement())
  email        String    @unique @db.VarChar(255)
  username     String    @unique @db.VarChar(100)
  passwordHash String    @map("password_hash") @db.VarChar(255)
  displayName  String?   @map("display_name") @db.VarChar(200)
  bio          String?
  avatarUrl    String?   @map("avatar_url")
  isActive     Boolean   @default(true) @map("is_active")
  createdAt    DateTime  @default(now()) @map("created_at") @db.Timestamptz
  updatedAt    DateTime  @updatedAt @map("updated_at") @db.Timestamptz

  posts    Post[]
  comments Comment[]

  @@map("users")
}

model Post {
  id          BigInt    @id @default(autoincrement())
  authorId    BigInt    @map("author_id")
  title       String    @db.VarChar(500)
  slug        String    @unique @db.VarChar(500)
  excerpt     String?
  content     String
  status      String    @default("draft") @db.VarChar(20)
  publishedAt DateTime? @map("published_at") @db.Timestamptz
  viewCount   Int       @default(0) @map("view_count")
  createdAt   DateTime  @default(now()) @map("created_at") @db.Timestamptz
  updatedAt   DateTime  @updatedAt @map("updated_at") @db.Timestamptz

  author   User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  comments Comment[]
  tags     PostTag[]

  @@index([authorId])
  @@index([status])
  @@map("posts")
}

model Comment {
  id         BigInt   @id @default(autoincrement())
  postId     BigInt   @map("post_id")
  authorId   BigInt   @map("author_id")
  parentId   BigInt?  @map("parent_id")
  content    String
  isApproved Boolean  @default(false) @map("is_approved")
  createdAt  DateTime @default(now()) @map("created_at") @db.Timestamptz

  post     Post      @relation(fields: [postId], references: [id], onDelete: Cascade)
  author   User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  parent   Comment?  @relation("CommentThread", fields: [parentId], references: [id])
  children Comment[] @relation("CommentThread")

  @@index([postId])
  @@map("comments")
}

model Tag {
  id          BigInt    @id @default(autoincrement())
  name        String    @unique @db.VarChar(100)
  slug        String    @unique @db.VarChar(100)
  description String?

  posts PostTag[]

  @@map("tags")
}

model PostTag {
  postId    BigInt   @map("post_id")
  tagId     BigInt   @map("tag_id")
  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz

  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag  Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([postId, tagId])
  @@map("post_tags")
}
// JPA / Hibernate (Java/Spring Boot)

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 255)
    private String email;

    @Column(nullable = false, unique = true, length = 100)
    private String username;

    @Column(name = "password_hash", nullable = false, length = 255)
    private String passwordHash;

    @Column(name = "display_name", length = 200)
    private String displayName;

    @Column(columnDefinition = "TEXT")
    private String bio;

    @Column(name = "avatar_url", columnDefinition = "TEXT")
    private String avatarUrl;

    @Column(name = "is_active", nullable = false)
    private boolean isActive = true;

    @Column(name = "created_at", nullable = false,
            columnDefinition = "TIMESTAMPTZ DEFAULT now()")
    private OffsetDateTime createdAt;

    @Column(name = "updated_at", nullable = false,
            columnDefinition = "TIMESTAMPTZ DEFAULT now()")
    private OffsetDateTime updatedAt;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Post> posts = new ArrayList<>();
}

@Entity
@Table(name = "posts", indexes = {
    @Index(name = "idx_posts_author_id", columnList = "author_id"),
    @Index(name = "idx_posts_status",    columnList = "status")
})
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "author_id", nullable = false)
    private User author;

    @Column(nullable = false, length = 500)
    private String title;

    @Column(nullable = false, unique = true, length = 500)
    private String slug;

    @Column(columnDefinition = "TEXT")
    private String excerpt;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private PostStatus status = PostStatus.DRAFT;

    @Column(name = "published_at",
            columnDefinition = "TIMESTAMPTZ")
    private OffsetDateTime publishedAt;

    @Column(name = "view_count", nullable = false)
    private int viewCount = 0;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PostTag> tags = new ArrayList<>();
}
# SQLAlchemy 2.x (Python)

from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import (
    BigInteger, Boolean, Column, DateTime, ForeignKey,
    Index, Integer, String, Text, UniqueConstraint, func
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"

    id:            Mapped[int]           = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    email:         Mapped[str]           = mapped_column(String(255), nullable=False, unique=True)
    username:      Mapped[str]           = mapped_column(String(100), nullable=False, unique=True)
    password_hash: Mapped[str]           = mapped_column(String(255), nullable=False)
    display_name:  Mapped[Optional[str]] = mapped_column(String(200))
    bio:           Mapped[Optional[str]] = mapped_column(Text)
    avatar_url:    Mapped[Optional[str]] = mapped_column(Text)
    is_active:     Mapped[bool]          = mapped_column(Boolean, nullable=False, default=True)
    created_at:    Mapped[datetime]      = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at:    Mapped[datetime]      = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

    posts:    Mapped[list["Post"]]    = relationship(back_populates="author", cascade="all, delete-orphan")
    comments: Mapped[list["Comment"]] = relationship(back_populates="author", cascade="all, delete-orphan")


class Post(Base):
    __tablename__ = "posts"
    __table_args__ = (
        UniqueConstraint("slug", name="uq_posts_slug"),
        Index("idx_posts_author_id", "author_id"),
        Index("idx_posts_status",    "status"),
    )

    id:           Mapped[int]            = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    author_id:    Mapped[int]            = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    title:        Mapped[str]            = mapped_column(String(500), nullable=False)
    slug:         Mapped[str]            = mapped_column(String(500), nullable=False)
    excerpt:      Mapped[Optional[str]]  = mapped_column(Text)
    content:      Mapped[str]            = mapped_column(Text, nullable=False)
    status:       Mapped[str]            = mapped_column(String(20), nullable=False, default="draft")
    published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
    view_count:   Mapped[int]            = mapped_column(Integer, nullable=False, default=0)
    created_at:   Mapped[datetime]       = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at:   Mapped[datetime]       = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

    author:   Mapped["User"]       = relationship(back_populates="posts")
    comments: Mapped[list["Comment"]] = relationship(back_populates="post", cascade="all, delete-orphan")
    post_tags: Mapped[list["PostTag"]] = relationship(back_populates="post", cascade="all, delete-orphan")


class Comment(Base):
    __tablename__ = "comments"
    __table_args__ = (
        Index("idx_comments_post_id",   "post_id"),
        Index("idx_comments_parent_id", "parent_id"),
    )

    id:          Mapped[int]           = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    post_id:     Mapped[int]           = mapped_column(BigInteger, ForeignKey("posts.id",    ondelete="CASCADE"), nullable=False)
    author_id:   Mapped[int]           = mapped_column(BigInteger, ForeignKey("users.id",    ondelete="CASCADE"), nullable=False)
    parent_id:   Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("comments.id", ondelete="CASCADE"))
    content:     Mapped[str]           = mapped_column(Text, nullable=False)
    is_approved: Mapped[bool]          = mapped_column(Boolean, nullable=False, default=False)
    created_at:  Mapped[datetime]      = mapped_column(DateTime(timezone=True), server_default=func.now())

    post:     Mapped["Post"]          = relationship(back_populates="comments")
    author:   Mapped["User"]          = relationship(back_populates="comments")
    parent:   Mapped[Optional["Comment"]]  = relationship(remote_side="Comment.id", back_populates="children")
    children: Mapped[list["Comment"]] = relationship(back_populates="parent")


class Tag(Base):
    __tablename__ = "tags"

    id:          Mapped[int]           = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    name:        Mapped[str]           = mapped_column(String(100), nullable=False, unique=True)
    slug:        Mapped[str]           = mapped_column(String(100), nullable=False, unique=True)
    description: Mapped[Optional[str]] = mapped_column(Text)

    post_tags: Mapped[list["PostTag"]] = relationship(back_populates="tag", cascade="all, delete-orphan")


class PostTag(Base):
    __tablename__ = "post_tags"

    post_id:    Mapped[int]      = mapped_column(BigInteger, ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True)
    tag_id:     Mapped[int]      = mapped_column(BigInteger, ForeignKey("tags.id",  ondelete="CASCADE"), primary_key=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

    post: Mapped["Post"] = relationship(back_populates="post_tags")
    tag:  Mapped["Tag"]  = relationship(back_populates="post_tags")
// Entity Framework Core (C# / .NET)

public class User
{
    public long Id { get; set; }
    public string Email { get; set; } = null!;
    public string Username { get; set; } = null!;
    public string PasswordHash { get; set; } = null!;
    public string? DisplayName { get; set; }
    public string? Bio { get; set; }
    public string? AvatarUrl { get; set; }
    public bool IsActive { get; set; } = true;
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;

    public ICollection<Post> Posts { get; set; } = [];
    public ICollection<Comment> Comments { get; set; } = [];
}

public class Post
{
    public long Id { get; set; }
    public long AuthorId { get; set; }
    public string Title { get; set; } = null!;
    public string Slug { get; set; } = null!;
    public string? Excerpt { get; set; }
    public string Content { get; set; } = null!;
    public PostStatus Status { get; set; } = PostStatus.Draft;
    public DateTimeOffset? PublishedAt { get; set; }
    public int ViewCount { get; set; }
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;

    public User Author { get; set; } = null!;
    public ICollection<Comment> Comments { get; set; } = [];
    public ICollection<PostTag> PostTags { get; set; } = [];
}

public class Comment
{
    public long Id { get; set; }
    public long PostId { get; set; }
    public long AuthorId { get; set; }
    public long? ParentId { get; set; }
    public string Content { get; set; } = null!;
    public bool IsApproved { get; set; }
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

    public Post Post { get; set; } = null!;
    public User Author { get; set; } = null!;
    public Comment? Parent { get; set; }
    public ICollection<Comment> Children { get; set; } = [];
}

// EF Core configuration (IEntityTypeConfiguration<T> pattern)
public class PostConfiguration : IEntityTypeConfiguration<Post>
{
    public void Configure(EntityTypeBuilder<Post> builder)
    {
        builder.ToTable("posts");
        builder.HasKey(p => p.Id);
        builder.Property(p => p.Title).HasMaxLength(500).IsRequired();
        builder.Property(p => p.Slug).HasMaxLength(500).IsRequired();
        builder.HasIndex(p => p.Slug).IsUnique();
        builder.HasIndex(p => p.AuthorId);
        builder.HasIndex(p => p.Status);
        builder.Property(p => p.Status).HasConversion<string>();

        builder.HasOne(p => p.Author)
               .WithMany(u => u.Posts)
               .HasForeignKey(p => p.AuthorId)
               .OnDelete(DeleteBehavior.Cascade);
    }
}

Best Practices

Naming Conventions

ความสม่ำเสมอสำคัญกว่า convention เฉพาะที่คุณเลือก เลือกหนึ่งแบบและบังคับใช้ทุกตาราง:

  • ชื่อตาราง: lowercase snake_case พหูพจน์ (users, order_items ไม่ใช่ User หรือ orderItem)
  • ชื่อคอลัมน์: lowercase snake_case (created_at, user_id ไม่ใช่ createdAt หรือ UserId)
  • Primary keys: ใช้ id เสมอ — ไม่ใช่ user_id ในตาราง users ไม่ใช่ pk_user
  • Foreign keys: {referenced_table_singular}_iduser_id, post_id, category_id
  • Boolean columns: นำหน้าด้วย is_, has_, หรือ can_is_active, has_verified_email
  • Timestamps: created_at และ updated_at ทุกตาราง ต้องเป็นแบบรับรู้ timezone เสมอ
  • Junction tables: รวมชื่อตารางทั้งสองตามตัวอักษร — post_tags (ไม่ใช่ tag_posts หรือ posts_to_tags)
  • Indexes: idx_{table}_{columns}idx_posts_author_id, idx_orders_status_created_at
  • Constraints: uq_{table}_{column}, fk_{table}_{referenced_table}, chk_{table}_{column}

เมื่อไรควร Normalize vs Denormalize

Third Normal Form (3NF) คือจุดเริ่มต้นที่เหมาะสำหรับ OLTP schema ส่วนใหญ่ Normalize จนกว่าจะเจ็บปวด แล้วค่อย denormalize อย่างมีกลยุทธ์

Normalize เมื่อ:

  • ข้อมูลถูกเขียนบ่อย (normalization ลด write amplification)
  • Data integrity มีความสำคัญ — คุณรับความไม่สม่ำเสมอไม่ได้
  • Data model ยังอยู่ระหว่างการพัฒนา — normalized schema เปลี่ยนแปลงได้ง่ายกว่า

Denormalize เมื่อ:

  • Read performance มีความสำคัญ และข้อมูลถูกอ่านมากกว่าเขียนมาก
  • คุณต้องการหลีกเลี่ยง join ที่มีค่าใช้จ่ายสูงข้ามตารางขนาดใหญ่ (เช่น reporting dashboards)
  • คุณใช้คอลัมน์ JSONB เพื่อเก็บข้อมูล semi-structured ที่แตกต่างกันในแต่ละแถว
  • คุณพิสูจน์แล้วว่า normalization เป็น bottleneck จากการ profiling จริง — ไม่ใช่แค่การสันนิษฐาน

pattern เชิงปฏิบัตินิยมที่พบบ่อย: เก็บ write path ให้ fully normalized อย่างสมบูรณ์ และใช้ materialized views หรือ read replicas ที่มีโครงสร้าง denormalized สำหรับ read path ของคุณ

การจัดทำเอกสาร Constraints

Constraints ไม่ใช่แค่กลไกการบังคับใช้ของ database — มันคือเอกสาร constraint ทุกอันบอกผู้อ่านบางอย่างเกี่ยวกับ business rules ที่ฝังอยู่ใน schema:

  • NOT NULL บอกว่าฟิลด์นั้นจำเป็นตาม business domain
  • UNIQUE บอกว่าไม่มีสองแถวที่แสดง logical entity เดียวกันได้
  • CHECK บอกว่าค่าต้องตอบสนอง business rule
  • FOREIGN KEY บอกว่า entity นี้ไม่สามารถมีอยู่ได้โดยไม่มี parent
  • DEFAULT บอกว่าระบบจ่ายค่าอะไรเมื่อ application ไม่ได้ระบุ

ตั้งชื่อ constraints ของคุณอย่างชัดเจนแทนที่จะพึ่งพาชื่อที่สร้างอัตโนมัติ CONSTRAINT uq_users_email UNIQUE (email) อ้างอิงได้ง่ายกว่ามากใน error messages และ migrations กว่า users_email_key

การ Version-Control การเปลี่ยนแปลง Schema

ปฏิบัติกับ migrations เหมือน code artifacts ชั้นหนึ่ง:

  1. ใช้ migration tool — Flyway, Liquibase, Alembic, Prisma Migrate หรือ Django migrations อย่าใช้การเปลี่ยนแปลง schema ด้วยมือใน production
  2. หนึ่งการเปลี่ยนแปลงต่อ migration — migration ที่เพิ่มคอลัมน์และสร้าง index คือสองการเปลี่ยนแปลงเชิง logic แยกมันออกจากกันเพื่อให้ rollback แม่นยำ
  3. เขียนทั้ง up และ down migrations — แม้คุณจะไม่เคย rollback up migration เลย down migration ก็จัดทำเอกสาร inverse operation และบังคับให้คุณคิดผ่านมัน
  4. อย่าแก้ไข migration ที่ commit แล้ว — committed migrations คือประวัติที่ไม่เปลี่ยนแปลง แก้ไขด้วย migration ใหม่
  5. ตรวจสอบ migrations เหมือน code — รวมการเปลี่ยนแปลง schema ใน pull requests migration ที่ drop คอลัมน์ที่มีข้อมูลสมควรได้รับการตรวจสอบอย่างเดียวกับการเปลี่ยนแปลง code ที่เกี่ยวกับความปลอดภัย
  6. เก็บ diagram ไว้พร้อมกับ migrations — เก็บ docs/schema.mmd หรือ docs/schema.puml ใน repository ของคุณและอัปเดตมันเป็นส่วนหนึ่งของ PR เดียวกันกับทุก migration

การเปรียบเทียบ Tools

Toolเหมาะสำหรับจุดแข็งข้อจำกัด
dbdiagram.ioเอกสารด่วน, การแชร์syntax DBML รวดเร็ว output สวยงาม shareable linksOnline เท่านั้น free tier จำกัด
DBeaverMulti-database reverse engineeringรองรับ 80+ databases ER diagram จาก live DB Community edition ฟรีการ export diagram อาจมีปัญหา
pgAdminงานเฉพาะ PostgreSQLbuilt-in ERD tool, query plan visualizer, integrate กับ PG อย่างแน่นหนาPostgreSQL เท่านั้น
DataGripPower users, IDE integrationschema visualization ยอดเยี่ยม smart migrations, multi-DBต้องชำระเงิน (JetBrains subscription)
draw.io / diagrams.netCustom diagrams, presentationsฟรีทั้งหมด offline ปรับแต่งได้สูง integrate กับ Confluenceด้วยมือ — ไม่มี database reverse engineering
MermaidDocs-as-code, Git-nativerender ใน GitHub/GitLab/Notion, version-controlled, plain textการปรับแต่งภาพจำกัด
PlantUMLEnterprise/Confluence docsควบคุม layout ได้มาก CI/CD friendly, IntelliJ integrationต้องการ Java หรือ server rendering
LucidchartTeam collaborationreal-time collaboration, database import, Jira/Confluence pluginsต้องชำระเงิน

สำหรับทีมส่วนใหญ่ การผสมผสานเชิงปฏิบัติคือ: Mermaid สำหรับ diagrams ที่ฝังอยู่ใน Git repositories (ใน README.md, ADRs, PR descriptions) และ DBeaver หรือ DataGrip สำหรับ reverse-engineering และสำรวจ databases ที่ใช้งานจริง

บทสรุป

Database diagram ที่อยู่ใน repository ของคุณพร้อมกับ migration files มีคุณค่ามากกว่า diagram ที่ฝังอยู่ใน Confluence page ซึ่งอัปเดตครั้งสุดท้ายเมื่อสองปีที่แล้วอย่างหาที่เปรียบไม่ได้ tooling ของ diagram ที่ดีที่สุดสำหรับทีมวิศวกรรมคือที่ปฏิบัติกับ diagram เหมือน code — version-controlled, diffable, reviewable และ render อัตโนมัติใน platform ที่ทีมของคุณใช้อยู่แล้ว

เริ่มต้นด้วย Mermaid erDiagram ใน folder docs/ ของโปรเจ็กต์ของคุณวันนี้ เพิ่ม CI step ที่ตรวจสอบ Mermaid syntax ทำให้การอัปเดต diagram เป็นส่วนที่จำเป็นของกระบวนการเปลี่ยนแปลง schema ของคุณ ใน 6 เดือน วิศวกรทุกคนในทีมของคุณจะขอบคุณคุณ — รวมถึงตัวคุณเองในตอนตี 3 ระหว่าง incident

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง