Installation
Install the TypeORM adapter:npm install @woltz/rich-domain @woltz/rich-domain-typeorm typeorm reflect-metadata
Setup TypeORM
Createsrc/data-source.ts:
import { DataSource } from "typeorm";
import "reflect-metadata";
export const AppDataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "user",
password: "password",
database: "mydb",
synchronize: true,
logging: false,
entities: ["src/infrastructure/entities/**/*.ts"],
migrations: [],
subscribers: [],
});
import { AppDataSource } from "./data-source";
await AppDataSource.initialize();
console.log("Data Source initialized");
Define Entity Schema
Create your TypeORM entity:import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
@Entity("products")
export class ProductEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column()
name!: string;
@Column("decimal")
price!: number;
@Column("int")
stock!: number;
@Column()
status!: string;
@Column()
createdAt!: Date;
@Column()
updatedAt!: Date;
}
Define Your Aggregate
Create your domain model:import { z } from "zod";
import { Aggregate, EntityValidation, Id } from "@woltz/rich-domain";
const productSchema = z.object({
id: z.custom<Id>((val) => val instanceof Id),
name: z.string().min(3),
price: z.number().positive(),
stock: z.number().int().min(0),
status: z.enum(["active", "inactive"]),
});
type ProductProps = z.infer<typeof productSchema>;
class Product extends Aggregate<ProductProps> {
protected static validation: EntityValidation<ProductProps> = {
schema: productSchema,
config: {
onCreate: true,
onUpdate: true,
throwOnError: true,
},
};
get name() {
return this.props.name;
}
get price() {
return this.props.price;
}
get stock() {
return this.props.stock;
}
updatePrice(newPrice: number) {
this.props.price = newPrice;
}
decreaseStock(amount: number) {
this.props.stock -= amount;
}
activate() {
this.props.status = "active";
}
deactivate() {
this.props.status = "inactive";
}
}
Create Mappers
ToDomain Mapper
Maps from TypeORM to Domain:import { Mapper, Id } from "@woltz/rich-domain";
export class ProductToDomainMapper extends Mapper<ProductEntity, Product> {
build(entity: ProductEntity): Product {
return new Product({
id: Id.from(entity.id),
name: entity.name,
price: entity.price,
stock: entity.stock,
status: entity.status as "active" | "inactive",
});
}
}
ToPersistence Mapper
Maps from Domain to TypeORM usingTypeORMToPersistence:
import { EntityManager } from "typeorm";
import {
TypeORMToPersistence,
TypeORMUnitOfWork,
Transactional
} from "@woltz/rich-domain-typeorm";
import { EntitySchemaRegistry } from "@woltz/rich-domain";
export class ProductToPersistenceMapper extends TypeORMToPersistence<Product> {
protected readonly registry = new EntitySchemaRegistry().register({
entity: "Product",
table: "products",
});
protected readonly entityClasses = new Map<string, new () => any>([
["Product", ProductEntity],
]);
@Transactional()
protected async onCreate(product: Product, em: EntityManager): Promise<void> {
const entity = new ProductEntity();
entity.id = product.id.value;
entity.name = product.name;
entity.price = product.price;
entity.stock = product.stock;
entity.status = product.status;
entity.createdAt = new Date();
entity.updatedAt = new Date();
await em.save(entity);
}
}
onCreatereceivesEntityManageras second parameter- Use
@Transactional()decorator for automatic transaction management entityClassesMap is required for the BatchExecutoronUpdateis handled automatically byTypeORMBatchExecutor- no need to override
Create Repository
Implement the repository:import {
TypeORMRepository,
TypeORMUnitOfWork,
SearchableField
} from "@woltz/rich-domain-typeorm";
import { AppDataSource } from "./data-source";
import { Repository } from "typeorm";
const uow = new TypeORMUnitOfWork(AppDataSource);
interface IProductRepository {
save(product: Product): Promise<void>;
findById(id: string): Promise<Product | null>;
}
class ProductRepository
extends TypeORMRepository<Product, ProductEntity>
implements IProductRepository
{
constructor() {
const typeormRepo = AppDataSource.getRepository(ProductEntity);
super({
typeormRepository: typeormRepo,
toDomainMapper: new ProductToDomainMapper(),
toPersistenceMapper: new ProductToPersistenceMapper(uow),
uow,
alias: "product", // Optional: alias for QueryBuilder
});
}
protected getSearchableFields(): SearchableField<ProductEntity>[] {
return ["name"]; // Case-insensitive search by default
}
protected getDefaultRelations(): string[] {
return []; // Add relations if needed: ["category", "supplier"]
}
async findById(id: string): Promise<Product | null> {
const entity = await this.typeormRepo.findOne({
where: { id },
relations: this.getDefaultRelations(),
});
return entity ? this.toDomainMapper.build(entity) : null;
}
}
Key points:
- ToPersistenceMapper receives
uowin constructor - Use
getSearchableFields()to define which fields support search - Use
getDefaultRelations()to eagerly load relations typeormRepois available from the base class for custom queries
## Use It
```typescript
// Initialize TypeORM
await AppDataSource.initialize();
const repository = new ProductRepository();
// Create product
const product = new Product({
name: "Mechanical Keyboard",
price: 149.99,
stock: 50,
status: "active",
});
// Save to database
await repository.save(product);
console.log("Product created:", product.id.value);
// Update
product.updatePrice(129.99);
product.decreaseStock(5);
await repository.save(product);
// Query
const found = await repository.findById(product.id.value);
console.log("Found product:", found?.name); // "Mechanical Keyboard"
// Use Criteria for complex queries
const criteria = Criteria.create<Product>()
.where("status", "equals", "active")
.where("price", "greaterThan", 100)
.orderBy("name", "asc")
.paginate(1, 10);
const results = await repository.find(criteria);
console.log(results.data); // Array of products
console.log(results.meta); // Pagination info
Working with Relations
1:N Owned Relations
// TypeORM Entities
@Entity("users")
export class UserEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column()
email!: string;
@Column()
name!: string;
@OneToMany(() => PostEntity, post => post.author)
posts!: PostEntity[];
}
@Entity("posts")
export class PostEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column()
authorId!: string;
@ManyToOne(() => UserEntity, user => user.posts)
author!: UserEntity;
}
// ToPersistence with 1:N
export class UserToPersistenceMapper extends TypeORMToPersistence<User> {
protected readonly registry = new EntitySchemaRegistry()
.register({
entity: "User",
table: "user",
collections: {
posts: {
type: "owned",
entity: "Post",
},
},
})
.register({
entity: "Post",
table: "post",
parentFk: {
field: "authorId",
parentEntity: "User",
},
});
protected readonly entityClasses = new Map([
["User", UserEntity],
["Post", PostEntity],
]);
@Transactional()
protected async onCreate(user: User, em: EntityManager): Promise<void> {
// Create user
const userEntity = new UserEntity();
userEntity.id = user.id.value;
userEntity.email = user.email;
userEntity.name = user.name;
await em.save(userEntity);
// Create owned posts
for (const post of user.posts) {
const postEntity = new PostEntity();
postEntity.id = post.id.value;
postEntity.title = post.title;
postEntity.authorId = user.id.value;
await em.save(postEntity);
}
}
}
N:N with Junction Tables
// TypeORM Entities
@Entity("posts")
export class PostEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@ManyToMany(() => TagEntity)
@JoinTable({
name: "_PostToTag",
joinColumn: { name: "A" },
inverseJoinColumn: { name: "B" },
})
tags!: TagEntity[];
}
@Entity("tags")
export class TagEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column()
name!: string;
}
// ToPersistence with N:N
export class PostToPersistenceMapper extends TypeORMToPersistence<Post> {
protected readonly registry = new EntitySchemaRegistry().register({
entity: "Post",
table: "post",
collections: {
tags: {
type: "reference",
entity: "Tag",
junction: {
table: "_PostToTag",
sourceKey: "A",
targetKey: "B",
},
},
},
});
protected readonly entityClasses = new Map([
["Post", PostEntity],
["Tag", TagEntity],
]);
@Transactional()
protected async onCreate(post: Post, em: EntityManager): Promise<void> {
// Create post
const entity = new PostEntity();
entity.id = post.id.value;
entity.title = post.title;
entity.content = post.content;
await em.save(entity);
// Create junction records
if (post.tags.length > 0) {
for (const tag of post.tags) {
await em.query(
`INSERT INTO "_PostToTag" ("A", "B") VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[entity.id, tag.id.value]
);
}
}
}
}
// Usage
const post = Post.create({
title: "My Post",
content: "Content here",
});
const tag1 = new Tag({ id: Id.from("tag-1") });
const tag2 = new Tag({ id: Id.from("tag-2") });
post.addTag(tag1);
post.addTag(tag2);
await postRepository.save(post);
// Post and junction records are created automatically
Unit of Work with Transactions
Use the@Transactional decorator for automatic transaction management:
import { Transactional } from "@woltz/rich-domain-typeorm";
class ProductService {
constructor(private repository: ProductRepository) {}
@Transactional()
async transferStock(fromId: string, toId: string, amount: number) {
const from = await this.repository.findById(fromId);
const to = await this.repository.findById(toId);
if (!from || !to) throw new Error("Product not found");
from.decreaseStock(amount);
to.increaseStock(amount);
await this.repository.save(from);
await this.repository.save(to);
// Commits on success, rolls back on error
}
}
async function transferStock(fromId: string, toId: string, amount: number) {
await uow.transaction(async () => {
const from = await repository.findById(fromId);
const to = await repository.findById(toId);
if (!from || !to) throw new Error("Product not found");
from.decreaseStock(amount);
to.increaseStock(amount);
await repository.save(from);
await repository.save(to);
});
}
Advanced Queries
TypeORM gives you full access to QueryBuilder:class ProductRepository extends TypeORMRepository<Product, ProductEntity> {
async findLowStock(threshold: number): Promise<Product[]> {
const entities = await this.repository
.createQueryBuilder("product")
.where("product.stock < :threshold", { threshold })
.andWhere("product.status = :status", { status: "active" })
.orderBy("product.stock", "ASC")
.getMany();
return entities.map(e => this.toDomainMapper.map(e));
}
async findByPriceRange(min: number, max: number): Promise<Product[]> {
const entities = await this.repository
.createQueryBuilder("product")
.where("product.price BETWEEN :min AND :max", { min, max })
.getMany();
return entities.map(e => this.toDomainMapper.map(e));
}
}
Transactional Outbox (optional)
If your aggregates emit domain events, pass an optionaloutboxStore in TypeORMRepositoryConfig. When set, save() persists uncommitted events to the outbox table in the same transaction as the aggregate — so events are not lost if the process crashes before dispatchAll().
See Transactional Outbox for the full setup (OutboxEntity, event bus decorator, background publisher). Repository wiring is documented under TypeORM Integration → Transactional Outbox.
Next Steps
TypeORM Integration
Deep dive into TypeORM adapter features
Transactional Outbox
Guaranteed domain event delivery with the outbox pattern
Repository Pattern
Learn advanced repository patterns
Change Tracking
Understand how changes are tracked
Transactions
Advanced transaction management