如何使用mikro-orm构建富领域模型?探讨其领域与DAL解耦设计及TS能力
Great question—let's tackle this from two angles: first, best practices for implementing rich domain models with MikroORM, and second, how MikroORM's design enables that clean domain-DAL separation you're targeting with the repository pattern, while keeping TypeScript productivity high.
Best Practices for Rich Domain Models with MikroORM
1. Treat Entities as Your Core Domain Models
MikroORM doesn't force you to split domain models and database entities—your @Entity() classes are your domain models. Embed business logic directly in them instead of relying on anemic models with logic in external services.
Example of a rich Order entity:
@Entity() class Order { @PrimaryKey() id: number; @ManyToOne(() => User) user: User; @OneToMany(() => OrderItem, item => item.order, { eager: true }) items = new Collection<OrderItem>(this); @Property() status: 'draft' | 'confirmed' | 'shipped' = 'draft'; // Domain logic: Calculate order total calculateTotal(): number { return this.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0); } // Domain logic: Validate and confirm order confirm(): void { if (this.status !== 'draft') { throw new Error('Only draft orders can be confirmed'); } if (this.items.isEmpty()) { throw new Error('Cannot confirm an empty order'); } this.status = 'confirmed'; } }
2. Use Embeddables for Value Objects
MikroORM's @Embeddable() decorator is perfect for implementing immutable value objects (like Money, Address, or Email)—domain concepts that don't have their own identity but are part of an entity.
Example of a Money value object:
@Embeddable() class Money { @Property() amount: number; @Property() currency: string; constructor(amount: number, currency: string) { if (amount < 0) throw new Error('Amount cannot be negative'); if (!['USD', 'EUR', 'GBP'].includes(currency)) { throw new Error('Unsupported currency'); } this.amount = amount; this.currency = currency; } // Value object behavior: Add two Money instances add(other: Money): Money { if (this.currency !== other.currency) { throw new Error('Cannot add money with different currencies'); } return new Money(this.amount + other.amount, this.currency); } } // Usage in an entity @Entity() class Product { @PrimaryKey() id: number; @Property() name: string; @Embedded(() => Money) price: Money; }
3. Leverage Lifecycle Hooks for Domain Validation
Use MikroORM's lifecycle hooks (like @BeforeInsert, @BeforeUpdate) to enforce domain rules at the entity level, ensuring consistency before changes hit the database.
Example:
@Entity() class User { @PrimaryKey() id: number; @Property({ unique: true }) email: string; @Property() passwordHash: string; @BeforeInsert() @BeforeUpdate() validateEmail(): void { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(this.email)) { throw new Error('Invalid email format'); } } }
4. Avoid Leaking Database Logic into the Domain
Keep entity methods focused on domain behavior, not database operations. Use repositories (covered next) to handle querying and persistence concerns.
How MikroORM Enables Domain-DAL Decoupling with the Repository Pattern
MikroORM's design is inherently aligned with the repository pattern, making it easy to separate your domain layer from the data access layer (DAL) while retaining TypeScript type safety and productivity.
1. Native Repository Support
Every MikroORM entity has a built-in repository, but you can also create custom repositories to encapsulate complex queries and persistence logic. The key here is to define an interface in your domain layer, then implement it with MikroORM in the DAL.
Step 1: Define a Domain Repository Interface
// domain/repositories/OrderRepository.ts export interface OrderRepository { findById(id: number): Promise<Order | null>; save(order: Order): Promise<void>; findConfirmedOrdersForUser(userId: number): Promise<Order[]>; delete(id: number): Promise<void>; }
Step 2: Implement with MikroORM
// dal/repositories/MikroORMOrderRepository.ts import { EntityRepository, Repository } from '@mikro-orm/core'; import { Order } from '../entities/Order'; import { OrderRepository } from '../../domain/repositories/OrderRepository'; @Repository(Order) export class MikroORMOrderRepository extends EntityRepository<Order> implements OrderRepository { async findConfirmedOrdersForUser(userId: number): Promise<Order[]> { return this.createQueryBuilder('o') .where({ user: userId, status: 'confirmed' }) .leftJoinAndSelect('o.items', 'items') .getResult(); } // The other methods (findById, save, delete) are inherited from EntityRepository }
2. EntityManager as a Unit of Work
MikroORM's EntityManager acts as a Unit of Work, tracking all changes to entities and flushing them to the database in a single transaction. This lets your domain layer work with entities without worrying about low-level database operations.
Example of using the Unit of Work:
// domain/services/OrderService.ts import { OrderRepository } from '../repositories/OrderRepository'; import { EntityManager } from '@mikro-orm/core'; export class OrderService { constructor( private orderRepo: OrderRepository, private em: EntityManager ) {} async createOrder(userId: number, items: { productId: number; quantity: number }[]): Promise<Order> { const user = await this.em.findOneOrFail(User, userId); const order = new Order(user); // Add items to order (domain logic in Order entity) for (const item of items) { const product = await this.em.findOneOrFail(Product, item.productId); order.addItem(product, item.quantity); } // Persist via repository (or directly via em.persist()) await this.orderRepo.save(order); return order; } }
3. TypeScript First-Class Citizenship
MikroORM is written entirely in TypeScript, so all repositories, entities, and the EntityManager have full type support. This eliminates runtime errors and boosts productivity with autocompletion.
For example:
em.find(Order, { id: 1 })returnsPromise<Order | null>(type-safe)- Custom repository methods inherit type safety from the entity
- Embeddables and relations are fully typed
4. Dependency Injection for Clean Separation
You can use dependency injection (e.g., with NestJS, Angular, or a custom container) to inject the repository interface into your domain services. This means your domain layer never references MikroORM directly—only the abstract repository interface.
Example with NestJS:
// app.module.ts import { Module } from '@nestjs/common'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { OrderService } from './domain/services/OrderService'; import { Order } from './dal/entities/Order'; import { MikroORMOrderRepository } from './dal/repositories/MikroORMOrderRepository'; import { OrderRepository } from './domain/repositories/OrderRepository'; @Module({ imports: [MikroOrmModule.forFeature([Order])], providers: [ OrderService, { provide: OrderRepository, useClass: MikroORMOrderRepository, }, ], }) export class AppModule {}
Key Takeaways
- Rich Domain Models: Use MikroORM entities as your domain models, embed value objects with
@Embeddable(), and put business logic directly in entities. - Domain-DAL Decoupling: Define abstract repository interfaces in your domain layer, implement them with MikroORM's custom repositories, and use the EntityManager as a Unit of Work.
- Productivity & Type Safety: MikroORM's TypeScript support ensures type safety across all layers, while its built-in features (repositories, lifecycle hooks) reduce boilerplate.
内容的提问来源于stack exchange,提问作者Sergio Bernal

