9

Mini Capstone ด้าน Domain Modeling

โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน

th/topic_09_mini_capstone

ภาพรวม

ทำไมหัวข้อนี้จึงสำคัญ

capstone นี้มีไว้เพื่อรวบความคิดทั้งคอร์สเข้ามาอยู่ใน domain เดียว ผู้เรียนจะขยับจาก การดูฟีเจอร์เป็นชิ้น ๆ ไปสู่ระบบขนาดเล็กที่มี domain objects, การเปลี่ยน state, validation, การร่วมงานกันของ object และพฤติกรรมด้านการส่งออก

สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้

เมื่อจบหัวข้อนี้ คุณควรจะ:

  • model domain ขนาดเล็กด้วย objects ที่มีหน้าที่แยกจากกันชัดเจน
  • รักษากฎของ domain เช่น ขีดจำกัดการยืม
  • ประกอบ services รอบ domain objects ที่มีอยู่ แทนที่จะยัดทุกอย่างลงใน class เดียว
  • ใช้ exporter แบบ duck-typed เพื่อแยกการแสดงผลออกจาก domain logic
  • อธิบายได้ว่า capstone นี้แสดงสไตล์ Ruby ที่คอร์สนี้สอนมาตลอดอย่างไร

จุดที่ผมใช้ดูความเข้าใจ

ผมอยากให้คุณอธิบายได้ว่าทำไม model จึงแยกออกเป็น Book, Member, Library และ LibraryReport

โน้ตสั้น

capstone ไม่จำเป็นต้องใหญ่ มันต้องใหญ่พอที่จะเผยให้เห็นว่าผู้เรียนเชื่อมไอเดียจากหัวข้อ ก่อน ๆ เข้าหากันได้หรือไม่:

  • หน้าที่ของ object ที่ชัด
  • validation และกฎของ domain
  • การเปลี่ยน state ที่อ่านออก
  • การร่วมงานกันผ่าน composition
  • การขยายระบบผ่าน exporter แบบ duck-typed

จุดที่ Ruby ทำได้ดีในหัวข้อนี้:

  • object เล็ก ๆ สามารถจำลองลำดับงานที่มีความหมายได้อย่างชัดเจน
  • กฎทางธุรกิจอยู่ใกล้ object ที่เกี่ยวข้องจริง
  • เราเพิ่มพฤติกรรมด้านผลลัพธ์ได้ โดยไม่ต้องรื้อ domain หลักใหม่

จุดที่ต้องระวังในหัวข้อนี้:

  • ต่อให้ domain เล็ก ก็ยังเละได้ถ้าเส้นแบ่งความรับผิดชอบอ่อน
  • โค้ดที่ดู "ง่าย" จะกลายเป็น procedural เร็วมาก ถ้าทุกอย่างถูกยัดลงใน class เดียว

คำถามชวนคิด:

  • object ไหนเป็นเจ้าของกฎข้อไหน และเพราะอะไร

ตัวอย่างแบบลงมือดู

Example 1: กฎการยืมอยู่บน member

borrow limit ควรอยู่บน Member ไม่ใช่ script ที่ทำตัวคล้าย controller เพราะมันเป็นส่วน หนึ่งของข้อจำกัดใน domain ของสมาชิกคนนั้นเอง

นี่เป็นการตัดสินใจที่สอนอะไรได้ดี:

  • กฎอยู่ใกล้ state ที่มันคุ้มครอง
  • object สื่อหน้าที่ของตัวเองได้ชัด

Example 2: การส่งออกสถานะการยืม

library อาจต้องเปิดเผยสถานะการยืมออกมาได้หลายรูปแบบ:

  • เป็น hash สำหรับใช้ภายใน
  • เป็น JSON สำหรับ API
  • เป็น CSV สำหรับรายงานการปฏิบัติงาน

จุดนี้ทำให้เส้นแบ่งของ exporter ดูสมจริง และทำให้เห็นผลตอบแทนของสิ่งที่เรียนมาในหัวข้อ 7

โพยสั้น

Domain objects

  • Book: ข้อมูลของสิ่งที่ยืมได้
  • Member: กฎการยืมและชุดหนังสือที่ยืมอยู่
  • Library: คลังหนังสือและตัวประสานงานการยืม
  • LibraryReport: เส้นแบ่งระหว่าง domain กับการแสดงผล/ส่งออก

รูปแบบการร่วมงาน

lib = Library.new
member = Member.new
lib.lend("isbn-1", member)
cheatsheet.md
ruby

เส้นแบ่งของการ export

class HashExporter
  def export(state)
    state
  end
end
cheatsheet.md
ruby

