Skip to main content

Ontologic

A toolkit for building software that speaks your domain's language.

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);