rich-domain

@woltz/rich-domain-typeorm

TypeORM adapter for @woltz/rich-domain - bringing Domain-Driven Design patterns to TypeORM with automatic change tracking and batch operations.

Features

Installation

npm install @woltz/rich-domain-typeorm @woltz/rich-domain typeorm

Quick Start

1. Setup DataSource and UnitOfWork

import { DataSource } from "typeorm";
import { TypeORMUnitOfWork } from "@woltz/rich-domain-typeorm";

const dataSource = new DataSource({
  type: "postgres",
  host: "localhost",
  port: 5432,
  username: "user",
  password: "password",
  database: "mydb",
  entities: [UserEntity, PostEntity, TagEntity],
  synchronize: true,
});

await dataSource.initialize();
const uow = new TypeORMUnitOfWork(dataSource);

2. Define Your Domain Entity

import { Aggregate, Id } from "@woltz/rich-domain";
import { z } from "zod";

const UserSchema = z.object({
  id: z.custom<Id>(),
  email: z.string().email(),
  name: z.string(),
  posts: z.array(z.instanceof(Post)),
  createdAt: z.date(),
  updatedAt: z.date(),
});

export class User extends Aggregate<z.infer<typeof UserSchema>> {
  protected static validation = { schema: UserSchema };

  addPost(post: Post): void {
    this.props.posts.push(post);
  }

  // Getters...
}

3. Create TypeORM Entities

import { Entity, PrimaryColumn, Column, OneToMany } from "typeorm";

@Entity("users")
export class UserEntity {
  @PrimaryColumn("uuid")
  id!: string;

  @Column()
  email!: string;

  @Column()
  name!: string;

  @OneToMany(() => PostEntity, (post) => post.author)
  posts!: PostEntity[];

  @Column()
  createdAt!: Date;

  @Column()
  updatedAt!: Date;
}

4. Create Persistence Mapper

import {
  TypeORMToPersistence,
  EntitySchemaRegistry,
} from "@woltz/rich-domain-typeorm";

export class UserToPersistenceMapper extends TypeORMToPersistence<User> {
  protected readonly registry = new EntitySchemaRegistry()
    .register({
      entity: "User",
      table: "users",
      collections: {
        posts: {
          type: "owned", // 1:N - Posts are owned by User
          entity: "Post",
        },
      },
    })
    .register({
      entity: "Post",
      table: "posts",
      fields: {
        content: "main_content", // Map domain field to DB column
      },
      parentFk: {
        field: "authorId",
        parentEntity: "User",
      },
    });

  protected readonly entityClasses = new Map<string, new () => any>([
    ["User", UserEntity],
    ["Post", PostEntity],
  ]);

  protected async onCreate(aggregate: User, em: EntityManager): Promise<void> {
    // Create root entity
    const entity = new UserEntity();
    entity.id = aggregate.id.value;
    entity.email = aggregate.email;
    entity.name = aggregate.name;
    entity.createdAt = aggregate.createdAt;
    entity.updatedAt = aggregate.updatedAt;
    await em.save(entity);

    // Create owned entities (Posts)
    for (const post of aggregate.posts) {
      const postEntity = new PostEntity();
      postEntity.id = post.id.value;
      postEntity.title = post.title;
      postEntity.mainContent = post.content;
      postEntity.authorId = aggregate.id.value;
      postEntity.createdAt = post.createdAt;
      postEntity.updatedAt = post.updatedAt;
      await em.save(postEntity);
    }
  }
}

5. Create Repository

import { TypeORMRepository, SearchableField } from "@woltz/rich-domain-typeorm";

export class TypeORMUserRepository extends TypeORMRepository<User, UserEntity> {
  constructor(repo: Repository<UserEntity>, uow: TypeORMUnitOfWork) {
    super({
      typeormRepository: repo,
      toDomainMapper: new UserToDomainMapper(),
      toPersistenceMapper: new UserToPersistenceMapper(uow),
      uow,
    });
  }

  // Load posts by default
  protected getDefaultRelations(): string[] {
    return ["posts"];
  }

  // Enable case-insensitive search
  protected getSearchableFields(): SearchableField<UserEntity>[] {
    return [
      "name", // Case-insensitive by default
      "email", // Case-insensitive by default
      "posts.title", // Nested relation search
    ];
  }
}

6. Use in Your Service

import { Transactional } from "@woltz/rich-domain-typeorm";

export class UserService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly uow: TypeORMUnitOfWork
  ) {}

  @Transactional() // Automatic transaction management
  async createUser(data: CreateUserInput): Promise<User> {
    const user = new User({
      id: new Id(),
      email: data.email,
      name: data.name,
      posts: [],
      createdAt: new Date(),
      updatedAt: new Date(),
    });

    await this.userRepo.save(user); // Automatic change tracking!
    return user;
  }

  @Transactional()
  async addPost(userId: string, postData: CreatePostInput): Promise<void> {
    const user = await this.userRepo.findById(userId);
    if (!user) throw new Error("User not found");

    const post = new Post({
      id: new Id(),
      title: postData.title,
      content: postData.content,
      authorId: userId,
      tags: [],
      published: false,
      createdAt: new Date(),
      updatedAt: new Date(),
    });

    user.addPost(post);
    await this.userRepo.save(user); // BatchExecutor handles the Post creation!
  }
}

Advanced Features

N:N Relationships with Junction Tables

For many-to-many relationships, configure the junction table in your registry:

// Domain Entity
export class Post extends Entity<PostProps> {
  addTag(tag: Tag): void {
    this.props.tags.push(tag);
  }

  removeTag(tag: Tag): void {
    this.props.tags = this.props.tags.filter(t => !t.id.equals(tag.id));
  }
}

// TypeORM Entity
@Entity("posts")
export class PostEntity {
  @ManyToMany(() => TagEntity, tag => tag.posts)
  @JoinTable({
    name: "_PostToTag",
    joinColumn: { name: "A", referencedColumnName: "id" },
    inverseJoinColumn: { name: "B", referencedColumnName: "id" }
  })
  tags!: TagEntity[];
}

// Registry Configuration
protected readonly registry = new EntitySchemaRegistry().register({
  entity: "Post",
  table: "posts",
  collections: {
    tags: {
      type: "reference",  // N:N - Tags are referenced
      entity: "Tag",
      junction: {
        table: "_PostToTag",
        sourceKey: "A",  // Must match JoinTable column names!
        targetKey: "B"
      }
    }
  }
});

When you add or remove tags, the adapter automatically manages the junction table:

const post = await postRepo.findById(postId);
post.addTag(new Tag({ id: new Id("promo") }));
await postRepo.save(post);
// β†’ Automatically: INSERT INTO "_PostToTag" ("A", "B") VALUES (postId, 'promo')

Configure search fields with optional case sensitivity:

protected getSearchableFields(): SearchableField<PostEntity>[] {
  return [
    'title',                                    // Case-insensitive (default)
    'mainContent',                              // Case-insensitive (default)
    { field: 'code', caseSensitive: true },     // Case-sensitive
    'author.name'                               // Nested relation (case-insensitive)
  ];
}

Usage with Criteria:

const criteria = Criteria.create<Post>()
  .search("hello") // Searches in title, mainContent, and author.name (case-insensitive)
  .where("published", "eq", true)
  .orderBy("createdAt", "desc")
  .paginate(1, 20);

const posts = await postRepo.find(criteria);
// β†’ SELECT * FROM posts
//    LEFT JOIN users ON posts.author_id = users.id
//    WHERE (LOWER(posts.title) LIKE LOWER('%hello%')
//           OR LOWER(posts.main_content) LIKE LOWER('%hello%')
//           OR LOWER(users.name) LIKE LOWER('%hello%'))
//    AND posts.published = true
//    ORDER BY posts.created_at DESC
//    LIMIT 20

Transaction Management

The @Transactional() decorator provides automatic transaction handling:

@Transactional()
async transferPosts(fromUserId: string, toUserId: string): Promise<void> {
  const fromUser = await this.userRepo.findById(fromUserId);
  const toUser = await this.userRepo.findById(toUserId);

  if (!fromUser || !toUser) throw new Error("User not found");

  // Move all posts from one user to another
  for (const post of fromUser.posts) {
    fromUser.removePost(post);
    toUser.addPost(post);
  }

  await this.userRepo.save(fromUser);
  await this.userRepo.save(toUser);

  // βœ… Both saves succeed β†’ COMMIT
  // ❌ Any error β†’ ROLLBACK (nothing persisted)
}

Nested Transactions: The decorator is idempotent - if already in a transaction, it reuses it:

@Transactional()
async outer() {
  await this.methodA();  // βœ… Uses same transaction
  await this.methodB();  // βœ… Uses same transaction
}

@Transactional()
async methodA() {
  // This decorator detects existing transaction and reuses it
}

@Transactional()
async methodB() {
  // This decorator detects existing transaction and reuses it
}

How It Works

Change Tracking Flow

1. Load Aggregate from DB
   β”œβ”€ TypeORMRepository.findById()
   └─ Creates snapshot of current state

2. Modify Aggregate (Domain Logic)
   β”œβ”€ user.addPost(post)
   β”œβ”€ post.addTag(tag)
   └─ Proxy tracks all changes

3. Save Aggregate
   β”œβ”€ TypeORMRepository.save(user)
   β”œβ”€ Detects changes via getChanges()
   └─ Routes to appropriate handler:
       β”œβ”€ New aggregate β†’ onCreate()
       └─ Existing β†’ BatchExecutor

4. BatchExecutor Processes Changes
   β”œβ”€ Deletes (leaf β†’ root, depth DESC)
   β”œβ”€ Creates (root β†’ leaf, depth ASC)
   └─ Updates (any order)

Collection Types

Type Description Example Behavior
owned Parent owns children (1:N) User has Posts Create/Delete entities
reference References existing entities (N:N) Post has Tags Connect/Disconnect via junction

onCreate vs BatchExecutor

For new aggregates (isNew() === true):

For existing aggregates with changes:

Best Practices

βœ… DO

❌ DON’T

API Reference

TypeORMRepository

class TypeORMRepository<TDomain, TEntity> extends Repository<TDomain> {
  // Query methods
  async findById(id: string): Promise<TDomain | null>;
  async find(criteria?: Criteria<TDomain>): Promise<PaginatedResult<TDomain>>;
  async findOne(criteria: Criteria<TDomain>): Promise<TDomain | null>;
  async count(criteria?: Criteria<TDomain>): Promise<number>;
  async exists(id: string): Promise<boolean>;
  async findAll(): Promise<TDomain[]>;

  // Persistence methods
  async save(aggregate: TDomain): Promise<void>;
  async delete(aggregate: TDomain): Promise<void>;
  async deleteById(id: string): Promise<void>;

  // Configuration hooks
  protected getDefaultRelations(): string[];
  protected getSearchableFields(): SearchableField<TEntity>[];
}

EntitySchemaRegistry

interface EntitySchemaRegistry {
  register(config: {
    entity: string;
    table?: string;
    fields?: Record<string, string>;
    collections?: Record<
      string,
      {
        type: "owned" | "reference";
        entity: string;
        junction?: {
          table: string;
          sourceKey: string;
          targetKey: string;
        };
      }
    >;
    parentFk?: {
      field: string;
      parentEntity: string;
    };
  }): EntitySchemaRegistry;
}

SearchableFieldConfig

type SearchableField<T> =
  | keyof T
  | `${string}.${string}` // Nested fields
  | {
      field: string;
      caseSensitive?: boolean; // Default: false
    };

Examples

See the fastify-with-typeorm example for a complete working application demonstrating:

License

MIT