domain-objects
A simple, convenient way to represent domain objects, leverage domain knowledge, and add runtime validation in your code base.
Guided by Domain Driven Design
Purpose
- promote speaking in a domain driven manner, in code and in speech, by formally defining domain objects
- to make software safer and easier to debug, by supporting run time type checking
- to leverage domain knowledge in your code base
- e.g., in comparisons of objects
- e.g., in schema based runtime validation
Install
npm install --save domain-objects
Usage Examples
literal
import { DomainLiteral } from 'domain-objects';
interface Address {
street: string;
suite: string | null;
city: string;
state: string;
postal: string;
}
class Address extends DomainLiteral<Address> implements Address {}
const austin = new Address({
street: '123 South Congress',
suite: null,
city: 'Austin',
state: 'Texas',
postal: '78704',
});
entity
import { DomainEntity } from 'domain-objects';
interface RocketShip {
uuid?: string;
serialNumber: string;
fuelQuantity: number;
passengers: number;
homeAddress: Address;
}
class RocketShip extends DomainEntity<RocketShip> implements RocketShip {
public static unique = ['serialNumber'];
public static updatable = ['fuelQuantity', 'homeAddress'];
}
const ship = new RocketShip({
serialNumber: 'SN5',
fuelQuantity: 9001,
passengers: 21,
homeAddress: new Address({ ... }),
});
event
import { DomainEvent } from 'domain-objects';
interface AirQualityMeasuredEvent {
locationUuid: string;
sensorUuid: string;
occurredAt: string;
temperature: string;
humidity: string;
pressure: string;
pm2p5: string;
pm5p0: string;
pm10p0: string;
}
class AirQualityMeasuredEvent extends DomainEvent<AirQualityMeasuredEvent> implements AirQualityMeasuredEvent {
public static unique = ['locationUuid', 'sensorUuid', 'occurredAt'];
}
const event = new AirQualityMeasuredEvent({
locationUuid: '8e34eb9b-2874-43e0-bc89-73a73d50ac5c',
sensorUuid: 'a17f7941-1211-44f4-a22a-b61f220527da',
occurredAt: '2021-07-08T11:13:38.780Z',
temperature: '31.52°C',
humidity: '27%rh',
pressure: '29.99bar',
pm2p5: '9ug/m3',
pm5p0: '11ug/m3',
pm10p0: '17ug/m3',
});
runtime validation
everyone has types until they get punched in the runtime - mike typeson
interface Address {
id?: number;
galaxy: string;
solarSystem: string;
planet: string;
continent: string;
}
const schema = Joi.object().keys({
id: Joi.number().optional(),
galaxy: Joi.string().valid(['Milky Way', 'Andromeda']).required(),
solarSystem: Joi.string().required(),
planet: Joi.string().required(),
continent: Joi.string().required(),
});
class Address extends DomainLiteral<Address> implements Address {
public static schema = schema;
}
const northAmerica = new Address({
galaxy: 'Milky Way',
solarSystem: 'Sun',
planet: 'Earth',
continent: 'North America',
});
const westDolphia = new Address({
galaxy: 'AndromedA',
solarSystem: 'Asparagia',
planet: 'Dracena',
continent: 'West Dolphia',
});
identity comparison
import { serialize, getUniqueIdentifier } from 'domain-objects';
const northAmerica = new Address({
galaxy: 'Milky Way',
solarSystem: 'Sun',
planet: 'Earth',
continent: 'North America',
});
const northAmericaWithId = new Address({
id: 821,
galaxy: 'Milky Way',
solarSystem: 'Sun',
planet: 'Earth',
continent: 'North America',
});
const areTheSame = serialize(getUniqueIdentifier(northAmerica)) === serialize(getUniqueIdentifier(northAmericaWithId));
change detection
import { serialize, omitMetadataValues } from 'domain-objects';
const sn5 = new Spaceship({
serialNumber: 'SN5',
fuelQuantity: 9001,
passengers: 21,
});
const sn5Saved = new Spaceship({ ...sn5, id: 821, updatedAt: now() });
const hadChangeDuringSave = serialize(omitMetadataValues(sn5)) !== serialize(omitMetadataValues(sn5Saved));
expect(hadChangeDuringSave).toEqual(false);
const sn5AfterFlying = new Spaceship({ ...sn5, fuelQuantity: 4500 });
const hadChangeAfterFlying = serialize(omitMetadataValues(spaceport)) !== serialize(omitMetadataValues(spaceportAfterFlight));
expect(hadChangeAfterFlying).toEqual(true);
Features
Modeling
Modeling is a fundamental part of domain driven design. Here is how you can represent your model in your code - to aid in building a ubiquitous language.
DomainLiteral
In Domain Driven Design, a Literal (a.k.a. Value Object), is a type of Domain Object for which:
- properties are immutable
- i.e., it represents some literal value which happens to have a structured object shape
- i.e., if you change the value of any of its properties, it is a different literal
- identity does not matter
- i.e., it is uniquely identifiable by its non-metadata properties
interface Address {
street: string;
suite: string | null;
city: string;
state: string;
postal: string;
}
class Address extends DomainLiteral<Address> implements Address {}
const austin = new Address({
street: '123 South Congress',
suite: null,
city: 'Austin',
state: 'Texas',
postal: '78704',
});
DomainEntity
In Domain Driven Design, an Entity is a type of Domain Object for which:
- properties change over time
- identity matters
- i.e., it represents a distinct existence
- e.g., two entities could have the same properties, differing only by id, and are still considered different entities
- e.g., you can update properties on an entity and it is still considered the same entity
interface RocketShip {
uuid?: string;
serialNumber: string;
fuelQuantity: number;
passengers: number;
homeAddress: Address;
}
class RocketShip extends DomainEntity<RocketShip> implements RocketShip {
public static unique = ['serialNumber'];
}
const ship = new RocketShip({
serialNumber: 'SN5,
fuelQuantity: 9001,
passengers: 21,
homeAddress: new Address({ ... }),
});
Run Time Validation
Runtime validation is a great way to fail fast and prevent unexpected errors.
domain-objects
supports an easy way to add runtime validation, by defining a Zod
, Yup
, or Joi
schema.
When you provide a schema in your type definition, your domain objects will now be run time validated at instantiation.
example:
interface RocketShip {
serialNumber: string;
fuelQuantity: number;
passengers: number;
}
const schema = Joi.object().keys({
serialNumber: Joi.string().uuid().required(),
fuelQuantity: Joi.number().required(),
passengers: Joi.number().max(42).required(),
});
class RocketShip extends DomainObject<RocketShip> implements RocketShip {
public static schema = schema;
}
new RocketShip({
serialNumber: uuid(),
fuelQuantity: 9001,
passengers: 50,
});
We made sure that the errors are as descriptive as possible to help with debugging. For example, the error that would have been shown above has the following message:
Errors on 1 properties were found while validating properties for domain object RocketShip.:
[
{
"message": "\"passengers\" must be less than or equal to 42",
"path": "passengers",
"type": "number.max"
}
]
Props Provided:
{
"serialNumber": "eeb6988c-d877-4268-b841-bde2f40b377e",
"fuelQuantity": 9001,
"passengers": 50
}
Nested Hydration
TL:DR; Without DomainObject.nested
, you will need to manually instantiate nested domain objects every time. If you forget, getUniqueIdentifier
and serialize
will throw errors.
Nested hydration is useful when instantiating DomainObjects that are composed of other DomainObjects. For example, in the RocketShip
example above, RocketShip
has Address
as a nested property (i.e., typeof Spaceship.address === Address
).
When attempting to manipulate DomainObjects with nested DomainObjects, like the Spaceship.address example, it is important that all nested domain objects are instantiated with their class. Otherwise, if RocketShip.address
is not an instanceof Address
, then we will not be able to utilize the domain information baked into the static properties of Address
(e.g., that it is a DomainLiteral).
domain-objects
makes it easy to instantiate nested DomainObjects, by exposing the DomainObject.nested
static property.
For example:
interface PlantPot {
diameterInInches: number;
}
class PlantPot extends DomainLiteral<PlantPot> implements PlantPot {}
interface PlantOwner {
name: string;
}
class PlantOwner extends DomainEntity<PlantOwner> implements PlantOwner {}
interface Plant {
pot: PlantPot;
owners: PlantOwner[];
lastWatered: string;
}
class Plant extends DomainEntity<Plant> implements Plant {
public static nested = { pot: PlantPot, owners: PlantOwner };
}
const plant = new Plant({
pot: { diameterInInches: 7 },
owners: [{ name: 'bob' }],
lastWatered: 'monday',
});
expect(plant.pot).toBeInstanceOf(PlantPot);
plant.owners.forEach((owner) => expect(owner).toBeInstance(PlantOwner));
You may be thinking to yourself, "Didn't i just define what the nested DomainObjects were in the type definition, when defining the interface? Why do i have to define it again?". Agreed! Unfortunately, typescript removes all type information at runtime. Therefore, we have no choice but to repeat this information in another way if we want to use this information at runtime. (See #8 for progress on automating this).
fn getUniqueIdentifier(obj: DomainEntity | DomainLiteral)
Domain models inform us of what properties uniquely identify a domain object.
i.e.,:
- literals are uniquely identified by all of their non-metadata properties
- entities are uniquely identified by an explicitly subset of their properties, declared via the
.unique
static property
this getUniqueIdentifier
function leverages this knowledge to return a normal object containing only the properties that uniquely identify the domain object you give it.
fn serialize(value: any)
Domain modeling gives additional information that we can use for change detection
and identity comparisons
.
domain-objects
allows us to use that information conveniently with the functions serialize
.
serialize
deterministically converts any object you give it into a string representation:
- deterministically sort all array items
- deterministically sort all object keys
- remove non-unique properties from nested domain objects
due to this deterministic serialization, we are able to use this fn for change detection
and identity comparisons
. See the examples section above for an example of each.