Factory
@modularityjs/factory is the test-data factory primitive: defineFactory with deterministic sequences, traits, deep-partial overrides, and an app-supplied persistence hook. Zero deps, no DI, no Module — like exception and retry, it sits on the infrastructure allowlist and is importable from anywhere without module wiring. That includes production code: the same factories drive vitest specs and database seeders.
Defining a factory
The definition builds the whole object and receives a context with a 1-based sequence that increments per built object:
import { defineFactory } from '@modularityjs/factory';
interface User {
email: string;
name: string;
role: 'user' | 'admin';
createdAt: Date;
}
const userFactory = defineFactory<User>(
({ sequence }) => ({
email: `user-${sequence}@example.com`,
name: `User ${sequence}`,
role: 'user',
createdAt: new Date('2026-01-01T00:00:00Z'),
}),
{
traits: {
admin: () => ({ role: 'admin' }),
},
},
);
const user = userFactory.build(); // sequence 1
const three = userFactory.buildMany(3); // sequences 2–4No faker (or any randomness) is bundled — the definition function is the seam. Deterministic sequence-derived values keep tests reproducible by default; an app that wants realistic noise calls its own faker inside the definition.
Traits
with(...traits) returns a derived factory with the named traits applied on top of the definition (definition → traits in argument order → overrides). Derived factories share the root sequence counter, so uniqueness invariants (email above) hold across variants:
const admin = userFactory.with('admin').build(); // sequence 5 — same counter
admin.role; // 'admin'
admin.email; // 'user-5@example.com' — still unique vs. plain buildsUnknown trait names throw a ValidationException.
Overrides
build/buildMany accept a deep-partial override — an object, or a function receiving the same { sequence } context:
const named = userFactory.build({ name: 'Ada' });
const scoped = userFactory.build(({ sequence }) => ({
email: `user-${sequence}@corp.test`,
}));Merge rules:
- Plain objects merge recursively — override only the nested field you care about.
- Arrays,
Dates, and class instances replace wholesale — an existing entity swapped in as an association is not merged field-by-field. undefinedoverride values are skipped — spread conditionals don't erase definition values.
Persistence: withCreate
The factory has no ORM coupling. The app supplies the persistence hook via withCreate, unlocking create/createMany:
const persistedUsers = userFactory.withCreate((user) =>
repository.save(repository.create(user)),
);
const saved = await persistedUsers.create(); // build → hook
const many = await persistedUsers.createMany(10); // sequential, not Promise.allcreateMany runs sequentially by design — deterministic sequence and insert order. Calling create without a hook attached throws a ValidationException.
Test isolation
reset() resets one factory's sequence to 0 (the next build is 1); resetAllFactories() resets every factory created via defineFactory — a one-line beforeEach:
import { resetAllFactories } from '@modularityjs/factory';
import { beforeEach } from 'vitest';
beforeEach(() => {
resetAllFactories();
});Consumption lanes
In a vitest spec
import { resetAllFactories } from '@modularityjs/factory';
import { beforeEach, describe, expect, it } from 'vitest';
import { userFactory } from '../factories/user.factory.js';
beforeEach(() => resetAllFactories());
describe('UserService', () => {
it('rejects a duplicate email', async () => {
const existing = userFactory.build();
const service = new UserService(fakeStoreWith([existing]));
await expect(
service.register(userFactory.build({ email: existing.email })),
).rejects.toThrow();
});
});In a database seeder
Factories are a zero-dep primitive precisely so production seeders can use them. If they lived in @modularityjs/testing — a devDependency — a seeder importing them would drag test tooling into the production dependency graph and fail in a pruned production install. Because factory is a runtime-safe primitive with no module wiring, a Seeder contributed to DatabaseSeedersPool can build its rows from the same factory the specs use:
import { Seeder } from '@modularityjs/database';
import { Inject, Injectable } from '@modularityjs/di';
import { DataSource } from 'typeorm';
import { UserEntity } from '../modules/user/index.js';
import { userFactory } from '../factories/user.factory.js';
@Injectable()
export class DemoUsersSeeder extends Seeder {
readonly name = 'demo-users';
constructor(@Inject(DataSource) private readonly dataSource: DataSource) {
super();
}
async run(): Promise<void> {
const repository = this.dataSource.getRepository(UserEntity);
const users = userFactory.withCreate((user) =>
repository.upsert(user, ['email']),
);
await users.createMany(25); // upsert on email — idempotent re-runs
}
}Seeders must be idempotent (run() is re-runnable by design) — the deterministic sequence-derived email plus an upsert keyed on it makes repeated seeding a no-op.