Skip to main content

Installation

Install the TypeORM adapter:
npm install @woltz/rich-domain @woltz/rich-domain-typeorm typeorm reflect-metadata

Setup TypeORM

Create src/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: [],
});
Initialize in your app:
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 using TypeORMToPersistence:
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);
  }
}
  • onCreate receives EntityManager as second parameter
  • Use @Transactional() decorator for automatic transaction management
  • entityClasses Map is required for the BatchExecutor
  • onUpdate is handled automatically by TypeORMBatchExecutor - 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 uow in constructor
  • Use getSearchableFields() to define which fields support search
  • Use getDefaultRelations() to eagerly load relations
  • typeormRepo is 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
  }
}
Or use the imperative API:
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 optional outboxStore 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