Inspired by domain-driven design (DDD), DomainJS is a domain-driven design framework for scalable systems.
In Domain-driven design (DDD) an entity is a representation of an object within a given domain, for example: a book, product order, and user would be entities within a domain that handles purchase orders for an e-commerce website that sold books. Let's take a look at an entity type definition for a user within our domain.
// User.ts
import {
Entity,
} from '@cosmicmind/domainjs'
export type User = Entity & {
id: string
name: string
age: number
}
The above example is a user entity type definition. It represents an object, in this case a user within our domain. The user is defined by the id, name, and age attribute values. When working with entities, DomainJS will immediately help within the following areas of concern:
- How to validate entities and reliably construct new entity instances?
- How to observe the entity lifecycle?
Let's take a look at the following code example to understand entity validation in DomainJS.
import {
User,
} from './User'
function someFunction(user: User): void {
// ... do something
}
const user: User = {
id: '123',
name: 'Sarah',
age: 29,
}
// ...
someFunction(user)
In the above code, we can see that the user
passed to someFunction
actually doesn't provide any guarantees of its validity. In order to guarantee validity within
someFunction
, validation logic would need to be executed within the function itself. An issue arises when we need to constantly validate our entities, causing validation
logic to exist in multiple places within our codebase. DomainJS defines the validation logic within the entity itself and calls the appropriate validators when creating and
updating entities, for example:
// User.ts
// ...
export const makeUser = defineEntity<User>({
attributes: {
id: {
validator(value): boolean | never {
// id validation logic
// return true | false
// or throw an error
},
},
name: {
validator(value): boolean | never {
// name validation logic
// return true | false
// or throw an error
},
},
age: {
validator(value): boolean | never {
// age validation logic
// return true | false
// or throw an error
},
},
},
})
import {
makeUser,
} from './User'
function someFunction(user: User): void {
// ... do something
}
const user = makeUser({
id: '123',
name: 'Sarah',
age: 29,
})
console.log(user.id) // "123"
console.log(user.name) // "Sarah"
console.log(user.age) // 29
someFunction(user)
By using the constructor function returned by defineEntity<User>(...)
, each user entity is guaranteed to be valid.
It is impossible for the user entity to be created and reach the code at line someFunction(user)
if it is invalid.
DomainJS organizes lifecycle hooks within the entity definition itself, like so:
export const makeUser = defineEntity<User>({
created(user) {
// ... do something
},
trace(user) {
// ... do something
},
attributes: {
// ...
age: {
validator(value): boolean | never {
// ... do something
},
udpated(newValue, oldValue, user): void {
// ... do something
},
},
// ...
},
})
The above example shows the various lifecycle hooks available for entities. Let's take a look at each one of these hooks to understand when they are executed.
The created
lifecycle hook is executed only once when an instance is initially created.
The updated
lifecycle hook is executed after an attribute has been updated.
The trace
lifecycle hook is executed after the created
and updated
lifecycle hooks.
A Value Object (VO) in Domain-driven design encapsulates a single value and its validity. Further to ensuring its validity, a VO provides specific functionality that is relevant to the value itself, for example:
// Email.ts
import {
Value,
defineValue,
} from '@cosmicmind/domainjs'
export class Email extends Value<string> {
get domainAddress(): string {
return this.value.split('@')[1]
}
}
export const makeEmail = defineValue(Email, {
created(email): void {
// ... do something
},
trace(email) {
// ... do something
},
validator(value): boolean | never {
// email validation logic
// return true | false
// or throw an error
},
})
import {
makeEmail,
} from './Email'
const email = makeEmail('[email protected]')
console.log(email.value) // "[email protected]"
console.log(email.domainAddress) // "domain.com"
... more to come ...
// User.ts
import {
Entity,
defineEntity,
} from '@cosmicmind/domainjs'
import {
Email,
} from './Email'
export type User = Entity & {
id: string
name: string
age: number
email: Email
}
export const makeUser = defineEntity<User>({
attributes: {
id: {
validator(value): boolean | never {
// id validation logic
// return true | false
// or throw an error
},
},
name: {
validator(value): boolean | never {
// name validation logic
// return true | false
// or throw an error
},
},
age: {
validator(value): boolean | never {
// age validation logic
// return true | false
// or throw an error
},
},
},
})
import {
makeUser,
} from './User'
import {
makeEmail,
} from './Email'
const user = makeUser({
id: '123',
name: 'Daniel',
age: 29,
email: makeEmail('[email protected]'),
})
console.log(user.id) // "123"
console.log(user.name) // "Daniel"
console.log(user.age) // 29
console.log(user.email.value) // "[email protected]"
console.log(user.email.domainAddress) // "domain.com"
Value Objects are great for parameter passing and letting the function know that it is using a valid value. For example:
import {
Email,
} from './Email'
function someFunction(email: Email): void {
if ('domain.com' === email.domainAddress) {
// ... do something
}
}
... more to come ...
... more to come ...
// UserAggregate.ts
import {
Aggregate,
defineAggregate,
} from '@cosmicmind/domainjs'
import {
User,
} from './User'
import {
Email,
} from './Email'
export class UserAggregate extends Aggregate<User> {
get id(): string {
return this.root.id
}
get email(): Email {
return this.root.email
}
registerAccount(): void {
// ... do something
}
}
export const makeUserAggregate = defineAggregate(UserAggregate, {
created(user) {
// ... do something
},
trace(user) {
// ... do something
},
attributes: {
// ...
age: {
validator(value): boolean | never {
// ... do something
},
udpated(newValue, oldValue, user): void {
// ... do something
},
},
// ...
},
})
import {
makeUserAggregate
} from './UserAggregate'
import {
makeEmail,
} from './Email'
const user = makeUserAggregate({
id: '123',
name: 'Daniel',
age: 29,
email: makeEmail('[email protected]'),
})
console.log(user.id) // "123"
console.log(user.name) // error cannot access (not exposed in UserAggragte)
console.log(user.age) // error cannot access (not exposed in UserAggragte)
console.log(user.email.value) // "[email protected]"
console.log(user.email.domainAddress) // "domain.com"
user.registerAccount() // ... account registration process
... more to come ...
... more to come ...
// RegisterAccountEvent.ts
import {
Event,
defineEvent,
} from '@cosmicmind/domainjs'
import {
User,
} from './User'
export type RegisterAccountEvent = Event & {
id: string
user: User
}
export const createRegisterAccountEvent = defineEvent<RegisterAccountEvent>({
attributes: {
id: {
validator(value): boolean | never {
// id validation logic
// return true | false
// or throw an error
},
},
},
})
Now that we have our RegisterAccountEvent
, let's add it to the UserAggregate
example.
// UserAggregate.ts
import {
Aggregate,
defineAggregate,
EventTopics,
} from '@cosmicmind/domainjs'
// ...
import {
RegisterAccountEvent,
createRegisterAccountEvent,
} from './RegisterAccountEvent'
export type UserAggregateEventTopics = EventTopics & {
'register-account': RegisterAccountEvent
}
export class UserAggregate extends Aggregate<User, UserAggregateEventTopics> {
// ...
registerAccount(): void {
// ... do something
this.publishSync('register-account', createRegisterAccountEvent({
id: '123',
user: this.root,
}))
}
}
// ...
import {
makeUserAggregate
} from './UserAggregate'
import {
makeEmail,
} from './Email'
const user = makeUserAggregate({
id: '123',
name: 'Daniel',
age: 29,
email: makeEmail('[email protected]'),
})
user.subscribe('register-account', (event: RegisterAccountEvent) => {
// ... do something
})
console.log(user.id) // "123"
console.log(user.name) // error cannot access (not exposed in UserAggragte)
console.log(user.age) // error cannot access (not exposed in UserAggragte)
console.log(user.email.value) // "[email protected]"
console.log(user.email.domainAddress) // "domain.com"
user.registerAccount() // ... account registration process and event is published
Additional documentation and examples will follow shortly. If you have any examples or use cases that you are interested in exploring, please create a discussion.
Thank you!