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

Next Steps