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)ruby
เส้นแบ่งของการ export
class HashExporter
def export(state)
state
end
endruby
สิ่งที่ควรจำเรื่องการออกแบบ
- วางกฎไว้ใกล้ object ที่เป็นเจ้าของกฎนั้น
- แยก logic เรื่องรายงานหรือผลลัพธ์ออกจากลำดับงานหลักของ domain
คู่มือการเรียน
จุดประสงค์ของหัวข้อนี้
ใช้ domain เรื่องการยืมหนังสือขนาดเล็กเพื่อรวบไอเดียทั้งคอร์สเข้ามา หัวข้อนี้จึงไม่ได้ เน้นไวยากรณ์ใหม่มากนัก แต่วัดว่าผู้เรียนสามารถออกแบบ Ruby model ที่สะอาดได้จริงหรือไม่
ลำดับที่ผมแนะนำ
- อ่าน
overview.md - อ่าน
shortnote.md - อ่าน
worked_examples.mdแล้วดูว่าหัวข้อก่อน ๆ กลับมาปรากฏตรงไหนบ้าง - เปิด
cheatsheet.mdไว้ตอนอ่านexample.rbและbasic_exercise.rb - ทำแบบฝึกหัดเรื่องลำดับการทำงานของห้องสมุด
- ทำแบบฝึกหัดเรื่อง 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
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
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
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
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
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
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}")"
Ruby course source
Study Prompts
อ่าน test ก่อน แล้วบอกให้ได้ว่าพฤติกรรมใดเป็น example, basic exercise และ advanced exercise
ลองทำแบบฝึกหัดก่อนเปิด answer files แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน