Installation
Install the TypeORM adapter:Copy
npm install @woltz/rich-domain @woltz/rich-domain-typeorm typeorm reflect-metadata
Setup TypeORM
Createsrc/data-source.ts:
Copy
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: [],
});
Copy
import { AppDataSource } from "./data-source";
await AppDataSource.initialize();
console.log("Data Source initialized");
Define Entity Schema
Create your TypeORM entity:Copy
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:Copy
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:Copy
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:
Copy
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:Copy
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
Copy
## 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
Copy
// 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
Copy
// 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:
Copy
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
}
}
Copy
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:Copy
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));
}
}