ข้ามไปยังเนื้อหาหลัก

Cross-import

Cross-import คือการ import ระหว่าง slices ที่แตกต่างกันแต่อยู่ใน layer เดียวกัน

ตัวอย่างเช่น:

  • การ import features/product จาก features/cart
  • การ import widgets/sidebar จาก widgets/header

Cross-imports ถือเป็น "Code Smell" หรือสัญญาณเตือนว่า slices ต่างๆ เริ่มมีความผูกพันกัน (Couple) มากเกินไป ในบางสถานการณ์อาจเลี่ยงไม่ได้จริงๆ แต่เราควรทำด้วยความตั้งใจและมีการพูดคุยหรือทำเอกสารแจ้งทีมให้รับรู้เสมอนะ

note

Layer shared และ app ไม่มี concept ของ slice ดังนั้นการ import ภายใน layer เหล่านี้ ไม่นับ ว่าเป็น cross-imports นะ

ทำไมถึงเป็น Code Smell?

Cross-imports ไม่ใช่แค่เรื่องของสไตล์การเขียนโค้ด แต่ถูกมองว่าเป็น Code Smell เพราะมันทำให้เส้นแบ่งระหว่าง Domain ไม่ชัดเจนและก่อให้เกิด dependencies แฝง

ลองนึกภาพว่า slice cart ต้องพึ่งพา Business Logic ของ product โดยตรงดูสิ ตอนแรกมันอาจจะดูสะดวกดี แต่จริงๆ แล้วมันสร้างปัญหาตามมาเพียบเลย:

  1. ความเป็นเจ้าของและความรับผิดชอบไม่ชัดเจน (Unclear ownership): พอ cart ไป import product ก็เริ่มงงแล้วว่า Logic นี้ใครเป็น "เจ้าของ" กันแน่? ถ้าทีม product เปลี่ยน Logic ภายใน ก็อาจจะไปทำ cart พังโดยไม่รู้ตัว ความคลุมเครือนี้ทำให้ไล่โค้ดยากและหาคนรับผิดชอบบั๊กหรือฟีเจอร์ลำบากขึ้น

  2. ขาดความเป็นอิสระและทดสอบยาก (Reduced isolation): ข้อดีหลักๆ ของ Architecture แบบ Sliced คือแต่ละ Slice ควรพัฒนา ทดสอบ และ Deploy ได้อย่างอิสระ แต่ Cross-imports เข้ามาทำลายสิ่งนี้ — จะเทส cart ทีก็ต้อง setup product ด้วย แถมแก้ไฟล์นึงอาจทำเทสของอีกไฟล์พังได้ง่ายๆ

  3. ภาระทางความคิดเพิ่มขึ้น (Increased cognitive load): จะแก้ cart ที ต้องมานั่งพะวงว่า product ทำงานยังไง โครงสร้างเป็นแบบไหน ยิ่ง Cross-imports เยอะ ยิ่งต้องไล่โค้ดข้าม Slice ไปมาจนปวดหัว แค่แก้จุดเล็กๆ อาจต้องรู้ Context ทั้งระบบเลยก็ได้

  4. ทางสู่ 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/profile
  • features/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):

features/userProfile/index.ts
export { UserProfilePanel } from './ui/UserProfilePanel';
features/activityFeed/index.ts
export { ActivityFeed } from './ui/ActivityFeed';
pages/UserDashboardPage.tsx
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):

features/commentList/ui/CommentList.tsx
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>
);
}
pages/PostPage.tsx
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:

features/commentList/ui/CommentList.vue
<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>
pages/PostPage.vue
<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 วิธีนี้คือ "ยอมรับมัน" แต่ลดความเสี่ยงด้วยการกำหนดขอบเขตที่เข้มงวด

ตัวอย่างโค้ด:

features/auth/index.ts

export { useAuth } from './model/useAuth';
export { AuthButton } from './ui/AuthButton';
features/profile/ui/ProfileMenu.tsx

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 ที่มีอยู่ใหม่อีกครั้ง

อ้างอิง (References)