BemÀstra domÀndriven design i JavaScript. LÀr dig Module Entity Pattern för att bygga skalbara, testbara och underhÄllbara applikationer med robusta domÀnobjektmodeller.
JavaScript Module Entity Patterns: En djupdykning i domÀnobjektmodellering
I mjukvaruutvecklingens vĂ€rld, sĂ€rskilt inom det dynamiska och stĂ€ndigt förĂ€nderliga JavaScript-ekosystemet, prioriterar vi ofta hastighet, ramverk och funktioner. Vi bygger komplexa anvĂ€ndargrĂ€nssnitt, ansluter till otaliga API:er och distribuerar applikationer i en svindlande takt. Men i denna brĂ„dska försummar vi ibland sjĂ€lva kĂ€rnan i vĂ„r applikation: affĂ€rsdomĂ€nen. Detta kan leda till vad som ofta kallas "Big Ball of Mud" â ett system dĂ€r affĂ€rslogiken Ă€r utspridd, data Ă€r ostrukturerad och att göra en enkel förĂ€ndring kan utlösa en kaskad av oförutsedda buggar.
Det Àr hÀr DomÀnobjektmodellering kommer in. Det Àr praktiken att skapa en rik, uttrycksfull modell av problemomrÄdet du arbetar i. Och i JavaScript Àr Module Entity Pattern ett kraftfullt, elegant och ramverksagnostiskt sÀtt att uppnÄ detta. Denna omfattande guide kommer att ta dig igenom teorin, praktiken och fördelarna med detta mönster, vilket ger dig möjlighet att bygga mer robusta, skalbara och underhÄllbara applikationer.
Vad Àr DomÀnobjektmodellering?
Innan vi dyker in i sjÀlva mönstret, lÄt oss klargöra vÄra termer. Det Àr avgörande att skilja detta koncept frÄn webblÀsarens Document Object Model (DOM).
- DomÀn: I mjukvara Àr 'domÀnen' det specifika ÀmnesomrÄdet som anvÀndarens verksamhet tillhör. För en e-handelsapplikation inkluderar domÀnen begrepp som Produkter, Kunder, BestÀllningar och Betalningar. För en social medieplattform inkluderar det AnvÀndare, InlÀgg, Kommentarer och Likes.
- DomÀnobjektmodellering: Detta Àr processen att skapa en mjukvarumodell som representerar enheterna, deras beteenden och deras relationer inom den affÀrsdomÀnen. Det handlar om att översÀtta verkliga koncept till kod.
En bra domÀnmodell Àr inte bara en samling databehÄllare. Det Àr en levande representation av dina affÀrsregler. Ett Order-objekt bör inte bara innehÄlla en lista över artiklar; det bör veta hur man berÀknar summan, hur man lÀgger till en ny artikel och om den kan avbestÀllas. Denna inkapsling av data och beteende Àr nyckeln till att bygga en motstÄndskraftig applikationskÀrna.
Det vanliga problemet: Anarki i "Model"-lagret
I mÄnga JavaScript-applikationer, sÀrskilt de som vÀxer organiskt, Àr 'modell'-lagret ofta en eftertanke. Vi ser ofta detta anti-mönster:
// NÄgonstans i en API-kontroller eller tjÀnst...
async function createUser(req, res) {
const { email, password, firstName, lastName } = req.body;
// AffÀrslogik och validering Àr utspridd hÀr
if (!email || !email.includes('@')) {
return res.status(400).send({ error: 'En giltig e-postadress krÀvs.' });
}
if (!password || password.length < 8) {
return res.status(400).send({ error: 'Lösenordet mÄste vara minst 8 tecken.' });
}
const user = {
email: email.toLowerCase(),
password: await hashPassword(password), // NÄgon verktygsfunktion
fullName: `${firstName} ${lastName}`, // Logik för hÀrledda data Àr hÀr
createdAt: new Date()
};
// Nu, vad Àr `user`? Det Àr bara ett vanligt objekt.
// Ingenting hindrar en annan utvecklare frÄn att göra detta senare:
// user.email = 'an-invalid-email';
// user.password = 'short';
await db.users.insert(user);
res.status(201).send(user);
}
Denna metod presenterar flera kritiska problem:
- Ingen enskild sanning: Reglerna för vad som utgör en giltig 'anvÀndare' definieras i denna enda styrenhet. Vad hÀnder om en annan del av systemet behöver skapa en anvÀndare? Kopierar du och klistrar in logiken? Detta leder till inkonsekvens och buggar.
- Anemisk domÀnmodell: `user`-objektet Àr bara en 'dum' datavÀska. Den har inget beteende och ingen sjÀlvmedvetenhet. All logik som fungerar pÄ den lever externt.
- LÄg sammanhÄllning: Logiken för att skapa en anvÀndares fullstÀndiga namn blandas med hantering av API-begÀran/svar och lösenordshashning.
- SvÄrt att testa: För att testa anvÀndarskapande logik mÄste du mocka HTTP-förfrÄgningar och svar, databaser och hashfunktioner. Du kan inte bara testa 'user'-konceptet isolerat.
- Implicita kontrakt: Resten av applikationen mÄste bara 'anta' att alla objekt som representerar en anvÀndare har en viss form och att dess data Àr giltiga. Det finns inga garantier.
Lösningen: JavaScript Module Entity Pattern
Module Entity Pattern löser dessa problem genom att anvÀnda en standard JavaScript-modul (en fil) för att definiera allt om ett enda domÀnkoncept. Denna modul blir den definitiva sanningen för den enheten.
En Module Entity exponerar typiskt en fabrikfunktion. Denna funktion ansvarar för att skapa en giltig instans av enheten. Objektet den returnerar Àr inte bara data; det Àr ett rikt domÀnobjekt som inkapslar sina egna data, validering och affÀrslogik.
Nyckelfunktioner i en Module Entity
- Inkapsling: Den paketerar data och funktionerna som arbetar med dessa data tillsammans.
- Validering vid grÀnsen: Den sÀkerstÀller att det Àr omöjligt att skapa en ogiltig enhet. Den skyddar sitt eget tillstÄnd.
- Klart API: Den exponerar en ren, avsiktlig uppsÀttning funktioner (ett publikt API) för att interagera med enheten, samtidigt som den döljer interna implementeringsdetaljer.
- OförÀnderlighet: Den producerar ofta oförÀnderliga eller skrivskyddade objekt för att förhindra oavsiktliga tillstÄndsÀndringar och sÀkerstÀlla förutsÀgbart beteende.
- Portabilitet: Den har noll beroenden av ramverk (som Express, React) eller externa system (som databaser, API:er). Det Àr ren affÀrslogik.
Huvudkomponenter i en Module Entity
LÄt oss bygga om vÄrt `User`-koncept med hjÀlp av detta mönster. Vi kommer att skapa en fil, `user.js` (eller `user.ts` för TypeScript-anvÀndare), och bygga den steg för steg.
1. Fabrikfunktionen: Din objektkonstruktor
IstÀllet för klasser kommer vi att anvÀnda en fabrikfunktion (t.ex. `buildUser`). Fabriker erbjuder stor flexibilitet, undviker att brottas med `this`-nyckelordet och gör privata tillstÄnd och inkapsling mer naturliga i JavaScript.
VÄrt mÄl Àr att skapa en funktion som tar rÄdata och returnerar ett vÀlformat, pÄlitligt User-objekt.
// fil: /domain/user.js
export default function buildMakeUser() {
// Denna inre funktion Àr den faktiska fabriken.
// Den har Ätkomst till eventuella beroenden som skickas till buildMakeUser, om det behövs.
return function makeUser({
id = generateId(), // LÄt oss anta en funktion för att generera ett unikt ID
firstName,
lastName,
email,
passwordHash,
createdAt = new Date()
}) {
// ... validering och logik kommer att gÄ hit ...
const user = {
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => email,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
};
// AnvÀnder Object.freeze för att göra objektet oförÀnderligt.
return Object.freeze(user);
}
}
LÀgg mÀrke till nÄgra saker hÀr. Vi anvÀnder en funktion som returnerar en funktion (en högre ordningens funktion). Detta Àr ett kraftfullt mönster för att injicera beroenden, som en unik ID-generator eller ett valideringsbibliotek, utan att koppla enheten till en specifik implementering. För tillfÀllet hÄller vi det enkelt.
2. Datavalidering: VĂ€ktaren vid porten
En enhet mÄste skydda sin egen integritet. Det bör vara omöjligt att skapa en `User` i ett ogiltigt tillstÄnd. Vi lÀgger till validering direkt inuti fabrikfunktionen. Om data Àr ogiltiga bör fabriken kasta ett felmeddelande och tydligt ange vad som Àr fel.
// fil: /domain/user.js
export default function buildMakeUser({ Id, isValidEmail, hashPassword }) {
return function makeUser({
id = Id.makeId(),
firstName,
lastName,
email,
password, // Vi tar nu ett vanligt lösenord och hanterar det inuti
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('AnvÀndaren mÄste ha ett giltigt ID.');
}
if (!firstName || firstName.length < 2) {
throw new Error('Förnamnet mÄste vara minst 2 tecken lÄngt.');
}
if (!lastName || lastName.length < 2) {
throw new Error('Efternamnet mÄste vara minst 2 tecken lÄngt.');
}
if (!email || !isValidEmail(email)) {
throw new Error('AnvÀndaren mÄste ha en giltig e-postadress.');
}
if (!password || password.length < 8) {
throw new Error('Lösenordet mÄste vara minst 8 tecken lÄngt.');
}
// Datormalisering och transformation sker hÀr
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
});
}
}
Nu mÄste alla delar av vÄrt system som vill skapa en `User` gÄ igenom denna fabrik. Vi fÄr garanterad validering varje gÄng. Vi har ocksÄ inkapslat logiken för att hasha lösenordet och normalisera e-postadressen. Resten av applikationen behöver inte veta eller bry sig om dessa detaljer.
3. AffÀrslogik: Inkapsla beteende
VĂ„rt `User`-objekt Ă€r fortfarande lite anemiskt. Det innehĂ„ller data, men det *gör* ingenting. LĂ„t oss lĂ€gga till beteende â metoder som representerar domĂ€nspecifika Ă„tgĂ€rder.
// ... inuti makeUser-funktionen ...
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,
// AffÀrslogik / Beteende
getFullName: () => `${firstName} ${lastName}`,
// En metod som beskriver en affÀrsregel
canVote: () => {
// I vissa lÀnder Àr röstrÀttsÄldern 18 Är. Detta Àr en affÀrsregel.
// LÄt oss anta att vi har en egenskap för födelsedatum.
const age = calculateAge(dateOfBirth);
return age >= 18;
}
});
// ...
Logiken för `getFullName` Àr inte lÀngre utspridd i nÄgon slumpmÀssig styrenhet; den tillhör `User`-enheten sjÀlv. Alla med ett `User`-objekt kan nu pÄ ett tillförlitligt sÀtt fÄ det fullstÀndiga namnet genom att anropa `user.getFullName()`. Logiken definieras en gÄng, pÄ ett stÀlle.
Bygga ett praktiskt exempel: Ett enkelt e-handelssystem
LÄt oss tillÀmpa detta mönster pÄ en mer sammankopplad domÀn. Vi kommer att modellera en `Product`, en `OrderItem` och en `Order`.
1. Modellering av `Product`-enheten
En produkt har ett namn, ett pris och lite lagerinformation. Den mÄste ha ett namn, och dess pris fÄr inte vara negativt.
// fil: /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('Produkten mÄste ha ett giltigt ID.');
}
if (!name || name.trim().length < 2) {
throw new Error('Produktnamnet mÄste vara minst 2 tecken.');
}
if (isNaN(price) || price <= 0) {
throw new Error('Produkten mÄste ha ett pris som Àr större Àn noll.');
}
if (isNaN(stock) || stock < 0) {
throw new Error('Lagret mÄste vara ett icke-negativt tal.');
}
return Object.freeze({
getId: () => id,
getName: () => name,
getDescription: () => description,
getPrice: () => price,
getStock: () => stock,
// AffÀrslogik
isAvailable: () => stock > 0,
// En metod som Àndrar tillstÄndet genom att returnera en ny instans
reduceStock: (amount) => {
if (amount > stock) {
throw new Error('Inte tillrÀckligt med lager tillgÀngligt.');
}
// Returnera ett NYTT produktobjekt med det uppdaterade lagret
return makeProduct({ id, name, description, price, stock: stock - amount });
}
});
}
}
Notera metoden `reduceStock`. Detta Àr ett avgörande koncept relaterat till oförÀnderlighet. IstÀllet för att Àndra egenskapen `stock` pÄ det befintliga objektet, returnerar den en *ny* `Product`-instans med det uppdaterade vÀrdet. Detta gör tillstÄndsÀndringar explicita och förutsÀgbara.
2. Modellering av `Order`-enheten (Roten för Aggregatet)
En `Order` Àr mer komplex. Det Àr vad Domain-Driven Design (DDD) kallar en "Aggregate Root." Det Àr en enhet som hanterar andra, mindre objekt inom sin grÀns. En `Order` innehÄller en lista över `OrderItem`s. Du lÀgger inte till en produkt direkt i en order; du lÀgger till en `OrderItem` som innehÄller en produkt och en kvantitet.
// fil: /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('Ordern mÄste ha ett giltigt ID.');
}
if (!customerId) {
throw new Error('Ordern mÄste ha ett kund-ID.');
}
let orderItems = [...items]; // Skapa en privat kopia att hantera
return Object.freeze({
getId: () => id,
getCustomerId: () => customerId,
getItems: () => [...orderItems], // Returnera en kopia för att förhindra extern modifiering
getStatus: () => status,
getCreatedAt: () => createdAt,
// AffÀrslogik
calculateTotal: () => {
return orderItems.reduce((total, item) => {
return total + (item.getPrice() * item.getQuantity());
}, 0);
},
addItem: (item) => {
// validateOrderItem Àr en funktion som sÀkerstÀller att artikeln Àr en giltig OrderItem-enhet
validateOrderItem(item);
// AffÀrsregel: förhindra att dubbletter lÀggs till, öka bara kvantiteten
const existingItemIndex = orderItems.findIndex(i => i.getProductId() === item.getProductId());
if (existingItemIndex > -1) {
const newQuantity = orderItems[existingItemIndex].getQuantity() + item.getQuantity();
// HÀr skulle du uppdatera kvantiteten pÄ den befintliga artikeln
// (Detta krÀver att artiklarna Àr muterbara eller har en uppdateringsmetod)
} else {
orderItems.push(item);
}
},
markPaid: () => {
if (status !== ORDER_STATUS.PENDING) {
throw new Error('Endast vÀntande order kan markeras som betalda.');
}
// Returnera en ny Order-instans med den uppdaterade statusen
return makeOrder({ id, customerId, items: orderItems, status: ORDER_STATUS.PAID, createdAt });
}
});
}
}
Denna `Order`-enhet upprÀtthÄller nu komplexa affÀrsregler:
- Den hanterar sin egen lista över artiklar.
- Den vet hur man berÀknar sin egen summa.
- Den upprÀtthÄller tillstÄndsövergÄngar (t.ex. kan du bara markera en `PENDING`-order som `PAID`).
AffÀrslogiken för order Àr nu snyggt inkapslad i denna modul, testbar isolerat och ÄteranvÀndbar i hela din applikation.
Avancerade mönster och övervÀganden
OförÀnderlighet: Hörnstenen för förutsÀgbarhet
Vi har berört oförÀnderlighet. Varför Àr det sÄ viktigt? NÀr objekt Àr oförÀnderliga kan du skicka dem runt i din applikation utan rÀdsla för att nÄgon avlÀgsen funktion ska Àndra deras tillstÄnd ovÀntat. Detta eliminerar en hel klass av buggar och gör din applikations dataflöde mycket lÀttare att resonera om.
Object.freeze() tillhandahÄller en ytlig frysning. För enheter med kapslade objekt eller arrayer (som vÄr `Order`) mÄste du vara mer försiktig. Till exempel, i `order.getItems()`, returnerade vi en kopia (`[...orderItems]`) för att förhindra att anroparen trycker in artiklar direkt i orderns interna array.
För komplexa applikationer kan bibliotek som Immer göra det mycket enklare att arbeta med oförÀnderliga kapslade strukturer, men grundprincipen kvarstÄr: behandla dina enheter som oförÀnderliga vÀrden. NÀr en förÀndring behöver ske, skapa ett nytt vÀrde.
Hantering av asynkrona operationer och persistens
Du kanske har mÀrkt att vÄra enheter Àr helt synkrona. De vet ingenting om databaser eller API:er. Detta Àr avsiktligt och en stor styrka i mönstret!
Enheter ska inte spara sig sjÀlva. En enhets uppgift Àr att upprÀtthÄlla affÀrsregler. Uppgiften att spara data i en databas tillhör ett annat lager i din applikation, ofta kallat ett Service Layer, Use Case Layer eller Repository Pattern.
SÄ hÀr interagerar de:
// fil: /use-cases/create-user.js
// Detta anvÀndningsfall Àr beroende av fabrikfunktionen för anvÀndarenheten och en databasÄtkomstfunktion.
export default function makeCreateUser({ makeUser, usersDatabase }) {
return async function createUser(userInfo) {
// 1. Skapa en giltig domÀnenhet. Detta steg validerar data.
const user = makeUser(userInfo);
// 2. Kontrollera efter affÀrsregler som krÀver externa data (t.ex. e-postadressens unikhet)
const exists = await usersDatabase.findByEmail({ email: user.getEmail() });
if (exists) {
throw new Error('E-postadressen anvÀnds redan.');
}
// 3. BehÄll enheten. Databasen behöver ett vanligt objekt.
const persisted = await usersDatabase.insert({
id: user.getId(),
firstName: user.getFirstName(),
// ... och sÄ vidare
});
return persisted;
}
}
Denna ansvarsfördelning Àr kraftfull:
- `User`-enheten Àr ren, synkron och lÀtt att enhetstesta.
- AnvÀndningsfallet `createUser` ansvarar för orkestrering och kan integrationstestas med en mockdatabas.
- Modulen `usersDatabase` ansvarar för den specifika databastekniken och kan testas separat.
Serialisering och deserialisering
Dina enheter, med sina metoder, Àr rika objekt. Men nÀr du skickar data över ett nÀtverk (t.ex. i ett JSON API-svar) eller lagrar det i en databas, behöver du en vanlig datarepresentation. Denna process kallas serialisering.
Ett vanligt mönster Àr att lÀgga till en `toJSON()` eller `toObject()`-metod till din enhet.
// ... inuti makeUser-funktionen ...
return Object.freeze({
getId: () => id,
// ... andra getters
// Serialiseringsmetod
toObject: () => ({
id,
firstName,
lastName,
email: normalizedEmail,
createdAt
// Observera att vi inte inkluderar passwordHash
})
});
Den omvÀnda processen, att ta vanliga data frÄn en databas eller API och förvandla det tillbaka till en rik domÀnenhet, Àr exakt vad din `makeUser`-fabrikfunktion Àr till för. Detta Àr deserialisering.
Typning med TypeScript eller JSDoc
Medan detta mönster fungerar perfekt i vanlig JavaScript, ger du det superkraft genom att lÀgga till statiska typer med TypeScript eller JSDoc. Typer gör att du formellt kan definiera 'formen' pÄ din enhet, vilket ger utmÀrkt automatisk komplettering och kontroller vid kompilering.
// fil: /domain/user.ts
// Definiera enhetens publika grÀnssnitt
export type User = Readonly<{
getId: () => string;
getFirstName: () => string;
// ... etc
getFullName: () => string;
}>;
// Fabrikfunktionen returnerar nu User-typen
export default function buildMakeUser(...) {
return function makeUser(...): User {
// ... implementering
}
}
De övergripande fördelarna med Module Entity Pattern
Genom att anta detta mönster fÄr du en mÀngd fördelar som vÀxer nÀr din applikation vÀxer:
- Enskild sanning: AffÀrsregler och datavalidering Àr centraliserade och entydiga. En förÀndring av en regel görs pÄ exakt ett stÀlle.
- Hög sammanhÄllning, lÄg koppling: Enheter Àr sjÀlvstÀndiga och beror inte pÄ externa system. Detta gör din kodbas modulÀr och lÀtt att refaktorera.
- ĂverlĂ€gsen testbarhet: Du kan skriva enkla, snabba enhetstester för din mest kritiska affĂ€rslogik utan att mocka hela vĂ€rlden.
- FörbÀttrad utvecklarupplevelse: NÀr en utvecklare behöver arbeta med en `User` har de ett tydligt, förutsÀgbart och sjÀlv-dokumenterande API att anvÀnda. Inget mer gissande om formen pÄ vanliga objekt.
- En grund för skalbarhet: Detta mönster ger dig en stabil, pÄlitlig kÀrna. NÀr du lÀgger till fler funktioner, ramverk eller anvÀndargrÀnssnittskomponenter, förblir din affÀrslogik skyddad och konsekvent.
Slutsats: Bygg en solid kÀrna för din applikation
I en vÀrld av snabbrörliga ramverk och bibliotek Àr det lÀtt att glömma att dessa verktyg Àr övergÄende. De kommer att förÀndras. Det som bestÄr Àr kÀrnlogiken i din affÀrsdomÀn. Att investera tid i att ordentligt modellera denna domÀn Àr inte bara en akademisk övning; det Àr en av de mest betydande lÄngsiktiga investeringar du kan göra i hÀlsan och livslÀngden för din mjukvara.
JavaScript Module Entity Pattern ger ett enkelt, kraftfullt och inbyggt sĂ€tt att implementera dessa idĂ©er. Det krĂ€ver inget tungt ramverk eller en komplex installation. Det utnyttjar sprĂ„kets grundlĂ€ggande funktioner â moduler, funktioner och stĂ€ngningar â för att hjĂ€lpa dig att bygga en ren, motstĂ„ndskraftig och förstĂ„elig kĂ€rna för din applikation. Börja med en nyckelenhet i ditt nĂ€sta projekt. Modellera dess egenskaper, validera dess skapande och ge den beteende. Du kommer att ta det första steget mot en mer robust och professionell mjukvaruarkitektur.