Skip to main content

Ontologic

Model What Matters

npm install ontologic


Domain Entity

Entities that protect themselves

A Domain Entity owns its state. External code can read it, but cannot mutate it directly. The entity is the single authority on what it can and cannot do.

No more validation logic scattered across services and controllers. The rules live where they belong.

Learn about Domain Entities
bank-account.ts
class BankAccount extends DomainEntity<State> {
deposit(amount: number): MoneyDeposited {
this.state.balance += amount;
return new MoneyDeposited(this.id(), { amount });
}

withdraw(amount: number)
: Result<MoneyWithdrawn, InsufficientFunds> {

if (this.state.balance < amount) {
return err(new InsufficientFunds({
available: this.state.balance,
requested: amount,
}));
}
this.state.balance -= amount;
return ok(new MoneyWithdrawn(this.id(), { amount }));
}
}
Domain Events

History you can trust

A Domain Event is a record that something meaningful happened — past tense, immutable, and named after a business fact.

Events are a versioned contract. Once consumers depend on them, they are public API. The Outbox pattern ensures every saved event is eventually delivered, with no risk of silent loss.

Learn about Domain Events
money-withdrawn.event.ts
class MoneyWithdrawn extends DomainEvent<
"MONEY_WITHDRAWN",
1,
{ amount: number }
> {
constructor(entityId: string, payload: { amount: number }) {
super({ name: "MONEY_WITHDRAWN", version: 1,
entityId, payload });
}
}

// Payload is deep-cloned at construction — immutable
const event = new MoneyWithdrawn(account.id(), { amount: 200 });
event.payload.amount = 0; // has no effect
Invariants

Rules that never sleep

An invariant is a rule that must always be true — not just after certain operations, but at all times, including when loading data from the database.

Invariants are checked on every state read. Corrupted data is caught the moment it enters the system, before it can cause any damage.

Learn about Invariants
bank-account.invariants.ts
const balanceIsPositive =
new BaseDomainInvariant<BankAccountState>(
"Balance must be positive",
(state) => state.balance >= 0
);

const balanceIsUnderLimit =
new BaseDomainInvariant<BankAccountState>(
"Balance is under limit",
(state) => state.balance <= 1_000_000
);

// Compose with logical operators
const validBalance =
balanceIsPositive.and(balanceIsUnderLimit);
Result Pattern

Failures with meaning

Not all failures are unexpected. In a business domain, some failures are entirely normal — and the caller should be expected to handle them.

The Result pattern makes this explicit. Domain failures are typed return values, not hidden exceptions. The type system forces callers to deal with every outcome.

Learn about the Result Pattern
debit-balance.use-case.ts
class InsufficientFunds extends DomainError<
"INSUFFICIENT_FUNDS",
{ available: number; requested: number }
> {}

// The caller cannot ignore the failure case
const result = account.withdraw(500);

if (result.isErr()) {
console.log(result.error.context.available);
return;
}

// TypeScript knows we succeeded here
await repository.saveWithEvents(account, [result.value]);
Repository

Persistence without compromise

The Repository hides all database details behind a clean, domain-friendly interface. Your entities stay free of infrastructure concerns and remain easy to test.

Saving an entity and its events in the same transaction guarantees consistency. You get both, or neither — never a state change without a record of what happened.

Learn about the Repository
bank-account.repository.ts
// Extend the built-in generic — no boilerplate needed
class BankAccountRepository
extends InMemoryRepository<BankAccount> {
constructor() {
super(BankAccount.fromState);
}
}

// Ready for tests and prototyping instantly
const repository = new BankAccountRepository();

// Entity state and event saved atomically
await repository.saveWithEvents(account, result.value);
Workflows

Multi-step processes, type-checked end to end

A Workflow chains typed steps into a resumable pipeline. Each step's output flows into the next step's input, and the type system enforces the order reordering the chain is a compile error.

Plug in a repository and the state is persisted in any case. A crashed run resumes exactly where it left off, with no re-execution of work that already succeeded.

Learn about Workflows
sepa-payment.workflow.ts
const workflow = new WorkflowBuilder<SepaPaymentRequest>({
id: randomUUID(),
name: "SEPA Payment",
input: { accountId, receiverIban, amount },
})
.addStep(checkAccountBalance)
.addStep(checkReceiverIsValid)
.addStep(checkAmlRisk)
.addStep(createSepaTransfer);

// Persist the state, resume after a crash
const transfer = await workflow.execute(repository);

Built-in Outbox Pattern

Event Bus

Deliver events to your system

The Event Bus handles delivery of domain events to the rest of your system. A pluggable connector interface keeps the publisher and listener logic independent of any specific broker.

Swap the connector to target SQS, Kafka, RabbitMQ, Redis, or any other broker. In-memory connectors are included for tests and local prototyping.

Learn about the Event Bus
order-events.listener.ts
const listener = new DomainEventBusListener<OrderEvents>({
listenerConnector: myConnector,
options: { validator: parseOrderEvents },
});

listener.listenTo("ORDER_PLACED", async (event, metadata) => {
// event is a real OrderPlaced instance
await notifyWarehouse(event.payload.orderId);
});

listener.listenTo("PAYMENT_RECEIVED", async (event, metadata) => {
await generateInvoice(event.payload);
});

await listener.start();
Message Relay

Deliver every event, survive every failure

The Message Relay reads events from the outbox table and forwards them to the event bus. It tracks exactly what has been published, so after a crash it resumes precisely where it left off.

Each event is checkpointed individually. A failure mid-batch never causes events to be skipped or the relay to restart from scratch.

Learn about the Message Relay
message-relay.ts
const relay = new MessageRelay(
repository,
new InMemoryMessageRelayStateRepository(),
"Order",
publisher,
);

relay.onError((error) => {
logger.error("relay error", { error });
});

// Trigger the relay whenever an entity is saved
repository.onChanges(relay.handler);