TypeScript Production · Phần 17 — Architecture & Type Boundaries
Dùng TypeScript để bảo vệ dependency direction: domain, ports/adapters, capability interfaces, composition root, anti-corruption layer và compile-time architecture tests.
Type-safe từng function không cứu được dependency graph rối. Ở cấp staff, TypeScript là công cụ để làm boundary và ownership có thể nhìn thấy, kiểm tra và thay đổi độc lập.
Một incident kiến trúc thường bắt đầu rất nhỏ: domain import DTO từ SDK vendor, use case gọi thẳng ORM model, UI deep-import helper nội bộ vì “chỉ cần một type”. Sáu tháng sau, đổi database làm 40 package compile fail dù business rule không đổi.
Mục tiêu bài này không phải vẽ nhiều layer. Mục tiêu là biến các invariant sau thành thứ compiler, module resolver và CI có thể kiểm:
- domain không phụ thuộc transport, framework hay persistence;
- external data được parse/map trước khi thành domain value;
- feature khác chỉ thấy public surface đã cam kết;
- adapter có thể thay ở composition root mà use case không đổi;
- type-only edge vẫn được tính là coupling và ownership.
Bắt đầu từ dependency direction
UI / HTTP handler
↓
application use case
↓
domain policy
↑
ports ← adapters (HTTP, DB, clock, telemetry)
Domain không import framework, database client hay schema transport. Adapter biết domain; domain chỉ biết capability nó cần.
Capability interface nhỏ
export interface UserRepository {
findById(id: UserId): Promise<User | null>;
save(user: User): Promise<void>;
}
export interface Clock {
now(): Date;
}
export type DeactivateUser = (
id: UserId,
reason: DeactivationReason
) => Promise<Result<User, DeactivateError>>;
export function makeDeactivateUser(deps: {
users: UserRepository;
clock: Clock;
audit: AuditLog;
}): DeactivateUser {
return async (id, reason) => {
// orchestration + domain policy
throw new Error('demo');
};
}
Interface được định nghĩa ở nơi tiêu thụ capability, không nhất thiết ở package adapter. Consumer quyết định abstraction tối thiểu.
Composition root là nơi wiring được phép “xấu”
const deactivateUser = makeDeactivateUser({
users: new PostgresUserRepository(pool),
clock: systemClock,
audit: new StructuredAuditLog(logger),
});
new, environment config và framework object tập trung ở entry point. Business code không tự import singleton database hoặc global config.
Anti-corruption layer
type VendorCustomer = {
customer_id: string;
status: 'A' | 'S' | 'D';
};
function toCustomer(dto: VendorCustomer): Customer {
return {
id: parseCustomerId(dto.customer_id),
status: ({ A: 'active', S: 'suspended', D: 'deleted' } as const)[
dto.status
],
};
}
Không cho vocabulary của vendor (customer_id, mã A/S/D) lan vào core. Khi vendor đổi, blast radius dừng ở adapter.
Type export cũng là dependency
import type không tạo runtime edge nhưng vẫn tạo coupling kiến trúc và compiler graph. Shared package chứa mọi interface thường biến thành “bãi rác trung tâm”. Chia theo domain ownership, không theo kind (types/, utils/, services/).
features/
billing/
domain/
application/
adapters/
public.ts
Chỉ public.ts là entry point feature khác được import. Internal deep import nên bị chặn bằng package exports/lint boundary.
Khi nào abstraction đáng giá?
Tạo port khi:
- core cần test deterministic;
- implementation thuộc infrastructure dễ thay/đắt/không ổn định;
- dependency direction cần đảo;
- capability có contract domain rõ.
Không tạo IUserService chỉ để bọc UserService một-một. Abstraction không có consumer pressure chỉ tăng navigation.
Architecture test
TypeScript không tự cấm import ngược. Kết hợp:
- package
exportschặn internal path; - lint rule/import graph rule cấm layer edge;
- project references tách compilation boundary;
- CI dependency-cycle check;
- contract tests cho adapter.
Mỗi lớp bảo vệ một failure khác nhau. Một sơ đồ trong README không chặn được import; một interface cũng không chặn deep path; project reference không tự chứng minh adapter giữ semantics domain.
Boundary phải tồn tại ở cả source, type và artifact
Một boundary production có ba mặt:
source graph ai được import ai
type surface consumer được phép biết contract nào
runtime artifact package thực sự export module nào
Chỉ tách folder giải quyết rất ít. Nếu ../../billing/internal/repository vẫn
resolve, boundary chỉ là convention.
Ví dụ package feature:
packages/billing/
src/
domain/
invoice.ts
money.ts
application/
issue-invoice.ts
adapters/
postgres-invoice-repository.ts
public.ts
package.json
tsconfig.json
public.ts re-export contract được sở hữu:
export type { Invoice, InvoiceId, Money } from './domain/invoice.js';
export type {
IssueInvoice,
IssueInvoiceCommand,
IssueInvoiceError,
} from './application/issue-invoice.js';
export { makeIssueInvoice } from './application/issue-invoice.js';
Không export adapter nếu consumer chỉ cần use case. “Có thể export” không đồng nghĩa “nên biến thành compatibility promise”.
Package exports thu hẹp runtime/public subpath:
{
"name": "@acme/billing",
"type": "module",
"exports": {
".": {
"types": "./dist/public.d.ts",
"import": "./dist/public.js"
}
}
}
Consumer có thể import @acme/billing, nhưng
@acme/billing/dist/adapters/postgres... không phải public contract.
Hai caveat:
exportsbảo vệ package consumer; relative import bên trong cùng package vẫn cần lint/import-graph rule;- editor/tsconfig/module resolution phải dùng mode hiểu
exports, nếu không source và artifact có thể cho kết quả khác nhau.
Project references encode build direction, không tự tạo architecture
Tách project theo ownership lớn giúp compiler nhìn thấy build graph:
// packages/billing/domain/tsconfig.json
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
Domain không có reference tới adapter. Application chỉ reference domain; adapter reference application/domain; composition root reference tất cả:
{
"files": [],
"references": [
{ "path": "./domain" },
{ "path": "./application" },
{ "path": "./adapters" },
{ "path": "./app" }
]
}
Project reference có giá trị khi boundary có ownership/build/cache riêng. Tách mỗi folder thành một project tạo config, declaration và graph overhead mà không tạo thêm invariant.
Alias paths cũng không phải firewall:
{
"compilerOptions": {
"paths": {
"@billing/*": ["packages/billing/src/*"]
}
}
}
Nó làm import ngắn hơn nhưng còn mở mọi deep path, và không tự rewrite specifier cho runtime. Boundary phải dựa trên exports, graph rule và artifact consumer test, không dựa vào vẻ đẹp của alias.
Type-only import vẫn là một dependency thật
import type biến mất sau emit, nhưng nó vẫn:
- buộc consumer compile khi declaration thay đổi;
- kéo symbol vào language-service graph;
- tạo ownership/semver coupling;
- có thể tạo type cycle và declaration leak.
// domain không nên làm việc này dù không có runtime import
import type { Prisma } from '@prisma/client';
export type User = Prisma.UserGetPayload<{
include: { roles: true };
}>;
Domain giờ bị định nghĩa bởi schema/utility của ORM. Thay bằng domain-owned type và mapper tại adapter:
type UserRow = {
id: string;
status: string;
created_at: Date;
};
function toUser(row: UserRow): User {
return User.restore({
id: parseUserId(row.id),
status: parseUserStatus(row.status),
createdAt: row.created_at,
});
}
Mapper là chỗ duplication có chủ đích: nó ngăn storage shape trở thành public domain ABI.
Port nên mô tả capability, không mô phỏng vendor
Generic repository nghe reusable:
interface Repository<Entity, Id> {
find(id: Id): Promise<Entity | null>;
save(entity: Entity): Promise<void>;
delete(id: Id): Promise<void>;
}
Nhưng nó thường đẩy query semantics sang caller, không mô tả consistency, transaction hay business intent. Capability domain hẹp dễ thay và test hơn:
interface Invoices {
findDraft(id: InvoiceId): Promise<InvoiceDraft | null>;
reserveNumber(year: number): Promise<InvoiceNumber>;
saveIssued(invoice: IssuedInvoice): Promise<void>;
}
Review port bằng bốn câu hỏi:
- Tên method nói ngôn ngữ domain hay ngôn ngữ vendor?
- Input/output có leak DTO, query builder, transaction object không?
- Error nào thuộc contract và error nào phải map ở adapter?
- Consumer thật sự cần toàn bộ method hay chỉ một capability nhỏ?
Error cũng có ownership
Adapter không nên làm domain xử lý PrismaClientKnownRequestError hay status
code 409:
type SaveInvoiceError =
| { kind: 'number-conflict' }
| { kind: 'storage-unavailable'; retryable: boolean };
interface Invoices {
saveIssued(invoice: IssuedInvoice): Promise<Result<void, SaveInvoiceError>>;
}
Adapter map vendor error vào vocabulary của port. Telemetry vẫn giữ cause nội
bộ, nhưng domain branching không phụ thuộc class lỗi bên thứ ba.
Capability accumulation thay cho service locator
Service locator/global container làm dependency ẩn:
// khó biết use case cần gì, test có thể đọc global state
const db = container.resolve('database');
Factory parameter làm capability explicit và cho satisfies kiểm composition:
type IssueInvoiceDeps = {
invoices: Invoices;
clock: Clock;
ids: IdGenerator;
audit: AuditLog;
};
const billingDeps = {
invoices: new PostgresInvoices(pool),
clock: systemClock,
ids: cryptoIds,
audit: structuredAudit,
} satisfies IssueInvoiceDeps;
const issueInvoice = makeIssueInvoice(billingDeps);
satisfies bắt dependency thiếu/thừa trên literal mà vẫn giữ concrete type cho
composition root. Business code chỉ nhận port type; root được phép biết concrete
adapter.
Với request-scoped dependency, lifetime cũng là contract runtime. TypeScript không chứng minh singleton/request/transaction lifetime; container test và load test vẫn cần.
Transaction boundary thuộc use case
Nếu từng repository tự commit, use case nhiều bước không còn atomic. Nhưng đưa ORM transaction object vào domain lại leak infrastructure.
Một port có thể mô tả unit of work bằng capability:
type TransactionPorts = {
invoices: Invoices;
outbox: Outbox;
};
interface UnitOfWork {
run<Result>(
work: (ports: TransactionPorts) => Promise<Result>
): Promise<Result>;
}
Application quyết định thao tác nào cùng transaction; adapter quyết định cách map thành transaction database. Cần runtime test cho rollback, retry và callback không được dùng ports sau khi transaction đóng—type không tự chứng minh lifetime.
Anti-corruption layer phải parse trước khi map
Ví dụ ban đầu nhận VendorCustomer typed sẵn, nhưng network trả dữ liệu không
tin cậy. Pipeline đúng:
unknown response
→ parse VendorCustomerV1 | VendorCustomerV2
→ map vendor vocabulary
→ enforce domain invariant
→ Customer
type VendorCustomerV1 = {
version: 1;
customer_id: string;
status: 'A' | 'S' | 'D';
};
type VendorCustomerV2 = {
version: 2;
id: string;
state: 'active' | 'suspended' | 'deleted';
};
function normalizeVendorCustomer(
dto: VendorCustomerV1 | VendorCustomerV2
): Customer {
switch (dto.version) {
case 1:
return toCustomerV1(dto);
case 2:
return toCustomerV2(dto);
}
}
Parser/decoder sống trong adapter. Mapper không nhận unknown; nó nhận DTO đã
được runtime chứng minh. Khi vendor thêm version, exhaustive switch và parser
tests dẫn team tới đúng blast radius.
Correlation giữa command và handler không nên bị tách
Một registry kiểu này mất quan hệ:
type CommandName = 'issue-invoice' | 'void-invoice';
type AnyCommand = IssueInvoiceCommand | VoidInvoiceCommand;
type BrokenRegistry = Record<
CommandName,
(command: AnyCommand) => Promise<unknown>
>;
Map contract giữ key/input/output cùng source:
type BillingCommands = {
'issue-invoice': {
input: IssueInvoiceCommand;
output: IssuedInvoice;
};
'void-invoice': {
input: VoidInvoiceCommand;
output: VoidedInvoice;
};
};
type CommandHandlers<
Catalog extends Record<string, { input: unknown; output: unknown }>,
> = {
[K in keyof Catalog]: (
command: Catalog[K]['input']
) => Promise<Catalog[K]['output']>;
};
Đây là use case hợp lý cho mapped type vì nó enforce completeness và
correlation ở composition boundary. Đừng expose machinery sâu nếu consumer chỉ
cần named BillingHandlers.
Public type surface là một compatibility contract
Một function không annotation có thể emit return type chứa internal helper, conditional/intersection lớn hoặc type từ dependency:
// public boundary: annotation có tên, ổn định
export function issueInvoice(
command: IssueInvoiceCommand
): Promise<Result<IssuedInvoice, IssueInvoiceError>> {
return internalIssueInvoice(command);
}
Review .d.ts như review API:
- internal module/type có leak không;
- consumer cần import dependency chỉ để đọc signature không;
- rename/refactor implementation có đổi inferred public type không;
- union thêm member có buộc consumer exhaustive switch không;
- optional/readonly/inference có thay đổi dù runtime giữ nguyên không.
Type-only breaking change vẫn là breaking change với consumer TypeScript.
Architecture tests phải có negative fixture
Unit test happy path không chứng minh import bất hợp lệ bị chặn. Thêm consumer fixtures:
fixtures/
public-consumer/ # phải compile
illegal-deep-import/ # phải fail resolution/graph rule
adapter-contract/ # runtime suite cho từng adapter
Public consumer import package đã build, không import src:
import { makeIssueInvoice } from '@acme/billing';
import type { IssueInvoiceCommand } from '@acme/billing';
Illegal fixture cố import subpath không export. CI command phải assert process
thất bại vì đúng diagnostic; chỉ chạy tsc rồi thấy đỏ không đủ cho một negative
suite tự động.
Graph test cần cover cả import lẫn import type, dynamic import và generated
code. Runtime contract test cần chạy cùng behavior suite trên fake/in-memory và
adapter thật:
export function invoiceRepositoryContract(create: () => Promise<Invoices>) {
// save/find semantics, conflict mapping, ordering, retry behavior
}
Fake không nên “dễ tính” hơn production adapter. Nếu database enforce unique constraint hay transaction isolation, contract suite phải encode semantics đó.
Cycle: phân biệt runtime, type và ownership cycle
- runtime cycle có thể tạo partially initialized module;
- type-only cycle không emit import nhưng tăng compiler graph/coupling;
- ownership cycle nói hai package không thể release/thay đổi độc lập.
Tách một shared-types package thường chỉ dời cycle vào hub. Hãy tìm concept nào
thật sự sở hữu contract, hoặc introduce port ở consumer và mapper ở adapter.
Nếu hai feature luôn đổi cùng nhau và chia cùng owner/release, có thể boundary đang giả. Merge bounded context đôi khi tốt hơn thêm abstraction.
Migration bằng strangler boundary
Không cần clean toàn codebase trước khi có giá trị. Chọn một use case và dựng seam quanh nó:
- inventory import/runtime data hiện tại;
- định nghĩa domain input/output và port tại consumer;
- bọc legacy module trong adapter giữ behavior;
- composition root chuyển traffic qua use case mới;
- thêm contract/architecture tests;
- migrate consumer kế tiếp; xóa deep import khi usage về zero.
Adapter legacy được phép có assertion/any có audit, nhưng domain mới không
được import ngược. Đây là strictness ratchet ở cấp architecture: vùng sạch chỉ
mở rộng, không yêu cầu big-bang rewrite.
Metrics hữu ích:
- số deep imports còn lại theo feature;
- số public exports và declaration size;
- cycle count/runtime cycle incidents;
- thời gian thay adapter/upgrade vendor;
- escape hatch mới/cũ ở boundary.
Đừng tối ưu một con số mù quáng. Ít export hơn không tự động tốt nếu team phải copy contract; zero cycle không có nghĩa ownership rõ.
Failure modes cấp Staff
Interface ở provider
Adapter định nghĩa interface theo toàn bộ capability nó có, mọi consumer phụ thuộc một abstraction quá lớn. Đặt port ở nơi tiêu thụ.
Framework type đi xuyên core
Request, ORM row, schema library error hoặc logger instance xuất hiện trong
domain signature. Map ở adapter/composition root.
Barrel export mọi thứ
Convenience index biến internal helper thành accidental API và dễ tạo cycle. Whitelist export, test từ built artifact.
import type được xem là miễn phí
Runtime graph sạch nhưng compiler/ownership graph vẫn rối và type SemVer lan khắp monorepo.
Generic abstraction không có domain pressure
Repository<T>, Service<T> và Mapper<A, B> tạo indirection nhưng không giữ
invariant cụ thể.
Composition phân tán
Feature tự resolve global singleton, làm lifetime/dependency ẩn và test không deterministic.
Architecture rule chỉ nằm trong wiki
Rule bị vi phạm chính lúc deadline căng nhất. Enforce bằng resolver/exports, graph test và CI fixture.
Decision rules
- Boundary theo ownership/change cadence, không theo danh từ kỹ thuật chung.
- Port thuộc consumer và chỉ mô tả capability consumer cần.
- DTO/parser/mapper/vendor error ở adapter; domain nhận value đã có bằng chứng.
import typevẫn được tính trong dependency review.- Public export là compatibility promise; default là private.
- Project reference cho build/ownership boundary có giá trị, không cho từng folder.
- Composition root là nơi duy nhất biết concrete adapter và lifetime.
- Assertion/
anynếu cần nằm trong legacy/external adapter, có owner và test. - Một abstraction không tạo test seam, dependency inversion hoặc stability thì chưa có lý do tồn tại.
- Nếu pure types không enforce được runtime/module graph, dùng exports, lint, contract test hoặc code generation đúng tầng.
Lab
Xây boundary cho use case issueInvoice đang import trực tiếp ORM và payment
SDK.
- Vẽ source/type/runtime graph hiện tại; đánh dấu owner từng edge.
- Định nghĩa
IssueInvoiceCommand, result/error domain và bốn capability tối thiểu ở application package. - Parse payment response từ
unknown; map DTO/version/vendor error trong adapter. - Đặt transaction boundary qua
UnitOfWork, không leak transaction client. - Tạo composition root dùng
satisfiesđể wiring dependency. - Whitelist public surface bằng
exports; inspect declaration đã emit. - Thêm public consumer fixture phải compile và deep-import fixture phải fail.
- Thêm graph rule cấm cả value/type import từ domain/application về adapter.
- Chạy cùng repository contract suite trên in-memory và database adapter.
- Ghi migration/rollback plan và metrics cho deep import còn lại.
Done khi: thay Postgres adapter bằng in-memory/HTTP adapter chỉ sửa composition root; domain không import framework/vendor; payload sai bị chặn trước domain; illegal edge fail CI; built consumer chỉ thấy public contract; và team có seam để migrate phần legacy tiếp theo mà không đóng băng delivery.
Checklist review kiến trúc
- Concept này do package/feature nào sở hữu?
- Dependency direction có được tool enforce hay chỉ được vẽ?
- Type-only import nào tạo coupling ngoài ý muốn?
- Public
.d.tscó leak implementation/dependency không? - External
unknownđược parse ở đâu và mapper sống ở đâu? - Port có nhỏ theo consumer hay phản chiếu provider?
- Error, transaction và lifetime có semantics rõ không?
- Fake và adapter thật có cùng contract suite không?
- Boundary có giảm blast radius đo được không?
- Nếu xóa abstraction này, invariant nào biến mất?