Išmokite DDD JavaScript. Modulio esybės šablonas padeda kurti keičiamo mastelio ir patikimas programas su tvirtais domeno objekto modeliais.
JavaScript modulio esybės šablonai: išsamus domeno objekto modeliavimas
Programinės įrangos kūrimo pasaulyje, ypač dinamiškoje ir nuolat besivystančioje JavaScript ekosistemoje, dažnai pirmenybę teikiame greičiui, sistemoms ir funkcijoms. Kuriame sudėtingas vartotojo sąsajas, prisijungiame prie daugybės API ir diegiame programas stulbinančiu tempu. Tačiau šiame skubėjime kartais nepaisome paties mūsų programos branduolio: verslo domeno. Tai gali sukelti tai, kas dažnai vadinama „dideliu purvo kamuoliu“ – sistemą, kurioje verslo logika išsibarstoma, duomenys yra nestruktūrizuoti, o paprastas pakeitimas gali sukelti nenumatytų klaidų kaskadą.
Čia pasitelkiamas Domeno objekto modeliavimas. Tai praktika, leidžianti sukurti turtingą, išraiškingą problemos erdvės, kurioje dirbate, modelį. O JavaScript, modulio esybės šablonas yra galingas, elegantiškas ir nuo sistemos nepriklausomas būdas tai pasiekti. Šis išsamus vadovas supažindins jus su šio šablono teorija, praktika ir privalumais, suteikdamas galimybę kurti tvirtesnes, keičiamo mastelio ir prižiūrimas programas.
Kas yra Domeno objekto modeliavimas?
Prieš gilindamiesi į patį šabloną, išsiaiškinkime terminus. Labai svarbu atskirti šią koncepciją nuo naršyklės Dokumento objekto modelio (DOM).
- Domenas: Programinėje įrangoje „domenas“ yra konkreti sritis, kuriai priklauso vartotojo verslas. El. prekybos programai domenas apima tokias sąvokas kaip Produktai, Klientai, Užsakymai ir Mokėjimai. Socialinės žiniasklaidos platformai jis apima Vartotojus, Įrašus, Komentarus ir Patiktukus.
- Domeno objekto modeliavimas: Tai programinės įrangos modelio kūrimo procesas, kuris atspindi esybes, jų elgesį ir ryšius toje verslo srityje. Tai realaus pasaulio sąvokų vertimas į kodą.
Geras domeno modelis nėra tik duomenų konteinerių rinkinys. Tai gyvas jūsų verslo taisyklių atspindys. Užsakymo objektas neturėtų tiesiog laikyti prekių sąrašo; jis turėtų žinoti, kaip apskaičiuoti bendrą sumą, kaip pridėti naują prekę ir ar jį galima atšaukti. Šis duomenų ir elgesio inkapsuliavimas yra raktas į atsparaus programos branduolio kūrimą.
Dažna problema: Anarchija „modelio“ lygmenyje
Daugelyje JavaScript programų, ypač tų, kurios auga organiškai, „modelio“ lygmuo dažnai yra antrinė mintis. Dažnai matome šį antimodėlį:
// Somewhere in an API controller or service...
asyn function createUser(req, res) {
const { email, password, firstName, lastName } = req.body;
// Business logic and validation is scattered here
if (!email || !email.includes('@')) {
return res.status(400).send({ error: 'A valid email is required.' });
}
if (!password || password.length < 8) {
return res.status(400).send({ error: 'Password must be at least 8 characters.' });
}
const user = {
email: email.toLowerCase(),
password: await hashPassword(password), // Some utility function
fullName: `${firstName} ${lastName}`, // Logic for derived data is here
createdAt: new Date()
};
// Now, what is `user`? It's just a plain object.
// Nothing stops another developer from doing this later:
// user.email = 'an-invalid-email';
// user.password = 'short';
await db.users.insert(user);
res.status(201).send(user);
}
Šis metodas kelia keletą kritinių problemų:
- Vieno tiesos šaltinio nėra: Taisyklės, kas sudaro galiojantį „vartotoją“, apibrėžiamos šiame valdiklyje. Ką daryti, jei kitai sistemos daliai reikia sukurti vartotoją? Ar kopijuosite-įklijuosite logiką? Tai veda prie nenuoseklumo ir klaidų.
- Aneminis domeno modelis: `user` objektas yra tiesiog „kvaila“ duomenų talpykla. Jis neturi jokio elgesio ir jokio savęs suvokimo. Visa logika, kuri su juo veikia, gyvena išorėje.
- Maža sanglauda: Vartotojo vardo kūrimo logika sumaišyta su API užklausų/atsakymų tvarkymu ir slaptažodžio maišymu.
- Sudėtinga testuoti: Norėdami patikrinti vartotojo kūrimo logiką, turite imituoti HTTP užklausas ir atsakymus, duomenų bazes ir maišymo funkcijas. Negalite tiesiog testuoti „vartotojo“ koncepcijos atskirai.
- Numatomos sutartys: Likusi programos dalis tiesiog turi „manyti“, kad bet kuris objektas, atspindintis vartotoją, turi tam tikrą formą ir kad jo duomenys yra galiojantys. Nėra jokių garantijų.
Sprendimas: JavaScript modulio esybės šablonas
Modulio esybės šablonas sprendžia šias problemas, naudodamas standartinį JavaScript modulį (vieną failą), kad apibrėžtų viską apie vieną domeno koncepciją. Šis modulis tampa galutiniu tiesos šaltiniu tai esybei.
Modulio esybė paprastai eksponuoja gamyklos funkciją. Ši funkcija yra atsakinga už galiojančio esybės egzemplioriaus sukūrimą. Grąžinamas objektas yra ne tik duomenys; tai turtingas domeno objektas, kuris inkapsuliuoja savo duomenis, patvirtinimą ir verslo logiką.
Pagrindinės modulio esybės charakteristikos
- Inkapsuliavimas: Jis sujungia duomenis ir funkcijas, kurios veikia su tais duomenimis.
- Patvirtinimas riboje: Jis užtikrina, kad neįmanoma sukurti negaliojančios esybės. Jis saugo savo būseną.
- Aiškus API: Jis eksponuoja švarų, tikslingą funkcijų rinkinį (viešą API), skirtą sąveikai su esybe, slėpdamas vidines įgyvendinimo detales.
- Nekeistinamumas: Jis dažnai sukuria nekeičiamus arba tik skaitymo objektus, kad būtų išvengta atsitiktinių būsenos pokyčių ir užtikrintas numatomas elgesys.
- Perkeliamumas: Jis neturi jokių priklausomybių nuo sistemų (pvz., Express, React) ar išorinių sistemų (pvz., duomenų bazių, API). Tai gryna verslo logika.
Pagrindiniai modulio esybės komponentai
Atkurkime mūsų `User` koncepciją naudodami šį šabloną. Sukursime failą, `user.js` (arba `user.ts` TypeScript vartotojams), ir sukursime jį žingsnis po žingsnio.
1. Gamyklos funkcija: Jūsų objekto konstruktorius
Vietoj klasių naudosime gamyklos funkciją (pvz., `buildUser`). Gamyklos siūlo didelį lankstumą, leidžia išvengti „this“ raktažodžio problemų ir leidžia natūraliau valdyti privačią būseną bei inkapsuliaciją JavaScript.
Mūsų tikslas yra sukurti funkciją, kuri paims neapdorotus duomenis ir grąžins tinkamai suformuotą, patikimą Vartotojo objektą.
// file: /domain/user.js
export default function buildMakeUser() {
// This inner function is the actual factory.
// It has access to any dependencies passed to buildMakeUser, if needed.
return function makeUser({
id = generateId(), // Let's assume a function to generate a unique ID
firstName,
lastName,
email,
passwordHash,
createdAt = new Date()
}) {
// ... validation and logic will go here ...
const user = {
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => email,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
};
// Using Object.freeze to make the object immutable.
return Object.freeze(user);
}
}
Atkreipkite dėmesį į keletą dalykų. Naudojame funkciją, kuri grąžina funkciją (aukštesnio lygio funkcija). Tai galingas šablonas priklausomybėms įterpti, pvz., unikaliam ID generatoriui ar patvirtinimo bibliotekai, nesiejant esybės su konkrečia implementacija. Kol kas paliksime paprasta.
2. Duomenų patvirtinimas: Sargybinis prie vartų
Esybė turi apsaugoti savo vientisumą. Turi būti neįmanoma sukurti `User` negaliojančioje būsenoje. Patvirtinimą pridedame tiesiai į gamyklos funkciją. Jei duomenys negalioja, gamykla turėtų išmesti klaidą, aiškiai nurodydama, kas negerai.
// file: /domain/user.js
export default function buildMakeUser({ Id, isValidEmail, hashPassword }) {
return function makeUser({
id = Id.makeId(),
firstName,
lastName,
email,
password, // We now take a plain password and handle it inside
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('User must have a valid id.');
}
if (!firstName || firstName.length < 2) {
throw new Error('First name must be at least 2 characters long.');
}
if (!lastName || lastName.length < 2) {
throw new Error('Last name must be at least 2 characters long.');
}
if (!email || !isValidEmail(email)) {
throw new Error('User must have a valid email address.');
}
if (!password || password.length < 8) {
throw new Error('Password must be at least 8 characters long.');
}
// Data normalization and transformation happens here
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
});
}
}
Dabar bet kuri mūsų sistemos dalis, norinti sukurti `User`, turi eiti per šią gamyklą. Kiekvieną kartą garantuojame patvirtinimą. Taip pat inkapsuliavome slaptažodžio maišymo ir el. pašto adreso normalizavimo logiką. Likutinei programos daliai nereikia žinoti ar rūpintis šiomis detalėmis.
3. Verslo logika: Elgesio inkapsuliavimas
Mūsų `User` objektas vis dar šiek tiek aneminis. Jis laiko duomenis, bet nieko *nedaro*. Pridėkime elgesį – metodus, kurie atspindi domenui būdingus veiksmus.
// ... inside the makeUser function ...
if (!password || password.length < 8) {
// ...
}
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt,
// Business Logic / Behavior
getFullName: () => `${firstName} ${lastName}`,
// A method that describes a business rule
canVote: () => {
// In some countries, voting age is 18. This is a business rule.
// Let's assume we have a dateOfBirth property.
const age = calculateAge(dateOfBirth);
return age >= 18;
}
});
// ...
Logika `getFullName` nebėra išsibarstoma kokiam nors atsitiktiniam valdiklyje; ji priklauso pačiai `User` esybei. Kiekvienas, turintis `User` objektą, dabar gali patikimai gauti pilną vardą, iškviesdamas `user.getFullName()`. Logika apibrėžiama vieną kartą, vienoje vietoje.
Kuriame praktinį pavyzdį: paprasta el. prekybos sistema
Pritaikykime šį šabloną labiau susijusiam domenui. Sumodeliuosime `Product`, `OrderItem` ir `Order`.
1. `Product` esybės modeliavimas
Produktas turi pavadinimą, kainą ir tam tikrą atsargų informaciją. Jis privalo turėti pavadinimą, o jo kaina negali būti neigiama.
// file: /domain/product.js
export default function buildMakeProduct({ Id }) {
return function makeProduct({
id = Id.makeId(),
name,
description,
price,
stock = 0
}) {
if (!Id.isValidId(id)) {
throw new Error('Product must have a valid ID.');
}
if (!name || name.trim().length < 2) {
throw new Error('Product name must be at least 2 characters.');
}
if (isNaN(price) || price <= 0) {
throw new Error('Product must have a price greater than zero.');
}
if (isNaN(stock) || stock < 0) {
throw new Error('Stock must be a non-negative number.');
}
return Object.freeze({
getId: () => id,
getName: () => name,
getDescription: () => description,
getPrice: () => price,
getStock: () => stock,
// Business logic
isAvailable: () => stock > 0,
// A method that modifies state by returning a new instance
reduceStock: (amount) => {
if (amount > stock) {
throw new Error('Not enough stock available.');
}
// Return a NEW product object with the updated stock
return makeProduct({ id, name, description, price, stock: stock - amount });
}
});
}
}
Atkreipkite dėmesį į `reduceStock` metodą. Tai yra esminė koncepcija, susijusi su nekeistinamumu. Užuot keitus `stock` savybę esamame objekte, ji grąžina *naują* `Product` egzempliorių su atnaujinta verte. Tai daro būsenos pokyčius aiškius ir numatomus.
2. `Order` esybės modeliavimas (Agregato šaknis)
„Order“ yra sudėtingesnis. Tai, ką Domain-Driven Design (DDD) vadina „Agregato šaknimi“. Tai yra esybė, kuri valdo kitus, mažesnius objektus savo ribose. „Order“ yra `OrderItem` sąrašas. Jūs nepridedate produkto tiesiai į užsakymą; jūs pridedate `OrderItem`, kuriame yra produktas ir kiekis.
// file: /domain/order.js
export const ORDER_STATUS = {
PENDING: 'PENDING',
PAID: 'PAID',
SHIPPED: 'SHIPPED',
CANCELLED: 'CANCELLED'
};
export default function buildMakeOrder({ Id, validateOrderItem }) {
return function makeOrder({
id = Id.makeId(),
customerId,
items = [],
status = ORDER_STATUS.PENDING,
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('Order must have a valid ID.');
}
if (!customerId) {
throw new Error('Order must have a customer ID.');
}
let orderItems = [...items]; // Create a private copy to manage
return Object.freeze({
getId: () => id,
getCustomerId: () => customerId,
getItems: () => [...orderItems], // Return a copy to prevent external modification
getStatus: () => status,
getCreatedAt: () => createdAt,
// Business Logic
calculateTotal: () => {
return orderItems.reduce((total, item) => {
return total + (item.getPrice() * item.getQuantity());
}, 0);
},
addItem: (item) => {
// validateOrderItem is a function that ensures the item is a valid OrderItem entity
validateOrderItem(item);
// Business rule: prevent adding duplicates, just increase quantity
const existingItemIndex = orderItems.findIndex(i => i.getProductId() === item.getProductId());
if (existingItemIndex > -1) {
const newQuantity = orderItems[existingItemIndex].getQuantity() + item.getQuantity();
// Here you'd update the quantity on the existing item
// (This requires items to be mutable or have an update method)
} else {
orderItems.push(item);
}
},
markPaid: () => {
if (status !== ORDER_STATUS.PENDING) {
throw new Error('Only pending orders can be marked as paid.');
}
// Return a new Order instance with the updated status
return makeOrder({ id, customerId, items: orderItems, status: ORDER_STATUS.PAID, createdAt });
}
});
}
}
Ši `Order` esybė dabar vykdo sudėtingas verslo taisykles:
- Ji valdo savo prekių sąrašą.
- Ji žino, kaip apskaičiuoti savo bendrą sumą.
- Ji užtikrina būsenų perėjimus (pvz., galite pažymėti `PENDING` užsakymą tik kaip `PAID`).
Užsakymų verslo logika dabar yra tvarkingai inkapsuliuota šiame modulyje, testuojama izoliuotai ir daugkartinio naudojimo visoje jūsų programoje.
Išplėstiniai šablonai ir svarstymai
Nekeistinamumas: Nuspėjamumo pagrindas
Mes palietėme nekeistinamumą. Kodėl tai taip svarbu? Kai objektai yra nekeičiami, galite juos perduoti savo programoje, nesibaimindami, kad kokia nors tolima funkcija netikėtai pakeis jų būseną. Tai pašalina visą klaidų klasę ir palengvina jūsų programos duomenų srauto supratimą.
Object.freeze() suteikia negilų užšaldymą. Esybėms su įdėtais objektais ar masyvais (kaip mūsų `Order`) turite būti atsargesni. Pavyzdžiui, `order.getItems()` mes grąžinome kopiją (`[...orderItems]`), kad neleistume išoriniam iškvietėjui tiesiogiai įdėti elementų į užsakymo vidinį masyvą.
Sudėtingoms programoms tokios bibliotekos kaip Immer gali žymiai palengvinti darbą su nekeičiamomis įdėtomis struktūromis, tačiau pagrindinis principas išlieka: traktuokite savo esybes kaip nekeičiamas vertes. Kai reikia atlikti pakeitimą, sukurkite naują vertę.
Asinchroninių operacijų ir patvarumo tvarkymas
Galbūt pastebėjote, kad mūsų esybės yra visiškai sinchroninės. Jos nieko nežino apie duomenų bazes ar API. Tai yra tyčinis ir pagrindinis šablono stiprybė!
Esybės neturėtų išsisaugoti pačios. Esybės užduotis yra užtikrinti verslo taisykles. Duomenų saugojimo duomenų bazėje užduotis priklauso kitam jūsų programos sluoksniui, dažnai vadinamam paslaugų sluoksniu, naudojimo atvejų sluoksniu arba talpyklos šablonu.
Štai kaip jie sąveikauja:
// file: /use-cases/create-user.js
// This use case depends on the user entity factory and a database access function.
export default function makeCreateUser({ makeUser, usersDatabase }) {
return async function createUser(userInfo) {
// 1. Create a valid domain entity. This step validates the data.
const user = makeUser(userInfo);
// 2. Check for business rules that require external data (e.g., email uniqueness)
const exists = await usersDatabase.findByEmail({ email: user.getEmail() });
if (exists) {
throw new Error('Email address is already in use.');
}
// 3. Persist the entity. The database needs a plain object.
const persisted = await usersDatabase.insert({
id: user.getId(),
firstName: user.getFirstName(),
// ... and so on
});
return persisted;
}
}
Šis atsakomybių atskyrimas yra galingas:
- `User` esybė yra gryna, sinchroninė ir lengvai patikrinama vienetais.
- `createUser` naudojimo atvejis yra atsakingas už orkestravimą ir gali būti tikrinamas integravimu su imitacine duomenų baze.
- `usersDatabase` modulis yra atsakingas už konkrečią duomenų bazės technologiją ir gali būti tikrinamas atskirai.
Serilizacija ir deseralizacija
Jūsų esybės, su savo metodais, yra turtingi objektai. Tačiau kai siunčiate duomenis per tinklą (pvz., JSON API atsakyme) arba saugote juos duomenų bazėje, jums reikia paprastos duomenų reprezentacijos. Šis procesas vadinamas serilizacija.
Dažnas šablonas yra pridėti `toJSON()` arba `toObject()` metodą prie jūsų esybės.
// ... inside the makeUser function ...
return Object.freeze({
getId: () => id,
// ... other getters
// Serialization method
toObject: () => ({
id,
firstName,
lastName,
email: normalizedEmail,
createdAt
// Notice we don't include the passwordHash
})
});
Atvirkštinis procesas, imant paprastus duomenis iš duomenų bazės ar API ir paverčiant juos atgal į turtingą domeno esybę, yra būtent tai, kam skirta jūsų `makeUser` gamyklos funkcija. Tai yra deserializacija.
Tipizavimas su TypeScript arba JSDoc
Nors šis šablonas puikiai veikia gryname JavaScript, pridėjus statinius tipus su TypeScript ar JSDoc, jis tampa dar galingesnis. Tipai leidžia formaliai apibrėžti jūsų esybės „formą“, suteikiant puikų automatinį užpildymą ir kompiliavimo laiko patikrinimus.
// file: /domain/user.ts
// Define the entity's public interface
export type User = Readonly<{
getId: () => string;
getFirstName: () => string;
// ... etc
getFullName: () => string;
}>;
// The factory function now returns the User type
export default function buildMakeUser(...) {
return function makeUser(...): User {
// ... implementation
}
}
Bendrieji modulio esybės šablono privalumai
Priėmę šį šabloną, gausite daugybę privalumų, kurie stiprės, kai jūsų programa augs:
- Vienas tiesos šaltinis: Verslo taisyklės ir duomenų patvirtinimas yra centralizuoti ir nedviprasmiški. Taisyklės pakeitimas atliekamas tik vienoje vietoje.
- Didelė sanglauda, maža priklausomybė: Esybės yra savarankiškos ir nepriklauso nuo išorinių sistemų. Tai daro jūsų kodą moduliuojamą ir lengvai refaktoruojamą.
- Aukščiausias testuojamumas: Galite rašyti paprastus, greitus vieneto testus savo kritiškiausiai verslo logikai, neimituojant viso pasaulio.
- Pagerinta kūrėjo patirtis: Kai kūrėjui reikia dirbti su `User`, jis turi aiškų, numatomą ir save dokumentuojantį API, kurį gali naudoti. Nebereikia spėlioti paprastų objektų formos.
- Mastelio keitimo pagrindas: Šis šablonas suteikia jums stabilų, patikimą branduolį. Pridėjus daugiau funkcijų, sistemų ar UI komponentų, jūsų verslo logika išlieka apsaugota ir nuosekli.
Išvada: Sukurkite tvirtą savo programos branduolį
Sparčiai besikeičiančių sistemų ir bibliotekų pasaulyje lengva pamiršti, kad šios priemonės yra laikinos. Jos keisis. Kas išlieka, tai pagrindinė jūsų verslo domeno logika. Investuoti laiką į tinkamą šio domeno modeliavimą nėra tik akademinis pratimas; tai yra viena svarbiausių ilgalaikių investicijų, kurias galite padaryti į savo programinės įrangos sveikatą ir ilgaamžiškumą.
JavaScript modulio esybės šablonas suteikia paprastą, galingą ir gimtąjį būdą įgyvendinti šias idėjas. Tam nereikia sunkios sistemos ar sudėtingo nustatymo. Jis pasinaudoja pagrindinėmis kalbos savybėmis – moduliais, funkcijomis ir uždariniais – siekiant padėti jums sukurti švarų, atsparų ir suprantamą jūsų programos branduolį. Pradėkite nuo vienos pagrindinės esybės kitame projekte. Sumodeliuokite jos savybes, patvirtinkite jos sukūrimą ir suteikite jai elgesį. Taip žengsite pirmąjį žingsnį tvirtesnės ir profesionalesnės programinės įrangos architektūros link.