@nivinjoseph/n-domain
v3.2.5
Published
Domain Driven Design and Event Sourcing based framework for business layer implementation
Readme
n-domain
Overview
n-domain is a TypeScript framework that provides a robust foundation for implementing business logic using Domain-Driven Design (DDD) and Event Sourcing patterns. It helps you create maintainable and scalable domain models while enforcing best practices in domain-driven design.
Features
- Domain-Driven Design Support: Built-in abstractions for DDD concepts like Aggregates, Entities, Value Objects, and Domain Events
- Event Sourcing: Native support for event-sourced aggregates, snapshots, rebasing, and point-in-time reconstruction
- Replay Safety: Created events freeze the aggregate's initial defaults so replays are isolated from future code changes; a fingerprint helper supports drift-guard tests
- State Versioning:
typeVersion-based state migration with a built-in guard against loading unmigrated state - Multi-Tenancy:
Org*variants of the core types that scope aggregates to an organization - Type Safety: Written in TypeScript with strong typing support
Installation
# Using npm
npm install @nivinjoseph/n-domain
# Using yarn
yarn add @nivinjoseph/n-domainDomain Organization
The framework encourages a clean and organized domain structure. Here's how to organize your domain:
domain/
├── todo.ts # Aggregate root implementation
├── todo-state.ts # State interface and state factory
├── events/ # Domain events
│ ├── todo-domain-event.ts # Abstract event base for this aggregate
│ ├── todo-created.ts
│ ├── todo-title-updated.ts
│ └── todo-rebased.ts
└── value-objects/ # Value objects
└── todo-description.tsKey Components
Aggregate Root (
todo.ts)- Main business entity; handles business logic
- Manages state changes exclusively through events
State (
todo-state.ts)- Defines the state interface (extends
AggregateState) - Implements the state factory (
AggregateStateFactory) which owns defaults, migrations, and snapshot deserialization
- Defines the state interface (extends
Domain Events (
events/)- Represent state changes; immutable and serializable
- Each aggregate defines an abstract event base class that implements
refType
Value Objects (
value-objects/)- Immutable, no identity; extend
DomainObject
- Immutable, no identity; extend
Core Concepts
Aggregate Roots
Aggregate roots are the main building blocks of your domain model. They encapsulate business logic and ensure consistency boundaries. Use AggregateFactory to instantiate aggregates:
import { given } from "@nivinjoseph/n-defensive";
import { serialize } from "@nivinjoseph/n-util";
import { AggregateFactory, AggregateRoot, DomainContext, DomainHelper } from "@nivinjoseph/n-domain";
import { TodoCreated } from "./events/todo-created.js";
import { TodoDomainEvent } from "./events/todo-domain-event.js";
import { TodoRebased } from "./events/todo-rebased.js";
import { TodoTitleUpdated } from "./events/todo-title-updated.js";
import { TodoState, TodoStateFactory } from "./todo-state.js";
import { TodoDescription } from "./value-objects/todo-description.js";
@serialize("App") // your app's serialization namespace
export class Todo extends AggregateRoot<TodoState, TodoDomainEvent>
{
public get title(): string { return this.state.title; }
public get description(): string | null { return this.state.description?.description ?? null; }
public get isCompleted(): boolean { return this.state.isCompleted; }
public static create(domainContext: DomainContext, title: string, description: string | null): Todo
{
given(domainContext, "domainContext").ensureHasValue().ensureIsObject();
given(title, "title").ensureHasValue().ensureIsString();
given(description as string, "description").ensureIsString();
const createdEvent = new TodoCreated({
todoId: DomainHelper.generateId("tdo"),
title,
description: description != null ? TodoDescription.create(description) : null
});
return new AggregateFactory(Todo, domainContext, new TodoStateFactory())
.createFromEvents([createdEvent]);
}
public updateTitle(title: string): void
{
given(title, "title").ensureHasValue().ensureIsString();
title = title.trim();
this.applyEvent(new TodoTitleUpdated({ title }));
}
// rebase() is protected on AggregateRoot; expose it by overriding with your rebased event
public override rebase(version: number): void
{
super.rebase(version, (defaultState, rebaseState, rebaseVersion) =>
new TodoRebased({ defaultState, rebaseState, rebaseVersion }));
}
}Domain Events
Every aggregate defines an abstract event base class that implements refType (used for n-eda compatibility). Concrete events extend it:
import { DomainEvent } from "@nivinjoseph/n-domain";
import { TodoState } from "../todo-state.js";
export abstract class TodoDomainEvent extends DomainEvent<TodoState>
{
// Return the aggregate's type name as a string literal.
// Do NOT import the aggregate class here — that creates a circular dependency that blows up at runtime.
public get refType(): string { return "Todo"; }
}Concrete events carry the data necessary to modify the aggregate state and implement applyEvent:
import { given } from "@nivinjoseph/n-defensive";
import { serialize } from "@nivinjoseph/n-util";
import { DomainEventData } from "@nivinjoseph/n-domain";
import { TodoState } from "../todo-state.js";
import { TodoDescription } from "../value-objects/todo-description.js";
import { TodoDomainEvent } from "./todo-domain-event.js";
@serialize("App")
export class TodoCreated extends TodoDomainEvent
{
private readonly _todoId: string;
private readonly _title: string;
private readonly _description: TodoDescription | null;
@serialize
public get todoId(): string { return this._todoId; }
@serialize
public get title(): string { return this._title; }
@serialize
public get description(): TodoDescription | null { return this._description; }
public constructor(data: EventData)
{
given(data, "data").ensureHasValue().ensureIsObject();
data.$isCreatedEvent = true; // only on the creation event
super(data);
const { todoId, title, description } = data;
given(todoId, "todoId").ensureHasValue().ensureIsString();
this._todoId = todoId;
given(title, "title").ensureHasValue().ensureIsString();
this._title = title;
given(description, "description").ensureIsType(TodoDescription);
this._description = description;
}
protected applyEvent(state: TodoState): void
{
given(state, "state").ensureHasValue().ensureIsObject();
state.id = this._todoId; // the created event MUST set the aggregate id
state.title = this._title;
state.description = this._description;
}
}
interface EventData extends DomainEventData
{
todoId: string;
title: string;
description: TodoDescription | null;
}State Management
State is defined by an interface extending AggregateState and produced by a factory extending AggregateStateFactory:
import { AggregateState, AggregateStateFactory } from "@nivinjoseph/n-domain";
import { TodoDescription } from "./value-objects/todo-description.js";
export interface TodoState extends AggregateState
{
title: string;
description: TodoDescription | null;
isCompleted: boolean;
}
export class TodoStateFactory extends AggregateStateFactory<TodoState>
{
public create(): TodoState
{
return {
...this.createDefaultAggregateState(),
title: null as any,
description: null,
isCompleted: false
};
}
}State migrations (typeVersion)
createDefaultAggregateState() initializes typeVersion to 1. When you make a breaking change to the state shape:
- Bump
typeVersionin your factory'screate(). - Override
update(state)to migrate older state forward (and set itstypeVersionaccordingly).
The AggregateRoot constructor runs every loaded state through update() and throws if the resulting typeVersion doesn't match the current create() output — so an unmigrated snapshot or stream fails fast instead of loading silently corrupted state.
export class TodoStateFactory extends AggregateStateFactory<TodoState>
{
public create(): TodoState
{
return {
...this.createDefaultAggregateState(),
typeVersion: 2, // bumped due to shape change
title: null as any,
description: null,
isCompleted: false
} as TodoState;
}
public override update(state: TodoState): TodoState
{
if (state.typeVersion === 1)
{
// migrate v1 -> v2 here
(state as { typeVersion: number; }).typeVersion = 2;
}
return state;
}
}Replay safety: frozen created-event defaults
When a new aggregate is created, the pristine output of the state factory's create() (with base fields stripped) is frozen into the created event and serialized as $frozenDefaultState. On every future replay, this frozen snapshot is overlaid as the base layer before events apply.
This means fields that no event ever writes are sourced from the stream rather than from a possibly-changed future create() — changing a default in create() no longer silently rewrites historical aggregates on replay. Brand-new fields added to create() later still fall through to the current default (additive evolution is preserved). Only created events carry this payload; other events' serialized shape is unchanged.
Drift guard
Because changing an existing default in create() is a meaningful (and easy-to-miss) act, AggregateStateHelper.fingerprintState() produces a stable, canonically-sorted SHA-512 fingerprint of a state object. The intended pattern is a drift-guard test: persist the fingerprint of create() in source control and fail the test when it changes unexpectedly.
import { AggregateStateHelper } from "@nivinjoseph/n-domain";
const EXPECTED_FINGERPRINT = "..."; // checked into source control
test("TodoStateFactory.create() output has not drifted", () =>
{
const fingerprint = AggregateStateHelper.fingerprintState(new TodoStateFactory().create());
assert.strictEqual(fingerprint, EXPECTED_FINGERPRINT);
});Domain Objects and Entities
DomainObject— base class for value objects.equals()is structural: two instances are equal if they have the same type and identical serialized state.DomainEntity— base class for entities (has anid).equals()is identity-based: two instances are equal if they have the same type and the sameid, regardless of state.
import { given } from "@nivinjoseph/n-defensive";
import { serialize } from "@nivinjoseph/n-util";
import { DomainObject } from "@nivinjoseph/n-domain";
@serialize("App")
export class TodoDescription extends DomainObject
{
private readonly _description: string;
@serialize
public get description(): string { return this._description; }
public constructor(data: { description: string; })
{
super(data);
const { description } = data;
given(description, "description").ensureHasValue().ensureIsString();
this._description = description;
}
}Multi-tenancy (Org* types)
For organization-scoped domains, use the Org* variants. They mirror the core types and additionally thread an organizationId through the context, state, events, and aggregate:
OrgDomainContext—DomainContextplusorganizationId: stringOrgConfigurableDomainContext—ConfigurableDomainContextplus a settableorganizationId; constructed with(userId, organizationId)OrgAggregateState—AggregateStateplusorganizationId: stringOrgAggregateStateFactory— constructed with anOrgDomainContext;createDefaultAggregateState()stampsorganizationIdinto the stateOrgDomainEvent/OrgDomainEventData— events carry$organizationId; on apply, the event'sorganizationIdis validated against the state's and an exception is thrown on mismatchOrgAggregateRoot— exposesorganizationIdand requires anOrgDomainContext;applyEventonly acceptsOrgDomainEvents
API Reference
AggregateRoot
Abstract base class for aggregate roots. Generic over <T extends AggregateState, TDomainEvent extends DomainEvent<T>>.
Constructor: (domainContext: DomainContext, events: ReadonlyArray<DomainEvent<T>>, stateFactory: AggregateStateFactory<T>, currentState?: T) — pass events or a snapshot state, never both. Prefer instantiating through AggregateFactory.
Properties:
context: DomainContext — the domain contextid: string — unique identifier for the aggregateretroEvents: ReadonlyArray<DomainEvent> — historical (persisted) events, ordered by versionretroVersion: number — version as of the historical eventscurrentEvents: ReadonlyArray<DomainEvent> — uncommitted events applied this sessioncurrentVersion: number — current version (same asversion)events: ReadonlyArray<DomainEvent> — all events (historical + current), ordered by versionversion: number — current version of the aggregatecreatedAt: number — creation timestamp (epoch ms)updatedAt: number — last update timestamp (epoch ms)isNew: boolean — true only for a freshly created aggregate (never for reconstructed ones)hasChanges: boolean — whether there are uncommitted eventsisReconstructed: boolean — whether this instance was produced byconstructVersion/constructBeforereconstructedFromVersion: number — version of the instance it was reconstructed fromisRebased: boolean — whether the stream contains a rebaserebasedFromVersion: number — version the aggregate was rebased fromstate: T (protected) — the current state; expose domain-specific getters off this
Static methods:
deserializeFromEvents(domainContext, aggregateType, stateFactory, eventData: ReadonlyArray<DomainEventData>): reconstruct an aggregate from serialized eventsdeserializeFromSnapshot(domainContext, aggregateType, stateFactory, stateSnapshot): reconstruct an aggregate from a snapshot
Instance methods:
serialize(): AggregateRootData — serialize the aggregate (id, version, timestamps, and all events)snapshot(...cloneKeys: ReadonlyArray<string>): T | object — snapshot of current state;cloneKeysnames state properties to deep-clone via JSON instead ofSerializableserializationconstructVersion(version: number): this — reconstruct the aggregate as of a specific versionconstructBefore(dateTime: number): this — reconstruct the aggregate as of just before a timestamphasEventOfType(eventType)/hasRetroEventOfType(eventType)/hasCurrentEventOfType(eventType): booleangetEventsOfType(eventType)/getRetroEventsOfType(eventType)/getCurrentEventsOfType(eventType): Arrayclone(createdEvent: DomainEvent<T>, serializedEventMutatorAndFilter?: (event: { $name: string; }) => boolean): this — create a new aggregate seeded bycreatedEvent, replaying this aggregate's non-created events onto it; the optional callback can mutate each serialized event and return false to drop ittest(): void — self-check that serialization, event replay, and snapshot round-trips all reproduce identical state; useful in testsapplyEvent(event: TDomainEvent)(protected) — apply a new event; call from your aggregate's behavior methodsrebase(version: number, rebasedEventFactoryFunc: (defaultState: object, rebaseState: object, rebaseVersion: number) => TDomainEvent)(protected) — collapse history up toversioninto a single rebase event produced by the factory function; override with a public method that supplies your aggregate's rebased event type
AggregateFactory
Instantiates aggregates without hand-writing constructor plumbing.
constructor(aggregateType, domainContext, stateFactory)createFromEvents(events: ReadonlyArray<TDomainEvent>): T
DomainEvent
Abstract base class for domain events. Generic over <T extends AggregateState>.
Properties:
aggregateId: string — ID of the aggregate this event belongs to (throws if accessed before the event is applied)id: string — unique event identifier (aggregateId-version) (throws if accessed before apply)userId: string — ID of the user who triggered the event (throws if accessed before apply)name: string — event type name (derived from the class name; validated against$nameon deserialization)partitionKey: string — same asaggregateId(n-eda compatibility)refId: string — same asaggregateId(n-eda compatibility)refType: string — abstract; the aggregate's type name (n-eda compatibility). Implement with a string literal, not an import of the aggregate classoccurredAt: number — timestamp when the event occurred (epoch ms)version: number — version number of the eventisCreatedEvent: boolean — whether this is the creation event
Methods:
apply(aggregate, domainContext, state): applies the event — stampsuserId/version/id, overlays$frozenDefaultStatefor created events, invokesapplyEvent, and updatescreatedAt/updatedAt. Called by the framework; you should not call this directlyserialize(): DomainEventData — created events additionally carry$frozenDefaultStateapplyEvent(state: T)(protected, abstract) — implement your event-specific state mutation here. The created event must setstate.id
DomainEventData
Serialized event shape: $aggregateId, $id, $userId, $name, $occurredAt, $version, $isCreatedEvent (all optional/null on unapplied events), and $frozenDefaultState (created events only). Extend this interface with your event's own payload fields.
AggregateRootData
Serialized aggregate shape: $id, $version, $createdAt, $updatedAt, $events.
AggregateState
Base interface for aggregate state.
Properties:
typeVersion: number (readonly) — version of the state shape; bump on breaking changes and migrate in the factory'supdate()id: string — unique identifier for the aggregateversion: number — current version of the aggregatecreatedAt: number — creation timestamp (epoch ms)updatedAt: number — last update timestamp (epoch ms)isRebased: boolean — whether the aggregate was rebasedrebasedFromVersion: number — version from which the aggregate was rebased
AggregateStateFactory
Abstract base class for state factories. Generic over <T extends AggregateState>.
create(): T (abstract) — produce the default state; must be deterministic (the framework verifies repeated calls are identical)update(state: T): T — hook for migrating loaded state forward acrosstypeVersions; default is identitydeserializeSnapshot(snapshot: T): T — revive serialized value objects inside a snapshot (usesAggregateStateHelper.deserializeSnapshotIntoState)createDefaultAggregateState()(protected): AggregateState — base-field defaults (typeVersion: 1,isRebased: false, etc.); spread this into yourcreate()output
AggregateStateHelper
Static utilities for working with state objects.
serializeStateIntoSnapshot(state, ...cloneKeys): object — serialize state (including nestedSerializables) into a plain snapshot; throws if a non-DomainObjectwith private fields is encountereddeserializeSnapshotIntoState(snapshot): object — revive registeredSerializabletypes inside a snapshotrebaseState(state, defaultState, rebaseState, rebaseVersion): void — layer a rebase snapshot over current defaults onto the state; call from your rebased event'sapplyEventfingerprintState(state): string — stable SHA-512 fingerprint of a state object with canonically sorted keys; intended forcreate()drift-guard tests
DomainObject
Abstract base class for value objects (extends Serializable).
equals(value): boolean — structural equality: same type name and identical serialized state
DomainEntity
Abstract base class for entities (extends DomainObject). Constructed with { id: string } in its data.
id: string — unique identifierequals(value): boolean — identity equality: same type name and sameid, regardless of state
DomainContext
Interface for domain context.
userId: string (readonly) — ID of the current user
ConfigurableDomainContext
DomainContext implementation with a mutable userId.
constructor(userId: string)userId: string — gettable and settable
DomainHelper
Static utilities.
now: number — current epoch millisecondsgenerateId(prefix: string): string — generate a sortable id of the formpfx_<date><ulid>; prefix must be exactly 3 alphabetic charactersaggregateTypeToSnakeCase(aggregateType): string — convert an aggregate class name to snake_case
Org* variants
OrgAggregateRoot, OrgAggregateState, OrgAggregateStateFactory, OrgDomainContext, OrgConfigurableDomainContext, OrgDomainEvent, OrgDomainEventData — organization-scoped counterparts of the core types; see Multi-tenancy above.
Best Practices
Event Design
- Keep events immutable; include only necessary data
- Define an abstract per-aggregate event base class that implements
refTypewith a string literal (never an import of the aggregate — circular dependency) - Use the
@serializedecorator on the class (with your app's namespace) and on every payload getter - Set
data.$isCreatedEvent = truein the created event's constructor, before callingsuper(data) - The created event's
applyEventmust setstate.id
Aggregate Design
- Keep aggregates focused and cohesive; maintain consistency boundaries
- Use a static
create(...)factory method that builds the created event and instantiates viaAggregateFactory - Mutate state only through
applyEvent(...)from behavior methods - Validate all public method inputs using
given
State Management
- Keep
create()deterministic — same output on every call - Bump
typeVersionon breaking state-shape changes and migrate inupdate() - Add a drift-guard test on
create()'s fingerprint (AggregateStateHelper.fingerprintState) so default changes are deliberate - Use value objects (extending
DomainObject) for structured state fields so snapshots serialize correctly
- Keep
Domain Organization
- Keep related files close together; use clear naming conventions
- Separate events and value objects into their own directories
- Call
aggregate.test()in your test suite to verify serialization/replay/snapshot round-trips
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Support
For issues and feature requests, please use the GitHub issue tracker.