สิ่งที่ควรจำเรื่องการออกแบบ

  • วางกฎไว้ใกล้ object ที่เป็นเจ้าของกฎนั้น
  • แยก logic เรื่องรายงานหรือผลลัพธ์ออกจากลำดับงานหลักของ domain

คู่มือการเรียน

จุดประสงค์ของหัวข้อนี้

ใช้ domain เรื่องการยืมหนังสือขนาดเล็กเพื่อรวบไอเดียทั้งคอร์สเข้ามา หัวข้อนี้จึงไม่ได้ เน้นไวยากรณ์ใหม่มากนัก แต่วัดว่าผู้เรียนสามารถออกแบบ Ruby model ที่สะอาดได้จริงหรือไม่

ลำดับที่ผมแนะนำ

  1. อ่าน overview.md
  2. อ่าน shortnote.md
  3. อ่าน worked_examples.md แล้วดูว่าหัวข้อก่อน ๆ กลับมาปรากฏตรงไหนบ้าง
  4. เปิด cheatsheet.md ไว้ตอนอ่าน example.rb และ basic_exercise.rb
  5. ทำแบบฝึกหัดเรื่องลำดับการทำงานของห้องสมุด
  6. ทำแบบฝึกหัดเรื่อง reporting/export

สิ่งที่ผมอยากให้คุณสังเกต

  • กฎของ domain ควรอยู่ใน object ที่เป็นเจ้าของมัน
  • โค้ดที่ทำหน้าที่ประสานงาน ควรถูกแยกจากโค้ดที่ทำหน้าที่แสดงผล
  • เส้นแบ่งของ exporter ทำให้ domain เปิดรับผลลัพธ์รูปแบบใหม่ได้ โดยไม่บิดรูปของมัน

คำถามที่อยากให้คุณพกไว้

  • ทำไม borrow limit ถึงถูกบังคับใน Member ไม่ใช่ Library
  • หัวข้อก่อนหน้านี้ข้อไหนกลับมาโผล่ใน report exporter บ้าง
  • ถ้า capstone นี้โตขึ้น คุณคิดว่าเส้นแบ่ง abstraction ถัดไปควรเกิดตรงไหน

Source Files and Tests

โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน

# EXAMPLE CODE
# Topic: topic_09_mini_capstone
#
# Purpose:
# - This file demonstrates reference implementation for the concept.
# - It should pass tests from the beginning.
# - Read and understand it before solving exercises.

class Book
  attr_reader :title, :author, :isbn

  def initialize(title:, author:, isbn:)
    @title = title
    @author = author
    @isbn = isbn
  end
end

class Member
  MAX_BORROW = 3
  attr_reader :borrowed

  def initialize
    @borrowed = []
  end

  def borrow(book)
    raise ArgumentError, "borrow limit reached" if @borrowed.size >= MAX_BORROW

    @borrowed << book
  end

  def return_book(book)
    @borrowed.delete(book)
  end
end
en/topic_09_mini_capstone/example.rb
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_09_mini_capstone
#
# What to do:
# - Implement or improve the class/methods in this file.
# - Read tests in tests/topic_09_mini_capstone_spec.rb under the "basic exercise" examples.
# - Make tests pass without breaking the example/advanced sections.
#
# Expected outcome:
# - You can run this topic tests and see all examples green after implementation.

class Library
  def initialize
    @books = []
    @lent = {}
  end

  def add_book(book)
    @books << book
  end

  def lend(isbn, member)
    return false if @lent.key?(isbn)

    book = @books.find { |b| b.isbn == isbn }
    return false unless book

    member.borrow(book)
    @lent[isbn] = member
    true
  end

  def return_book(isbn, member)
    return false unless @lent[isbn] == member

    book = @books.find { |b| b.isbn == isbn }
    member.return_book(book)
    @lent.delete(isbn)
    true
  end

  def lending_state
    @lent.transform_values { |member| member.object_id }
  end
end
en/topic_09_mini_capstone/basic_exercise.rb
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_09_mini_capstone
#
# Academic purpose:
# - Consolidate the course by separating domain behavior from output behavior.
# - Show that even a small domain benefits from composition and duck typing.
#
# Real-world use case:
# - Library, inventory, and lending systems often need multiple reports for operators, APIs,
#   and downstream systems.
# - Exporters are useful because the core lending model should not care whether the output is a
#   hash, JSON document, CSV file, or some future integration payload.
# - This mirrors a common production design boundary.
#
# Why Ruby is beautiful here:
# - The domain model stays small and readable.
# - The report object depends only on a tiny exporter contract.
# - Earlier course ideas combine naturally rather than feeling isolated.
#
# What to do:
# - Complete the challenge behavior requested by the guide.
# - Be able to explain how this file reuses ideas from composition and duck typing.
# - Use tests in tests/topic_09_mini_capstone_spec.rb under the "advanced exercise" examples.
#
# Expected outcome:
# - Advanced tests pass and you can describe the architecture, not only the syntax.

