Cross-import
Cross-import คือการ import ระหว่าง slices ที่แตกต่างกันแต่อยู่ใน layer เดียวกัน
ตัวอย่างเช่น:
- การ import
features/productจากfeatures/cart - การ import
widgets/sidebarจากwidgets/header
Cross-imports ถือเป็น "Code Smell" หรือสัญญาณเตือนว่า slices ต่างๆ เริ่มมีความผูกพันกัน (Couple) มากเกินไป ในบางสถานการณ์อาจเลี่ยงไม่ได้จริงๆ แต่เราควรทำด้วยความตั้งใจและมีการพูดคุยหรือทำเอกสารแจ้งทีมให้รับรู้เสมอนะ
Layer shared และ app ไม่มี concept ของ slice ดังนั้นการ import ภายใน layer เหล่านี้ ไม่นับ ว่าเป็น cross-imports นะ
ทำไมถึงเป็น Code Smell?
Cross-imports ไม่ใช่แค่เรื่องของสไตล์การเขียนโค้ด แต่ถูกมองว่าเป็น Code Smell เพราะมันทำให้เส้นแบ่งระหว่าง Domain ไม่ชัดเจนและก่อให้เกิด dependencies แฝง
ลองนึกภาพว่า slice cart ต้องพึ่งพา Business Logic ของ product โดยตรงดูสิ ตอนแรกมันอาจจะดูสะดวกดี แต่จริงๆ แล้วมันสร้างปัญหาตามมาเพียบเลย:
-
ความเป็นเจ้าของและความรับผิดชอบไม่ชัดเจน (Unclear ownership): พอ
cartไป importproductก็เริ่มงงแล้วว่า Logic นี้ใครเป็น "เจ้าของ" กันแน่? ถ้าทีมproductเปลี่ยน Logic ภายใน ก็อาจจะไปทำcartพังโดยไม่รู้ตัว ความคลุมเครือนี้ทำให้ไล่โค้ดยากและหาคนรับผิดชอบบั๊กหรือฟีเจอร์ลำบากขึ้น -
ขาดความเป็นอิสระและทดสอบยาก (Reduced isolation): ข้อดีหลักๆ ของ Architecture แบบ Sliced คือแต่ละ Slice ควรพัฒนา ทดสอบ และ Deploy ได้อย่างอิสระ แต่ Cross-imports เข้ามาทำลายสิ่งนี้ — จะเทส
cartทีก็ต้อง setupproductด้วย แถมแก้ไฟล์นึงอาจทำเทสของอีกไฟล์พังได้ง่ายๆ -
ภาระทางความคิดเพิ่มขึ้น (Increased cognitive load): จะแก้
cartที ต้องมานั่งพะวงว่าproductทำงานยังไง โครงสร้างเป็นแบบไหน ยิ่ง Cross-imports เยอะ ยิ่งต้องไล่โค้ดข้าม Slice ไปมาจนปวดหัว แค่แก้จุดเล็กๆ อาจต้องรู้ Context ทั้งระบบเลยก็ได้ -
ทางสู่ Circular Dependencies: Cross-imports มักเริ่มจาก dependencies ทางเดียว แต่เผลอแป๊บเดียวอาจกลายเป็นสองทาง (A import B, แล้ว B ก็ import A) ทำให้ Slices ล็อกติดกันแน่น แกะออกยาก และ Refactor ลำบากขึ้นเรื่อยๆ
จุดประสงค์ของการแบ่งขอบเขต Domain คือเพื่อให้แต่ละ Slice โฟกัสหน้าที่ของตัวเองและปรับเปลี่ยนได้ง่าย ยิ่ง Dependencies หลวมเท่าไหร่ เรายิ่งคาดเดาผลกระทบได้ง่ายขึ้นเท่านั้น แต่ Cross-imports เข้ามาทำลายกำแพงนี้ ทำให้ผลกระทบวงกว้างขึ้นและ Refactor ยากขึ้น — นี่แหละเหตุผลที่เรามองว่ามันเป็น Code Smell ที่ควรแก้
ในหัวข้อถัดไป เราจะมาดูว่าปัญหานี้มักโผล่มาแบบไหนในโปรเจกต์จริง และมีกลยุทธ์อะไรให้เลือกใช้บ้าง
Cross-imports ใน layer Entities
Cross-imports ใน entities มักเกิดจากการแบ่ง Entity ละเอียดเกินไป ก่อนจะหันไปพึ่ง @x ลองพิจารณาดูซิว่า ควรรวมขอบเขต (Boundaries) เข้าด้วยกันไหม?
บางทีมใช้ @x เป็นพื้นที่สำหรับ Cross-import ของ entities โดยเฉพาะ แต่ขอบอกเลยว่าควรใช้เป็น ทางเลือกสุดท้าย (Last Resort) — เป็น สิ่งที่ต้องยอมแลกอย่างจำใจ ไม่ใช่วิธีที่แนะนำนะ
ให้มองว่า @x เป็นเหมือนประตูปริศนาสำหรับการอ้างอิง Domain ที่เลี่ยงไม่ได้จริงๆ — ไม่ใช่เครื่องมือสำหรับใช้ซ้ำทั่วไป การใช้มันพร่ำเพรื่อจะทำให้ขอบเขตของ Entity ผูกติดกันแน่นและ Refactor ยากในอนาคต
สำหรับรายละเอียดเกี่ยวกับ @x ลองดูที่ เอกสาร Public API
ถ้าอยากดูตัวอย่างจริงๆ ของการอ้างอิงข้าม Business Entities ดูได้ที่:
Features และ Widgets: หลากหลายกลยุทธ์
ใน layer features และ widgets การจะบอกว่า Cross-imports ห้ามเด็ดขาด อาจจะดูตึงเกินไปหน่อย ในความเป็นจริงเรามี หลายกลยุทธ์ ให้เลือกใช้ ส่วนนี้เราจะไม่เน้นโค้ดมากนัก แต่จะเน้น Patterns ที่คุณเลือกใช้ได้ตามความเหมาะสมของทีมและบริบทโปรเจกต์
Strategy A: Slice merge (รวม Slice)
ถ้า Slice สองตัวมันไม่ค่อยจะอิสระจากกันเท่าไหร่ แถมยังต้องแก้พร้อมกันตลอด ก็จับมันมารวมเป็น Slice เดียวกันซะเลยสิ้นเรื่อง
ตัวอย่าง (ก่อนรวม):
features/profilefeatures/profileSettings
ถ้าสองตัวนี้ Cross-import กันไปมาและต้องไปด้วยกันตลอด ก็แปลว่าจริงๆ แล้วมันคือ Feature เดียวกันนั่นแหละ การรวมมันเข้าไปอยู่ใน features/profile มักจะเป็นทางเลือกที่ง่ายและคลีนกว่าเยอะ
Strategy B: ดัน Shared Domain Flows ลงไปที่ entities (Domain-only)
ถ้าหลาย Feature ต้องใช้ Flow ระดับ Domain ร่วมกัน ให้ย้าย Flow นั้นลงไปอยู่ใน Domain Slice ใน entities ซะ (เช่น entities/session)
หลักการสำคัญ:
entitiesเก็บ Domain Types และ Domain Logic เท่านั้น- UI เก็บไว้ที่
features/widgets - Features ทำการ import และเรียกใช้ Domain Logic จาก
entities
ตัวอย่างเช่น ถ้าทั้ง features/auth และ features/profile ต้องการตรวจสอบ Session ทั้งคู่ ก็ให้เอาฟังก์ชันตรวจสอบ Session ไปไว้ที่ entities/session แล้วให้ทั้งสอง Feature เรียกใช้จากที่นั่นแทน
ดูแนวทางเพิ่มเติมได้ที่ Layers reference — Entities
Strategy C: ประกอบร่างจาก Layer ด้านบน (Pages / App)
แทนที่จะให้ Slices ใน Layer เดียวกันเชื่อมต่อกันเองผ่าน Cross-imports ให้เราไปประกอบพวกมันที่ระดับสูงกว่า (pages / app) แทน วิธีนี้ใช้ Pattern ที่เรียกว่า Inversion of Control (IoC) — แทนที่ Slices จะต้องรู้จักกันเอง ให้ Layer ด้านบนเป็นคนจับพวกมันมาเจอกัน
เทคนิค IoC ทั่วไปได้แก่:
- Render props (React): ส่ง Component หรือฟังก์ชัน render เข้าไปเป็น Props
- Slots (Vue): ใช้ Named slots เพื่อแทรก Content จาก Parent
- Dependency Injections: ส่ง Dependencies ผ่าน Props หรือ Context
ตัวอย่าง Basic composition (React):
export { UserProfilePanel } from './ui/UserProfilePanel';
export { ActivityFeed } from './ui/ActivityFeed';
import React from 'react';
import { UserProfilePanel } from '@/features/userProfile';
import { ActivityFeed } from '@/features/activityFeed';
export function UserDashboardPage() {
return (
<div>
<UserProfilePanel />
<ActivityFeed />
</div>
);
}
ด้วยโครงสร้างนี้ features/userProfile และ features/activityFeed จะไม่รู้จักกันเลย pages/UserDashboardPage จะทำหน้าที่เอาพวกมันมาประกอบเป็นหน้าจอที่สมบูรณ์เอง
ตัวอย่าง Render props (React):
เมื่อ Feature นึงต้องการ Render content ของอีก Feature นึง ให้ใช้ Render props เพื่อกลับด้าน Dependency (Invert dependency):
interface CommentListProps {
comments: Comment[];
renderUserAvatar?: (userId: string) => React.ReactNode;
}
export function CommentList({ comments, renderUserAvatar }: CommentListProps) {
return (
<ul>
{comments.map(comment => (
<li key={comment.id}>
{renderUserAvatar?.(comment.userId)}
<span>{comment.text}</span>
</li>
))}
</ul>
);
}
import { CommentList } from '@/features/commentList';
import { UserAvatar } from '@/features/userProfile';
export function PostPage() {
return (
<CommentList
comments={comments}
renderUserAvatar={(userId) => <UserAvatar userId={userId} />}
/>
);
}
ตอนนี้ CommentList ก็ไม่ต้อง import จาก userProfile แล้ว — หน้า Page จะเป็นคนฉีด (Inject) Avatar Component เข้ามาให้เอง
ตัวอย่าง Slots (Vue):
ระบบ Slot ของ Vue เป็นวิธีที่เป็นธรรมชาติมากๆ ในการประกอบ Features เข้าด้วยกันโดยไม่ต้อง Cross-import:
<template>
<ul>
<li v-for="comment in comments" :key="comment.id">
<slot name="avatar" :userId="comment.userId" />
<span>{{ comment.text }}</span>
</li>
</ul>
</template>
<script setup lang="ts">
defineProps<{
comments: Comment[];
}>();
</script>
<template>
<CommentList :comments="comments">
<template #avatar="{ userId }">
<UserAvatar :userId="userId" />
</template>
</CommentList>
</template>
<script setup lang="ts">
import { CommentList } from '@/features/commentList';
import { UserAvatar } from '@/features/userProfile';
</script>
Feature CommentList ยังคงเป็นอิสระจาก userProfile อย่างสมบูรณ์ หน้า Page ใช้ Slots เพื่อประกอบพวกมันเข้าด้วยกัน
Strategy D: Reuse ข้าม Feature ผ่าน Public API เท่านั้น
ถ้ากลยุทธ์ข้างบนยังไม่ตอบโจทย์และจำเป็นต้อง Reuse ข้าม Feature จริงๆ ให้ทำผ่าน Public API ที่ประกาศไว้อย่างชัดเจนเท่านั้น (เช่น exported hooks หรือ UI components) หลีกเลี่ยงการเข้าถึง store/model หรือ Implementation ภายในของ Slice อื่นโดยตรง
ต่างจาก Strategy A-C ที่พยายามกำจัด Cross-imports วิธีนี้คือ "ยอมรับมัน" แต่ลดความเสี่ยงด้วยการกำหนดขอบเขตที่เข้มงวด
ตัวอย่างโค้ด:
export { useAuth } from './model/useAuth';
export { AuthButton } from './ui/AuthButton';
import React from 'react';
import { useAuth, AuthButton } from '@/features/auth';
export function ProfileMenu() {
const { user } = useAuth();
if (!user) {
return <AuthButton />;
}
return <div>{user.name}</div>;
}
ตัวอย่างเช่น ห้าม features/profile ไป import จาก path อย่าง features/auth/model/internal/* เด็ดขาด ให้ใช้แค่สิ่งที่ features/auth ยอมเปิดเผยผ่าน Public API เท่านั้นพอ
เมื่อไหร่ที่ Cross-imports กลายเป็นปัญหา?
หลังจากดูกลยุทธ์ต่างๆ แล้ว คำถามที่ตามมาคือ:
เมื่อไหร่ที่ Cross-import ยอมรับได้ และเมื่อไหร่ที่ควรมองว่าเป็น Code Smell แล้วต้อง Refactor?
สัญญาณเตือนที่พบบ่อย:
- มีการพึ่งพา store/model/business logic ของ Slice อื่นโดยตรง
- มีการ Import ลึกเข้าไปในไฟล์ภายในของ Slice อื่น
- Bidirectional dependencies (A import B และ B ก็ import A)
- แก้ Slice นึงแล้วทำอีก Slice พังบ่อยๆ
- Flow ที่ควรจะไปประกอบกันที่
pages/appถูกยัดเยียดให้ Cross-import กันเองใน Layer เดียวกัน
ถ้าเจอสัญญาณพวกนี้เมื่อไหร่ ให้มองว่าเป็น Code Smell ไว้ก่อนเลย และลองพิจารณาใช้กลยุทธ์ที่แนะนำไปข้างต้นดูนะ
ความเข้มงวดขึ้นอยู่กับการตัดสินใจของทีม/โปรเจกต์
จะเข้มงวดกับกฎพวกนี้แค่ไหน ขึ้นอยู่กับทีมและบริบทของโปรเจกต์คุณเลย
ตัวอย่างเช่น:
- ใน โปรเจกต์ระยะเริ่มต้น (Early-stage) ที่มีการทดลองเยอะๆ การยอมให้มี Cross-imports บ้างอาจช่วยให้ไปได้เร็วขึ้น (Speed trade-off)
- ใน ระบบระยะยาวหรือระบบที่มีกฎเกณฑ์เข้มงวด (เช่น Fintech หรือบริการขนาดใหญ่) การแบ่งขอบเขตที่ชัดเจนมักคุ้มค่ากว่าในแง่ของการดูแลรักษาและความเสถียร
Cross-imports ไม่ใช่ข้อห้ามตายตัว มันคือ Dependencies ที่ โดยทั่วไปควรหลีกเลี่ยง แต่บางครั้งก็ถูกนำมาใช้อย่างตั้งใจ
ถ้าคุณตัดสินใจจะมี Cross-import:
- ให้มองว่ามันคือทางเลือกทางสถาปัตยกรรมที่ตั้งใจเลือกแล้ว
- เขียนเอกสารหรือเหตุผลกำกับไว้ด้วย
- หมั่นกลับมาทบทวนเป็นระยะเมื่อระบบโตขึ้น
ทีมควรตกลงกันเรื่อง:
- ระดับความเข้มงวดที่ต้องการ
- จะสะท้อนเรื่องนี้ผ่าน Lint rules, Code review และ Documentation อย่างไร
- เมื่อไหร่และอย่างไรที่จะกลับมาประเมิน Cross-imports ที่มีอยู่ใหม่อีกครั้ง