Aller au contenu principal
Discover

Domain Model

What this video covers

A walk-through of how Ontologic models a domain in TypeScript. Entities own their state and expose behavior rather than setters. Invariants. Rules like “balance must stay positive” are checked on every state read, so corrupted data is caught the moment it enters the system. Domain events record what happened in past tense, immutably, and form a versioned contract for the rest of the system. The Result pattern makes expected failures (insufficient funds, validation errors) into typed return values that callers cannot ignore. The Repository persists entity state and events atomically in the same transaction.

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

Built-In Event Bus

(powered by outbox pattern)

What this video covers

A demo of the outbox pattern built into Ontologic. Domain events are written to an outbox in the same transaction as the entity state, guaranteeing they cannot be lost on a crash. The Message Relay reads from the outbox, forwards events to the event bus, and checkpoints each one individually so after a failure, delivery resumes precisely where it stopped, with no skipped events and no need to restart the relay from scratch. Pluggable connectors let you target SQS, Kafka, RabbitMQ, Redis, or any other broker without touching the publisher or listener logic.

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

Workflows

What this video covers

A tour of Ontologic's two workflow flavors. Step workflows thread typed steps into a sequential pipeline. Each step's output feeds the next step's input, and the type system rejects out-of-order composition at compile time. Graph workflows model the same idea as a directed acyclic graph: each node names its children, runs them concurrently, and receives their results as a typed record. Both flavors persist state to a repository, so when a multi-step business process crashes mid-run, you simply restart the workflow and it resumes from the last completed step. No recovery scripts, no replaying already-successful work.

Step Workflow

Step by step processes

A Step by step Workflow threads typed steps into a sequential 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 Step Workflows
step-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);
Graph Workflow

Complex Workflow with parallel branches

A Graph Workflow is a DAG of nodes. Each node names its children, runs them concurrently, and receives their outputs as a typed record.

Same observability, persistence, and resume guarantees as the step flavor. A crashed run skips any node whose result is already persisted and re-runs only what's missing.

Learn about Graph Workflows
graph-workflow.ts
class SepaWorkflow extends GraphWorkflow<Inputs, Transfer> {
constructor(params: { id: string; input: Inputs }) {
super({ ...params, name: "SEPA", repository });
this.build((input) => this.#root(input));
}

#root(input: Inputs) {
const balance = new WorkflowNode({
name: "Check Balance", children: {},
handler: async () => ({ ok: input.amount <= 3000 }),
});
const aml = new WorkflowNode({
name: "AML Check", children: {},
handler: async () => ({ score: 0.12 }),
});
return new WorkflowNode({
name: "Create Transfer",
children: { balance, aml },
handler: async ({ balance, aml }) =>
buildTransfer(input, balance, aml),
});
}
}