class HashExporter
  def export(state)
    state
  end
end

class LibraryReport
  def initialize(library:, exporter:)
    @library = library
    @exporter = exporter
  end

  def call
    @exporter.export(@library.lending_state)
  end
end
en/topic_09_mini_capstone/adv_exercise.rb
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_09_mini_capstone
#
# Solution idea:
# - `Library` coordinates inventory and lending state.
# - Lending succeeds only if the book exists and is not already lent out.
# - Returning succeeds only when the same member currently holds the book.
# - `lending_state` exposes a simple reporting-friendly view of the current loans.

class Library
  def initialize
    @books = []
    @lent = {}
  end

  def add_book(book)
    @books << book
  end

  def lend(isbn, member)
    return false if @lent.key?(isbn)

    book = @books.find { |candidate| candidate.isbn == isbn }
    return false unless book

    member.borrow(book)
    @lent[isbn] = member
    true
  end

  def return_book(isbn, member)
    return false unless @lent[isbn] == member

    book = @books.find { |candidate| candidate.isbn == isbn }
    member.return_book(book)
    @lent.delete(isbn)
    true
  end

  def lending_state
    @lent.transform_values { |member| member.object_id }
  end
end
en/topic_09_mini_capstone/answer_basic_exercise.rb
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_09_mini_capstone
#
# Solution idea:
# - Keep reporting outside the core `Library` domain object.
# - The exporter is duck-typed: any object with `export(state)` can be used.
# - This keeps the domain model open to future output formats without changing the lending logic.

class HashExporter
  def export(state)
    state
  end
end

class LibraryReport
  def initialize(library:, exporter:)
    @library = library
    @exporter = exporter
  end

  def call
    @exporter.export(@library.lending_state)
  end
end
en/topic_09_mini_capstone/answer_adv_exercise.rb
Ruby course source
# This spec is your learning companion for topic_09_mini_capstone.
#
# How to use this file:
# 1) Run tests and observe failures/successes.
# 2) Keep EXAMPLE specs green from the beginning.
# 3) Implement BASIC exercise until BASIC specs pass.
# 4) Implement ADVANCED exercise and pass edge cases.
#
# Expected final result:
# - All examples in this file pass.
# - You understand both the concept and the implementation tradeoffs.

require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"

RSpec.describe "topic_09_mini_capstone" do
  describe "EXAMPLE purpose: understand the reference implementation" do
    it "creates books and enforces member borrow limits" do
      member = Member.new
      3.times { |i| member.borrow(Book.new(title: "T#{i}", author: "A", isbn: i.to_s)) }
      expect { member.borrow(Book.new(title: "Overflow", author: "A", isbn: "x")) }.to raise_error(ArgumentError, /limit/)
    end
  end

  describe "BASIC EXERCISE purpose: implement the comparable task" do
    it "lends and returns books through library" do
      lib = Library.new
      member = Member.new
      book = Book.new(title: "Practical Ruby", author: "A", isbn: "isbn-1")

      lib.add_book(book)

      expect(lib.lend("isbn-1", member)).to eq(true)
      expect(member.borrowed).to eq([book])
      expect(lib.return_book("isbn-1", member)).to eq(true)
      expect(member.borrowed).to eq([])
    end
  end

  describe "ADVANCED EXERCISE purpose: solve challenge and edge cases" do
    it "exports lending state via duck-typed exporter" do
      lib = Library.new
      member = Member.new
      book = Book.new(title: "Practical Ruby", author: "A", isbn: "isbn-1")

      lib.add_book(book)
      lib.lend("isbn-1", member)

      report = LibraryReport.new(library: lib, exporter: HashExporter.new)
      expect(report.call).to eq({ "isbn-1" => member.object_id })
    end
  end
end
en/topic_09_mini_capstone/tests/topic_09_mini_capstone_spec.rb
Ruby course source
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"

exec "${ROOT_DIR}/run_tests.sh" "$(basename "${SCRIPT_DIR}")"
en/topic_09_mini_capstone/run_topic_tests.sh
Ruby course source

Study Prompts

  1. อ่าน test ก่อน แล้วบอกให้ได้ว่าพฤติกรรมใดเป็น example, basic exercise และ advanced exercise

  2. ลองทำแบบฝึกหัดก่อนเปิด answer files แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน