TypeORM adapter for @woltz/rich-domain - bringing Domain-Driven Design patterns to TypeORM with automatic change tracking and batch operations.
@Transactional() decoratornpm install @woltz/rich-domain-typeorm @woltz/rich-domain typeorm
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);
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...
}
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;
}
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);
}
}
}
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
];
}
}
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!
}
}
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
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
}
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)
| 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 |
For new aggregates (isNew() === true):
onCreate() is called to create the root entityonCreate()For existing aggregates with changes:
onCreate() is NOT called@Transactional() on service methods that modify datagetDefaultRelations() to eagerly load related entitiesSearchableField<TEntity>[] for type-safe search configurationowned for 1:N relationships where parent controls lifecyclereference for N:N relationships with independent entities@Transactional() to mapper methods (redundant)@JoinTable)onCreate() (it wonβt work)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>[];
}
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;
}
type SearchableField<T> =
| keyof T
| `${string}.${string}` // Nested fields
| {
field: string;
caseSensitive?: boolean; // Default: false
};
See the fastify-with-typeorm example for a complete working application demonstrating:
MIT